refactor progress
This commit is contained in:
22
packages/api/src/user/IUserRepository.tsx
Normal file
22
packages/api/src/user/IUserRepository.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IUserRepositoryAggregate} from '@fluxer/api/src/user/repositories/IUserRepositoryAggregate';
|
||||
|
||||
export interface IUserRepository extends IUserRepositoryAggregate {}
|
||||
65
packages/api/src/user/UserCacheHelpers.tsx
Normal file
65
packages/api/src/user/UserCacheHelpers.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
export async function getCachedUserPartialResponse(params: {
|
||||
userId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
const {userId, userCacheService, requestCache} = params;
|
||||
return await userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
}
|
||||
|
||||
export async function getCachedUserPartialResponses(params: {
|
||||
userIds: Array<UserID>;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Map<UserID, UserPartialResponse>> {
|
||||
const {userIds, userCacheService, requestCache} = params;
|
||||
return await userCacheService.getUserPartialResponses(userIds, requestCache);
|
||||
}
|
||||
|
||||
export async function mapUserToPartialResponseWithCache(params: {
|
||||
user: User;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
const {user, userCacheService, requestCache} = params;
|
||||
const cached = requestCache.userPartials.get(user.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return userCacheService.setUserPartialResponseFromUserInBackground(user, requestCache);
|
||||
}
|
||||
|
||||
export async function invalidateUserCache(params: {userId: UserID; userCacheService: UserCacheService}): Promise<void> {
|
||||
const {userId, userCacheService} = params;
|
||||
await userCacheService.invalidateUserCache(userId);
|
||||
}
|
||||
|
||||
export async function updateUserCache(params: {user: User; userCacheService: UserCacheService}): Promise<void> {
|
||||
const {user, userCacheService} = params;
|
||||
await userCacheService.setUserPartialResponseFromUser(user);
|
||||
}
|
||||
109
packages/api/src/user/UserHarvestModel.tsx
Normal file
109
packages/api/src/user/UserHarvestModel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserHarvestRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {HarvestStatusResponseSchema} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export class UserHarvest {
|
||||
userId: UserID;
|
||||
harvestId: bigint;
|
||||
requestedAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
storageKey: string | null;
|
||||
fileSize: bigint | null;
|
||||
progressPercent: number;
|
||||
progressStep: string | null;
|
||||
errorMessage: string | null;
|
||||
downloadUrlExpiresAt: Date | null;
|
||||
|
||||
constructor(row: UserHarvestRow) {
|
||||
this.userId = row.user_id;
|
||||
this.harvestId = row.harvest_id;
|
||||
this.requestedAt = row.requested_at;
|
||||
this.startedAt = row.started_at ?? null;
|
||||
this.completedAt = row.completed_at ?? null;
|
||||
this.failedAt = row.failed_at ?? null;
|
||||
this.storageKey = row.storage_key ?? null;
|
||||
this.fileSize = row.file_size ?? null;
|
||||
this.progressPercent = row.progress_percent;
|
||||
this.progressStep = row.progress_step ?? null;
|
||||
this.errorMessage = row.error_message ?? null;
|
||||
this.downloadUrlExpiresAt = row.download_url_expires_at ?? null;
|
||||
}
|
||||
|
||||
toRow(): UserHarvestRow {
|
||||
return {
|
||||
user_id: this.userId,
|
||||
harvest_id: this.harvestId,
|
||||
requested_at: this.requestedAt,
|
||||
started_at: this.startedAt,
|
||||
completed_at: this.completedAt,
|
||||
failed_at: this.failedAt,
|
||||
storage_key: this.storageKey,
|
||||
file_size: this.fileSize,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
toResponse(): {
|
||||
harvest_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
failed_at: string | null;
|
||||
file_size: string | null;
|
||||
progress_percent: number;
|
||||
progress_step: string | null;
|
||||
error_message: string | null;
|
||||
download_url_expires_at: string | null;
|
||||
expires_at: string | null;
|
||||
} {
|
||||
return {
|
||||
harvest_id: this.harvestId.toString(),
|
||||
status: this.getStatus(),
|
||||
created_at: this.requestedAt.toISOString(),
|
||||
started_at: this.startedAt?.toISOString() ?? null,
|
||||
completed_at: this.completedAt?.toISOString() ?? null,
|
||||
failed_at: this.failedAt?.toISOString() ?? null,
|
||||
file_size: this.fileSize?.toString() ?? null,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
|
||||
expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
getStatus(): 'pending' | 'processing' | 'completed' | 'failed' {
|
||||
if (this.failedAt) return 'failed';
|
||||
if (this.completedAt) return 'completed';
|
||||
if (this.startedAt) return 'processing';
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
export type UserHarvestResponse = z.infer<typeof HarvestStatusResponseSchema>;
|
||||
158
packages/api/src/user/UserHarvestRepository.tsx
Normal file
158
packages/api/src/user/UserHarvestRepository.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Db, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {UserHarvestRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {UserHarvests} from '@fluxer/api/src/Tables';
|
||||
import {UserHarvest} from '@fluxer/api/src/user/UserHarvestModel';
|
||||
|
||||
const FIND_HARVEST_CQL = UserHarvests.selectCql({
|
||||
where: [UserHarvests.where.eq('user_id'), UserHarvests.where.eq('harvest_id')],
|
||||
});
|
||||
|
||||
const createFindUserHarvestsQuery = (limit: number) =>
|
||||
UserHarvests.selectCql({
|
||||
where: UserHarvests.where.eq('user_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
const FIND_LATEST_HARVEST_CQL = UserHarvests.selectCql({
|
||||
where: UserHarvests.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class UserHarvestRepository {
|
||||
async create(harvest: UserHarvest): Promise<void> {
|
||||
await upsertOne(UserHarvests.upsertAll(harvest.toRow()));
|
||||
Logger.debug({userId: harvest.userId, harvestId: harvest.harvestId}, 'Created harvest record');
|
||||
}
|
||||
|
||||
async update(harvest: UserHarvest): Promise<void> {
|
||||
const row = harvest.toRow();
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: row.user_id, harvest_id: row.harvest_id},
|
||||
{
|
||||
started_at: Db.set(row.started_at),
|
||||
completed_at: Db.set(row.completed_at),
|
||||
failed_at: Db.set(row.failed_at),
|
||||
storage_key: Db.set(row.storage_key),
|
||||
file_size: Db.set(row.file_size),
|
||||
progress_percent: Db.set(row.progress_percent),
|
||||
progress_step: Db.set(row.progress_step),
|
||||
error_message: Db.set(row.error_message),
|
||||
download_url_expires_at: Db.set(row.download_url_expires_at),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId: harvest.userId, harvestId: harvest.harvestId}, 'Updated harvest record');
|
||||
}
|
||||
|
||||
async findByUserAndHarvestId(userId: UserID, harvestId: bigint): Promise<UserHarvest | null> {
|
||||
const row = await fetchOne<UserHarvestRow>(FIND_HARVEST_CQL, {
|
||||
user_id: userId,
|
||||
harvest_id: harvestId,
|
||||
});
|
||||
return row ? new UserHarvest(row) : null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: UserID, limit: number = 10): Promise<Array<UserHarvest>> {
|
||||
const rows = await fetchMany<UserHarvestRow>(createFindUserHarvestsQuery(limit), {
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.map((row) => new UserHarvest(row));
|
||||
}
|
||||
|
||||
async findLatestByUserId(userId: UserID): Promise<UserHarvest | null> {
|
||||
const row = await fetchOne<UserHarvestRow>(FIND_LATEST_HARVEST_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return row ? new UserHarvest(row) : null;
|
||||
}
|
||||
|
||||
async updateProgress(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
progressPercent: number,
|
||||
progressStep: string,
|
||||
): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId, harvestId, progressPercent, progressStep}, 'Updated harvest progress');
|
||||
}
|
||||
|
||||
async markAsStarted(userId: UserID, harvestId: bigint): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set('Starting harvest'),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId, harvestId}, 'Marked harvest as started');
|
||||
}
|
||||
|
||||
async markAsCompleted(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
storageKey: string,
|
||||
fileSize: bigint,
|
||||
downloadUrlExpiresAt: Date,
|
||||
): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId, harvestId, storageKey, fileSize}, 'Marked harvest as completed');
|
||||
}
|
||||
|
||||
async markAsFailed(userId: UserID, harvestId: bigint, errorMessage: string): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.error({userId, harvestId, errorMessage}, 'Marked harvest as failed');
|
||||
}
|
||||
}
|
||||
87
packages/api/src/user/UserHelpers.tsx
Normal file
87
packages/api/src/user/UserHelpers.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface PremiumCheckable {
|
||||
premiumType: number | null;
|
||||
premiumUntil: Date | null;
|
||||
premiumWillCancel: boolean;
|
||||
flags: bigint;
|
||||
}
|
||||
|
||||
export function checkIsPremium(user: PremiumCheckable): boolean {
|
||||
if (Config.instance.selfHosted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((user.flags & UserFlags.PREMIUM_ENABLED_OVERRIDE) !== 0n) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user.premiumType == null || user.premiumType <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.premiumUntil == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const untilMs = user.premiumUntil.getTime();
|
||||
|
||||
if (user.premiumWillCancel) {
|
||||
return nowMs <= untilMs;
|
||||
}
|
||||
|
||||
return nowMs <= untilMs + ms('3 days');
|
||||
}
|
||||
|
||||
export const PREMIUM_CLEAR_FIELDS = [
|
||||
'premium_type',
|
||||
'premium_since',
|
||||
'premium_until',
|
||||
'premium_will_cancel',
|
||||
'premium_billing_cycle',
|
||||
] as const;
|
||||
|
||||
export type PremiumClearField = (typeof PREMIUM_CLEAR_FIELDS)[number];
|
||||
|
||||
export function shouldStripExpiredPremium(user: PremiumCheckable): boolean {
|
||||
if ((user.premiumType ?? 0) <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !checkIsPremium(user);
|
||||
}
|
||||
|
||||
export function mapExpiredPremiumFields<T>(mapper: (field: PremiumClearField) => T): Record<PremiumClearField, T> {
|
||||
const result = {} as Record<PremiumClearField, T>;
|
||||
for (const field of PREMIUM_CLEAR_FIELDS) {
|
||||
result[field] = mapper(field);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createPremiumClearPatch(): Partial<UserRow> {
|
||||
return mapExpiredPremiumFields(() => null) as Partial<UserRow>;
|
||||
}
|
||||
446
packages/api/src/user/UserMappers.tsx
Normal file
446
packages/api/src/user/UserMappers.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {getGlobalLimitConfigSnapshot} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {GuildChannelOverride} from '@fluxer/api/src/models/GuildChannelOverride';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {MuteConfiguration} from '@fluxer/api/src/models/MuteConfiguration';
|
||||
import type {Relationship} from '@fluxer/api/src/models/Relationship';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import {isUserAdult} from '@fluxer/api/src/utils/AgeUtils';
|
||||
import {
|
||||
DEFAULT_GUILD_FOLDER_ICON,
|
||||
DELETED_USER_DISCRIMINATOR,
|
||||
DELETED_USER_GLOBAL_NAME,
|
||||
DELETED_USER_USERNAME,
|
||||
type GuildFolderIcon,
|
||||
PUBLIC_USER_FLAGS,
|
||||
SuspiciousActivityFlags,
|
||||
UNCATEGORIZED_FOLDER_ID,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {
|
||||
RelationshipResponse,
|
||||
UserGuildSettingsResponse,
|
||||
UserPartialResponse,
|
||||
UserPrivateResponse,
|
||||
UserProfileResponse,
|
||||
UserSettingsResponse,
|
||||
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
const PUBLIC_USER_FLAGS_WITHOUT_STAFF = PUBLIC_USER_FLAGS & ~UserFlags.STAFF;
|
||||
|
||||
function getVisiblePublicUserFlags(userFlags: bigint): bigint {
|
||||
return (userFlags & UserFlags.STAFF_HIDDEN) !== 0n ? PUBLIC_USER_FLAGS_WITHOUT_STAFF : PUBLIC_USER_FLAGS;
|
||||
}
|
||||
|
||||
function mapUserFlagsToPublicBitfield(user: User): number {
|
||||
const flags = user.flags ?? 0n;
|
||||
return Number(flags & getVisiblePublicUserFlags(flags));
|
||||
}
|
||||
|
||||
export function mapUserToPartialResponse(user: User): UserPartialResponse {
|
||||
const isBot = user.isBot;
|
||||
|
||||
let avatarHash = user.avatarHash;
|
||||
const snapshot = getGlobalLimitConfigSnapshot();
|
||||
|
||||
if (avatarHash?.startsWith('a_')) {
|
||||
if (snapshot != null) {
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasAnimatedAvatar = resolveLimitSafe(snapshot, ctx, 'feature_animated_avatar', 0);
|
||||
if (hasAnimatedAvatar === 0 && !isBot) {
|
||||
avatarHash = avatarHash.substring(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isDeleted = (user.flags & UserFlags.DELETED) !== 0n && !user.isSystem;
|
||||
if (isDeleted) {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: DELETED_USER_USERNAME,
|
||||
discriminator: DELETED_USER_DISCRIMINATOR.toString().padStart(4, '0'),
|
||||
global_name: DELETED_USER_GLOBAL_NAME,
|
||||
avatar: null,
|
||||
avatar_color: null,
|
||||
bot: isBot || undefined,
|
||||
system: user.isSystem || undefined,
|
||||
flags: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
discriminator: user.discriminator.toString().padStart(4, '0'),
|
||||
global_name: user.globalName,
|
||||
avatar: avatarHash,
|
||||
avatar_color: user.avatarColor,
|
||||
bot: isBot || undefined,
|
||||
system: user.isSystem || undefined,
|
||||
flags: mapUserFlagsToPublicBitfield(user),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPartialUserFieldsChanged(oldUser: User, newUser: User): boolean {
|
||||
const oldPartial = mapUserToPartialResponse(oldUser);
|
||||
const newPartial = mapUserToPartialResponse(newUser);
|
||||
|
||||
return (
|
||||
oldPartial.username !== newPartial.username ||
|
||||
oldPartial.discriminator !== newPartial.discriminator ||
|
||||
oldPartial.global_name !== newPartial.global_name ||
|
||||
oldPartial.avatar !== newPartial.avatar ||
|
||||
oldPartial.avatar_color !== newPartial.avatar_color ||
|
||||
oldPartial.bot !== newPartial.bot ||
|
||||
oldPartial.system !== newPartial.system ||
|
||||
oldPartial.flags !== newPartial.flags
|
||||
);
|
||||
}
|
||||
|
||||
export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
const snapshot = getGlobalLimitConfigSnapshot();
|
||||
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
|
||||
const partialResponse = mapUserToPartialResponse(user);
|
||||
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasAnimatedBanner = resolveLimitSafe(snapshot, ctx, 'feature_animated_banner', 0);
|
||||
const isActuallyPremium = user.isPremium();
|
||||
const traitSet = new Set<string>();
|
||||
for (const trait of user.traits ?? []) {
|
||||
if (trait && trait !== 'premium') {
|
||||
traitSet.add(trait);
|
||||
}
|
||||
}
|
||||
if (isActuallyPremium) {
|
||||
traitSet.add('premium');
|
||||
}
|
||||
|
||||
let requiredActions: Array<string> | undefined;
|
||||
if (user.suspiciousActivityFlags != null && user.suspiciousActivityFlags > 0) {
|
||||
const actions: Array<string> = [];
|
||||
for (const [key, value] of Object.entries(SuspiciousActivityFlags)) {
|
||||
if (user.suspiciousActivityFlags & value) {
|
||||
actions.push(key);
|
||||
}
|
||||
}
|
||||
if (actions.length > 0) {
|
||||
requiredActions = actions;
|
||||
}
|
||||
}
|
||||
|
||||
const traits = Array.from(traitSet).sort();
|
||||
|
||||
return {
|
||||
...partialResponse,
|
||||
flags: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS_WITHOUT_STAFF),
|
||||
is_staff: isStaff,
|
||||
acls: Array.from(user.acls),
|
||||
traits,
|
||||
email: user.email ?? null,
|
||||
email_bounced: user.emailBounced,
|
||||
phone: user.phone ?? null,
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
accent_color: user.accentColor,
|
||||
banner: hasAnimatedBanner > 0 ? user.bannerHash : null,
|
||||
banner_color: hasAnimatedBanner > 0 ? user.bannerColor : null,
|
||||
mfa_enabled: (user.authenticatorTypes?.size ?? 0) > 0,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : undefined,
|
||||
verified: user.emailVerified,
|
||||
premium_type: isActuallyPremium ? user.premiumType : 0,
|
||||
premium_since: isActuallyPremium ? (user.premiumSince?.toISOString() ?? null) : null,
|
||||
premium_until: user.premiumUntil?.toISOString() ?? null,
|
||||
premium_will_cancel: user.premiumWillCancel ?? false,
|
||||
premium_billing_cycle: user.premiumBillingCycle || null,
|
||||
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
|
||||
premium_badge_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_HIDDEN),
|
||||
premium_badge_masked: !!(user.flags & UserFlags.PREMIUM_BADGE_MASKED),
|
||||
premium_badge_timestamp_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN),
|
||||
premium_badge_sequence_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN),
|
||||
premium_purchase_disabled: !!(user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED),
|
||||
premium_enabled_override: !!(user.flags & UserFlags.PREMIUM_ENABLED_OVERRIDE),
|
||||
password_last_changed_at: user.passwordLastChangedAt?.toISOString() ?? null,
|
||||
required_actions: requiredActions ?? null,
|
||||
nsfw_allowed: isUserAdult(user.dateOfBirth),
|
||||
has_dismissed_premium_onboarding:
|
||||
user.premiumSince != null &&
|
||||
user.premiumOnboardingDismissedAt != null &&
|
||||
user.premiumOnboardingDismissedAt >= user.premiumSince,
|
||||
has_ever_purchased: user.hasEverPurchased,
|
||||
has_unread_gift_inventory:
|
||||
user.giftInventoryServerSeq != null &&
|
||||
(user.giftInventoryClientSeq == null || user.giftInventoryClientSeq < user.giftInventoryServerSeq),
|
||||
unread_gift_inventory_count:
|
||||
user.giftInventoryServerSeq != null ? user.giftInventoryServerSeq - (user.giftInventoryClientSeq ?? 0) : 0,
|
||||
used_mobile_client: !!(user.flags & UserFlags.USED_MOBILE_CLIENT),
|
||||
pending_bulk_message_deletion:
|
||||
user.pendingBulkMessageDeletionAt != null
|
||||
? {
|
||||
scheduled_at: user.pendingBulkMessageDeletionAt.toISOString(),
|
||||
channel_count: user.pendingBulkMessageDeletionChannelCount ?? 0,
|
||||
message_count: user.pendingBulkMessageDeletionMessageCount ?? 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUserToProfileResponse(user: User): UserProfileResponse {
|
||||
const snapshot = getGlobalLimitConfigSnapshot();
|
||||
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasAnimatedBanner = resolveLimitSafe(snapshot, ctx, 'feature_animated_banner', 0);
|
||||
|
||||
return {
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
banner: hasAnimatedBanner > 0 ? user.bannerHash : null,
|
||||
banner_color: hasAnimatedBanner > 0 ? user.bannerColor : null,
|
||||
accent_color: user.accentColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUserToOAuthResponse(user: User, opts?: {includeEmail?: boolean}) {
|
||||
const includeEmail = opts?.includeEmail && !!user.email;
|
||||
return {
|
||||
sub: user.id.toString(),
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
discriminator: user.discriminator.toString().padStart(4, '0'),
|
||||
avatar: user.avatarHash,
|
||||
verified: includeEmail ? (user.emailVerified ?? false) : undefined,
|
||||
email: includeEmail ? (user.email ?? null) : null,
|
||||
flags: mapUserFlagsToPublicBitfield(user),
|
||||
global_name: user.globalName ?? null,
|
||||
bot: user.isBot || false,
|
||||
system: user.isSystem || false,
|
||||
acls: Array.from(user.acls),
|
||||
avatar_color: user.avatarColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGuildMemberToProfileResponse(
|
||||
guildMember: GuildMember | null | undefined,
|
||||
): UserProfileResponse | null {
|
||||
if (!guildMember) return null;
|
||||
|
||||
return {
|
||||
bio: guildMember.bio,
|
||||
pronouns: guildMember.pronouns,
|
||||
banner: guildMember.bannerHash,
|
||||
accent_color: guildMember.accentColor,
|
||||
};
|
||||
}
|
||||
|
||||
interface GuildFolderResponse {
|
||||
id: number;
|
||||
name: string | null;
|
||||
color: number | null;
|
||||
flags: number;
|
||||
icon: GuildFolderIcon;
|
||||
guild_ids: Array<string>;
|
||||
}
|
||||
|
||||
function reconcileGuildFolders(
|
||||
folders: Array<GuildFolderResponse>,
|
||||
memberGuildIds: ReadonlyArray<GuildID>,
|
||||
): Array<GuildFolderResponse> {
|
||||
const memberSet = new Set(memberGuildIds.map(String));
|
||||
const seenGuildIds = new Set<string>();
|
||||
|
||||
const reconciledFolders = folders.map((folder) => ({
|
||||
...folder,
|
||||
guild_ids: folder.guild_ids.filter((guildId) => {
|
||||
if (!memberSet.has(guildId)) {
|
||||
return false;
|
||||
}
|
||||
if (seenGuildIds.has(guildId)) {
|
||||
return false;
|
||||
}
|
||||
seenGuildIds.add(guildId);
|
||||
return true;
|
||||
}),
|
||||
}));
|
||||
|
||||
const missingGuildIds = memberGuildIds.filter((id) => !seenGuildIds.has(String(id))).map(String);
|
||||
|
||||
if (missingGuildIds.length > 0) {
|
||||
const uncategorizedIndex = reconciledFolders.findIndex((f) => f.id === UNCATEGORIZED_FOLDER_ID);
|
||||
if (uncategorizedIndex === -1) {
|
||||
reconciledFolders.push({
|
||||
id: UNCATEGORIZED_FOLDER_ID,
|
||||
name: null,
|
||||
color: null,
|
||||
flags: 0,
|
||||
icon: DEFAULT_GUILD_FOLDER_ICON,
|
||||
guild_ids: missingGuildIds,
|
||||
});
|
||||
} else {
|
||||
reconciledFolders[uncategorizedIndex].guild_ids = [
|
||||
...reconciledFolders[uncategorizedIndex].guild_ids,
|
||||
...missingGuildIds,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return reconciledFolders.filter((f) => f.guild_ids.length > 0);
|
||||
}
|
||||
|
||||
export function mapUserSettingsToResponse(params: {
|
||||
settings: UserSettings;
|
||||
memberGuildIds?: ReadonlyArray<GuildID>;
|
||||
}): UserSettingsResponse {
|
||||
const {settings, memberGuildIds} = params;
|
||||
|
||||
let guildFolders: Array<GuildFolderResponse>;
|
||||
|
||||
if (settings.guildFolders != null && settings.guildFolders.length > 0) {
|
||||
guildFolders = settings.guildFolders.map((folder) => ({
|
||||
id: folder.folderId,
|
||||
name: folder.name,
|
||||
color: folder.color,
|
||||
flags: folder.flags,
|
||||
icon: folder.icon,
|
||||
guild_ids: folder.guildIds.map(String),
|
||||
}));
|
||||
} else if (settings.guildPositions != null && settings.guildPositions.length > 0) {
|
||||
guildFolders = [
|
||||
{
|
||||
id: UNCATEGORIZED_FOLDER_ID,
|
||||
name: null,
|
||||
color: null,
|
||||
flags: 0,
|
||||
icon: DEFAULT_GUILD_FOLDER_ICON,
|
||||
guild_ids: settings.guildPositions.map(String),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
guildFolders = [];
|
||||
}
|
||||
|
||||
if (memberGuildIds != null) {
|
||||
guildFolders = reconcileGuildFolders(guildFolders, memberGuildIds);
|
||||
}
|
||||
|
||||
return {
|
||||
status: settings.status,
|
||||
status_resets_at: settings.statusResetsAt?.toISOString() ?? null,
|
||||
status_resets_to: settings.statusResetsTo,
|
||||
theme: settings.theme,
|
||||
locale: settings.locale,
|
||||
restricted_guilds: [...settings.restrictedGuilds].map(String),
|
||||
bot_restricted_guilds: [...settings.botRestrictedGuilds].map(String),
|
||||
default_guilds_restricted: settings.defaultGuildsRestricted,
|
||||
bot_default_guilds_restricted: settings.botDefaultGuildsRestricted,
|
||||
inline_attachment_media: settings.inlineAttachmentMedia,
|
||||
inline_embed_media: settings.inlineEmbedMedia,
|
||||
gif_auto_play: settings.gifAutoPlay,
|
||||
render_embeds: settings.renderEmbeds,
|
||||
render_reactions: settings.renderReactions,
|
||||
animate_emoji: settings.animateEmoji,
|
||||
animate_stickers: settings.animateStickers,
|
||||
render_spoilers: settings.renderSpoilers,
|
||||
message_display_compact: settings.compactMessageDisplay,
|
||||
friend_source_flags: settings.friendSourceFlags,
|
||||
incoming_call_flags: settings.incomingCallFlags,
|
||||
group_dm_add_permission_flags: settings.groupDmAddPermissionFlags,
|
||||
guild_folders: guildFolders,
|
||||
custom_status: settings.customStatus
|
||||
? {
|
||||
text: settings.customStatus.text,
|
||||
expires_at: settings.customStatus.expiresAt?.toISOString(),
|
||||
emoji_id: settings.customStatus.emojiId?.toString(),
|
||||
emoji_name: settings.customStatus.emojiName,
|
||||
emoji_animated: settings.customStatus.emojiAnimated,
|
||||
}
|
||||
: null,
|
||||
afk_timeout: settings.afkTimeout,
|
||||
time_format: settings.timeFormat,
|
||||
developer_mode: settings.developerMode,
|
||||
trusted_domains: [...settings.trustedDomains],
|
||||
default_hide_muted_channels: settings.defaultHideMutedChannels,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapRelationshipToResponse(params: {
|
||||
relationship: Relationship;
|
||||
userPartialResolver: (userId: UserID) => Promise<UserPartialResponse>;
|
||||
}): Promise<RelationshipResponse> {
|
||||
const {relationship, userPartialResolver} = params;
|
||||
const userPartial = await userPartialResolver(relationship.targetUserId);
|
||||
return {
|
||||
id: relationship.targetUserId.toString(),
|
||||
type: relationship.type,
|
||||
user: userPartial,
|
||||
since: relationship.since?.toISOString(),
|
||||
nickname: relationship.nickname,
|
||||
};
|
||||
}
|
||||
|
||||
const mapMuteConfigToResponse = (
|
||||
muteConfig: MuteConfiguration | null,
|
||||
): {end_time: string | null; selected_time_window: number} | null =>
|
||||
muteConfig
|
||||
? {
|
||||
end_time: muteConfig.endTime?.toISOString() ?? null,
|
||||
selected_time_window: muteConfig.selectedTimeWindow ?? 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
function mapChannelOverrideToResponse(override: GuildChannelOverride): {
|
||||
collapsed: boolean;
|
||||
message_notifications: number;
|
||||
muted: boolean;
|
||||
mute_config: {end_time: string | null; selected_time_window: number} | null;
|
||||
} {
|
||||
return {
|
||||
collapsed: override.collapsed,
|
||||
message_notifications: override.messageNotifications ?? 0,
|
||||
muted: override.muted,
|
||||
mute_config: mapMuteConfigToResponse(override.muteConfig),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapUserGuildSettingsToResponse(settings: UserGuildSettings): UserGuildSettingsResponse {
|
||||
return {
|
||||
guild_id: settings.guildId === createGuildID(0n) ? null : settings.guildId.toString(),
|
||||
message_notifications: settings.messageNotifications ?? 0,
|
||||
muted: settings.muted,
|
||||
mute_config: mapMuteConfigToResponse(settings.muteConfig),
|
||||
mobile_push: settings.mobilePush,
|
||||
suppress_everyone: settings.suppressEveryone,
|
||||
suppress_roles: settings.suppressRoles,
|
||||
hide_muted_channels: settings.hideMutedChannels,
|
||||
channel_overrides: settings.channelOverrides.size
|
||||
? Object.fromEntries(
|
||||
Array.from(settings.channelOverrides.entries()).map(([channelId, override]) => [
|
||||
channelId.toString(),
|
||||
mapChannelOverrideToResponse(override),
|
||||
]),
|
||||
)
|
||||
: null,
|
||||
version: settings.version,
|
||||
};
|
||||
}
|
||||
161
packages/api/src/user/UserModel.tsx
Normal file
161
packages/api/src/user/UserModel.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MAX_GUILDS_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {Locales} from '@fluxer/constants/src/Locales';
|
||||
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
||||
import {
|
||||
DEFAULT_GUILD_FOLDER_ICON,
|
||||
GuildFolderIcons,
|
||||
ThemeTypes,
|
||||
UserNotificationSettings,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {DateTimeType} from '@fluxer/schema/src/primitives/QueryValidators';
|
||||
import {ColorType, createStringType, Int32Type, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {z} from 'zod';
|
||||
|
||||
const SettableStatusTypes = {
|
||||
ONLINE: StatusTypes.ONLINE,
|
||||
DND: StatusTypes.DND,
|
||||
IDLE: StatusTypes.IDLE,
|
||||
INVISIBLE: StatusTypes.INVISIBLE,
|
||||
} as const;
|
||||
const StatusTypeValues = Object.values(SettableStatusTypes) as Array<ValueOf<typeof SettableStatusTypes>>;
|
||||
const ThemeTypeValues = Object.values(ThemeTypes) as Array<ValueOf<typeof ThemeTypes>>;
|
||||
const LocaleValues = Object.values(Locales) as Array<ValueOf<typeof Locales>>;
|
||||
const GuildFolderIconValues = Object.values(GuildFolderIcons) as Array<ValueOf<typeof GuildFolderIcons>>;
|
||||
|
||||
const StatusTypeSchema = z.enum(
|
||||
StatusTypeValues as [ValueOf<typeof SettableStatusTypes>, ...Array<ValueOf<typeof SettableStatusTypes>>],
|
||||
);
|
||||
const ThemeTypeSchema = z.enum(ThemeTypeValues as [ValueOf<typeof ThemeTypes>, ...Array<ValueOf<typeof ThemeTypes>>]);
|
||||
const LocaleSchema = z.enum(LocaleValues as [ValueOf<typeof Locales>, ...Array<ValueOf<typeof Locales>>]);
|
||||
const GuildFolderIconSchema = z.enum(
|
||||
GuildFolderIconValues as [ValueOf<typeof GuildFolderIcons>, ...Array<ValueOf<typeof GuildFolderIcons>>],
|
||||
);
|
||||
const MessageNotificationsType = z.union([
|
||||
z.literal(UserNotificationSettings.ALL_MESSAGES),
|
||||
z.literal(UserNotificationSettings.ONLY_MENTIONS),
|
||||
z.literal(UserNotificationSettings.NO_MESSAGES),
|
||||
z.literal(UserNotificationSettings.INHERIT),
|
||||
]);
|
||||
const MuteConfigType = z
|
||||
.object({
|
||||
end_time: DateTimeType.nullish().describe('When the mute expires'),
|
||||
selected_time_window: z.number().int().describe('Duration of mute in seconds'),
|
||||
})
|
||||
.nullish();
|
||||
const ChannelOverrideType = z.object({
|
||||
collapsed: z.boolean().describe('Whether the channel is collapsed in the sidebar'),
|
||||
message_notifications: MessageNotificationsType.describe('Notification setting for this channel'),
|
||||
muted: z.boolean().describe('Whether notifications are muted for this channel'),
|
||||
mute_config: MuteConfigType.describe('Configuration for temporary mute'),
|
||||
});
|
||||
|
||||
export const UserSettingsUpdateRequest = z
|
||||
.object({
|
||||
flags: z.number().int().describe('Bitfield of user settings flags'),
|
||||
status: StatusTypeSchema.describe('Current online status (online, idle, dnd, invisible)'),
|
||||
status_resets_at: DateTimeType.nullish().describe('When the status should reset'),
|
||||
status_resets_to: StatusTypeSchema.nullish().describe('Status to reset to after timer'),
|
||||
theme: ThemeTypeSchema.describe('UI theme preference (dark or light)'),
|
||||
guild_positions: z
|
||||
.array(SnowflakeType)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
|
||||
.describe('Ordered array of guild IDs for sidebar positioning'),
|
||||
locale: LocaleSchema.describe('User language/locale preference'),
|
||||
restricted_guilds: z
|
||||
.array(SnowflakeType)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
|
||||
.describe('Guild IDs where DMs from members are restricted'),
|
||||
bot_restricted_guilds: z
|
||||
.array(SnowflakeType)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
|
||||
.describe('Guild IDs where DMs from bots are restricted'),
|
||||
default_guilds_restricted: z.boolean().describe('Default DM restriction for new guilds'),
|
||||
bot_default_guilds_restricted: z.boolean().describe('Default bot DM restriction for new guilds'),
|
||||
inline_attachment_media: z.boolean().describe('Auto-display images and videos inline'),
|
||||
inline_embed_media: z.boolean().describe('Auto-display embedded media inline'),
|
||||
gif_auto_play: z.boolean().describe('Auto-play GIFs when visible'),
|
||||
render_embeds: z.boolean().describe('Show link embeds in messages'),
|
||||
render_reactions: z.boolean().describe('Show reactions on messages'),
|
||||
animate_emoji: z.boolean().describe('Animate custom emoji'),
|
||||
animate_stickers: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(2)
|
||||
.describe('Sticker animation setting (0=never, 1=on hover, 2=always)'),
|
||||
render_spoilers: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(2)
|
||||
.describe('Spoiler display setting (0=hidden, 1=on hover, 2=always)'),
|
||||
message_display_compact: z.boolean().describe('Use compact message display mode'),
|
||||
friend_source_flags: Int32Type.describe('Bitfield for friend request source permissions'),
|
||||
incoming_call_flags: Int32Type.describe('Bitfield for incoming call permissions'),
|
||||
group_dm_add_permission_flags: Int32Type.describe('Bitfield for group DM add permissions'),
|
||||
guild_folders: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().int().min(-1).describe('Unique folder identifier (-1 for uncategorized)'),
|
||||
name: createStringType(0, 32).nullish().describe('Folder display name'),
|
||||
color: ColorType.nullish().default(0x000000).describe('Folder color as integer'),
|
||||
flags: Int32Type.default(0).describe('Bitfield for guild folder display behaviour'),
|
||||
icon: GuildFolderIconSchema.default(DEFAULT_GUILD_FOLDER_ICON).describe('Selected icon for the guild folder'),
|
||||
guild_ids: z
|
||||
.array(SnowflakeType)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`)
|
||||
.describe('Guild IDs contained in this folder'),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.describe('Array of guild folder configurations'),
|
||||
custom_status: CustomStatusPayload.nullish().describe('Custom status with text and emoji'),
|
||||
afk_timeout: z.number().int().min(60).max(600).describe('AFK timeout in seconds (60-600)'),
|
||||
time_format: z.number().int().min(0).max(2).describe('Time format preference (0=12h, 1=24h, 2=relative)'),
|
||||
developer_mode: z.boolean().describe('Enable developer mode features'),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const UserGuildSettingsUpdateRequest = z
|
||||
.object({
|
||||
message_notifications: MessageNotificationsType.describe('Default notification level for the guild'),
|
||||
muted: z.boolean().describe('Whether the guild is muted'),
|
||||
mute_config: MuteConfigType.describe('Configuration for temporary mute'),
|
||||
mobile_push: z.boolean().describe('Whether to send mobile push notifications'),
|
||||
suppress_everyone: z.boolean().describe('Suppress @everyone and @here mentions'),
|
||||
suppress_roles: z.boolean().describe('Suppress role mentions'),
|
||||
hide_muted_channels: z.boolean().describe('Hide muted channels from sidebar'),
|
||||
channel_overrides: z
|
||||
.record(
|
||||
SnowflakeType.transform((value) => value.toString()),
|
||||
ChannelOverrideType,
|
||||
)
|
||||
.nullish()
|
||||
.describe('Per-channel notification settings overrides'),
|
||||
})
|
||||
.partial();
|
||||
1052
packages/api/src/user/controllers/UserAccountController.tsx
Normal file
1052
packages/api/src/user/controllers/UserAccountController.tsx
Normal file
File diff suppressed because it is too large
Load Diff
511
packages/api/src/user/controllers/UserAuthController.tsx
Normal file
511
packages/api/src/user/controllers/UserAuthController.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {
|
||||
DisableTotpRequest,
|
||||
EnableMfaTotpRequest,
|
||||
MfaBackupCodesRequest,
|
||||
MfaBackupCodesResponse,
|
||||
PhoneAddRequest,
|
||||
PhoneSendVerificationRequest,
|
||||
PhoneVerifyRequest,
|
||||
PhoneVerifyResponse,
|
||||
SudoMfaMethodsResponse,
|
||||
SudoVerificationSchema,
|
||||
WebAuthnChallengeResponse,
|
||||
WebAuthnCredentialListResponse,
|
||||
WebAuthnCredentialUpdateRequest,
|
||||
WebAuthnRegisterRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
|
||||
export function UserAuthController(app: HonoApp) {
|
||||
app.post(
|
||||
'/users/@me/mfa/totp/enable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_ENABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', EnableMfaTotpRequest),
|
||||
OpenAPI({
|
||||
operationId: 'enable_totp_mfa',
|
||||
summary: 'Enable TOTP multi-factor authentication',
|
||||
responseSchema: MfaBackupCodesResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Enable time-based one-time password (TOTP) MFA on the current account. Returns backup codes for account recovery. Requires sudo mode verification.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
return ctx.json(
|
||||
await ctx.get('userAuthRequestService').enableTotp({
|
||||
user,
|
||||
data: body,
|
||||
sudoContext: sudoResult,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/totp/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_DISABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', DisableTotpRequest),
|
||||
OpenAPI({
|
||||
operationId: 'disable_totp_mfa',
|
||||
summary: 'Disable TOTP multi-factor authentication',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Disable TOTP multi-factor authentication on the current account. Requires sudo mode verification for security.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userAuthRequestService').disableTotp({user, data: body, sudoContext: sudoResult});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/backup-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', MfaBackupCodesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_backup_codes_mfa',
|
||||
summary: 'Get backup codes for multi-factor authentication',
|
||||
responseSchema: MfaBackupCodesResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Generate and retrieve new backup codes for account recovery. Requires sudo mode verification. Old codes are invalidated.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
return ctx.json(
|
||||
await ctx.get('userAuthRequestService').getBackupCodes({user, data: body, sudoContext: sudoResult}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone/send-verification',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('json', PhoneSendVerificationRequest),
|
||||
OpenAPI({
|
||||
operationId: 'send_phone_verification_code',
|
||||
summary: 'Send phone verification code',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Request a verification code to be sent via SMS to the provided phone number. Requires authentication.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('userAuthRequestService').sendPhoneVerificationCode({
|
||||
user: ctx.get('user'),
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone/verify',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_VERIFY_CODE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('json', PhoneVerifyRequest),
|
||||
OpenAPI({
|
||||
operationId: 'verify_phone_code',
|
||||
summary: 'Verify phone code',
|
||||
responseSchema: PhoneVerifyResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description: 'Verify a phone number by confirming the SMS verification code. Returns phone verification status.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await ctx.get('userAuthRequestService').verifyPhoneCode({user: ctx.get('user'), data: ctx.req.valid('json')}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_ADD),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', PhoneAddRequest),
|
||||
OpenAPI({
|
||||
operationId: 'add_phone_to_account',
|
||||
summary: 'Add phone number to account',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Add or update the phone number associated with the current account. Requires sudo mode verification. Phone must be verified before use.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
const {phone_token: _phoneToken, ...sudoBody} = body;
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userAuthRequestService').addPhoneToAccount({
|
||||
user,
|
||||
data: body,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_REMOVE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
OpenAPI({
|
||||
operationId: 'remove_phone_from_account',
|
||||
summary: 'Remove phone number from account',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Remove the phone number from the current account. Requires sudo mode verification. SMS MFA will be disabled if enabled.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userAuthRequestService').removePhoneFromAccount(user);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/sms/enable',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_ENABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
OpenAPI({
|
||||
operationId: 'enable_sms_mfa',
|
||||
summary: 'Enable SMS multi-factor authentication',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Enable SMS-based multi-factor authentication on the current account. Requires sudo mode verification and a verified phone number.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await ctx.get('userAuthRequestService').enableSmsMfa(user);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/sms/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_DISABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
OpenAPI({
|
||||
operationId: 'disable_sms_mfa',
|
||||
summary: 'Disable SMS multi-factor authentication',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Disable SMS-based multi-factor authentication on the current account. Requires sudo mode verification for security.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userAuthRequestService').disableSmsMfa(user);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/authorized-ips',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_AUTHORIZED_IPS_FORGET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
OpenAPI({
|
||||
operationId: 'forget_authorized_ips',
|
||||
summary: 'Forget authorized IPs for current user',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Clears all authorized IP addresses for the current user. After calling this endpoint, the user will be required to re-authorize any new IP addresses they log in from. Requires sudo mode verification.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userAuthRequestService').forgetAuthorizedIps(user);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/mfa/webauthn/credentials',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'list_webauthn_credentials',
|
||||
summary: 'List WebAuthn credentials',
|
||||
responseSchema: WebAuthnCredentialListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieve all registered WebAuthn credentials (security keys, biometric devices) for the current user. Requires authentication.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('userAuthRequestService').listWebAuthnCredentials(ctx.get('user')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/credentials/registration-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTRATION_OPTIONS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
OpenAPI({
|
||||
operationId: 'get_webauthn_registration_options',
|
||||
summary: 'Get WebAuthn registration options',
|
||||
responseSchema: WebAuthnChallengeResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Generate challenge and options to register a new WebAuthn credential. Requires sudo mode verification.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
return ctx.json(await ctx.get('userAuthRequestService').generateWebAuthnRegistrationOptions(user));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/credentials',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTER),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', WebAuthnRegisterRequest),
|
||||
OpenAPI({
|
||||
operationId: 'register_webauthn_credential',
|
||||
summary: 'Register WebAuthn credential',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Complete registration of a new WebAuthn credential (security key or biometric device). Requires sudo mode verification.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {response, challenge, name, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await ctx.get('userAuthRequestService').registerWebAuthnCredential({
|
||||
user,
|
||||
data: {response, challenge, name},
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', CredentialIdParam),
|
||||
Validator('json', WebAuthnCredentialUpdateRequest),
|
||||
SudoModeMiddleware,
|
||||
OpenAPI({
|
||||
operationId: 'update_webauthn_credential',
|
||||
summary: 'Update WebAuthn credential',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description: 'Update the name or settings of a registered WebAuthn credential. Requires sudo mode verification.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {credential_id} = ctx.req.valid('param');
|
||||
const {name, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userAuthRequestService').renameWebAuthnCredential({
|
||||
user,
|
||||
credentialId: credential_id,
|
||||
data: {name},
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', CredentialIdParam),
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
OpenAPI({
|
||||
operationId: 'delete_webauthn_credential',
|
||||
summary: 'Delete WebAuthn credential',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Remove a registered WebAuthn credential from the current account. Requires sudo mode verification for security.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {credential_id} = ctx.req.valid('param');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userAuthRequestService').deleteWebAuthnCredential({user, credentialId: credential_id});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/sudo/mfa-methods',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_MFA_METHODS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'list_sudo_mfa_methods',
|
||||
summary: 'List sudo multi-factor authentication methods',
|
||||
responseSchema: SudoMfaMethodsResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieve all available MFA methods for sudo mode verification (TOTP, SMS, WebAuthn). Requires authentication.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('userAuthRequestService').listSudoMfaMethods(ctx.get('user')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/sudo/mfa/sms/send',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_SMS_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'send_sudo_sms_code',
|
||||
summary: 'Send sudo SMS code',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Request an SMS code to be sent for sudo mode verification. Used before entering sensitive account settings.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('userAuthRequestService').sendSudoSmsCode(ctx.get('user'));
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/sudo/webauthn/authentication-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_WEBAUTHN_OPTIONS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'get_sudo_webauthn_authentication_options',
|
||||
summary: 'Get sudo WebAuthn authentication options',
|
||||
responseSchema: WebAuthnChallengeResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Generate WebAuthn challenge for sudo mode verification using a registered security key or biometric device.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(await ctx.get('userAuthRequestService').getSudoWebAuthnOptions(ctx.get('user')));
|
||||
},
|
||||
);
|
||||
}
|
||||
131
packages/api/src/user/controllers/UserChannelController.tsx
Normal file
131
packages/api/src/user/controllers/UserChannelController.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createChannelID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function UserChannelController(app: HonoApp) {
|
||||
app.get(
|
||||
'/users/@me/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'list_private_channels',
|
||||
summary: 'List private channels',
|
||||
responseSchema: z.array(ChannelResponse),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves all private channels (direct messages) accessible to the current user. Returns list of channel objects with metadata including recipient information.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userChannelRequestService').listPrivateChannels({
|
||||
userId: ctx.get('user').id,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
Validator('json', CreatePrivateChannelRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_private_channel',
|
||||
summary: 'Create private channel',
|
||||
responseSchema: ChannelResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Creates a new private channel (direct message) between the current user and one or more recipients. Returns the newly created channel object.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userChannelRequestService').createPrivateChannel({
|
||||
userId: ctx.get('user').id,
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/channels/:channel_id/pin',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'pin_direct_message_channel',
|
||||
summary: 'Pin direct message channel',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Pins a private message channel for the current user. Pinned channels appear at the top of the channel list for easy access.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('userChannelRequestService').pinChannel({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(ctx.req.valid('param').channel_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/channels/:channel_id/pin',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'unpin_direct_message_channel',
|
||||
summary: 'Unpin direct message channel',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Unpins a private message channel for the current user. The channel will return to its normal position in the channel list based on activity.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('userChannelRequestService').unpinChannel({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(ctx.req.valid('param').channel_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
272
packages/api/src/user/controllers/UserContentController.tsx
Normal file
272
packages/api/src/user/controllers/UserContentController.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createChannelID, createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {HarvestIdParam, MessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {
|
||||
HarvestCreationResponseSchema,
|
||||
HarvestDownloadUrlResponse,
|
||||
HarvestStatusResponseSchema,
|
||||
HarvestStatusResponseSchemaNullable,
|
||||
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
|
||||
import {
|
||||
SaveMessageRequest,
|
||||
UserMentionsQueryRequest,
|
||||
UserSavedMessagesQueryRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {SavedMessageEntryListResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
export function UserContentController(app: HonoApp) {
|
||||
app.get(
|
||||
'/users/@me/mentions',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('query', UserMentionsQueryRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_mentions_for_current_user',
|
||||
summary: 'List mentions for current user',
|
||||
responseSchema: MessageListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves messages where the current user was mentioned. Supports filtering by role mentions, everyone mentions, and specific guilds. Returns paginated list of messages.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {limit, roles, everyone, guilds, before} = ctx.req.valid('query');
|
||||
const response = await ctx.get('userContentRequestService').listMentions({
|
||||
userId: ctx.get('user').id,
|
||||
limit,
|
||||
everyone,
|
||||
roles,
|
||||
guilds,
|
||||
before: before ? createMessageID(before) : undefined,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/mentions/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', MessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_mention',
|
||||
summary: 'Delete mention',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
"Removes a mention from the current user's mention history. Does not delete the original message, only removes it from the user's personal mention list.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('userContentRequestService').deleteMention({
|
||||
userId: ctx.get('user').id,
|
||||
messageId: createMessageID(ctx.req.valid('param').message_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/saved-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('query', UserSavedMessagesQueryRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_saved_messages',
|
||||
summary: 'List saved messages',
|
||||
responseSchema: SavedMessageEntryListResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves all messages saved by the current user. Messages are saved privately for easy reference. Returns paginated list of saved messages with metadata.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userContentRequestService').listSavedMessages({
|
||||
userId: ctx.get('user').id,
|
||||
limit: ctx.req.valid('query').limit,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/saved-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', SaveMessageRequest),
|
||||
OpenAPI({
|
||||
operationId: 'save_message',
|
||||
summary: 'Save message',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Saves a message for the current user. Saved messages can be accessed later from the saved messages list. Messages are saved privately.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('json');
|
||||
await ctx.get('userContentRequestService').saveMessage({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(channel_id),
|
||||
messageId: createMessageID(message_id),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/saved-messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', MessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'unsave_message',
|
||||
summary: 'Unsave message',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
"Removes a message from the current user's saved messages. Does not delete the original message, only removes it from the user's saved collection.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('userContentRequestService').unsaveMessage({
|
||||
userId: ctx.get('user').id,
|
||||
messageId: createMessageID(ctx.req.valid('param').message_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/harvest',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_DATA_HARVEST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'request_data_harvest',
|
||||
summary: 'Request data harvest',
|
||||
responseSchema: HarvestCreationResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Requests a data harvest of all user data and content. Initiates an asynchronous process to compile and prepare all data for download in a portable format. Returns harvest ID and status.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('userContentRequestService').requestHarvest({userId: ctx.get('user').id});
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/latest',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_LATEST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'get_latest_data_harvest',
|
||||
summary: 'Get latest data harvest',
|
||||
responseSchema: HarvestStatusResponseSchemaNullable,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves the status of the most recent data harvest request. Returns null if no harvest has been requested yet. Shows progress and estimated completion time.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const harvest = await ctx.get('userContentRequestService').getLatestHarvest({userId: ctx.get('user').id});
|
||||
return ctx.json(harvest, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/:harvestId',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_STATUS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', HarvestIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_data_harvest_status',
|
||||
summary: 'Get data harvest status',
|
||||
responseSchema: HarvestStatusResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves detailed status information for a specific data harvest. Shows progress, completion status, and other metadata about the harvest request.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {harvestId} = ctx.req.valid('param');
|
||||
const harvest = await ctx.get('userContentRequestService').getHarvestStatus({
|
||||
userId: ctx.get('user').id,
|
||||
harvestId,
|
||||
});
|
||||
return ctx.json(harvest, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/:harvestId/download',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', HarvestIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_data_harvest_download_url',
|
||||
summary: 'Get data harvest download URL',
|
||||
responseSchema: HarvestDownloadUrlResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves the download URL for a completed data harvest. The URL is temporary and expires after a set time. Can only be accessed for completed harvests.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {harvestId} = ctx.req.valid('param');
|
||||
const result = await ctx.get('userContentRequestService').getHarvestDownloadUrl({
|
||||
userId: ctx.get('user').id,
|
||||
harvestId,
|
||||
storageService: ctx.get('storageService'),
|
||||
});
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
35
packages/api/src/user/controllers/UserController.tsx
Normal file
35
packages/api/src/user/controllers/UserController.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {UserAccountController} from '@fluxer/api/src/user/controllers/UserAccountController';
|
||||
import {UserAuthController} from '@fluxer/api/src/user/controllers/UserAuthController';
|
||||
import {UserChannelController} from '@fluxer/api/src/user/controllers/UserChannelController';
|
||||
import {UserContentController} from '@fluxer/api/src/user/controllers/UserContentController';
|
||||
import {UserRelationshipController} from '@fluxer/api/src/user/controllers/UserRelationshipController';
|
||||
import {UserScheduledMessageController} from '@fluxer/api/src/user/controllers/UserScheduledMessageController';
|
||||
|
||||
export function UserController(app: HonoApp) {
|
||||
UserAccountController(app);
|
||||
UserAuthController(app);
|
||||
UserRelationshipController(app);
|
||||
UserChannelController(app);
|
||||
UserContentController(app);
|
||||
UserScheduledMessageController(app);
|
||||
}
|
||||
193
packages/api/src/user/controllers/UserRelationshipController.tsx
Normal file
193
packages/api/src/user/controllers/UserRelationshipController.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
FriendRequestByTagRequest,
|
||||
RelationshipNicknameUpdateRequest,
|
||||
RelationshipTypePutRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function UserRelationshipController(app: HonoApp) {
|
||||
app.get(
|
||||
'/users/@me/relationships',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIPS_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'list_user_relationships',
|
||||
summary: 'List user relationships',
|
||||
responseSchema: z.array(RelationshipResponse),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves all relationships for the current user, including friends, friend requests (incoming and outgoing), and blocked users. Returns list of relationship objects with type and metadata.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userRelationshipRequestService').listRelationships({
|
||||
userId: ctx.get('user').id,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/relationships',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', FriendRequestByTagRequest),
|
||||
OpenAPI({
|
||||
operationId: 'send_friend_request_by_tag',
|
||||
summary: 'Send friend request by tag',
|
||||
responseSchema: RelationshipResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Sends a friend request to a user identified by username tag (username#discriminator). Returns the new relationship object. Can fail if user not found or request already sent.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userRelationshipRequestService').sendFriendRequestByTag({
|
||||
userId: ctx.get('user').id,
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'send_friend_request',
|
||||
summary: 'Send friend request',
|
||||
responseSchema: RelationshipResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Sends a friend request to a user identified by user ID. Returns the new relationship object. Can fail if user not found or request already sent.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userRelationshipRequestService').sendFriendRequest({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_ACCEPT),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', RelationshipTypePutRequest),
|
||||
OpenAPI({
|
||||
operationId: 'accept_or_update_friend_request',
|
||||
summary: 'Accept or update friend request',
|
||||
responseSchema: RelationshipResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Accepts a pending incoming friend request from a user or updates the relationship type. Can also be used to change friend relationship to blocked status. Returns updated relationship object.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userRelationshipRequestService').updateRelationshipType({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'remove_relationship',
|
||||
summary: 'Remove relationship',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Removes a relationship with another user by ID. Removes friends, cancels friend requests (incoming or outgoing), or unblocks a blocked user depending on current relationship type.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('userRelationshipRequestService').removeRelationship({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', RelationshipNicknameUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_relationship_nickname',
|
||||
summary: 'Update relationship nickname',
|
||||
responseSchema: RelationshipResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
"Updates the nickname associated with a relationship (friend or blocked user). Nicknames are personal labels that override the user's display name in the current user's view. Returns updated relationship object.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
const response = await ctx.get('userRelationshipRequestService').updateNickname({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {parseScheduledMessageInput} from '@fluxer/api/src/channel/controllers/ScheduledMessageParsing';
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {ScheduledMessageIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ScheduledMessageResponseSchema} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
|
||||
import type {Context} from 'hono';
|
||||
import {z} from 'zod';
|
||||
|
||||
export function UserScheduledMessageController(app: HonoApp) {
|
||||
app.get(
|
||||
'/users/@me/scheduled-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'list_scheduled_messages',
|
||||
summary: 'List scheduled messages',
|
||||
responseSchema: z.array(ScheduledMessageResponseSchema),
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves all scheduled messages for the current user. Returns list of messages that are scheduled to be sent at a future date and time.',
|
||||
}),
|
||||
async (ctx: Context<HonoEnv>) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessages = await scheduledMessageService.listScheduledMessages(userId);
|
||||
|
||||
return ctx.json(
|
||||
scheduledMessages.map((message) => message.toResponse()),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ScheduledMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_scheduled_message',
|
||||
summary: 'Get scheduled message',
|
||||
responseSchema: ScheduledMessageResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Retrieves details of a specific scheduled message by ID. Returns the message content, scheduled send time, and status.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessage = await scheduledMessageService.getScheduledMessage(userId, scheduledMessageId);
|
||||
|
||||
if (!scheduledMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ScheduledMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_scheduled_message',
|
||||
summary: 'Cancel scheduled message',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Cancels and deletes a scheduled message before it is sent. The message will not be delivered if cancelled.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
await scheduledMessageService.cancelScheduledMessage(userId, scheduledMessageId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ScheduledMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'update_scheduled_message',
|
||||
summary: 'Update scheduled message',
|
||||
responseSchema: ScheduledMessageResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Updates an existing scheduled message before it is sent. Can modify message content, scheduled time, and timezone. Returns updated scheduled message details.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessageId = createMessageID(ctx.req.valid('param').scheduled_message_id);
|
||||
|
||||
const existingMessage = await scheduledMessageService.getScheduledMessage(user.id, scheduledMessageId);
|
||||
if (!existingMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const channelId = existingMessage.channelId;
|
||||
|
||||
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
|
||||
ctx,
|
||||
user,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const scheduledMessage = await scheduledMessageService.updateScheduledMessage({
|
||||
user,
|
||||
channelId,
|
||||
data: message,
|
||||
scheduledLocalAt,
|
||||
timezone,
|
||||
scheduledMessageId,
|
||||
existing: existingMessage,
|
||||
});
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 200);
|
||||
},
|
||||
);
|
||||
}
|
||||
169
packages/api/src/user/repositories/GiftCodeRepository.tsx
Normal file
169
packages/api/src/user/repositories/GiftCodeRepository.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {GiftCodeRow} from '@fluxer/api/src/database/types/PaymentTypes';
|
||||
import {GiftCode} from '@fluxer/api/src/models/GiftCode';
|
||||
import {GiftCodes, GiftCodesByCreator, GiftCodesByPaymentIntent, GiftCodesByRedeemer} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_GIFT_CODES_BY_CREATOR_QUERY = GiftCodesByCreator.selectCql({
|
||||
where: GiftCodesByCreator.where.eq('created_by_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY = GiftCodesByPaymentIntent.selectCql({
|
||||
columns: ['code'],
|
||||
where: GiftCodesByPaymentIntent.where.eq('stripe_payment_intent_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GIFT_CODE_QUERY = GiftCodes.selectCql({
|
||||
where: GiftCodes.where.eq('code'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class GiftCodeRepository {
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(GiftCodes.upsertAll(data));
|
||||
batch.addPrepared(
|
||||
GiftCodesByCreator.upsertAll({
|
||||
created_by_user_id: data.created_by_user_id,
|
||||
code: data.code,
|
||||
}),
|
||||
);
|
||||
|
||||
if (data.stripe_payment_intent_id) {
|
||||
batch.addPrepared(
|
||||
GiftCodesByPaymentIntent.upsertAll({
|
||||
stripe_payment_intent_id: data.stripe_payment_intent_id,
|
||||
code: data.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
const row = await fetchOne<GiftCodeRow>(FETCH_GIFT_CODE_QUERY, {code});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GiftCode(row);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
const row = await fetchOne<{code: string}>(FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY, {
|
||||
stripe_payment_intent_id: paymentIntentId,
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.findGiftCode(row.code);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
const codes = await fetchMany<{code: string}>(FETCH_GIFT_CODES_BY_CREATOR_QUERY, {
|
||||
created_by_user_id: userId,
|
||||
});
|
||||
|
||||
if (codes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gifts: Array<GiftCode> = [];
|
||||
for (const {code} of codes) {
|
||||
const gift = await this.findGiftCode(code);
|
||||
if (gift) {
|
||||
gifts.push(gift);
|
||||
}
|
||||
}
|
||||
|
||||
return gifts;
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
const redeemedAt = new Date();
|
||||
|
||||
const q = GiftCodes.patchByPkIf(
|
||||
{code},
|
||||
{
|
||||
redeemed_by_user_id: Db.set(userId),
|
||||
redeemed_at: Db.set(redeemedAt),
|
||||
},
|
||||
{col: 'redeemed_by_user_id', expectedParam: 'expected_redeemer', expectedValue: null},
|
||||
);
|
||||
|
||||
const result = await executeConditional(q);
|
||||
|
||||
if (result.applied) {
|
||||
await upsertOne(
|
||||
GiftCodesByRedeemer.upsertAll({
|
||||
redeemed_by_user_id: userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const patch: Record<string, ReturnType<typeof Db.set>> = {};
|
||||
if (data['redeemed_at'] !== undefined) {
|
||||
patch['redeemed_at'] = Db.set(data['redeemed_at']);
|
||||
}
|
||||
if (data['redeemed_by_user_id'] !== undefined) {
|
||||
patch['redeemed_by_user_id'] = Db.set(data['redeemed_by_user_id']);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
batch.addPrepared(GiftCodes.patchByPk({code}, patch));
|
||||
}
|
||||
|
||||
if (data.redeemed_by_user_id) {
|
||||
batch.addPrepared(
|
||||
GiftCodesByRedeemer.upsertAll({
|
||||
redeemed_by_user_id: data.redeemed_by_user_id,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
await upsertOne(
|
||||
GiftCodes.patchByPk(
|
||||
{code},
|
||||
{
|
||||
checkout_session_id: Db.set(checkoutSessionId),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
|
||||
export interface IUserAccountRepository {
|
||||
create(data: UserRow): Promise<User>;
|
||||
upsert(data: UserRow, oldData?: UserRow | null): Promise<User>;
|
||||
patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User>;
|
||||
findUnique(userId: UserID): Promise<User | null>;
|
||||
findUniqueAssert(userId: UserID): Promise<User>;
|
||||
findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null>;
|
||||
findDiscriminatorsByUsername(username: string): Promise<Set<number>>;
|
||||
findByEmail(email: string): Promise<User | null>;
|
||||
findByPhone(phone: string): Promise<User | null>;
|
||||
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null>;
|
||||
findByStripeCustomerId(stripeCustomerId: string): Promise<User | null>;
|
||||
listUsers(userIds: Array<UserID>): Promise<Array<User>>;
|
||||
listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>>;
|
||||
|
||||
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
|
||||
|
||||
addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
|
||||
removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void>;
|
||||
findUsersPendingDeletion(now: Date): Promise<Array<User>>;
|
||||
findUsersPendingDeletionByDate(deletionDate: string): Promise<Array<{user_id: bigint; deletion_reason_code: number}>>;
|
||||
isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean>;
|
||||
scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
|
||||
|
||||
deleteUserSecondaryIndices(userId: UserID): Promise<void>;
|
||||
removeFromAllGuilds(userId: UserID): Promise<void>;
|
||||
|
||||
updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void>;
|
||||
updateSubscriptionStatus(
|
||||
userId: UserID,
|
||||
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
|
||||
): Promise<{finalVersion: number | null}>;
|
||||
}
|
||||
88
packages/api/src/user/repositories/IUserAuthRepository.tsx
Normal file
88
packages/api/src/user/repositories/IUserAuthRepository.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
|
||||
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
|
||||
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
|
||||
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
|
||||
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
|
||||
|
||||
export interface IUserAuthRepository {
|
||||
listAuthSessions(userId: UserID): Promise<Array<AuthSession>>;
|
||||
getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null>;
|
||||
createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession>;
|
||||
updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void>;
|
||||
deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void>;
|
||||
revokeAuthSession(sessionIdHash: Buffer): Promise<void>;
|
||||
deleteAllAuthSessions(userId: UserID): Promise<void>;
|
||||
|
||||
listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>>;
|
||||
createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>>;
|
||||
clearMfaBackupCodes(userId: UserID): Promise<void>;
|
||||
consumeMfaBackupCode(userId: UserID, code: string): Promise<void>;
|
||||
deleteAllMfaBackupCodes(userId: UserID): Promise<void>;
|
||||
|
||||
getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null>;
|
||||
createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken>;
|
||||
deleteEmailVerificationToken(token: string): Promise<void>;
|
||||
|
||||
getPasswordResetToken(token: string): Promise<PasswordResetToken | null>;
|
||||
createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken>;
|
||||
deletePasswordResetToken(token: string): Promise<void>;
|
||||
|
||||
getEmailRevertToken(token: string): Promise<EmailRevertToken | null>;
|
||||
createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken>;
|
||||
deleteEmailRevertToken(token: string): Promise<void>;
|
||||
|
||||
createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void>;
|
||||
getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null>;
|
||||
deletePhoneToken(token: PhoneVerificationToken): Promise<void>;
|
||||
updateUserActivity(userId: UserID, clientIp: string): Promise<void>;
|
||||
checkIpAuthorized(userId: UserID, ip: string): Promise<boolean>;
|
||||
createAuthorizedIp(userId: UserID, ip: string): Promise<void>;
|
||||
createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void>;
|
||||
authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null>;
|
||||
deleteAllAuthorizedIps(userId: UserID): Promise<void>;
|
||||
|
||||
listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>>;
|
||||
getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null>;
|
||||
createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void>;
|
||||
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
|
||||
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
|
||||
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
|
||||
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
|
||||
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
|
||||
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
|
||||
export interface PrivateChannelSummary {
|
||||
channelId: ChannelID;
|
||||
isGroupDm: boolean;
|
||||
channelType: number | null;
|
||||
lastMessageId: MessageID | null;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface ListHistoricalDmChannelOptions {
|
||||
limit: number;
|
||||
beforeChannelId?: ChannelID;
|
||||
afterChannelId?: ChannelID;
|
||||
}
|
||||
|
||||
export interface HistoricalDmChannelSummary {
|
||||
channelId: ChannelID;
|
||||
channelType: number | null;
|
||||
recipientIds: Array<UserID>;
|
||||
lastMessageId: MessageID | null;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface IUserChannelRepository {
|
||||
listPrivateChannels(userId: UserID): Promise<Array<Channel>>;
|
||||
deleteAllPrivateChannels(userId: UserID): Promise<void>;
|
||||
listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>>;
|
||||
listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>>;
|
||||
listHistoricalDmChannelsPaginated(
|
||||
userId: UserID,
|
||||
options: ListHistoricalDmChannelOptions,
|
||||
): Promise<Array<HistoricalDmChannelSummary>>;
|
||||
recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void>;
|
||||
|
||||
findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null>;
|
||||
createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel>;
|
||||
isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean>;
|
||||
openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void>;
|
||||
closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void>;
|
||||
|
||||
getPinnedDms(userId: UserID): Promise<Array<ChannelID>>;
|
||||
getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>>;
|
||||
addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
|
||||
removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
|
||||
deletePinnedDmsByUserId(userId: UserID): Promise<void>;
|
||||
|
||||
deleteAllReadStates(userId: UserID): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
|
||||
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
|
||||
import type {PushSubscriptionRow, RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
|
||||
import type {Payment} from '@fluxer/api/src/models/Payment';
|
||||
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
|
||||
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
|
||||
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
|
||||
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
|
||||
|
||||
export interface IUserContentRepository {
|
||||
getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null>;
|
||||
listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean,
|
||||
includeRole: boolean,
|
||||
includeGuilds: boolean,
|
||||
limit: number,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>>;
|
||||
createRecentMention(mention: ExactRow<RecentMentionRow>): Promise<RecentMention>;
|
||||
createRecentMentions(mentions: Array<ExactRow<RecentMentionRow>>): Promise<void>;
|
||||
deleteRecentMention(mention: RecentMention): Promise<void>;
|
||||
deleteAllRecentMentions(userId: UserID): Promise<void>;
|
||||
|
||||
listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>>;
|
||||
createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage>;
|
||||
deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void>;
|
||||
deleteAllSavedMessages(userId: UserID): Promise<void>;
|
||||
|
||||
createGiftCode(data: ExactRow<GiftCodeRow>): Promise<void>;
|
||||
findGiftCode(code: string): Promise<GiftCode | null>;
|
||||
findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null>;
|
||||
findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>>;
|
||||
redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}>;
|
||||
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
|
||||
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
|
||||
|
||||
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
|
||||
createPushSubscription(data: ExactRow<PushSubscriptionRow>): Promise<PushSubscription>;
|
||||
deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void>;
|
||||
getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>>;
|
||||
deleteAllPushSubscriptions(userId: UserID): Promise<void>;
|
||||
|
||||
createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void>;
|
||||
updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}>;
|
||||
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
|
||||
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
|
||||
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
|
||||
|
||||
listVisionarySlots(): Promise<Array<VisionarySlot>>;
|
||||
expandVisionarySlots(byCount: number): Promise<void>;
|
||||
shrinkVisionarySlots(toCount: number): Promise<void>;
|
||||
reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
|
||||
unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {RelationshipRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {Relationship} from '@fluxer/api/src/models/Relationship';
|
||||
import type {UserNote} from '@fluxer/api/src/models/UserNote';
|
||||
|
||||
export interface IUserRelationshipRepository {
|
||||
listRelationships(sourceUserId: UserID): Promise<Array<Relationship>>;
|
||||
hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean>;
|
||||
getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null>;
|
||||
upsertRelationship(relationship: RelationshipRow): Promise<Relationship>;
|
||||
deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void>;
|
||||
deleteAllRelationships(userId: UserID): Promise<void>;
|
||||
backfillRelationshipsIndex(userId: UserID, relationships: Array<Relationship>): Promise<void>;
|
||||
|
||||
getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null>;
|
||||
getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>>;
|
||||
upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote>;
|
||||
clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void>;
|
||||
deleteAllNotes(userId: UserID): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
|
||||
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
|
||||
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
|
||||
|
||||
export interface IUserRepositoryAggregate
|
||||
extends IUserAccountRepository,
|
||||
IUserAuthRepository,
|
||||
IUserSettingsRepository,
|
||||
IUserRelationshipRepository,
|
||||
IUserChannelRepository,
|
||||
IUserContentRepository {}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
|
||||
import type {UserGuildSettingsRow, UserSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
|
||||
export interface IUserSettingsRepository {
|
||||
findSettings(userId: UserID): Promise<UserSettings | null>;
|
||||
upsertSettings(settings: ExactRow<UserSettingsRow>): Promise<UserSettings>;
|
||||
deleteUserSettings(userId: UserID): Promise<void>;
|
||||
|
||||
findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null>;
|
||||
findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>>;
|
||||
upsertGuildSettings(settings: ExactRow<UserGuildSettingsRow>): Promise<UserGuildSettings>;
|
||||
deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void>;
|
||||
deleteAllUserGuildSettings(userId: UserID): Promise<void>;
|
||||
}
|
||||
204
packages/api/src/user/repositories/PaymentRepository.tsx
Normal file
204
packages/api/src/user/repositories/PaymentRepository.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeVersionedUpdate, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
|
||||
import {Payment} from '@fluxer/api/src/models/Payment';
|
||||
import {Payments, PaymentsByPaymentIntent, PaymentsBySubscription, PaymentsByUser} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY = Payments.selectCql({
|
||||
where: Payments.where.eq('checkout_session_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY = PaymentsByPaymentIntent.selectCql({
|
||||
columns: ['checkout_session_id'],
|
||||
where: PaymentsByPaymentIntent.where.eq('payment_intent_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY = PaymentsBySubscription.selectCql({
|
||||
where: PaymentsBySubscription.where.eq('subscription_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENTS_BY_USER_QUERY = PaymentsByUser.selectCql({
|
||||
columns: ['checkout_session_id'],
|
||||
where: PaymentsByUser.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENTS_BY_IDS_QUERY = Payments.selectCql({
|
||||
where: Payments.where.in('checkout_session_id', 'checkout_session_ids'),
|
||||
});
|
||||
|
||||
export class PaymentRepository {
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const paymentRow: PaymentRow = {
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
user_id: data.user_id,
|
||||
price_id: data.price_id,
|
||||
product_type: data.product_type,
|
||||
status: data.status,
|
||||
is_gift: data.is_gift,
|
||||
created_at: data.created_at,
|
||||
stripe_customer_id: null,
|
||||
payment_intent_id: null,
|
||||
subscription_id: null,
|
||||
invoice_id: null,
|
||||
amount_cents: 0,
|
||||
currency: '',
|
||||
gift_code: null,
|
||||
completed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
batch.addPrepared(Payments.upsertAll(paymentRow));
|
||||
|
||||
batch.addPrepared(
|
||||
PaymentsByUser.upsertAll({
|
||||
user_id: data.user_id,
|
||||
created_at: data.created_at,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
const checkoutSessionId = data.checkout_session_id;
|
||||
|
||||
const result = await executeVersionedUpdate(
|
||||
() =>
|
||||
fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
|
||||
checkout_session_id: checkoutSessionId,
|
||||
}),
|
||||
(current) => {
|
||||
type PatchOp = ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>;
|
||||
const patch: Record<string, PatchOp> = {};
|
||||
|
||||
const addField = <K extends keyof PaymentRow>(key: K) => {
|
||||
const newVal = data[key];
|
||||
const oldVal = current?.[key];
|
||||
if (newVal === null) {
|
||||
if (current && oldVal !== null && oldVal !== undefined) {
|
||||
patch[key] = Db.clear();
|
||||
}
|
||||
} else if (newVal !== undefined) {
|
||||
patch[key] = Db.set(newVal);
|
||||
}
|
||||
};
|
||||
|
||||
addField('stripe_customer_id');
|
||||
addField('payment_intent_id');
|
||||
addField('subscription_id');
|
||||
addField('invoice_id');
|
||||
addField('amount_cents');
|
||||
addField('currency');
|
||||
addField('status');
|
||||
addField('gift_code');
|
||||
addField('completed_at');
|
||||
|
||||
return {
|
||||
pk: {checkout_session_id: checkoutSessionId},
|
||||
patch,
|
||||
};
|
||||
},
|
||||
Payments,
|
||||
);
|
||||
|
||||
if (result.applied) {
|
||||
await this.updatePaymentIndexes(data);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updatePaymentIndexes(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (data.payment_intent_id) {
|
||||
batch.addPrepared(
|
||||
PaymentsByPaymentIntent.upsertAll({
|
||||
payment_intent_id: data.payment_intent_id,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.subscription_id) {
|
||||
const payment = await this.getPaymentByCheckoutSession(data.checkout_session_id);
|
||||
if (payment?.priceId && payment.productType) {
|
||||
batch.addPrepared(
|
||||
PaymentsBySubscription.upsertAll({
|
||||
subscription_id: data.subscription_id,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
user_id: payment.userId,
|
||||
price_id: payment.priceId,
|
||||
product_type: payment.productType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
const result = await fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
|
||||
checkout_session_id: checkoutSessionId,
|
||||
});
|
||||
return result ? new Payment(result) : null;
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
const mapping = await fetchOne<{checkout_session_id: string}>(FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY, {
|
||||
payment_intent_id: paymentIntentId,
|
||||
});
|
||||
if (!mapping) return null;
|
||||
return this.getPaymentByCheckoutSession(mapping.checkout_session_id);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
const result = await fetchOne<PaymentBySubscriptionRow>(FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY, {
|
||||
subscription_id: subscriptionId,
|
||||
});
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
|
||||
const paymentRefs = await fetchMany<{checkout_session_id: string}>(FETCH_PAYMENTS_BY_USER_QUERY, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (paymentRefs.length === 0) return [];
|
||||
const rows = await fetchMany<PaymentRow>(FETCH_PAYMENTS_BY_IDS_QUERY, {
|
||||
checkout_session_ids: paymentRefs.map((r) => r.checkout_session_id),
|
||||
});
|
||||
return rows.map((r) => new Payment(r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {PushSubscriptionRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
|
||||
import {PushSubscriptions} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
|
||||
where: PushSubscriptions.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
|
||||
where: PushSubscriptions.where.in('user_id', 'user_ids'),
|
||||
});
|
||||
|
||||
export class PushSubscriptionRepository {
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
const rows = await fetchMany<PushSubscriptionRow>(FETCH_PUSH_SUBSCRIPTIONS_CQL, {user_id: userId});
|
||||
return rows.map((row) => new PushSubscription(row));
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
await upsertOne(PushSubscriptions.upsertAll(data));
|
||||
return new PushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
await deleteOneOrMany(PushSubscriptions.deleteByPk({user_id: userId, subscription_id: subscriptionId}));
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const rows = await fetchMany<PushSubscriptionRow>(FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL, {user_ids: userIds});
|
||||
|
||||
const map = new Map<UserID, Array<PushSubscription>>();
|
||||
for (const row of rows) {
|
||||
const sub = new PushSubscription(row);
|
||||
const existing = map.get(row.user_id) ?? [];
|
||||
existing.push(sub);
|
||||
map.set(row.user_id, existing);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PushSubscriptions.delete({where: PushSubscriptions.where.eq('user_id')}).bind({user_id: userId}),
|
||||
);
|
||||
}
|
||||
}
|
||||
151
packages/api/src/user/repositories/RecentMentionRepository.tsx
Normal file
151
packages/api/src/user/repositories/RecentMentionRepository.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMessageID, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {RecentMention} from '@fluxer/api/src/models/RecentMention';
|
||||
import {RecentMentions, RecentMentionsByGuild} from '@fluxer/api/src/Tables';
|
||||
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
const FETCH_RECENT_MENTION_CQL = RecentMentions.selectCql({
|
||||
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.eq('message_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchRecentMentionsQuery = (limit: number) =>
|
||||
RecentMentions.selectCql({
|
||||
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.lt('message_id', 'before_message_id')],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class RecentMentionRepository {
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
const mention = await fetchOne<RecentMentionRow>(FETCH_RECENT_MENTION_CQL, {
|
||||
user_id: userId,
|
||||
message_id: messageId,
|
||||
});
|
||||
return mention ? new RecentMention(mention) : null;
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean = true,
|
||||
includeRole: boolean = true,
|
||||
includeGuilds: boolean = true,
|
||||
limit: number = 25,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
const fetchLimit = Math.max(limit * 2, 50);
|
||||
const query = createFetchRecentMentionsQuery(fetchLimit);
|
||||
const params: {user_id: UserID; before_message_id: MessageID} = {
|
||||
user_id: userId,
|
||||
before_message_id: before || createMessageID(generateSnowflake()),
|
||||
};
|
||||
const allMentions = await fetchMany<RecentMentionRow>(query, params);
|
||||
const filteredMentions = allMentions.filter((mention) => {
|
||||
if (!includeEveryone && mention.is_everyone) return false;
|
||||
if (!includeRole && mention.is_role) return false;
|
||||
if (!includeGuilds && mention.guild_id != null) return false;
|
||||
return true;
|
||||
});
|
||||
return filteredMentions.slice(0, limit).map((mention) => new RecentMention(mention));
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(RecentMentions.upsertAll(mention));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.insert({
|
||||
user_id: mention.user_id,
|
||||
guild_id: mention.guild_id,
|
||||
message_id: mention.message_id,
|
||||
channel_id: mention.channel_id,
|
||||
is_everyone: mention.is_everyone,
|
||||
is_role: mention.is_role,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
return new RecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
if (mentions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batch = new BatchBuilder();
|
||||
for (const mention of mentions) {
|
||||
batch.addPrepared(RecentMentions.upsertAll(mention));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.insert({
|
||||
user_id: mention.user_id,
|
||||
guild_id: mention.guild_id,
|
||||
message_id: mention.message_id,
|
||||
channel_id: mention.channel_id,
|
||||
is_everyone: mention.is_everyone,
|
||||
is_role: mention.is_role,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(RecentMentions.deleteByPk({user_id: mention.userId, message_id: mention.messageId}));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.deleteByPk({
|
||||
user_id: mention.userId,
|
||||
guild_id: mention.guildId,
|
||||
message_id: mention.messageId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
const mentions = await fetchMany<{guild_id: bigint; message_id: bigint}>(
|
||||
RecentMentions.selectCql({
|
||||
columns: ['guild_id', 'message_id'],
|
||||
where: RecentMentions.where.eq('user_id'),
|
||||
}),
|
||||
{
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(RecentMentions.delete({where: RecentMentions.where.eq('user_id')}).bind({user_id: userId}));
|
||||
|
||||
for (const mention of mentions) {
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.deleteByPk({
|
||||
guild_id: mention.guild_id,
|
||||
user_id: userId,
|
||||
message_id: mention.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createMessageID, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {SavedMessageRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
|
||||
import {SavedMessages} from '@fluxer/api/src/Tables';
|
||||
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
const createFetchSavedMessagesQuery = (limit: number) =>
|
||||
SavedMessages.selectCql({
|
||||
where: [SavedMessages.where.eq('user_id'), SavedMessages.where.lt('message_id', 'before_message_id')],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class SavedMessageRepository {
|
||||
async listSavedMessages(
|
||||
userId: UserID,
|
||||
limit: number = 25,
|
||||
before: MessageID = createMessageID(generateSnowflake()),
|
||||
): Promise<Array<SavedMessage>> {
|
||||
const fetchLimit = Math.max(limit * 2, 50);
|
||||
const savedMessageRows = await fetchMany<SavedMessageRow>(createFetchSavedMessagesQuery(fetchLimit), {
|
||||
user_id: userId,
|
||||
before_message_id: before,
|
||||
});
|
||||
const savedMessages: Array<SavedMessage> = [];
|
||||
for (const savedMessageRow of savedMessageRows) {
|
||||
if (savedMessages.length >= limit) break;
|
||||
savedMessages.push(new SavedMessage(savedMessageRow));
|
||||
}
|
||||
return savedMessages;
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
const savedMessageRow: SavedMessageRow = {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
saved_at: new Date(),
|
||||
};
|
||||
await upsertOne(SavedMessages.upsertAll(savedMessageRow));
|
||||
return new SavedMessage(savedMessageRow);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
await deleteOneOrMany(SavedMessages.deleteByPk({user_id: userId, message_id: messageId}));
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(SavedMessages.delete({where: SavedMessages.where.eq('user_id')}).bind({user_id: userId}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ScheduledMessageRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {ScheduledMessage} from '@fluxer/api/src/models/ScheduledMessage';
|
||||
import {ScheduledMessages} from '@fluxer/api/src/Tables';
|
||||
|
||||
export class ScheduledMessageRepository {
|
||||
private readonly fetchCql = ScheduledMessages.selectCql({
|
||||
where: [ScheduledMessages.where.eq('user_id')],
|
||||
});
|
||||
|
||||
async listScheduledMessages(userId: UserID, limit: number = 25): Promise<Array<ScheduledMessage>> {
|
||||
const rows = await fetchMany<ScheduledMessageRow>(this.fetchCql, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const messages = rows.map((row) => ScheduledMessage.fromRow(row));
|
||||
return messages.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, limit);
|
||||
}
|
||||
|
||||
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
|
||||
const row = await fetchOne<ScheduledMessageRow>(
|
||||
ScheduledMessages.selectCql({
|
||||
where: [ScheduledMessages.where.eq('user_id'), ScheduledMessages.where.eq('scheduled_message_id')],
|
||||
}),
|
||||
{
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
},
|
||||
);
|
||||
return row ? ScheduledMessage.fromRow(row) : null;
|
||||
}
|
||||
|
||||
async upsertScheduledMessage(message: ScheduledMessage, _ttlSeconds: number): Promise<void> {
|
||||
await upsertOne(ScheduledMessages.upsertAll(message.toRow()));
|
||||
}
|
||||
|
||||
async deleteScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ScheduledMessages.deleteByPk({
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string, ttlSeconds: number): Promise<void> {
|
||||
await upsertOne(
|
||||
ScheduledMessages.patchByPkWithTtl(
|
||||
{
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
},
|
||||
{
|
||||
status: Db.set('invalid'),
|
||||
status_reason: Db.set(reason),
|
||||
invalidated_at: Db.set(new Date()),
|
||||
},
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
packages/api/src/user/repositories/UserAccountRepository.tsx
Normal file
147
packages/api/src/user/repositories/UserAccountRepository.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {UserAccountRepository as UserAccountCrudRepository} from '@fluxer/api/src/user/repositories/account/UserAccountRepository';
|
||||
import {UserDeletionRepository} from '@fluxer/api/src/user/repositories/account/UserDeletionRepository';
|
||||
import {UserGuildRepository} from '@fluxer/api/src/user/repositories/account/UserGuildRepository';
|
||||
import {UserLookupRepository} from '@fluxer/api/src/user/repositories/account/UserLookupRepository';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
|
||||
export class UserAccountRepository implements IUserAccountRepository {
|
||||
private accountRepo: UserAccountCrudRepository;
|
||||
private lookupRepo: UserLookupRepository;
|
||||
private deletionRepo: UserDeletionRepository;
|
||||
private guildRepo: UserGuildRepository;
|
||||
|
||||
constructor() {
|
||||
this.accountRepo = new UserAccountCrudRepository();
|
||||
this.lookupRepo = new UserLookupRepository(this.accountRepo.findUnique.bind(this.accountRepo));
|
||||
this.deletionRepo = new UserDeletionRepository(this.accountRepo.findUnique.bind(this.accountRepo));
|
||||
this.guildRepo = new UserGuildRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.accountRepo.create(data);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.accountRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.accountRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.accountRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.upsert(data, oldData);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.patchUpsert(userId, patchData, oldData);
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.deleteUserSecondaryIndices(userId);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByEmail(email);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByPhone(phone);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByStripeCustomerId(stripeCustomerId);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByStripeSubscriptionId(stripeSubscriptionId);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
return this.lookupRepo.findByUsernameDiscriminator(username, discriminator);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
return this.lookupRepo.findDiscriminatorsByUsername(username);
|
||||
}
|
||||
|
||||
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
|
||||
const result = await this.accountRepo.getActivityTracking(userId);
|
||||
return result ?? {last_active_at: null, last_active_ip: null};
|
||||
}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.deletionRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
return this.deletionRepo.findUsersPendingDeletion(now);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
return this.deletionRepo.findUsersPendingDeletionByDate(deletionDate);
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
return this.deletionRepo.isUserPendingDeletion(userId, deletionDate);
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
return this.deletionRepo.removePendingDeletion(userId, pendingDeletionAt);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.deletionRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
return this.guildRepo.getUserGuildIds(userId);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
return this.guildRepo.removeFromAllGuilds(userId);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
return this.accountRepo.updateLastActiveAt(params);
|
||||
}
|
||||
|
||||
async updateSubscriptionStatus(
|
||||
userId: UserID,
|
||||
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
|
||||
): Promise<{finalVersion: number | null}> {
|
||||
return this.accountRepo.updateSubscriptionStatus(userId, updates);
|
||||
}
|
||||
}
|
||||
227
packages/api/src/user/repositories/UserAuthRepository.tsx
Normal file
227
packages/api/src/user/repositories/UserAuthRepository.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
|
||||
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
|
||||
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
|
||||
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
|
||||
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
|
||||
import {AuthSessionRepository} from '@fluxer/api/src/user/repositories/auth/AuthSessionRepository';
|
||||
import {IpAuthorizationRepository} from '@fluxer/api/src/user/repositories/auth/IpAuthorizationRepository';
|
||||
import {MfaBackupCodeRepository} from '@fluxer/api/src/user/repositories/auth/MfaBackupCodeRepository';
|
||||
import {TokenRepository} from '@fluxer/api/src/user/repositories/auth/TokenRepository';
|
||||
import {WebAuthnRepository} from '@fluxer/api/src/user/repositories/auth/WebAuthnRepository';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
|
||||
|
||||
export class UserAuthRepository implements IUserAuthRepository {
|
||||
private authSessionRepository: AuthSessionRepository;
|
||||
private mfaBackupCodeRepository: MfaBackupCodeRepository;
|
||||
private tokenRepository: TokenRepository;
|
||||
private ipAuthorizationRepository: IpAuthorizationRepository;
|
||||
private webAuthnRepository: WebAuthnRepository;
|
||||
|
||||
constructor(userAccountRepository: IUserAccountRepository) {
|
||||
this.authSessionRepository = new AuthSessionRepository();
|
||||
this.mfaBackupCodeRepository = new MfaBackupCodeRepository();
|
||||
this.tokenRepository = new TokenRepository();
|
||||
this.ipAuthorizationRepository = new IpAuthorizationRepository(userAccountRepository);
|
||||
this.webAuthnRepository = new WebAuthnRepository();
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
return this.authSessionRepository.listAuthSessions(userId);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
return this.authSessionRepository.getAuthSessionByToken(sessionIdHash);
|
||||
}
|
||||
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
return this.authSessionRepository.createAuthSession(sessionData);
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
const session = await this.getAuthSessionByToken(sessionIdHash);
|
||||
if (!session) return;
|
||||
await this.authSessionRepository.updateAuthSessionLastUsed(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
return this.authSessionRepository.deleteAuthSessions(userId, sessionIdHashes);
|
||||
}
|
||||
|
||||
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
|
||||
const session = await this.getAuthSessionByToken(sessionIdHash);
|
||||
if (!session) return;
|
||||
await this.deleteAuthSessions(session.userId, [sessionIdHash]);
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
return this.authSessionRepository.deleteAllAuthSessions(userId);
|
||||
}
|
||||
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
return this.mfaBackupCodeRepository.listMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
return this.mfaBackupCodeRepository.createMfaBackupCodes(userId, codes);
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.clearMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.consumeMfaBackupCode(userId, code);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.deleteAllMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
return this.tokenRepository.getEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
return this.tokenRepository.createEmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deleteEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
return this.tokenRepository.getPasswordResetToken(token);
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
return this.tokenRepository.createPasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deletePasswordResetToken(token);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
return this.tokenRepository.getEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
return this.tokenRepository.createEmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deleteEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.tokenRepository.createPhoneToken(token, phone, userId);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return this.tokenRepository.getPhoneToken(token);
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
return this.tokenRepository.deletePhoneToken(token);
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
return this.ipAuthorizationRepository.checkIpAuthorized(userId, ip);
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.createAuthorizedIp(userId, ip);
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.createIpAuthorizationToken(userId, token, email);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.ipAuthorizationRepository.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
return this.ipAuthorizationRepository.getAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
return this.ipAuthorizationRepository.deleteAllAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
return this.webAuthnRepository.listWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
return this.webAuthnRepository.getWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialCounter(userId, credentialId, counter);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
return this.webAuthnRepository.getUserIdByCredentialId(credentialId);
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
return this.webAuthnRepository.deleteAllWebAuthnCredentials(userId);
|
||||
}
|
||||
}
|
||||
536
packages/api/src/user/repositories/UserChannelRepository.tsx
Normal file
536
packages/api/src/user/repositories/UserChannelRepository.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
deleteOneOrMany,
|
||||
fetchMany,
|
||||
fetchManyInChunks,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ChannelRow, DmStateRow, PrivateChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import {Channels, DmStates, PinnedDms, PrivateChannels, ReadStates, UserDmHistory} from '@fluxer/api/src/Tables';
|
||||
import type {
|
||||
HistoricalDmChannelSummary,
|
||||
IUserChannelRepository,
|
||||
ListHistoricalDmChannelOptions,
|
||||
PrivateChannelSummary,
|
||||
} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
|
||||
interface PinnedDmRow {
|
||||
user_id: UserID;
|
||||
channel_id: ChannelID;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ChannelDetailsRow {
|
||||
channel_id: ChannelID;
|
||||
type: number;
|
||||
recipient_ids: Set<UserID> | null;
|
||||
last_message_id: MessageID | null;
|
||||
soft_deleted: boolean;
|
||||
}
|
||||
|
||||
const CHECK_PRIVATE_CHANNEL_CQL = PrivateChannels.selectCql({
|
||||
columns: ['channel_id'],
|
||||
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.eq('channel_id')],
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_CQL = Channels.selectCql({
|
||||
columns: [
|
||||
'channel_id',
|
||||
'guild_id',
|
||||
'type',
|
||||
'name',
|
||||
'topic',
|
||||
'icon_hash',
|
||||
'url',
|
||||
'parent_id',
|
||||
'position',
|
||||
'owner_id',
|
||||
'recipient_ids',
|
||||
'nsfw',
|
||||
'rate_limit_per_user',
|
||||
'bitrate',
|
||||
'user_limit',
|
||||
'rtc_region',
|
||||
'last_message_id',
|
||||
'last_pin_timestamp',
|
||||
'permission_overwrites',
|
||||
'nicks',
|
||||
'soft_deleted',
|
||||
],
|
||||
where: [Channels.where.eq('channel_id'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_DM_STATE_CQL = DmStates.selectCql({
|
||||
where: [DmStates.where.eq('hi_user_id'), DmStates.where.eq('lo_user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PINNED_DMS_CQL = PinnedDms.selectCql({
|
||||
where: PinnedDms.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_PRIVATE_CHANNELS_CQL = PrivateChannels.selectCql({
|
||||
where: PrivateChannels.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_OPEN_PRIVATE_CHANNELS_BY_IDS_CQL = PrivateChannels.selectCql({
|
||||
columns: ['channel_id'],
|
||||
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.in('channel_id', 'channel_ids')],
|
||||
});
|
||||
|
||||
const HISTORICAL_DM_CHANNELS_CQL = UserDmHistory.selectCql({
|
||||
columns: ['channel_id'],
|
||||
where: UserDmHistory.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_METADATA_CQL = Channels.selectCql({
|
||||
columns: ['channel_id', 'type', 'last_message_id', 'soft_deleted'],
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_DETAILS_CQL = Channels.selectCql({
|
||||
columns: ['channel_id', 'type', 'recipient_ids', 'last_message_id', 'soft_deleted'],
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
});
|
||||
|
||||
const FETCH_CHANNELS_IN_CQL = Channels.selectCql({
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
});
|
||||
|
||||
function sortBySortOrder(a: PinnedDmRow, b: PinnedDmRow): number {
|
||||
return a.sort_order - b.sort_order;
|
||||
}
|
||||
|
||||
async function fetchPinnedDms(userId: UserID): Promise<Array<PinnedDmRow>> {
|
||||
return fetchMany<PinnedDmRow>(FETCH_PINNED_DMS_CQL, {user_id: userId});
|
||||
}
|
||||
|
||||
export class UserChannelRepository implements IUserChannelRepository {
|
||||
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
const pinnedDms = [...(await fetchPinnedDms(userId))];
|
||||
|
||||
const existingDm = pinnedDms.find((dm) => dm.channel_id === channelId);
|
||||
if (existingDm) {
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
let highestSortOrder = -1;
|
||||
for (const dm of pinnedDms) {
|
||||
if (dm.sort_order > highestSortOrder) {
|
||||
highestSortOrder = dm.sort_order;
|
||||
}
|
||||
}
|
||||
|
||||
const newSortOrder = highestSortOrder + 1;
|
||||
|
||||
await upsertOne(
|
||||
PinnedDms.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
sort_order: newSortOrder,
|
||||
}),
|
||||
);
|
||||
|
||||
pinnedDms.push({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
sort_order: newSortOrder,
|
||||
});
|
||||
|
||||
pinnedDms.sort(sortBySortOrder);
|
||||
return pinnedDms.map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PrivateChannels.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
|
||||
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
|
||||
const loUserId = user1Id > user2Id ? user2Id : user1Id;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const channelRow: ChannelRow = {
|
||||
channel_id: channelId,
|
||||
guild_id: null,
|
||||
type: ChannelTypes.DM,
|
||||
name: null,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: null,
|
||||
owner_id: null,
|
||||
recipient_ids: new Set([user1Id, user2Id]),
|
||||
nsfw: null,
|
||||
rate_limit_per_user: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
batch.addPrepared(Channels.upsertAll(channelRow));
|
||||
batch.addPrepared(
|
||||
DmStates.upsertAll({
|
||||
hi_user_id: hiUserId,
|
||||
lo_user_id: loUserId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
PrivateChannels.upsertAll({
|
||||
user_id: user1Id,
|
||||
channel_id: channelId,
|
||||
is_gdm: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
|
||||
return new Channel(channelRow);
|
||||
}
|
||||
|
||||
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PrivateChannels.deleteCql({
|
||||
where: PrivateChannels.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllReadStates(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ReadStates.deleteCql({
|
||||
where: ReadStates.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
|
||||
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
|
||||
const loUserId = user1Id > user2Id ? user2Id : user1Id;
|
||||
|
||||
const dmState = await fetchOne<DmStateRow>(FETCH_DM_STATE_CQL, {
|
||||
hi_user_id: hiUserId,
|
||||
lo_user_id: loUserId,
|
||||
});
|
||||
|
||||
if (!dmState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
|
||||
channel_id: dmState.channel_id,
|
||||
soft_deleted: false,
|
||||
});
|
||||
|
||||
return channel ? new Channel(channel) : null;
|
||||
}
|
||||
|
||||
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder);
|
||||
}
|
||||
|
||||
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
|
||||
const result = await fetchOne<{channel_id: bigint}>(CHECK_PRIVATE_CHANNEL_CQL, {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
|
||||
return result != null;
|
||||
}
|
||||
|
||||
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channelIds = rows.map((row) => row.channel_id);
|
||||
const channelRows = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_IN_CQL, channelIds, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: false,
|
||||
}));
|
||||
|
||||
return channelRows.map((row) => new Channel(row));
|
||||
}
|
||||
|
||||
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
|
||||
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channelIds = rows.map((row) => row.channel_id);
|
||||
const fetchMetadataForSoftDeleted = async (
|
||||
ids: Array<ChannelID>,
|
||||
softDeleted: boolean,
|
||||
): Promise<
|
||||
Array<{
|
||||
channel_id: ChannelID;
|
||||
type: number;
|
||||
last_message_id: MessageID | null;
|
||||
soft_deleted: boolean;
|
||||
}>
|
||||
> => {
|
||||
return fetchManyInChunks(FETCH_CHANNEL_METADATA_CQL, ids, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: softDeleted,
|
||||
}));
|
||||
};
|
||||
|
||||
const channelMap = new Map<
|
||||
ChannelID,
|
||||
{
|
||||
channel_id: ChannelID;
|
||||
type: number;
|
||||
last_message_id: MessageID | null;
|
||||
soft_deleted: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const openChannelRows = await fetchMetadataForSoftDeleted(channelIds, false);
|
||||
for (const row of openChannelRows) {
|
||||
channelMap.set(row.channel_id, row);
|
||||
}
|
||||
|
||||
const missingChannelIds = channelIds.filter((id) => !channelMap.has(id));
|
||||
if (missingChannelIds.length > 0) {
|
||||
const deletedChannelRows = await fetchMetadataForSoftDeleted(missingChannelIds, true);
|
||||
for (const row of deletedChannelRows) {
|
||||
if (!channelMap.has(row.channel_id)) {
|
||||
channelMap.set(row.channel_id, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const channelRow = channelMap.get(row.channel_id);
|
||||
return {
|
||||
channelId: row.channel_id,
|
||||
isGroupDm: row.is_gdm ?? false,
|
||||
channelType: channelRow ? channelRow.type : null,
|
||||
lastMessageId: channelRow ? channelRow.last_message_id : null,
|
||||
open: Boolean(channelRow && !channelRow.soft_deleted),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>> {
|
||||
const rows = await fetchMany<{channel_id: ChannelID}>(HISTORICAL_DM_CHANNELS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.map((row) => row.channel_id);
|
||||
}
|
||||
|
||||
async listHistoricalDmChannelsPaginated(
|
||||
userId: UserID,
|
||||
options: ListHistoricalDmChannelOptions,
|
||||
): Promise<Array<HistoricalDmChannelSummary>> {
|
||||
if (options.beforeChannelId !== undefined && options.afterChannelId !== undefined) {
|
||||
throw new Error('Cannot paginate with both beforeChannelId and afterChannelId');
|
||||
}
|
||||
|
||||
let rows: Array<{channel_id: ChannelID}>;
|
||||
if (options.afterChannelId !== undefined) {
|
||||
const query = UserDmHistory.select({
|
||||
columns: ['channel_id'],
|
||||
where: [UserDmHistory.where.eq('user_id'), UserDmHistory.where.gt('channel_id', 'after_channel_id')],
|
||||
orderBy: {col: 'channel_id', direction: 'ASC'},
|
||||
limit: options.limit,
|
||||
});
|
||||
rows = await fetchMany<{channel_id: ChannelID}>(
|
||||
query.bind({
|
||||
user_id: userId,
|
||||
after_channel_id: options.afterChannelId,
|
||||
}),
|
||||
);
|
||||
rows.reverse();
|
||||
} else if (options.beforeChannelId !== undefined) {
|
||||
const query = UserDmHistory.select({
|
||||
columns: ['channel_id'],
|
||||
where: [UserDmHistory.where.eq('user_id'), UserDmHistory.where.lt('channel_id', 'before_channel_id')],
|
||||
orderBy: {col: 'channel_id', direction: 'DESC'},
|
||||
limit: options.limit,
|
||||
});
|
||||
rows = await fetchMany<{channel_id: ChannelID}>(
|
||||
query.bind({
|
||||
user_id: userId,
|
||||
before_channel_id: options.beforeChannelId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const query = UserDmHistory.select({
|
||||
columns: ['channel_id'],
|
||||
where: UserDmHistory.where.eq('user_id'),
|
||||
orderBy: {col: 'channel_id', direction: 'DESC'},
|
||||
limit: options.limit,
|
||||
});
|
||||
rows = await fetchMany<{channel_id: ChannelID}>(
|
||||
query.bind({
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const channelIds = rows.map((row) => row.channel_id);
|
||||
if (channelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const openChannelRows = await fetchManyInChunks<{channel_id: ChannelID}>(
|
||||
FETCH_OPEN_PRIVATE_CHANNELS_BY_IDS_CQL,
|
||||
channelIds,
|
||||
(chunk) => ({
|
||||
user_id: userId,
|
||||
channel_ids: chunk,
|
||||
}),
|
||||
);
|
||||
const openChannelIds = new Set(openChannelRows.map((row) => row.channel_id));
|
||||
|
||||
const fetchChannelDetails = async (
|
||||
ids: Array<ChannelID>,
|
||||
softDeleted: boolean,
|
||||
): Promise<Array<ChannelDetailsRow>> => {
|
||||
return fetchManyInChunks<ChannelDetailsRow>(FETCH_CHANNEL_DETAILS_CQL, ids, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: softDeleted,
|
||||
}));
|
||||
};
|
||||
|
||||
const channelMap = new Map<ChannelID, ChannelDetailsRow>();
|
||||
const nonDeletedChannels = await fetchChannelDetails(channelIds, false);
|
||||
for (const channel of nonDeletedChannels) {
|
||||
channelMap.set(channel.channel_id, channel);
|
||||
}
|
||||
|
||||
const missingChannelIds = channelIds.filter((channelId) => !channelMap.has(channelId));
|
||||
if (missingChannelIds.length > 0) {
|
||||
const deletedChannels = await fetchChannelDetails(missingChannelIds, true);
|
||||
for (const channel of deletedChannels) {
|
||||
if (!channelMap.has(channel.channel_id)) {
|
||||
channelMap.set(channel.channel_id, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return channelIds.map((channelId) => {
|
||||
const channel = channelMap.get(channelId);
|
||||
return {
|
||||
channelId,
|
||||
channelType: channel?.type ?? null,
|
||||
recipientIds: channel?.recipient_ids ? Array.from(channel.recipient_ids) : [],
|
||||
lastMessageId: channel?.last_message_id ?? null,
|
||||
open: openChannelIds.has(channelId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
|
||||
let resolvedIsGroupDm: boolean;
|
||||
if (isGroupDm !== undefined) {
|
||||
resolvedIsGroupDm = isGroupDm;
|
||||
} else {
|
||||
const channelRow = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
|
||||
channel_id: channelId,
|
||||
soft_deleted: false,
|
||||
});
|
||||
resolvedIsGroupDm = channelRow?.type === ChannelTypes.GROUP_DM;
|
||||
}
|
||||
|
||||
await this.recordHistoricalDmChannel(userId, channelId, resolvedIsGroupDm);
|
||||
|
||||
await upsertOne(
|
||||
PrivateChannels.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
is_gdm: resolvedIsGroupDm,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void> {
|
||||
if (isGroupDm) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
UserDmHistory.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
await deleteOneOrMany(
|
||||
PinnedDms.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PinnedDms.deleteCql({
|
||||
where: PinnedDms.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {UserContactChangeLogRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {UserContactChangeLogs} from '@fluxer/api/src/Tables';
|
||||
|
||||
const createListLogsCql = (limit: number, includeCursor: boolean) =>
|
||||
UserContactChangeLogs.selectCql({
|
||||
where: includeCursor
|
||||
? [UserContactChangeLogs.where.eq('user_id'), UserContactChangeLogs.where.lt('event_id', 'before_event_id')]
|
||||
: UserContactChangeLogs.where.eq('user_id'),
|
||||
orderBy: {col: 'event_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
|
||||
export interface ContactChangeLogListParams {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
beforeEventId?: string;
|
||||
}
|
||||
|
||||
export interface ContactChangeLogInsertParams {
|
||||
userId: UserID;
|
||||
field: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
reason: string;
|
||||
actorUserId: UserID | null;
|
||||
eventAt?: Date;
|
||||
}
|
||||
|
||||
export class UserContactChangeLogRepository {
|
||||
async insertLog(params: ContactChangeLogInsertParams): Promise<void> {
|
||||
const eventAt = params.eventAt ?? new Date();
|
||||
await upsertOne(
|
||||
UserContactChangeLogs.insertWithNow(
|
||||
{
|
||||
user_id: params.userId,
|
||||
field: params.field,
|
||||
old_value: params.oldValue,
|
||||
new_value: params.newValue,
|
||||
reason: params.reason,
|
||||
actor_user_id: params.actorUserId,
|
||||
event_at: eventAt,
|
||||
},
|
||||
'event_id',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listLogs(params: ContactChangeLogListParams): Promise<Array<UserContactChangeLogRow>> {
|
||||
const {userId, limit, beforeEventId} = params;
|
||||
const query = createListLogsCql(limit, !!beforeEventId);
|
||||
const queryParams: {user_id: UserID; before_event_id?: string} = {
|
||||
user_id: userId,
|
||||
};
|
||||
if (beforeEventId) {
|
||||
queryParams.before_event_id = beforeEventId;
|
||||
}
|
||||
const rows = await fetchMany<
|
||||
Omit<UserContactChangeLogRow, 'user_id' | 'actor_user_id'> & {
|
||||
user_id: bigint;
|
||||
actor_user_id: bigint | null;
|
||||
}
|
||||
>(query, queryParams);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
user_id: createUserID(row.user_id),
|
||||
actor_user_id: row.actor_user_id != null ? createUserID(row.actor_user_id) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
203
packages/api/src/user/repositories/UserContentRepository.tsx
Normal file
203
packages/api/src/user/repositories/UserContentRepository.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
|
||||
import type {PushSubscriptionRow, RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
|
||||
import type {Payment} from '@fluxer/api/src/models/Payment';
|
||||
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
|
||||
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
|
||||
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
|
||||
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
|
||||
import {GiftCodeRepository} from '@fluxer/api/src/user/repositories/GiftCodeRepository';
|
||||
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
|
||||
import {PaymentRepository} from '@fluxer/api/src/user/repositories/PaymentRepository';
|
||||
import {PushSubscriptionRepository} from '@fluxer/api/src/user/repositories/PushSubscriptionRepository';
|
||||
import {RecentMentionRepository} from '@fluxer/api/src/user/repositories/RecentMentionRepository';
|
||||
import {SavedMessageRepository} from '@fluxer/api/src/user/repositories/SavedMessageRepository';
|
||||
import {VisionarySlotRepository} from '@fluxer/api/src/user/repositories/VisionarySlotRepository';
|
||||
|
||||
export class UserContentRepository implements IUserContentRepository {
|
||||
private giftCodeRepository: GiftCodeRepository;
|
||||
private paymentRepository: PaymentRepository;
|
||||
private pushSubscriptionRepository: PushSubscriptionRepository;
|
||||
private recentMentionRepository: RecentMentionRepository;
|
||||
private savedMessageRepository: SavedMessageRepository;
|
||||
private visionarySlotRepository: VisionarySlotRepository;
|
||||
|
||||
constructor() {
|
||||
this.giftCodeRepository = new GiftCodeRepository();
|
||||
this.paymentRepository = new PaymentRepository();
|
||||
this.pushSubscriptionRepository = new PushSubscriptionRepository();
|
||||
this.recentMentionRepository = new RecentMentionRepository();
|
||||
this.savedMessageRepository = new SavedMessageRepository();
|
||||
this.visionarySlotRepository = new VisionarySlotRepository();
|
||||
}
|
||||
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
return this.giftCodeRepository.createGiftCode(data);
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
return this.giftCodeRepository.findGiftCode(code);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
return this.giftCodeRepository.findGiftCodeByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
return this.giftCodeRepository.findGiftCodesByCreator(userId);
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
return this.giftCodeRepository.redeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.giftCodeRepository.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
return this.giftCodeRepository.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
|
||||
}
|
||||
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
return this.paymentRepository.createPayment(data);
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
return this.paymentRepository.updatePayment(data);
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
return this.paymentRepository.getSubscriptionInfo(subscriptionId);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return this.pushSubscriptionRepository.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
return this.pushSubscriptionRepository.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return this.pushSubscriptionRepository.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
return this.pushSubscriptionRepository.getBulkPushSubscriptions(userIds);
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
return this.pushSubscriptionRepository.deleteAllPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
return this.recentMentionRepository.getRecentMention(userId, messageId);
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean = true,
|
||||
includeRole: boolean = true,
|
||||
includeGuilds: boolean = true,
|
||||
limit: number = 25,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
return this.recentMentionRepository.listRecentMentions(
|
||||
userId,
|
||||
includeEveryone,
|
||||
includeRole,
|
||||
includeGuilds,
|
||||
limit,
|
||||
before,
|
||||
);
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
return this.recentMentionRepository.createRecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
return this.recentMentionRepository.createRecentMentions(mentions);
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
return this.recentMentionRepository.deleteRecentMention(mention);
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
return this.recentMentionRepository.deleteAllRecentMentions(userId);
|
||||
}
|
||||
|
||||
async listSavedMessages(userId: UserID, limit: number = 25, before?: MessageID): Promise<Array<SavedMessage>> {
|
||||
return this.savedMessageRepository.listSavedMessages(userId, limit, before);
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
return this.savedMessageRepository.createSavedMessage(userId, channelId, messageId);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
return this.savedMessageRepository.deleteSavedMessage(userId, messageId);
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
return this.savedMessageRepository.deleteAllSavedMessages(userId);
|
||||
}
|
||||
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
return this.visionarySlotRepository.listVisionarySlots();
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
return this.visionarySlotRepository.expandVisionarySlots(byCount);
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
return this.visionarySlotRepository.shrinkVisionarySlots(toCount);
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.visionarySlotRepository.reserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.visionarySlotRepository.unreserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
Db,
|
||||
deleteOneOrMany,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
nextVersion,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {NoteRow, RelationshipRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {Relationship} from '@fluxer/api/src/models/Relationship';
|
||||
import {UserNote} from '@fluxer/api/src/models/UserNote';
|
||||
import {Notes, Relationships, RelationshipsByTarget} from '@fluxer/api/src/Tables';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
|
||||
const FETCH_ALL_NOTES_CQL = Notes.selectCql({
|
||||
where: Notes.where.eq('source_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_NOTE_CQL = Notes.selectCql({
|
||||
where: [Notes.where.eq('source_user_id'), Notes.where.eq('target_user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIPS_CQL = Relationships.selectCql({
|
||||
where: Relationships.where.eq('source_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIP_CQL = Relationships.selectCql({
|
||||
where: [
|
||||
Relationships.where.eq('source_user_id'),
|
||||
Relationships.where.eq('target_user_id'),
|
||||
Relationships.where.eq('type'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_ALL_NOTES_FOR_DELETE_QUERY = Notes.selectCql({
|
||||
columns: ['source_user_id', 'target_user_id'],
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
export class UserRelationshipRepository implements IUserRelationshipRepository {
|
||||
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Notes.deleteByPk({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllNotes(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Notes.deleteCql({
|
||||
where: Notes.where.eq('source_user_id', 'user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
const allNotes = await fetchMany<{source_user_id: bigint; target_user_id: bigint}>(
|
||||
FETCH_ALL_NOTES_FOR_DELETE_QUERY,
|
||||
{},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const note of allNotes) {
|
||||
if (note.target_user_id === BigInt(userId)) {
|
||||
batch.addPrepared(
|
||||
Notes.deleteByPk({
|
||||
source_user_id: createUserID(note.source_user_id),
|
||||
target_user_id: createUserID(note.target_user_id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllRelationships(userId: UserID): Promise<void> {
|
||||
const FETCH_RELATIONSHIPS_BY_TARGET_CQL = RelationshipsByTarget.selectCql({
|
||||
where: RelationshipsByTarget.where.eq('target_user_id'),
|
||||
});
|
||||
|
||||
const relationshipsPointingToUser = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_BY_TARGET_CQL, {
|
||||
target_user_id: userId,
|
||||
});
|
||||
|
||||
if (relationshipsPointingToUser.length > 0) {
|
||||
const batch = new BatchBuilder();
|
||||
for (const rel of relationshipsPointingToUser) {
|
||||
batch.addPrepared(
|
||||
Relationships.deleteByPk({
|
||||
source_user_id: rel.source_user_id,
|
||||
target_user_id: rel.target_user_id,
|
||||
type: rel.type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
await deleteOneOrMany(
|
||||
RelationshipsByTarget.deleteCql({
|
||||
where: RelationshipsByTarget.where.eq('target_user_id', 'user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
await deleteOneOrMany(
|
||||
Relationships.deleteCql({
|
||||
where: Relationships.where.eq('source_user_id', 'user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
|
||||
await Promise.all([
|
||||
deleteOneOrMany(
|
||||
Relationships.deleteByPk({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
type,
|
||||
}),
|
||||
),
|
||||
deleteOneOrMany(
|
||||
RelationshipsByTarget.deleteByPk({
|
||||
target_user_id: targetUserId,
|
||||
source_user_id: sourceUserId,
|
||||
type,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
|
||||
const relationship = await fetchOne<RelationshipRow>(FETCH_RELATIONSHIP_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
type,
|
||||
});
|
||||
return relationship ? new Relationship(relationship) : null;
|
||||
}
|
||||
|
||||
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
|
||||
const note = await fetchOne<NoteRow>(FETCH_NOTE_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
});
|
||||
return note ? new UserNote(note) : null;
|
||||
}
|
||||
|
||||
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
|
||||
const notes = await fetchMany<NoteRow>(FETCH_ALL_NOTES_CQL, {source_user_id: sourceUserId});
|
||||
const noteMap = new Map<UserID, string>();
|
||||
for (const note of notes) {
|
||||
noteMap.set(note.target_user_id, note.note);
|
||||
}
|
||||
return noteMap;
|
||||
}
|
||||
|
||||
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
|
||||
const relationships = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
});
|
||||
return relationships.map((rel) => new Relationship(rel));
|
||||
}
|
||||
|
||||
async hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean> {
|
||||
const relationships = await fetchMany<RelationshipRow>(
|
||||
Relationships.selectCql({
|
||||
where: Relationships.where.eq('source_user_id'),
|
||||
limit: limit + 1,
|
||||
}),
|
||||
{source_user_id: sourceUserId},
|
||||
);
|
||||
return relationships.length >= limit;
|
||||
}
|
||||
|
||||
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
|
||||
const result = await executeVersionedUpdate<RelationshipRow, 'source_user_id' | 'target_user_id' | 'type'>(
|
||||
() =>
|
||||
fetchOne(FETCH_RELATIONSHIP_CQL, {
|
||||
source_user_id: relationship.source_user_id,
|
||||
target_user_id: relationship.target_user_id,
|
||||
type: relationship.type,
|
||||
}),
|
||||
(current) => ({
|
||||
pk: {
|
||||
source_user_id: relationship.source_user_id,
|
||||
target_user_id: relationship.target_user_id,
|
||||
type: relationship.type,
|
||||
},
|
||||
patch: {
|
||||
nickname: Db.set(relationship.nickname),
|
||||
since: Db.set(relationship.since),
|
||||
version: Db.set(nextVersion(current?.version)),
|
||||
},
|
||||
}),
|
||||
Relationships,
|
||||
);
|
||||
|
||||
const finalRelationship: RelationshipRow = {
|
||||
...relationship,
|
||||
version: result.finalVersion ?? 1,
|
||||
};
|
||||
|
||||
await executeVersionedUpdate<RelationshipRow, 'target_user_id' | 'source_user_id' | 'type'>(
|
||||
() =>
|
||||
fetchOne(
|
||||
RelationshipsByTarget.selectCql({
|
||||
where: [
|
||||
RelationshipsByTarget.where.eq('target_user_id'),
|
||||
RelationshipsByTarget.where.eq('source_user_id'),
|
||||
RelationshipsByTarget.where.eq('type'),
|
||||
],
|
||||
limit: 1,
|
||||
}),
|
||||
{
|
||||
target_user_id: relationship.target_user_id,
|
||||
source_user_id: relationship.source_user_id,
|
||||
type: relationship.type,
|
||||
},
|
||||
),
|
||||
(current) => ({
|
||||
pk: {
|
||||
target_user_id: relationship.target_user_id,
|
||||
source_user_id: relationship.source_user_id,
|
||||
type: relationship.type,
|
||||
},
|
||||
patch: {
|
||||
nickname: Db.set(relationship.nickname),
|
||||
since: Db.set(relationship.since),
|
||||
version: Db.set(nextVersion(current?.version)),
|
||||
},
|
||||
}),
|
||||
RelationshipsByTarget,
|
||||
);
|
||||
|
||||
return new Relationship(finalRelationship);
|
||||
}
|
||||
|
||||
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
|
||||
const result = await executeVersionedUpdate<NoteRow, 'source_user_id' | 'target_user_id'>(
|
||||
() => fetchOne(FETCH_NOTE_CQL, {source_user_id: sourceUserId, target_user_id: targetUserId}),
|
||||
(current) => ({
|
||||
pk: {source_user_id: sourceUserId, target_user_id: targetUserId},
|
||||
patch: {note: Db.set(note), version: Db.set(nextVersion(current?.version))},
|
||||
}),
|
||||
Notes,
|
||||
);
|
||||
return new UserNote({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
note,
|
||||
version: result.finalVersion ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
async backfillRelationshipsIndex(_userId: UserID, relationships: Array<Relationship>): Promise<void> {
|
||||
if (relationships.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const rel of relationships) {
|
||||
const row = rel.toRow();
|
||||
batch.addPrepared(
|
||||
RelationshipsByTarget.upsertAll({
|
||||
target_user_id: row.target_user_id,
|
||||
source_user_id: row.source_user_id,
|
||||
type: row.type,
|
||||
nickname: row.nickname,
|
||||
since: row.since,
|
||||
version: row.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
658
packages/api/src/user/repositories/UserRepository.tsx
Normal file
658
packages/api/src/user/repositories/UserRepository.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID, PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
|
||||
import type {
|
||||
PushSubscriptionRow,
|
||||
RecentMentionRow,
|
||||
RelationshipRow,
|
||||
UserGuildSettingsRow,
|
||||
UserRow,
|
||||
UserSettingsRow,
|
||||
} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
|
||||
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
|
||||
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
|
||||
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
|
||||
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
|
||||
import type {Payment} from '@fluxer/api/src/models/Payment';
|
||||
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
|
||||
import type {ReadState} from '@fluxer/api/src/models/ReadState';
|
||||
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
|
||||
import type {Relationship} from '@fluxer/api/src/models/Relationship';
|
||||
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserNote} from '@fluxer/api/src/models/UserNote';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
|
||||
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
|
||||
import {ReadStateRepository} from '@fluxer/api/src/read_state/ReadStateRepository';
|
||||
import type {
|
||||
HistoricalDmChannelSummary,
|
||||
ListHistoricalDmChannelOptions,
|
||||
PrivateChannelSummary,
|
||||
} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
|
||||
import type {IUserRepositoryAggregate} from '@fluxer/api/src/user/repositories/IUserRepositoryAggregate';
|
||||
import {UserAccountRepository} from '@fluxer/api/src/user/repositories/UserAccountRepository';
|
||||
import {UserAuthRepository} from '@fluxer/api/src/user/repositories/UserAuthRepository';
|
||||
import {UserChannelRepository} from '@fluxer/api/src/user/repositories/UserChannelRepository';
|
||||
import {UserContentRepository} from '@fluxer/api/src/user/repositories/UserContentRepository';
|
||||
import {UserRelationshipRepository} from '@fluxer/api/src/user/repositories/UserRelationshipRepository';
|
||||
import {UserSettingsRepository} from '@fluxer/api/src/user/repositories/UserSettingsRepository';
|
||||
|
||||
export class UserRepository implements IUserRepositoryAggregate {
|
||||
private accountRepo: UserAccountRepository;
|
||||
private settingsRepo: UserSettingsRepository;
|
||||
private authRepo: UserAuthRepository;
|
||||
private relationshipRepo: UserRelationshipRepository;
|
||||
private channelRepo: UserChannelRepository;
|
||||
private contentRepo: UserContentRepository;
|
||||
private readStateRepo: ReadStateRepository;
|
||||
|
||||
constructor() {
|
||||
this.accountRepo = new UserAccountRepository();
|
||||
this.settingsRepo = new UserSettingsRepository();
|
||||
this.authRepo = new UserAuthRepository(this.accountRepo);
|
||||
this.relationshipRepo = new UserRelationshipRepository();
|
||||
this.channelRepo = new UserChannelRepository();
|
||||
this.contentRepo = new UserContentRepository();
|
||||
this.readStateRepo = new ReadStateRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.accountRepo.create(data);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.upsert(data, oldData);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.patchUpsert(userId, patchData, oldData);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.accountRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.accountRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
return this.accountRepo.findByUsernameDiscriminator(username, discriminator);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
return this.accountRepo.findDiscriminatorsByUsername(username);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.accountRepo.findByEmail(email);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
return this.accountRepo.findByPhone(phone);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
return this.accountRepo.findByStripeSubscriptionId(stripeSubscriptionId);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
return this.accountRepo.findByStripeCustomerId(stripeCustomerId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.accountRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
return this.accountRepo.getUserGuildIds(userId);
|
||||
}
|
||||
|
||||
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
|
||||
return this.accountRepo.getActivityTracking(userId);
|
||||
}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.accountRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
return this.accountRepo.removePendingDeletion(userId, pendingDeletionAt);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
return this.accountRepo.findUsersPendingDeletion(now);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
return this.accountRepo.findUsersPendingDeletionByDate(deletionDate);
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
return this.accountRepo.isUserPendingDeletion(userId, deletionDate);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.accountRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.deleteUserSecondaryIndices(userId);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.removeFromAllGuilds(userId);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
return this.accountRepo.updateLastActiveAt(params);
|
||||
}
|
||||
|
||||
async updateSubscriptionStatus(
|
||||
userId: UserID,
|
||||
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
|
||||
): Promise<{finalVersion: number | null}> {
|
||||
return this.accountRepo.updateSubscriptionStatus(userId, updates);
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings | null> {
|
||||
return this.settingsRepo.findSettings(userId);
|
||||
}
|
||||
|
||||
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
|
||||
return this.settingsRepo.upsertSettings(settings);
|
||||
}
|
||||
|
||||
async deleteUserSettings(userId: UserID): Promise<void> {
|
||||
return this.settingsRepo.deleteUserSettings(userId);
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return this.settingsRepo.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
|
||||
return this.settingsRepo.findAllGuildSettings(userId);
|
||||
}
|
||||
|
||||
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
|
||||
return this.settingsRepo.upsertGuildSettings(settings);
|
||||
}
|
||||
|
||||
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
|
||||
return this.settingsRepo.deleteGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
|
||||
return this.settingsRepo.deleteAllUserGuildSettings(userId);
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
return this.authRepo.listAuthSessions(userId);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
return this.authRepo.getAuthSessionByToken(sessionIdHash);
|
||||
}
|
||||
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
return this.authRepo.createAuthSession(sessionData);
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
return this.authRepo.updateAuthSessionLastUsed(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
return this.authRepo.deleteAuthSessions(userId, sessionIdHashes);
|
||||
}
|
||||
|
||||
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
|
||||
return this.authRepo.revokeAuthSession(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllAuthSessions(userId);
|
||||
}
|
||||
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
return this.authRepo.listMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
return this.authRepo.createMfaBackupCodes(userId, codes);
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.authRepo.clearMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
return this.authRepo.consumeMfaBackupCode(userId, code);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
return this.authRepo.getEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
return this.authRepo.createEmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
return this.authRepo.deleteEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
return this.authRepo.getPasswordResetToken(token);
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
return this.authRepo.createPasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
return this.authRepo.deletePasswordResetToken(token);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
return this.authRepo.getEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
return this.authRepo.createEmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
return this.authRepo.deleteEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.authRepo.createPhoneToken(token, phone, userId);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return this.authRepo.getPhoneToken(token);
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
return this.authRepo.deletePhoneToken(token);
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
return this.authRepo.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
return this.authRepo.checkIpAuthorized(userId, ip);
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
return this.authRepo.createAuthorizedIp(userId, ip);
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void> {
|
||||
return this.authRepo.createIpAuthorizationToken(userId, token, email);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.authRepo.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
return this.authRepo.getAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
return this.authRepo.listWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
return this.authRepo.getWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialCounter(userId, credentialId, counter);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
return this.authRepo.getUserIdByCredentialId(credentialId);
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
|
||||
return this.relationshipRepo.listRelationships(sourceUserId);
|
||||
}
|
||||
|
||||
async hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean> {
|
||||
return this.relationshipRepo.hasReachedRelationshipLimit(sourceUserId, limit);
|
||||
}
|
||||
|
||||
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
|
||||
return this.relationshipRepo.getRelationship(sourceUserId, targetUserId, type);
|
||||
}
|
||||
|
||||
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
|
||||
return this.relationshipRepo.upsertRelationship(relationship);
|
||||
}
|
||||
|
||||
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
|
||||
return this.relationshipRepo.deleteRelationship(sourceUserId, targetUserId, type);
|
||||
}
|
||||
|
||||
async deleteAllRelationships(userId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.deleteAllRelationships(userId);
|
||||
}
|
||||
|
||||
async backfillRelationshipsIndex(userId: UserID, relationships: Array<Relationship>): Promise<void> {
|
||||
return this.relationshipRepo.backfillRelationshipsIndex(userId, relationships);
|
||||
}
|
||||
|
||||
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
|
||||
return this.relationshipRepo.getUserNote(sourceUserId, targetUserId);
|
||||
}
|
||||
|
||||
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
|
||||
return this.relationshipRepo.getUserNotes(sourceUserId);
|
||||
}
|
||||
|
||||
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
|
||||
return this.relationshipRepo.upsertUserNote(sourceUserId, targetUserId, note);
|
||||
}
|
||||
|
||||
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.clearUserNote(sourceUserId, targetUserId);
|
||||
}
|
||||
|
||||
async deleteAllNotes(userId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.deleteAllNotes(userId);
|
||||
}
|
||||
|
||||
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
return this.channelRepo.listPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.listHistoricalDmChannelIds(userId);
|
||||
}
|
||||
|
||||
async listHistoricalDmChannelsPaginated(
|
||||
userId: UserID,
|
||||
options: ListHistoricalDmChannelOptions,
|
||||
): Promise<Array<HistoricalDmChannelSummary>> {
|
||||
return this.channelRepo.listHistoricalDmChannelsPaginated(userId, options);
|
||||
}
|
||||
|
||||
async recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void> {
|
||||
return this.channelRepo.recordHistoricalDmChannel(userId, channelId, isGroupDm);
|
||||
}
|
||||
|
||||
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
|
||||
return this.channelRepo.listPrivateChannelSummaries(userId);
|
||||
}
|
||||
|
||||
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deleteAllPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
|
||||
return this.channelRepo.findExistingDmState(user1Id, user2Id);
|
||||
}
|
||||
|
||||
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
|
||||
return this.channelRepo.createDmChannelAndState(user1Id, user2Id, channelId);
|
||||
}
|
||||
|
||||
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
|
||||
return this.channelRepo.isDmChannelOpen(userId, channelId);
|
||||
}
|
||||
|
||||
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
|
||||
return this.channelRepo.openDmForUser(userId, channelId, isGroupDm);
|
||||
}
|
||||
|
||||
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
return this.channelRepo.closeDmForUser(userId, channelId);
|
||||
}
|
||||
|
||||
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.getPinnedDms(userId);
|
||||
}
|
||||
|
||||
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
|
||||
return this.channelRepo.getPinnedDmsWithDetails(userId);
|
||||
}
|
||||
|
||||
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.addPinnedDm(userId, channelId);
|
||||
}
|
||||
|
||||
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.removePinnedDm(userId, channelId);
|
||||
}
|
||||
|
||||
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deletePinnedDmsByUserId(userId);
|
||||
}
|
||||
|
||||
async deleteAllReadStates(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deleteAllReadStates(userId);
|
||||
}
|
||||
|
||||
async getReadStates(userId: UserID): Promise<Array<ReadState>> {
|
||||
return this.readStateRepo.listReadStates(userId);
|
||||
}
|
||||
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
return this.contentRepo.getRecentMention(userId, messageId);
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean,
|
||||
includeRole: boolean,
|
||||
includeGuilds: boolean,
|
||||
limit: number,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
return this.contentRepo.listRecentMentions(userId, includeEveryone, includeRole, includeGuilds, limit, before);
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
return this.contentRepo.createRecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
return this.contentRepo.createRecentMentions(mentions);
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
return this.contentRepo.deleteRecentMention(mention);
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllRecentMentions(userId);
|
||||
}
|
||||
|
||||
async listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>> {
|
||||
return this.contentRepo.listSavedMessages(userId, limit, before);
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
return this.contentRepo.createSavedMessage(userId, channelId, messageId);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
return this.contentRepo.deleteSavedMessage(userId, messageId);
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllSavedMessages(userId);
|
||||
}
|
||||
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
return this.contentRepo.createGiftCode(data);
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
return this.contentRepo.findGiftCode(code);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
return this.contentRepo.findGiftCodeByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
return this.contentRepo.findGiftCodesByCreator(userId);
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
return this.contentRepo.redeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.contentRepo.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
return this.contentRepo.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return this.contentRepo.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
return this.contentRepo.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return this.contentRepo.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
return this.contentRepo.getBulkPushSubscriptions(userIds);
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
return this.contentRepo.createPayment(data);
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
return this.contentRepo.updatePayment(data);
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
return this.contentRepo.getSubscriptionInfo(subscriptionId);
|
||||
}
|
||||
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
return this.contentRepo.listVisionarySlots();
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
return this.contentRepo.expandVisionarySlots(byCount);
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
return this.contentRepo.shrinkVisionarySlots(toCount);
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.contentRepo.reserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.contentRepo.unreserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
}
|
||||
139
packages/api/src/user/repositories/UserSettingsRepository.tsx
Normal file
139
packages/api/src/user/repositories/UserSettingsRepository.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
buildPatchFromData,
|
||||
deleteOneOrMany,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
|
||||
import type {UserGuildSettingsRow, UserSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {USER_GUILD_SETTINGS_COLUMNS, USER_SETTINGS_COLUMNS} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import {UserGuildSettings as UserGuildSettingsTable, UserSettings as UserSettingsTable} from '@fluxer/api/src/Tables';
|
||||
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
|
||||
|
||||
const FETCH_USER_SETTINGS_CQL = UserSettingsTable.selectCql({
|
||||
where: UserSettingsTable.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
|
||||
where: [UserGuildSettingsTable.where.eq('user_id'), UserGuildSettingsTable.where.eq('guild_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_ALL_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
|
||||
where: UserGuildSettingsTable.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class UserSettingsRepository implements IUserSettingsRepository {
|
||||
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
UserGuildSettingsTable.deleteCql({
|
||||
where: UserGuildSettingsTable.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
UserGuildSettingsTable.deleteByPk({
|
||||
user_id: userId,
|
||||
guild_id: guildId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteUserSettings(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(UserSettingsTable.deleteByPk({user_id: userId}));
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
const settings = await fetchOne<UserGuildSettingsRow>(FETCH_USER_GUILD_SETTINGS_CQL, {
|
||||
user_id: userId,
|
||||
guild_id: guildId ? guildId : 0n,
|
||||
});
|
||||
return settings ? new UserGuildSettings(settings) : null;
|
||||
}
|
||||
|
||||
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
|
||||
const rows = await fetchMany<UserGuildSettingsRow>(FETCH_ALL_USER_GUILD_SETTINGS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.map((row) => new UserGuildSettings(row));
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings | null> {
|
||||
const settings = await fetchOne<UserSettingsRow>(FETCH_USER_SETTINGS_CQL, {user_id: userId});
|
||||
return settings ? new UserSettings(settings) : null;
|
||||
}
|
||||
|
||||
async upsertGuildSettings(settings: ExactRow<UserGuildSettingsRow>): Promise<UserGuildSettings> {
|
||||
const userId = settings.user_id;
|
||||
const guildId = settings.guild_id;
|
||||
|
||||
const result = await executeVersionedUpdate<UserGuildSettingsRow, 'user_id' | 'guild_id'>(
|
||||
() =>
|
||||
this.findGuildSettings(userId, guildId)
|
||||
.then((s) => s?.toRow() ?? null)
|
||||
.catch((error) => {
|
||||
Logger.error(
|
||||
{userId: userId.toString(), guildId: guildId.toString(), error},
|
||||
'Failed to fetch guild settings',
|
||||
);
|
||||
throw error;
|
||||
}),
|
||||
(current) => ({
|
||||
pk: {user_id: userId, guild_id: guildId},
|
||||
patch: buildPatchFromData(settings, current, USER_GUILD_SETTINGS_COLUMNS, ['user_id', 'guild_id']),
|
||||
}),
|
||||
UserGuildSettingsTable,
|
||||
);
|
||||
|
||||
return new UserGuildSettings({...settings, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async upsertSettings(settings: ExactRow<UserSettingsRow>): Promise<UserSettings> {
|
||||
const userId = settings.user_id;
|
||||
|
||||
const result = await executeVersionedUpdate<UserSettingsRow, 'user_id'>(
|
||||
() =>
|
||||
this.findSettings(userId)
|
||||
.then((s) => s?.toRow() ?? null)
|
||||
.catch((error) => {
|
||||
Logger.error({userId: userId.toString(), error}, 'Failed to fetch settings');
|
||||
throw error;
|
||||
}),
|
||||
(current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch: buildPatchFromData(settings, current, USER_SETTINGS_COLUMNS, ['user_id']),
|
||||
}),
|
||||
UserSettingsTable,
|
||||
);
|
||||
|
||||
return new UserSettings({...settings, version: result.finalVersion ?? 1});
|
||||
}
|
||||
}
|
||||
156
packages/api/src/user/repositories/VisionarySlotRepository.tsx
Normal file
156
packages/api/src/user/repositories/VisionarySlotRepository.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {VisionarySlotRow} from '@fluxer/api/src/database/types/PaymentTypes';
|
||||
import {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
|
||||
import {VisionarySlots} from '@fluxer/api/src/Tables';
|
||||
import {CannotShrinkReservedSlotsError} from '@fluxer/errors/src/domains/core/CannotShrinkReservedSlotsError';
|
||||
|
||||
const FETCH_ALL_VISIONARY_SLOTS_QUERY = VisionarySlots.selectCql();
|
||||
|
||||
const FETCH_VISIONARY_SLOT_QUERY = VisionarySlots.selectCql({
|
||||
where: VisionarySlots.where.eq('slot_index'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class VisionarySlotRepository {
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
const slots = await fetchMany<VisionarySlotRow>(FETCH_ALL_VISIONARY_SLOTS_QUERY, {});
|
||||
return slots.map((slot) => new VisionarySlot(slot));
|
||||
}
|
||||
|
||||
async getVisionarySlot(slotIndex: number): Promise<VisionarySlot | null> {
|
||||
const slot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
|
||||
slot_index: slotIndex,
|
||||
});
|
||||
return slot ? new VisionarySlot(slot) : null;
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
const existingSlots = await this.listVisionarySlots();
|
||||
const maxSlotIndex = existingSlots.length > 0 ? Math.max(...existingSlots.map((s) => s.slotIndex)) : 0;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (let i = 1; i <= byCount; i++) {
|
||||
const newSlotIndex = maxSlotIndex + i;
|
||||
batch.addPrepared(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: newSlotIndex,
|
||||
user_id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
const existingSlots = await this.listVisionarySlots();
|
||||
if (existingSlots.length <= toCount) return;
|
||||
|
||||
const sortedSlots = existingSlots.sort((a, b) => b.slotIndex - a.slotIndex);
|
||||
const slotsToRemove = sortedSlots.slice(0, existingSlots.length - toCount);
|
||||
|
||||
const reservedSlots = slotsToRemove.filter((slot) => slot.userId !== null);
|
||||
if (reservedSlots.length > 0) {
|
||||
throw new CannotShrinkReservedSlotsError(reservedSlots.map((s) => s.slotIndex));
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const slot of slotsToRemove) {
|
||||
batch.addPrepared(VisionarySlots.deleteByPk({slot_index: slot.slotIndex}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
|
||||
slot_index: slotIndex,
|
||||
});
|
||||
|
||||
if (!existingSlot) {
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async swapVisionarySlotReservations(
|
||||
slotIndexA: number,
|
||||
slotIndexB: number,
|
||||
): Promise<{userIdA: UserID | null; userIdB: UserID | null}> {
|
||||
const [slotA, slotB] = await Promise.all([
|
||||
fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {slot_index: slotIndexA}),
|
||||
fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {slot_index: slotIndexB}),
|
||||
]);
|
||||
|
||||
const userIdA = slotA?.user_id ?? null;
|
||||
const userIdB = slotB?.user_id ?? null;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndexA,
|
||||
user_id: userIdB,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndexB,
|
||||
user_id: userIdA,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
return {userIdA, userIdB};
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
|
||||
slot_index: slotIndex,
|
||||
});
|
||||
|
||||
if (!existingSlot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sentinelUserId = createUserID(BigInt(-1));
|
||||
if (userId !== sentinelUserId && existingSlot.user_id !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Db} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {User} from '@fluxer/api/src/models/User';
|
||||
import {UserDataRepository} from '@fluxer/api/src/user/repositories/account/crud/UserDataRepository';
|
||||
import {UserIndexRepository} from '@fluxer/api/src/user/repositories/account/crud/UserIndexRepository';
|
||||
import {UserSearchRepository} from '@fluxer/api/src/user/repositories/account/crud/UserSearchRepository';
|
||||
|
||||
export class UserAccountRepository {
|
||||
private dataRepo: UserDataRepository;
|
||||
private indexRepo: UserIndexRepository;
|
||||
private searchRepo: UserSearchRepository;
|
||||
|
||||
constructor() {
|
||||
this.dataRepo = new UserDataRepository();
|
||||
this.indexRepo = new UserIndexRepository();
|
||||
this.searchRepo = new UserSearchRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.upsert(data);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.dataRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.dataRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.dataRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.dataRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
const userId = data.user_id;
|
||||
const result = await this.dataRepo.upsertUserRow(data, oldData);
|
||||
|
||||
if (result.finalVersion === null) {
|
||||
throw new Error(`Failed to update user ${userId} after max retries due to concurrent updates`);
|
||||
}
|
||||
|
||||
const updatedData = {...data, version: result.finalVersion};
|
||||
const updatedUser = new User(updatedData);
|
||||
|
||||
await this.indexRepo.syncIndices(updatedData, oldData);
|
||||
await this.searchRepo.indexUser(updatedUser);
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User> {
|
||||
if (!oldData) {
|
||||
const existingUser = await this.findUniqueAssert(userId);
|
||||
oldData = existingUser.toRow();
|
||||
}
|
||||
|
||||
const definedPatchData = Object.fromEntries(Object.entries(patchData).filter(([, v]) => v !== undefined));
|
||||
|
||||
const userPatch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
|
||||
for (const [key, value] of Object.entries(definedPatchData)) {
|
||||
if (key === 'user_id') continue;
|
||||
|
||||
const userRowKey = key as keyof UserRow;
|
||||
|
||||
if (value === null) {
|
||||
const oldVal = oldData?.[userRowKey];
|
||||
if (oldVal !== null && oldVal !== undefined) {
|
||||
userPatch[key] = Db.clear();
|
||||
}
|
||||
} else {
|
||||
userPatch[key] = Db.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.dataRepo.patchUser(userId, userPatch, oldData);
|
||||
|
||||
if (result.finalVersion === null) {
|
||||
throw new Error(`Failed to update user ${userId} due to concurrent modification`);
|
||||
}
|
||||
|
||||
const updatedData: UserRow = {
|
||||
...oldData,
|
||||
...definedPatchData,
|
||||
user_id: userId,
|
||||
version: result.finalVersion,
|
||||
};
|
||||
const updatedUser = new User(updatedData);
|
||||
|
||||
await this.indexRepo.syncIndices(updatedData, oldData);
|
||||
|
||||
await this.searchRepo.updateUser(updatedUser);
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
const user = await this.findUnique(userId);
|
||||
if (!user) return;
|
||||
|
||||
await this.indexRepo.deleteIndices(
|
||||
userId,
|
||||
user.username,
|
||||
user.discriminator,
|
||||
user.email,
|
||||
user.phone,
|
||||
user.stripeSubscriptionId,
|
||||
);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
await this.dataRepo.updateLastActiveAt(params);
|
||||
}
|
||||
|
||||
async getActivityTracking(
|
||||
userId: UserID,
|
||||
): Promise<{last_active_at: Date | null; last_active_ip: string | null} | null> {
|
||||
return this.dataRepo.getActivityTracking(userId);
|
||||
}
|
||||
|
||||
async updateSubscriptionStatus(
|
||||
userId: UserID,
|
||||
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
|
||||
): Promise<{finalVersion: number | null}> {
|
||||
return this.dataRepo.updateSubscriptionStatus(userId, updates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {UsersPendingDeletion} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_USERS_PENDING_DELETION_BY_DATE_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id', 'pending_deletion_at'],
|
||||
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.lte('pending_deletion_at', 'now')],
|
||||
});
|
||||
|
||||
const FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id', 'deletion_reason_code'],
|
||||
where: UsersPendingDeletion.where.eq('deletion_date'),
|
||||
});
|
||||
|
||||
const FETCH_USER_PENDING_DELETION_CHECK_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id'],
|
||||
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.eq('user_id')],
|
||||
});
|
||||
|
||||
export class UserDeletionRepository {
|
||||
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
|
||||
|
||||
await upsertOne(
|
||||
UsersPendingDeletion.upsertAll({
|
||||
deletion_date: deletionDate,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
user_id: userId,
|
||||
deletion_reason_code: deletionReasonCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
const userIds = new Set<bigint>();
|
||||
|
||||
const startDate = new Date(now);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
|
||||
const currentDate = new Date(startDate);
|
||||
while (currentDate <= now) {
|
||||
const deletionDate = currentDate.toISOString().split('T')[0];
|
||||
|
||||
const rows = await fetchMany<{user_id: bigint; pending_deletion_at: Date}>(
|
||||
FETCH_USERS_PENDING_DELETION_BY_DATE_CQL,
|
||||
{
|
||||
deletion_date: deletionDate,
|
||||
now,
|
||||
},
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
userIds.add(row.user_id);
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const users: Array<User> = [];
|
||||
for (const userId of userIds) {
|
||||
const user = await this.findUniqueUser(createUserID(userId));
|
||||
if (user?.pendingDeletionAt && user.pendingDeletionAt <= now) {
|
||||
users.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
const rows = await fetchMany<{user_id: bigint; deletion_reason_code: number}>(
|
||||
FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL,
|
||||
{deletion_date: deletionDate},
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
const rows = await fetchMany<{user_id: bigint}>(FETCH_USER_PENDING_DELETION_CHECK_CQL, {
|
||||
deletion_date: deletionDate,
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
|
||||
|
||||
await deleteOneOrMany(
|
||||
UsersPendingDeletion.deleteByPk({
|
||||
deletion_date: deletionDate,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {GuildMemberByUserIdRow} from '@fluxer/api/src/database/types/GuildTypes';
|
||||
import {GuildMembers, GuildMembersByUserId} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_GUILD_MEMBERS_BY_USER_CQL = GuildMembersByUserId.selectCql({
|
||||
where: GuildMembersByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class UserGuildRepository {
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return guilds.map((g) => g.guild_id);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const guild of guilds) {
|
||||
batch.addPrepared(
|
||||
GuildMembers.deleteByPk({
|
||||
guild_id: guild.guild_id,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildMembersByUserId.deleteByPk({
|
||||
user_id: userId,
|
||||
guild_id: guild.guild_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {
|
||||
UserByEmailRow,
|
||||
UserByPhoneRow,
|
||||
UserByStripeCustomerIdRow,
|
||||
UserByStripeSubscriptionIdRow,
|
||||
UserByUsernameRow,
|
||||
} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {
|
||||
UserByEmail,
|
||||
UserByPhone,
|
||||
UserByStripeCustomerId,
|
||||
UserByStripeSubscriptionId,
|
||||
UserByUsername,
|
||||
} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_DISCRIMINATORS_BY_USERNAME_QUERY = UserByUsername.select({
|
||||
columns: ['discriminator', 'user_id'],
|
||||
where: UserByUsername.where.eq('username'),
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_EMAIL_QUERY = UserByEmail.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByEmail.where.eq('email_lower'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_PHONE_QUERY = UserByPhone.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByPhone.where.eq('phone'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY = UserByStripeCustomerId.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByStripeCustomerId.where.eq('stripe_customer_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY = UserByStripeSubscriptionId.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByStripeSubscriptionId.where.eq('stripe_subscription_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY = UserByUsername.select({
|
||||
columns: ['user_id'],
|
||||
where: [UserByUsername.where.eq('username'), UserByUsername.where.eq('discriminator')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class UserLookupRepository {
|
||||
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const emailLower = email.toLowerCase();
|
||||
const result = await fetchOne<Pick<UserByEmailRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_EMAIL_QUERY.bind({email_lower: emailLower}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByPhoneRow, 'user_id'>>(FETCH_USER_ID_BY_PHONE_QUERY.bind({phone}));
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByStripeCustomerIdRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY.bind({stripe_customer_id: stripeCustomerId}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByStripeSubscriptionIdRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY.bind({stripe_subscription_id: stripeSubscriptionId}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
const result = await fetchOne<Pick<UserByUsernameRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY.bind({username: usernameLower, discriminator}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
const result = await fetchMany<Pick<UserByUsernameRow, 'discriminator'>>(
|
||||
FETCH_DISCRIMINATORS_BY_USERNAME_QUERY.bind({username: usernameLower}),
|
||||
);
|
||||
return new Set(result.map((r) => r.discriminator));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
buildPatchFromData,
|
||||
Db,
|
||||
type DbOp,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {EMPTY_USER_ROW, USER_COLUMNS} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {User} from '@fluxer/api/src/models/User';
|
||||
import {Users} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FLUXER_BOT_USER_ID = 0n;
|
||||
const DELETED_USER_ID = 1n;
|
||||
|
||||
const FETCH_USERS_BY_IDS_CQL = Users.selectCql({
|
||||
where: Users.where.in('user_id', 'user_ids'),
|
||||
});
|
||||
|
||||
const FETCH_USER_BY_ID_CQL = Users.selectCql({
|
||||
where: Users.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const UPDATE_LAST_ACTIVE_CQL = `UPDATE users SET last_active_at = :last_active_at, last_active_ip = :last_active_ip WHERE user_id = :user_id`;
|
||||
|
||||
const FETCH_ACTIVITY_TRACKING_CQL = Users.selectCql({
|
||||
columns: ['last_active_at', 'last_active_ip'],
|
||||
where: Users.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
function createFetchAllUsersFirstPageCql(limit: number) {
|
||||
return Users.selectCql({limit});
|
||||
}
|
||||
|
||||
const createFetchAllUsersPaginatedCql = (limit: number) =>
|
||||
Users.selectCql({
|
||||
where: Users.where.tokenGt('user_id', 'last_user_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
type UserPatch = Partial<{
|
||||
[K in Exclude<keyof UserRow, 'user_id'> & string]: DbOp<UserRow[K]>;
|
||||
}>;
|
||||
|
||||
export class UserDataRepository {
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
if (userId === FLUXER_BOT_USER_ID) {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(FLUXER_BOT_USER_ID),
|
||||
username: 'Fluxer',
|
||||
discriminator: 0,
|
||||
bot: true,
|
||||
system: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (userId === DELETED_USER_ID) {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(DELETED_USER_ID),
|
||||
username: 'DeletedUser',
|
||||
discriminator: 0,
|
||||
bot: false,
|
||||
system: true,
|
||||
});
|
||||
}
|
||||
|
||||
const userRow = await fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
|
||||
if (!userRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new User(userRow);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return (await this.findUnique(userId))!;
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
let users: Array<UserRow>;
|
||||
|
||||
if (lastUserId) {
|
||||
const cql = createFetchAllUsersPaginatedCql(limit);
|
||||
users = await fetchMany<UserRow>(cql, {last_user_id: lastUserId});
|
||||
} else {
|
||||
const cql = createFetchAllUsersFirstPageCql(limit);
|
||||
users = await fetchMany<UserRow>(cql, {});
|
||||
}
|
||||
|
||||
return users.map((user) => new User(user));
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
if (userIds.length === 0) return [];
|
||||
const users = await fetchMany<UserRow>(FETCH_USERS_BY_IDS_CQL, {user_ids: userIds});
|
||||
return users.map((user) => new User(user));
|
||||
}
|
||||
|
||||
async upsertUserRow(data: UserRow, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
|
||||
const userId = data.user_id;
|
||||
|
||||
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) {
|
||||
return oldData;
|
||||
}
|
||||
const user = await this.findUnique(userId);
|
||||
return user?.toRow() ?? null;
|
||||
},
|
||||
(current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch: buildPatchFromData(data, current, USER_COLUMNS, ['user_id']),
|
||||
}),
|
||||
Users,
|
||||
);
|
||||
|
||||
return {finalVersion: result.finalVersion};
|
||||
}
|
||||
|
||||
async patchUser(userId: UserID, patch: UserPatch, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
|
||||
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) {
|
||||
return oldData;
|
||||
}
|
||||
const user = await this.findUnique(userId);
|
||||
return user?.toRow() ?? null;
|
||||
},
|
||||
(_current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch,
|
||||
}),
|
||||
Users,
|
||||
);
|
||||
|
||||
return {finalVersion: result.finalVersion};
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
const {userId, lastActiveAt, lastActiveIp} = params;
|
||||
const updateParams: {user_id: UserID; last_active_at: Date; last_active_ip?: string} = {
|
||||
user_id: userId,
|
||||
last_active_at: lastActiveAt,
|
||||
};
|
||||
if (lastActiveIp !== undefined) {
|
||||
updateParams.last_active_ip = lastActiveIp;
|
||||
}
|
||||
|
||||
await upsertOne(UPDATE_LAST_ACTIVE_CQL, updateParams);
|
||||
}
|
||||
|
||||
async getActivityTracking(
|
||||
userId: UserID,
|
||||
): Promise<{last_active_at: Date | null; last_active_ip: string | null} | null> {
|
||||
const result = await fetchOne<{last_active_at: Date | null; last_active_ip: string | null}>(
|
||||
FETCH_ACTIVITY_TRACKING_CQL,
|
||||
{user_id: userId},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateSubscriptionStatus(
|
||||
userId: UserID,
|
||||
updates: {
|
||||
premiumWillCancel: boolean;
|
||||
computedPremiumUntil: Date | null;
|
||||
},
|
||||
): Promise<{finalVersion: number | null}> {
|
||||
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
|
||||
async () => {
|
||||
const user = await this.findUnique(userId);
|
||||
return user?.toRow() ?? null;
|
||||
},
|
||||
(current) => {
|
||||
const currentPremiumUntil = current?.premium_until ?? null;
|
||||
const computedPremiumUntil = updates.computedPremiumUntil;
|
||||
|
||||
let nextPremiumUntil: Date | null = currentPremiumUntil;
|
||||
|
||||
if (computedPremiumUntil) {
|
||||
if (!nextPremiumUntil || computedPremiumUntil > nextPremiumUntil) {
|
||||
nextPremiumUntil = computedPremiumUntil;
|
||||
}
|
||||
}
|
||||
|
||||
const patch: UserPatch = {
|
||||
premium_will_cancel: Db.set(updates.premiumWillCancel),
|
||||
premium_until: nextPremiumUntil ? Db.set(nextPremiumUntil) : Db.clear(),
|
||||
};
|
||||
|
||||
return {
|
||||
pk: {user_id: userId},
|
||||
patch,
|
||||
};
|
||||
},
|
||||
Users,
|
||||
);
|
||||
|
||||
return {finalVersion: result.finalVersion};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import {
|
||||
UserByEmail,
|
||||
UserByPhone,
|
||||
UserByStripeCustomerId,
|
||||
UserByStripeSubscriptionId,
|
||||
UserByUsername,
|
||||
} from '@fluxer/api/src/Tables';
|
||||
|
||||
export class UserIndexRepository {
|
||||
async syncIndices(data: UserRow, oldData?: UserRow | null): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (!!data.username && data.discriminator != null && data.discriminator !== undefined) {
|
||||
batch.addPrepared(
|
||||
UserByUsername.upsertAll({
|
||||
username: data.username.toLowerCase(),
|
||||
discriminator: data.discriminator,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.username && oldData.discriminator != null && oldData.discriminator !== undefined) {
|
||||
if (
|
||||
oldData.username.toLowerCase() !== data.username?.toLowerCase() ||
|
||||
oldData.discriminator !== data.discriminator
|
||||
) {
|
||||
batch.addPrepared(
|
||||
UserByUsername.deleteByPk({
|
||||
username: oldData.username.toLowerCase(),
|
||||
discriminator: oldData.discriminator,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.upsertAll({
|
||||
email_lower: data.email.toLowerCase(),
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.email && oldData.email.toLowerCase() !== data.email?.toLowerCase()) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.deleteByPk({
|
||||
email_lower: oldData.email.toLowerCase(),
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.upsertAll({
|
||||
phone: data.phone,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.phone && oldData.phone !== data.phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.deleteByPk({
|
||||
phone: oldData.phone,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.stripe_subscription_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.upsertAll({
|
||||
stripe_subscription_id: data.stripe_subscription_id,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.stripe_subscription_id && oldData.stripe_subscription_id !== data.stripe_subscription_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.deleteByPk({
|
||||
stripe_subscription_id: oldData.stripe_subscription_id,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.stripe_customer_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeCustomerId.upsertAll({
|
||||
stripe_customer_id: data.stripe_customer_id,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.stripe_customer_id && oldData.stripe_customer_id !== data.stripe_customer_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeCustomerId.deleteByPk({
|
||||
stripe_customer_id: oldData.stripe_customer_id,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteIndices(
|
||||
userId: UserID,
|
||||
username: string,
|
||||
discriminator: number,
|
||||
email?: string | null,
|
||||
phone?: string | null,
|
||||
stripeSubscriptionId?: string | null,
|
||||
): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
UserByUsername.deleteByPk({
|
||||
username: username.toLowerCase(),
|
||||
discriminator: discriminator,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (email) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.deleteByPk({
|
||||
email_lower: email.toLowerCase(),
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.deleteByPk({
|
||||
phone: phone,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (stripeSubscriptionId) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.deleteByPk({
|
||||
stripe_subscription_id: stripeSubscriptionId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
|
||||
|
||||
export class UserSearchRepository {
|
||||
async indexUser(user: User): Promise<void> {
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && 'indexUser' in userSearchService) {
|
||||
await userSearchService.indexUser(user).catch((error) => {
|
||||
Logger.error({userId: user.id, error}, 'Failed to index user in search');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(user: User): Promise<void> {
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && 'updateUser' in userSearchService) {
|
||||
await userSearchService.updateUser(user).catch((error) => {
|
||||
Logger.error({userId: user.id, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, Db, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {AuthSessionRow} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import {AuthSessions, AuthSessionsByUserId} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_AUTH_SESSIONS_CQL = AuthSessions.selectCql({
|
||||
where: AuthSessions.where.in('session_id_hash', 'session_id_hashes'),
|
||||
});
|
||||
|
||||
const FETCH_AUTH_SESSION_BY_TOKEN_CQL = AuthSessions.selectCql({
|
||||
where: AuthSessions.where.eq('session_id_hash'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL = AuthSessionsByUserId.selectCql({
|
||||
columns: ['session_id_hash'],
|
||||
where: AuthSessionsByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class AuthSessionRepository {
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(AuthSessions.insert(sessionData));
|
||||
batch.addPrepared(
|
||||
AuthSessionsByUserId.insert({
|
||||
user_id: sessionData.user_id,
|
||||
session_id_hash: sessionData.session_id_hash,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
return new AuthSession(sessionData);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
const session = await fetchOne<AuthSessionRow>(FETCH_AUTH_SESSION_BY_TOKEN_CQL, {session_id_hash: sessionIdHash});
|
||||
return session ? new AuthSession(session) : null;
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
const sessionHashes = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (sessionHashes.length === 0) return [];
|
||||
const sessions = await fetchMany<AuthSessionRow>(FETCH_AUTH_SESSIONS_CQL, {
|
||||
session_id_hashes: sessionHashes.map((s) => s.session_id_hash),
|
||||
});
|
||||
return sessions.map((session) => new AuthSession(session));
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
await upsertOne(
|
||||
AuthSessions.patchByPk(
|
||||
{session_id_hash: sessionIdHash},
|
||||
{
|
||||
approx_last_used_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
for (const sessionIdHash of sessionIdHashes) {
|
||||
batch.addPrepared(AuthSessions.deleteByPk({session_id_hash: sessionIdHash}));
|
||||
batch.addPrepared(AuthSessionsByUserId.deleteByPk({user_id: userId, session_id_hash: sessionIdHash}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
const sessions = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const session of sessions) {
|
||||
batch.addPrepared(
|
||||
AuthSessions.deleteByPk({
|
||||
session_id_hash: session.session_id_hash,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AuthSessionsByUserId.deleteByPk({
|
||||
user_id: userId,
|
||||
session_id_hash: session.session_id_hash,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {EmailChangeTicketRow, EmailChangeTokenRow} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import {EmailChangeTickets, EmailChangeTokens} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_TICKET_CQL = EmailChangeTickets.selectCql({
|
||||
where: EmailChangeTickets.where.eq('ticket'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_TOKEN_CQL = EmailChangeTokens.selectCql({
|
||||
where: EmailChangeTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class EmailChangeRepository {
|
||||
async createTicket(row: EmailChangeTicketRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTickets.insert(row));
|
||||
}
|
||||
|
||||
async updateTicket(row: EmailChangeTicketRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTickets.upsertAll(row));
|
||||
}
|
||||
|
||||
async findTicket(ticket: string): Promise<EmailChangeTicketRow | null> {
|
||||
return await fetchOne<EmailChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
|
||||
}
|
||||
|
||||
async deleteTicket(ticket: string): Promise<void> {
|
||||
await deleteOneOrMany(EmailChangeTickets.deleteByPk({ticket}));
|
||||
}
|
||||
|
||||
async createToken(row: EmailChangeTokenRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTokens.insert(row));
|
||||
}
|
||||
|
||||
async findToken(token: string): Promise<EmailChangeTokenRow | null> {
|
||||
return await fetchOne<EmailChangeTokenRow>(FETCH_TOKEN_CQL, {token_: token});
|
||||
}
|
||||
|
||||
async deleteToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(EmailChangeTokens.deleteByPk({token_: token}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createIpAuthorizationToken, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {AuthorizedIpRow, IpAuthorizationTokenRow} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import {AuthorizedIps, IpAuthorizationTokens, Users} from '@fluxer/api/src/Tables';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
const AUTHORIZE_IP_BY_TOKEN_CQL = IpAuthorizationTokens.selectCql({
|
||||
where: IpAuthorizationTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const CHECK_IP_AUTHORIZED_CQL = AuthorizedIps.selectCql({
|
||||
where: [AuthorizedIps.where.eq('user_id'), AuthorizedIps.where.eq('ip')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const GET_AUTHORIZED_IPS_CQL = AuthorizedIps.selectCql({
|
||||
where: AuthorizedIps.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class IpAuthorizationRepository {
|
||||
constructor(private userAccountRepository: IUserAccountRepository) {}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
const result = await fetchOne<AuthorizedIpRow>(CHECK_IP_AUTHORIZED_CQL, {
|
||||
user_id: userId,
|
||||
ip,
|
||||
});
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
await upsertOne(AuthorizedIps.insert({user_id: userId, ip}));
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string, email: string): Promise<void> {
|
||||
await upsertOne(
|
||||
IpAuthorizationTokens.insert({
|
||||
token_: createIpAuthorizationToken(token),
|
||||
user_id: userId,
|
||||
email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
const result = await fetchOne<IpAuthorizationTokenRow>(AUTHORIZE_IP_BY_TOKEN_CQL, {token_: token});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await deleteOneOrMany(
|
||||
IpAuthorizationTokens.deleteByPk({
|
||||
token_: createIpAuthorizationToken(token),
|
||||
user_id: result.user_id,
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await this.userAccountRepository.findUnique(result.user_id);
|
||||
if (!user || user.flags & UserFlags.DELETED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {userId: result.user_id, email: result.email};
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await upsertOne(
|
||||
Users.patchByPk(
|
||||
{user_id: userId},
|
||||
{
|
||||
last_active_at: Db.set(now),
|
||||
last_active_ip: Db.set(clientIp),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
|
||||
return ips.map((row) => ({ip: row.ip}));
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
|
||||
|
||||
for (const row of ips) {
|
||||
await deleteOneOrMany(
|
||||
AuthorizedIps.deleteByPk({
|
||||
user_id: userId,
|
||||
ip: row.ip,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMfaBackupCode, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {MfaBackupCodeRow} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
|
||||
import {MfaBackupCodes} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_MFA_BACKUP_CODES_CQL = MfaBackupCodes.selectCql({
|
||||
where: MfaBackupCodes.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class MfaBackupCodeRepository {
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
const codes = await fetchMany<MfaBackupCodeRow>(FETCH_MFA_BACKUP_CODES_CQL, {user_id: userId});
|
||||
return codes.map((code) => new MfaBackupCode(code));
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
const batch = new BatchBuilder();
|
||||
const backupCodes: Array<MfaBackupCode> = [];
|
||||
for (const code of codes) {
|
||||
const codeRow: MfaBackupCodeRow = {user_id: userId, code: createMfaBackupCode(code), consumed: false};
|
||||
batch.addPrepared(MfaBackupCodes.insert(codeRow));
|
||||
backupCodes.push(new MfaBackupCode(codeRow));
|
||||
}
|
||||
await batch.execute();
|
||||
return backupCodes;
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
const codes = await this.listMfaBackupCodes(userId);
|
||||
if (codes.length === 0) return;
|
||||
const batch = new BatchBuilder();
|
||||
for (const code of codes) {
|
||||
batch.addPrepared(MfaBackupCodes.deleteByPk({user_id: userId, code: createMfaBackupCode(code.code)}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
await upsertOne(
|
||||
MfaBackupCodes.patchByPk(
|
||||
{user_id: userId, code: createMfaBackupCode(code)},
|
||||
{
|
||||
consumed: Db.set(true),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(MfaBackupCodes.deleteCql({where: MfaBackupCodes.where.eq('user_id')}), {user_id: userId});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {PasswordChangeTicketRow} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import {PasswordChangeTickets} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_TICKET_CQL = PasswordChangeTickets.selectCql({
|
||||
where: PasswordChangeTickets.where.eq('ticket'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class PasswordChangeRepository {
|
||||
async createTicket(row: PasswordChangeTicketRow): Promise<void> {
|
||||
await upsertOne(PasswordChangeTickets.insert(row));
|
||||
}
|
||||
|
||||
async updateTicket(row: PasswordChangeTicketRow): Promise<void> {
|
||||
await upsertOne(PasswordChangeTickets.upsertAll(row));
|
||||
}
|
||||
|
||||
async findTicket(ticket: string): Promise<PasswordChangeTicketRow | null> {
|
||||
return await fetchOne<PasswordChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
|
||||
}
|
||||
|
||||
async deleteTicket(ticket: string): Promise<void> {
|
||||
await deleteOneOrMany(PasswordChangeTickets.deleteByPk({ticket}));
|
||||
}
|
||||
}
|
||||
143
packages/api/src/user/repositories/auth/TokenRepository.tsx
Normal file
143
packages/api/src/user/repositories/auth/TokenRepository.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
createEmailRevertToken,
|
||||
createEmailVerificationToken,
|
||||
createPasswordResetToken,
|
||||
} from '@fluxer/api/src/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
|
||||
import {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
|
||||
import {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
|
||||
import {EmailRevertTokens, EmailVerificationTokens, PasswordResetTokens, PhoneTokens} from '@fluxer/api/src/Tables';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
const FETCH_EMAIL_VERIFICATION_TOKEN_CQL = EmailVerificationTokens.selectCql({
|
||||
where: EmailVerificationTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PASSWORD_RESET_TOKEN_CQL = PasswordResetTokens.selectCql({
|
||||
where: PasswordResetTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_EMAIL_REVERT_TOKEN_CQL = EmailRevertTokens.selectCql({
|
||||
where: EmailRevertTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PHONE_TOKEN_CQL = PhoneTokens.selectCql({
|
||||
where: PhoneTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class TokenRepository {
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
const tokenRow = await fetchOne<EmailVerificationTokenRow>(FETCH_EMAIL_VERIFICATION_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new EmailVerificationToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
await upsertOne(EmailVerificationTokens.insert(tokenData));
|
||||
return new EmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
EmailVerificationTokens.deleteCql({
|
||||
where: EmailVerificationTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createEmailVerificationToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
const tokenRow = await fetchOne<PasswordResetTokenRow>(FETCH_PASSWORD_RESET_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new PasswordResetToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
await upsertOne(PasswordResetTokens.insert(tokenData));
|
||||
return new PasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PasswordResetTokens.deleteCql({
|
||||
where: PasswordResetTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createPasswordResetToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
const tokenRow = await fetchOne<EmailRevertTokenRow>(FETCH_EMAIL_REVERT_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new EmailRevertToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
await upsertOne(EmailRevertTokens.insert(tokenData));
|
||||
return new EmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
EmailRevertTokens.deleteCql({
|
||||
where: EmailRevertTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createEmailRevertToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
const TTL = seconds('15 minutes');
|
||||
await upsertOne(
|
||||
PhoneTokens.insertWithTtl(
|
||||
{
|
||||
token_: token,
|
||||
phone,
|
||||
user_id: userId,
|
||||
},
|
||||
TTL,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return await fetchOne<PhoneTokenRow>(FETCH_PHONE_TOKEN_CQL, {token_: token});
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PhoneTokens.deleteCql({
|
||||
where: PhoneTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: token},
|
||||
);
|
||||
}
|
||||
}
|
||||
171
packages/api/src/user/repositories/auth/WebAuthnRepository.tsx
Normal file
171
packages/api/src/user/repositories/auth/WebAuthnRepository.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {WebAuthnCredentialRow} from '@fluxer/api/src/database/types/AuthTypes';
|
||||
import {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
|
||||
import {WebAuthnCredentialLookup, WebAuthnCredentials} from '@fluxer/api/src/Tables';
|
||||
|
||||
const FETCH_USER_ID_BY_CREDENTIAL_ID_CQL = WebAuthnCredentialLookup.selectCql({
|
||||
where: WebAuthnCredentialLookup.where.eq('credential_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIALS_CQL = WebAuthnCredentials.selectCql({
|
||||
where: WebAuthnCredentials.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIAL_CQL = WebAuthnCredentials.selectCql({
|
||||
where: [WebAuthnCredentials.where.eq('user_id'), WebAuthnCredentials.where.eq('credential_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
|
||||
columns: ['credential_id'],
|
||||
where: WebAuthnCredentials.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class WebAuthnRepository {
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
|
||||
return credentials.map((cred) => new WebAuthnCredential(cred));
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
const cred = await fetchOne<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIAL_CQL, {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
});
|
||||
|
||||
if (!cred) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WebAuthnCredential(cred);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const credentialData = {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
public_key: publicKey,
|
||||
counter: counter,
|
||||
transports: transports,
|
||||
name: name,
|
||||
created_at: new Date(),
|
||||
last_used_at: null,
|
||||
version: 1 as const,
|
||||
};
|
||||
|
||||
await upsertOne(WebAuthnCredentials.insert(credentialData));
|
||||
|
||||
await upsertOne(
|
||||
WebAuthnCredentialLookup.insert({
|
||||
credential_id: credentialId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
counter: Db.set(counter),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
last_used_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
name: Db.set(name),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentialLookup.deleteByPk({
|
||||
credential_id: credentialId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
const row = await fetchOne<{credential_id: string; user_id: UserID}>(FETCH_USER_ID_BY_CREDENTIAL_ID_CQL, {
|
||||
credential_id: credentialId,
|
||||
});
|
||||
return row?.user_id ?? null;
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
const credentials = await fetchMany<{credential_id: string}>(FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const cred of credentials) {
|
||||
batch.addPrepared(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
user_id: userId,
|
||||
credential_id: cred.credential_id,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
WebAuthnCredentialLookup.deleteByPk({
|
||||
credential_id: cred.credential_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
56
packages/api/src/user/services/BaseUserUpdatePropagator.tsx
Normal file
56
packages/api/src/user/services/BaseUserUpdatePropagator.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {invalidateUserCache, updateUserCache} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
|
||||
export interface BaseUserUpdatePropagatorDeps {
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class BaseUserUpdatePropagator {
|
||||
constructor(protected readonly baseDeps: BaseUserUpdatePropagatorDeps) {}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
await this.baseDeps.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
|
||||
async invalidateUserCache(userId: UserID): Promise<void> {
|
||||
await invalidateUserCache({
|
||||
userId,
|
||||
userCacheService: this.baseDeps.userCacheService,
|
||||
});
|
||||
}
|
||||
|
||||
async updateUserCache(user: User): Promise<void> {
|
||||
await updateUserCache({
|
||||
user,
|
||||
userCacheService: this.baseDeps.userCacheService,
|
||||
});
|
||||
}
|
||||
}
|
||||
112
packages/api/src/user/services/CustomStatusValidator.tsx
Normal file
112
packages/api/src/user/services/CustomStatusValidator.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createEmojiID, type EmojiID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export interface ValidatedCustomStatus {
|
||||
text: string | null;
|
||||
expiresAt: Date | null;
|
||||
emojiId: EmojiID | null;
|
||||
emojiName: string | null;
|
||||
emojiAnimated: boolean;
|
||||
}
|
||||
|
||||
export class CustomStatusValidator {
|
||||
constructor(
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly packService: PackService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
async validate(userId: UserID, payload: z.infer<typeof CustomStatusPayload>): Promise<ValidatedCustomStatus> {
|
||||
const text = payload.text ?? null;
|
||||
const expiresAt = payload.expires_at ?? null;
|
||||
let emojiId: EmojiID | null = null;
|
||||
let emojiName: string | null = null;
|
||||
let emojiAnimated = false;
|
||||
|
||||
if (payload.emoji_id != null) {
|
||||
emojiId = createEmojiID(payload.emoji_id);
|
||||
|
||||
const emoji = await this.guildRepository.getEmojiById(emojiId);
|
||||
if (!emoji) {
|
||||
throw InputValidationError.fromCode('custom_status.emoji_id', ValidationErrorCodes.CUSTOM_EMOJI_NOT_FOUND);
|
||||
}
|
||||
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasGlobalExpressions = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_global_expressions',
|
||||
0,
|
||||
);
|
||||
|
||||
if (hasGlobalExpressions === 0) {
|
||||
throw InputValidationError.fromCode(
|
||||
'custom_status.emoji_id',
|
||||
ValidationErrorCodes.PREMIUM_REQUIRED_FOR_CUSTOM_EMOJI,
|
||||
);
|
||||
}
|
||||
|
||||
const guildMember = await this.guildRepository.getMember(emoji.guildId, userId);
|
||||
|
||||
let hasAccess = guildMember !== null;
|
||||
if (!hasAccess) {
|
||||
const resolver = await this.packService.createPackExpressionAccessResolver({
|
||||
userId,
|
||||
type: 'emoji',
|
||||
});
|
||||
const resolution = await resolver.resolve(emoji.guildId);
|
||||
hasAccess = resolution === 'accessible';
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
throw InputValidationError.fromCode(
|
||||
'custom_status.emoji_id',
|
||||
ValidationErrorCodes.EMOJI_REQUIRES_GUILD_OR_PACK_ACCESS,
|
||||
);
|
||||
}
|
||||
|
||||
emojiName = emoji.name;
|
||||
emojiAnimated = emoji.isAnimated;
|
||||
} else if (payload.emoji_name != null) {
|
||||
emojiName = payload.emoji_name;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
expiresAt,
|
||||
emojiId,
|
||||
emojiName,
|
||||
emojiAnimated,
|
||||
};
|
||||
}
|
||||
}
|
||||
411
packages/api/src/user/services/EmailChangeService.tsx
Normal file
411
packages/api/src/user/services/EmailChangeService.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '@fluxer/api/src/auth/services/AuthEmailService';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {EmailChangeRepository} from '@fluxer/api/src/user/repositories/auth/EmailChangeRepository';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export interface StartEmailChangeResult {
|
||||
ticket: string;
|
||||
require_original: boolean;
|
||||
original_email?: string | null;
|
||||
original_proof?: string | null;
|
||||
original_code_expires_at?: string | null;
|
||||
resend_available_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VerifyOriginalResult {
|
||||
original_proof: string;
|
||||
}
|
||||
|
||||
export interface RequestNewEmailResult {
|
||||
ticket: string;
|
||||
new_email: string;
|
||||
new_code_expires_at: string;
|
||||
resend_available_at: string | null;
|
||||
}
|
||||
|
||||
export class EmailChangeService {
|
||||
private readonly ORIGINAL_CODE_TTL_MS = ms('10 minutes');
|
||||
private readonly NEW_CODE_TTL_MS = ms('10 minutes');
|
||||
private readonly TOKEN_TTL_MS = ms('30 minutes');
|
||||
private readonly RESEND_COOLDOWN_MS = ms('30 seconds');
|
||||
|
||||
constructor(
|
||||
private readonly repo: EmailChangeRepository,
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
private readonly rateLimitService: IRateLimitService,
|
||||
) {}
|
||||
|
||||
async start(user: User): Promise<StartEmailChangeResult> {
|
||||
const isUnclaimed = user.isUnclaimedAccount();
|
||||
const hasEmail = !!user.email;
|
||||
if (!hasEmail && !isUnclaimed) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
|
||||
}
|
||||
|
||||
const ticket = this.generateTicket();
|
||||
const requireOriginal = !!user.emailVerified && hasEmail;
|
||||
const now = new Date();
|
||||
|
||||
let originalCode: string | null = null;
|
||||
let originalCodeExpiresAt: Date | null = null;
|
||||
let originalCodeSentAt: Date | null = null;
|
||||
|
||||
if (requireOriginal) {
|
||||
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, ms('15 minutes'));
|
||||
originalCode = this.generateCode();
|
||||
originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
|
||||
originalCodeSentAt = now;
|
||||
await this.emailService.sendEmailChangeOriginal(user.email!, user.username, originalCode, user.locale);
|
||||
}
|
||||
|
||||
const originalProof = requireOriginal ? null : this.generateProof();
|
||||
|
||||
await this.repo.createTicket({
|
||||
ticket,
|
||||
user_id: user.id,
|
||||
require_original: requireOriginal,
|
||||
original_email: user.email,
|
||||
original_verified: !requireOriginal,
|
||||
original_proof: originalProof,
|
||||
original_code: originalCode,
|
||||
original_code_sent_at: originalCodeSentAt,
|
||||
original_code_expires_at: originalCodeExpiresAt,
|
||||
new_email: null,
|
||||
new_code: null,
|
||||
new_code_sent_at: null,
|
||||
new_code_expires_at: null,
|
||||
status: requireOriginal ? 'pending_original' : 'pending_new',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ticket,
|
||||
require_original: requireOriginal,
|
||||
original_email: user.email ?? null,
|
||||
original_proof: originalProof,
|
||||
original_code_expires_at: originalCodeExpiresAt ? originalCodeExpiresAt.toISOString() : null,
|
||||
resend_available_at: requireOriginal ? new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
async resendOriginal(user: User, ticket: string): Promise<void> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.require_original || row.original_verified) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_ALREADY_VERIFIED);
|
||||
}
|
||||
if (!row.original_email) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.NO_ORIGINAL_EMAIL_ON_RECORD);
|
||||
}
|
||||
|
||||
this.assertCooldown(row.original_code_sent_at);
|
||||
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, ms('15 minutes'));
|
||||
|
||||
const now = new Date();
|
||||
const originalCode = this.generateCode();
|
||||
const originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendEmailChangeOriginal(row.original_email, user.username, originalCode, user.locale);
|
||||
|
||||
row.original_code = originalCode;
|
||||
row.original_code_sent_at = now;
|
||||
row.original_code_expires_at = originalCodeExpiresAt;
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
}
|
||||
|
||||
async verifyOriginal(user: User, ticket: string, code: string): Promise<VerifyOriginalResult> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.require_original) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_VERIFICATION_NOT_REQUIRED);
|
||||
}
|
||||
if (row.original_verified && row.original_proof) {
|
||||
return {original_proof: row.original_proof};
|
||||
}
|
||||
if (!row.original_code || !row.original_code_expires_at) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
|
||||
}
|
||||
if (row.original_code_expires_at.getTime() < Date.now()) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
|
||||
}
|
||||
if (row.original_code !== code.trim()) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const originalProof = this.generateProof();
|
||||
row.original_verified = true;
|
||||
row.original_proof = originalProof;
|
||||
row.status = 'pending_new';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
|
||||
return {original_proof: originalProof};
|
||||
}
|
||||
|
||||
async requestNewEmail(
|
||||
user: User,
|
||||
ticket: string,
|
||||
newEmail: string,
|
||||
originalProof: string,
|
||||
): Promise<RequestNewEmailResult> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.original_verified || !row.original_proof) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
|
||||
}
|
||||
if (row.original_proof !== originalProof) {
|
||||
throw InputValidationError.fromCode('original_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
|
||||
}
|
||||
const trimmedEmail = newEmail.trim();
|
||||
if (!trimmedEmail) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_IS_REQUIRED);
|
||||
}
|
||||
if (row.original_email && trimmedEmail.toLowerCase() === row.original_email.toLowerCase()) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.NEW_EMAIL_MUST_BE_DIFFERENT);
|
||||
}
|
||||
const existing = await this.userAccountRepository.findByEmail(trimmedEmail.toLowerCase());
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
|
||||
}
|
||||
|
||||
this.assertCooldown(row.new_code_sent_at);
|
||||
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, ms('15 minutes'));
|
||||
|
||||
const now = new Date();
|
||||
const newCode = this.generateCode();
|
||||
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendEmailChangeNew(trimmedEmail, user.username, newCode, user.locale);
|
||||
|
||||
row.new_email = trimmedEmail;
|
||||
row.new_code = newCode;
|
||||
row.new_code_sent_at = now;
|
||||
row.new_code_expires_at = newCodeExpiresAt;
|
||||
row.status = 'pending_new';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
new_email: trimmedEmail,
|
||||
new_code_expires_at: newCodeExpiresAt.toISOString(),
|
||||
resend_available_at: new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async resendNew(user: User, ticket: string): Promise<void> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.new_email) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.NO_NEW_EMAIL_REQUESTED);
|
||||
}
|
||||
this.assertCooldown(row.new_code_sent_at);
|
||||
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, ms('15 minutes'));
|
||||
|
||||
const now = new Date();
|
||||
const newCode = this.generateCode();
|
||||
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendEmailChangeNew(row.new_email, user.username, newCode, user.locale);
|
||||
|
||||
row.new_code = newCode;
|
||||
row.new_code_sent_at = now;
|
||||
row.new_code_expires_at = newCodeExpiresAt;
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
}
|
||||
|
||||
async verifyNew(user: User, ticket: string, code: string, originalProof: string): Promise<string> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.original_verified || !row.original_proof) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
|
||||
}
|
||||
if (row.original_proof !== originalProof) {
|
||||
throw InputValidationError.fromCode('original_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
|
||||
}
|
||||
if (!row.new_email || !row.new_code || !row.new_code_expires_at) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
|
||||
}
|
||||
if (row.new_code_expires_at.getTime() < Date.now()) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
|
||||
}
|
||||
if (row.new_code !== code.trim()) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(now.getTime() + this.TOKEN_TTL_MS);
|
||||
await this.repo.createToken({
|
||||
token_: token,
|
||||
user_id: user.id,
|
||||
new_email: row.new_email,
|
||||
expires_at: expiresAt,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
row.status = 'completed';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async consumeToken(userId: bigint, token: string): Promise<string> {
|
||||
const row = await this.repo.findToken(token);
|
||||
if (!row || row.user_id !== userId) {
|
||||
throw InputValidationError.fromCode('email_token', ValidationErrorCodes.INVALID_EMAIL_TOKEN);
|
||||
}
|
||||
if (row.expires_at.getTime() < Date.now()) {
|
||||
await this.repo.deleteToken(token);
|
||||
throw InputValidationError.fromCode('email_token', ValidationErrorCodes.EMAIL_TOKEN_EXPIRED);
|
||||
}
|
||||
await this.repo.deleteToken(token);
|
||||
return row.new_email;
|
||||
}
|
||||
|
||||
async requestBouncedNewEmail(user: User, newEmail: string): Promise<RequestNewEmailResult> {
|
||||
this.ensureBouncedEmailRecoveryAllowed(user);
|
||||
|
||||
const startResult = await this.start(user);
|
||||
if (startResult.require_original || !startResult.original_proof) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
|
||||
}
|
||||
|
||||
return await this.requestNewEmail(user, startResult.ticket, newEmail, startResult.original_proof);
|
||||
}
|
||||
|
||||
async resendBouncedNew(user: User, ticket: string): Promise<void> {
|
||||
this.ensureBouncedEmailRecoveryAllowed(user);
|
||||
await this.resendNew(user, ticket);
|
||||
}
|
||||
|
||||
async verifyBouncedNew(user: User, ticket: string, code: string): Promise<User> {
|
||||
this.ensureBouncedEmailRecoveryAllowed(user);
|
||||
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (row.require_original || !row.original_proof) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST);
|
||||
}
|
||||
|
||||
const emailToken = await this.verifyNew(user, ticket, code, row.original_proof);
|
||||
const updatedEmail = await this.consumeToken(user.id, emailToken);
|
||||
|
||||
const updates: {
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
email_bounced: boolean;
|
||||
suspicious_activity_flags?: number;
|
||||
} = {
|
||||
email: updatedEmail,
|
||||
email_verified: true,
|
||||
email_bounced: false,
|
||||
};
|
||||
|
||||
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
|
||||
const newFlags = user.suspiciousActivityFlags & ~EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
|
||||
if (newFlags !== user.suspiciousActivityFlags) {
|
||||
updates.suspicious_activity_flags = newFlags;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
|
||||
}
|
||||
|
||||
private async getTicketForUser(ticket: string, userId: bigint) {
|
||||
const row = await this.repo.findTicket(ticket);
|
||||
if (!row || row.user_id !== userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
|
||||
}
|
||||
if (row.status === 'completed') {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.TICKET_ALREADY_COMPLETED);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private ensureBouncedEmailRecoveryAllowed(user: User): void {
|
||||
if (!user.emailBounced) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
|
||||
}
|
||||
}
|
||||
|
||||
private generateCode(): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let raw = '';
|
||||
while (raw.length < 8) {
|
||||
const byte = crypto.randomBytes(1)[0];
|
||||
const idx = byte % alphabet.length;
|
||||
raw += alphabet[idx];
|
||||
}
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}`;
|
||||
}
|
||||
|
||||
private generateTicket(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private generateToken(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private generateProof(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private assertCooldown(sentAt: Date | null | undefined) {
|
||||
if (!sentAt) return;
|
||||
const nextAllowed = sentAt.getTime() + this.RESEND_COOLDOWN_MS;
|
||||
if (nextAllowed > Date.now()) {
|
||||
const retryAfter = Math.ceil((nextAllowed - Date.now()) / 1000);
|
||||
throw new RateLimitError({
|
||||
message: 'Please wait before resending.',
|
||||
retryAfter,
|
||||
limit: 1,
|
||||
resetTime: new Date(nextAllowed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRateLimit(identifier: string, maxAttempts: number, windowMs: number) {
|
||||
const result = await this.rateLimitService.checkLimit({identifier, maxAttempts, windowMs});
|
||||
if (!result.allowed) {
|
||||
throw new RateLimitError({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
retryAfter: result.retryAfter || 0,
|
||||
limit: result.limit,
|
||||
resetTime: result.resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
228
packages/api/src/user/services/PasswordChangeService.tsx
Normal file
228
packages/api/src/user/services/PasswordChangeService.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {PasswordChangeRepository} from '@fluxer/api/src/user/repositories/auth/PasswordChangeRepository';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export interface IAuthServiceForPasswordChange {
|
||||
hashPassword(password: string): Promise<string>;
|
||||
isPasswordPwned(password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface StartPasswordChangeResult {
|
||||
ticket: string;
|
||||
code_expires_at: string;
|
||||
resend_available_at: string;
|
||||
}
|
||||
|
||||
export interface VerifyPasswordChangeResult {
|
||||
verification_proof: string;
|
||||
}
|
||||
|
||||
export class PasswordChangeService {
|
||||
private readonly CODE_TTL_MS = ms('10 minutes');
|
||||
private readonly RESEND_COOLDOWN_MS = ms('30 seconds');
|
||||
|
||||
constructor(
|
||||
private readonly repo: PasswordChangeRepository,
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly authService: IAuthServiceForPasswordChange,
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
private readonly rateLimitService: IRateLimitService,
|
||||
) {}
|
||||
|
||||
async start(user: User): Promise<StartPasswordChangeResult> {
|
||||
if (!user.email) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
|
||||
}
|
||||
|
||||
await this.ensureRateLimit(`password_change:start:${user.id}`, 3, ms('15 minutes'));
|
||||
|
||||
const ticket = this.generateTicket();
|
||||
const now = new Date();
|
||||
const code = this.generateCode();
|
||||
const codeExpiresAt = new Date(now.getTime() + this.CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendPasswordChangeVerification(user.email, user.username, code, user.locale);
|
||||
|
||||
await this.repo.createTicket({
|
||||
ticket,
|
||||
user_id: user.id,
|
||||
code,
|
||||
code_sent_at: now,
|
||||
code_expires_at: codeExpiresAt,
|
||||
verified: false,
|
||||
verification_proof: null,
|
||||
status: 'pending',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ticket,
|
||||
code_expires_at: codeExpiresAt.toISOString(),
|
||||
resend_available_at: new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async resend(user: User, ticket: string): Promise<void> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!user.email) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.MUST_HAVE_EMAIL_TO_CHANGE_IT);
|
||||
}
|
||||
|
||||
this.assertCooldown(row.code_sent_at);
|
||||
await this.ensureRateLimit(`password_change:resend:${user.id}`, 3, ms('15 minutes'));
|
||||
|
||||
const now = new Date();
|
||||
const code = this.generateCode();
|
||||
const codeExpiresAt = new Date(now.getTime() + this.CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendPasswordChangeVerification(user.email, user.username, code, user.locale);
|
||||
|
||||
row.code = code;
|
||||
row.code_sent_at = now;
|
||||
row.code_expires_at = codeExpiresAt;
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
}
|
||||
|
||||
async verify(user: User, ticket: string, code: string): Promise<VerifyPasswordChangeResult> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
|
||||
if (row.verified && row.verification_proof) {
|
||||
return {verification_proof: row.verification_proof};
|
||||
}
|
||||
|
||||
if (!row.code || !row.code_expires_at) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
|
||||
}
|
||||
|
||||
if (row.code_expires_at.getTime() < Date.now()) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
|
||||
}
|
||||
|
||||
if (row.code !== code.trim()) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const verificationProof = this.generateProof();
|
||||
|
||||
row.verified = true;
|
||||
row.verification_proof = verificationProof;
|
||||
row.status = 'verified';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
|
||||
return {verification_proof: verificationProof};
|
||||
}
|
||||
|
||||
async complete(user: User, ticket: string, verificationProof: string, newPassword: string): Promise<void> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
|
||||
if (!row.verified || !row.verification_proof) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
|
||||
}
|
||||
|
||||
if (row.verification_proof !== verificationProof) {
|
||||
throw InputValidationError.fromCode('verification_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
|
||||
}
|
||||
|
||||
if (await this.authService.isPasswordPwned(newPassword)) {
|
||||
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
|
||||
}
|
||||
|
||||
const newPasswordHash = await this.authService.hashPassword(newPassword);
|
||||
|
||||
await this.userAccountRepository.patchUpsert(user.id, {
|
||||
password_hash: newPasswordHash,
|
||||
password_last_changed_at: new Date(),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
row.status = 'completed';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
}
|
||||
|
||||
private async getTicketForUser(ticket: string, userId: bigint) {
|
||||
const row = await this.repo.findTicket(ticket);
|
||||
if (!row || row.user_id !== userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
|
||||
}
|
||||
if (row.status === 'completed') {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.TICKET_ALREADY_COMPLETED);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private generateCode(): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let raw = '';
|
||||
while (raw.length < 8) {
|
||||
const byte = crypto.randomBytes(1)[0];
|
||||
const idx = byte % alphabet.length;
|
||||
raw += alphabet[idx];
|
||||
}
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}`;
|
||||
}
|
||||
|
||||
private generateTicket(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private generateProof(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private assertCooldown(sentAt: Date | null | undefined) {
|
||||
if (!sentAt) return;
|
||||
const nextAllowed = sentAt.getTime() + this.RESEND_COOLDOWN_MS;
|
||||
if (nextAllowed > Date.now()) {
|
||||
const retryAfter = Math.ceil((nextAllowed - Date.now()) / 1000);
|
||||
throw new RateLimitError({
|
||||
message: 'Please wait before resending.',
|
||||
retryAfter,
|
||||
limit: 1,
|
||||
resetTime: new Date(nextAllowed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRateLimit(identifier: string, maxAttempts: number, windowMs: number) {
|
||||
const result = await this.rateLimitService.checkLimit({identifier, maxAttempts, windowMs});
|
||||
if (!result.allowed) {
|
||||
throw new RateLimitError({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
retryAfter: result.retryAfter || 0,
|
||||
limit: result.limit,
|
||||
resetTime: result.resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/api/src/user/services/UserAccountLifecycleService.tsx
Normal file
116
packages/api/src/user/services/UserAccountLifecycleService.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
|
||||
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {UserOwnsGuildsError} from '@fluxer/errors/src/domains/guild/UserOwnsGuildsError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface UserAccountLifecycleServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
kvDeletionQueue: KVAccountDeletionQueueService;
|
||||
}
|
||||
|
||||
export class UserAccountLifecycleService {
|
||||
constructor(private readonly deps: UserAccountLifecycleServiceDeps) {}
|
||||
|
||||
async selfDisable(userId: UserID): Promise<void> {
|
||||
const user = await this.deps.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
flags: user.flags | UserFlags.DISABLED,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await this.deps.authService.terminateAllUserSessions(userId);
|
||||
|
||||
if (updatedUser) {
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.deps.updatePropagator.updateUserCache(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selfDelete(userId: UserID): Promise<void> {
|
||||
const user = await this.deps.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const ownedGuildIds = await this.deps.guildRepository.listOwnedGuildIds(userId);
|
||||
if (ownedGuildIds.length > 0) {
|
||||
throw new UserOwnsGuildsError();
|
||||
}
|
||||
|
||||
const gracePeriodMs = Config.deletionGracePeriodHours * ms('1 hour');
|
||||
const pendingDeletionAt = new Date(Date.now() + gracePeriodMs);
|
||||
|
||||
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
flags: user.flags | UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await this.deps.userAccountRepository.addPendingDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
|
||||
|
||||
await this.deps.kvDeletionQueue.scheduleDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
|
||||
|
||||
if (user.email) {
|
||||
await this.deps.emailService.sendSelfDeletionScheduledEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await this.deps.authService.terminateAllUserSessions(userId);
|
||||
|
||||
if (updatedUser) {
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.deps.updatePropagator.updateUserCache(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
310
packages/api/src/user/services/UserAccountLookupService.tsx
Normal file
310
packages/api/src/user/services/UserAccountLookupService.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ConnectionVisibilityFlags} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import {RelationshipTypes, UserFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
|
||||
interface UserAccountLookupServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
userChannelRepository: IUserChannelRepository;
|
||||
userRelationshipRepository: IUserRelationshipRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
guildService: GuildService;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
connectionRepository: IConnectionRepository;
|
||||
}
|
||||
|
||||
export class UserAccountLookupService {
|
||||
constructor(private readonly deps: UserAccountLookupServiceDeps) {}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return await this.deps.userAccountRepository.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return await this.deps.userAccountRepository.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async getUserProfile(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId?: GuildID;
|
||||
withMutualFriends?: boolean;
|
||||
withMutualGuilds?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<{
|
||||
user: User;
|
||||
guildMember?: GuildMemberResponse | null;
|
||||
guildMemberDomain?: GuildMember | null;
|
||||
premiumType?: number;
|
||||
premiumSince?: Date;
|
||||
premiumLifetimeSequence?: number;
|
||||
mutualFriends?: Array<User>;
|
||||
mutualGuilds?: Array<{id: string; nick: string | null}>;
|
||||
connections?: Array<UserConnectionRow>;
|
||||
}> {
|
||||
const {userId, targetId, guildId, withMutualFriends, withMutualGuilds, requestCache} = params;
|
||||
const user = await this.deps.userAccountRepository.findUnique(targetId);
|
||||
if (!user) throw new UnknownUserError();
|
||||
|
||||
if (userId !== targetId) {
|
||||
await this.validateProfileAccess(userId, targetId, user);
|
||||
}
|
||||
|
||||
let guildMember: GuildMemberResponse | null = null;
|
||||
let guildMemberDomain: GuildMember | null = null;
|
||||
|
||||
if (guildId != null) {
|
||||
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
|
||||
if (guildMemberDomain) {
|
||||
guildMember = await this.deps.guildService.getMember({
|
||||
userId,
|
||||
targetId,
|
||||
guildId,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let premiumType = user.premiumType ?? undefined;
|
||||
let premiumSince = user.premiumSince ?? undefined;
|
||||
let premiumLifetimeSequence = user.premiumLifetimeSequence ?? undefined;
|
||||
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_HIDDEN) {
|
||||
premiumType = undefined;
|
||||
premiumSince = undefined;
|
||||
premiumLifetimeSequence = undefined;
|
||||
} else {
|
||||
if (user.premiumType === UserPremiumTypes.LIFETIME) {
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_MASKED) {
|
||||
premiumType = UserPremiumTypes.SUBSCRIPTION;
|
||||
}
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN) {
|
||||
premiumLifetimeSequence = undefined;
|
||||
}
|
||||
}
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN) {
|
||||
premiumSince = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const [mutualFriends, mutualGuilds, connections] = await Promise.all([
|
||||
withMutualFriends && userId !== targetId ? this.getMutualFriends(userId, targetId) : undefined,
|
||||
withMutualGuilds && userId !== targetId ? this.getMutualGuilds(userId, targetId) : undefined,
|
||||
this.getVisibleConnections(userId, targetId),
|
||||
]);
|
||||
|
||||
return {
|
||||
user,
|
||||
guildMember,
|
||||
guildMemberDomain,
|
||||
premiumType,
|
||||
premiumSince,
|
||||
premiumLifetimeSequence,
|
||||
mutualFriends,
|
||||
mutualGuilds,
|
||||
connections,
|
||||
};
|
||||
}
|
||||
|
||||
private async validateProfileAccess(userId: UserID, targetId: UserID, targetUser: User): Promise<void> {
|
||||
if (targetUser.isBot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const friendship = await this.deps.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (friendship) {
|
||||
return;
|
||||
}
|
||||
|
||||
const incomingRequest = await this.deps.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.INCOMING_REQUEST,
|
||||
);
|
||||
if (incomingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [userGuildIds, targetGuildIds] = await Promise.all([
|
||||
this.deps.userAccountRepository.getUserGuildIds(userId),
|
||||
this.deps.userAccountRepository.getUserGuildIds(targetId),
|
||||
]);
|
||||
|
||||
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
|
||||
const hasMutualGuild = targetGuildIds.some((id) => userGuildIdSet.has(id.toString()));
|
||||
if (hasMutualGuild) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.hasSharedGroupDm(userId, targetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
private async hasSharedGroupDm(userId: UserID, targetId: UserID): Promise<boolean> {
|
||||
const privateChannels = await this.deps.userChannelRepository.listPrivateChannels(userId);
|
||||
return privateChannels.some(
|
||||
(channel) => channel.type === ChannelTypes.GROUP_DM && channel.recipientIds.has(targetId),
|
||||
);
|
||||
}
|
||||
|
||||
private async getMutualFriends(userId: UserID, targetId: UserID): Promise<Array<User>> {
|
||||
const [userRelationships, targetRelationships] = await Promise.all([
|
||||
this.deps.userRelationshipRepository.listRelationships(userId),
|
||||
this.deps.userRelationshipRepository.listRelationships(targetId),
|
||||
]);
|
||||
|
||||
const userFriendIds = new Set(
|
||||
userRelationships
|
||||
.filter((rel) => rel.type === RelationshipTypes.FRIEND)
|
||||
.map((rel) => rel.targetUserId.toString()),
|
||||
);
|
||||
|
||||
const mutualFriendIds = targetRelationships
|
||||
.filter((rel) => rel.type === RelationshipTypes.FRIEND && userFriendIds.has(rel.targetUserId.toString()))
|
||||
.map((rel) => rel.targetUserId);
|
||||
|
||||
if (mutualFriendIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const users = await this.deps.userAccountRepository.listUsers(mutualFriendIds);
|
||||
|
||||
return users.sort((a, b) => this.compareUsersByIdDesc(a, b));
|
||||
}
|
||||
|
||||
private compareUsersByIdDesc(a: User, b: User): number {
|
||||
if (b.id > a.id) return 1;
|
||||
if (b.id < a.id) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async getMutualGuilds(userId: UserID, targetId: UserID): Promise<Array<{id: string; nick: string | null}>> {
|
||||
const [userGuildIds, targetGuildIds] = await Promise.all([
|
||||
this.deps.userAccountRepository.getUserGuildIds(userId),
|
||||
this.deps.userAccountRepository.getUserGuildIds(targetId),
|
||||
]);
|
||||
|
||||
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
|
||||
const mutualGuildIds = targetGuildIds.filter((id) => userGuildIdSet.has(id.toString()));
|
||||
|
||||
if (mutualGuildIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const memberPromises = mutualGuildIds.map((guildId) => this.deps.guildRepository.getMember(guildId, targetId));
|
||||
const members = await Promise.all(memberPromises);
|
||||
|
||||
return mutualGuildIds.map((guildId, index) => ({
|
||||
id: guildId.toString(),
|
||||
nick: members[index]?.nickname ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async generateUniqueDiscriminator(username: string): Promise<number> {
|
||||
const usedDiscriminators = await this.deps.userAccountRepository.findDiscriminatorsByUsername(username);
|
||||
for (let i = 1; i <= 9999; i++) {
|
||||
if (!usedDiscriminators.has(i)) return i;
|
||||
}
|
||||
throw new Error('No available discriminators for this username');
|
||||
}
|
||||
|
||||
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
|
||||
const {username, discriminator} = params;
|
||||
const isAvailable = await this.deps.discriminatorService.isDiscriminatorAvailableForUsername(
|
||||
username,
|
||||
discriminator,
|
||||
);
|
||||
return !isAvailable;
|
||||
}
|
||||
|
||||
private async getVisibleConnections(viewerId: UserID, targetId: UserID): Promise<Array<UserConnectionRow>> {
|
||||
const connections = await this.deps.connectionRepository.findByUserId(targetId);
|
||||
const verified = connections.filter((connection) => connection.verified);
|
||||
|
||||
if (viewerId === targetId) {
|
||||
return verified;
|
||||
}
|
||||
|
||||
const [isFriend, hasMutualGuild] = await Promise.all([
|
||||
this.areFriends(viewerId, targetId),
|
||||
this.haveMutualGuild(viewerId, targetId),
|
||||
]);
|
||||
|
||||
return verified.filter((connection) => {
|
||||
const flags = connection.visibility_flags;
|
||||
|
||||
if (flags & ConnectionVisibilityFlags.EVERYONE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (flags & ConnectionVisibilityFlags.FRIENDS && isFriend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (flags & ConnectionVisibilityFlags.MUTUAL_GUILDS && hasMutualGuild) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private async areFriends(userId1: UserID, userId2: UserID): Promise<boolean> {
|
||||
const friendship = await this.deps.userRelationshipRepository.getRelationship(
|
||||
userId1,
|
||||
userId2,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
return friendship !== null;
|
||||
}
|
||||
|
||||
private async haveMutualGuild(userId1: UserID, userId2: UserID): Promise<boolean> {
|
||||
const [user1GuildIds, user2GuildIds] = await Promise.all([
|
||||
this.deps.userAccountRepository.getUserGuildIds(userId1),
|
||||
this.deps.userAccountRepository.getUserGuildIds(userId2),
|
||||
]);
|
||||
|
||||
const user1GuildIdSet = new Set(user1GuildIds.map((id) => id.toString()));
|
||||
return user2GuildIds.some((id) => user1GuildIdSet.has(id.toString()));
|
||||
}
|
||||
}
|
||||
59
packages/api/src/user/services/UserAccountNotesService.tsx
Normal file
59
packages/api/src/user/services/UserAccountNotesService.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
|
||||
interface UserAccountNotesServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
userRelationshipRepository: IUserRelationshipRepository;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
}
|
||||
|
||||
export class UserAccountNotesService {
|
||||
constructor(private readonly deps: UserAccountNotesServiceDeps) {}
|
||||
|
||||
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
|
||||
const {userId, targetId} = params;
|
||||
const note = await this.deps.userRelationshipRepository.getUserNote(userId, targetId);
|
||||
return note ? {note: note.note} : null;
|
||||
}
|
||||
|
||||
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
|
||||
const notes = await this.deps.userRelationshipRepository.getUserNotes(userId);
|
||||
return Object.fromEntries(Array.from(notes.entries()).map(([k, v]) => [k.toString(), v]));
|
||||
}
|
||||
|
||||
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
|
||||
const {userId, targetId, note} = params;
|
||||
const targetUser = await this.deps.userAccountRepository.findUnique(targetId);
|
||||
if (!targetUser) throw new UnknownUserError();
|
||||
|
||||
if (note) {
|
||||
await this.deps.userRelationshipRepository.upsertUserNote(userId, targetId, note);
|
||||
} else {
|
||||
await this.deps.userRelationshipRepository.clearUserNote(userId, targetId);
|
||||
}
|
||||
|
||||
await this.deps.updatePropagator.dispatchUserNoteUpdate({userId, targetId, note: note ?? ''});
|
||||
}
|
||||
}
|
||||
481
packages/api/src/user/services/UserAccountProfileService.tsx
Normal file
481
packages/api/src/user/services/UserAccountProfileService.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
|
||||
import {deriveDominantAvatarColor} from '@fluxer/api/src/utils/AvatarColorUtils';
|
||||
import * as EmojiUtils from '@fluxer/api/src/utils/EmojiUtils';
|
||||
import {MAX_BIO_LENGTH} from '@fluxer/constants/src/LimitConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface UserUpdateMetadata {
|
||||
invalidateAuthSessions?: boolean;
|
||||
}
|
||||
|
||||
type UserFieldUpdates = Partial<UserRow>;
|
||||
|
||||
export interface ProfileUpdateResult {
|
||||
updates: UserFieldUpdates;
|
||||
metadata: UserUpdateMetadata;
|
||||
preparedAvatarUpload: PreparedAssetUpload | null;
|
||||
preparedBannerUpload: PreparedAssetUpload | null;
|
||||
}
|
||||
|
||||
interface UserAccountProfileServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
entityAssetService: EntityAssetService;
|
||||
rateLimitService: IRateLimitService;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
limitConfigService: LimitConfigService;
|
||||
}
|
||||
|
||||
export class UserAccountProfileService {
|
||||
constructor(private readonly deps: UserAccountProfileServiceDeps) {}
|
||||
|
||||
async processProfileUpdates(params: {user: User; data: UserUpdateRequest}): Promise<ProfileUpdateResult> {
|
||||
const {user, data} = params;
|
||||
const updates: UserFieldUpdates = {
|
||||
avatar_hash: user.avatarHash,
|
||||
banner_hash: user.bannerHash,
|
||||
flags: user.flags,
|
||||
};
|
||||
const metadata: UserUpdateMetadata = {};
|
||||
|
||||
let preparedAvatarUpload: PreparedAssetUpload | null = null;
|
||||
let preparedBannerUpload: PreparedAssetUpload | null = null;
|
||||
|
||||
if (data.bio !== undefined) {
|
||||
await this.processBioUpdate({user, bio: data.bio, updates});
|
||||
}
|
||||
|
||||
if (data.pronouns !== undefined) {
|
||||
await this.processPronounsUpdate({user, pronouns: data.pronouns, updates});
|
||||
}
|
||||
|
||||
if (data.accent_color !== undefined) {
|
||||
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
|
||||
}
|
||||
|
||||
if (data.avatar !== undefined) {
|
||||
preparedAvatarUpload = await this.processAvatarUpdate({user, avatar: data.avatar, updates});
|
||||
}
|
||||
|
||||
if (data.banner !== undefined) {
|
||||
try {
|
||||
preparedBannerUpload = await this.processBannerUpdate({user, banner: data.banner, updates});
|
||||
} catch (error) {
|
||||
if (preparedAvatarUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(preparedAvatarUpload);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.isBot) {
|
||||
this.processPremiumBadgeFlags({user, data, updates});
|
||||
this.processPremiumOnboardingDismissal({user, data, updates});
|
||||
this.processGiftInventoryRead({user, data, updates});
|
||||
this.processUsedMobileClient({user, data, updates});
|
||||
}
|
||||
|
||||
return {updates, metadata, preparedAvatarUpload, preparedBannerUpload};
|
||||
}
|
||||
|
||||
async commitAssetChanges(result: ProfileUpdateResult): Promise<void> {
|
||||
if (result.preparedAvatarUpload) {
|
||||
await this.deps.entityAssetService.commitAssetChange({
|
||||
prepared: result.preparedAvatarUpload,
|
||||
deferDeletion: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.preparedBannerUpload) {
|
||||
await this.deps.entityAssetService.commitAssetChange({
|
||||
prepared: result.preparedBannerUpload,
|
||||
deferDeletion: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async rollbackAssetChanges(result: ProfileUpdateResult): Promise<void> {
|
||||
if (result.preparedAvatarUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedAvatarUpload);
|
||||
}
|
||||
|
||||
if (result.preparedBannerUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedBannerUpload);
|
||||
}
|
||||
}
|
||||
|
||||
private async processBioUpdate(params: {user: User; bio: string | null; updates: UserFieldUpdates}): Promise<void> {
|
||||
const {user, bio, updates} = params;
|
||||
|
||||
if (bio !== user.bio) {
|
||||
getMetricsService().counter({name: 'fluxer.users.bio_updated'});
|
||||
const bioRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `bio_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
|
||||
if (!bioRateLimit.allowed) {
|
||||
const minutes = Math.ceil((bioRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.fromCode('bio', ValidationErrorCodes.BIO_CHANGED_TOO_MANY_TIMES, {minutes});
|
||||
}
|
||||
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const maxBioLength = resolveLimitSafe(
|
||||
this.deps.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_bio_length',
|
||||
MAX_BIO_LENGTH,
|
||||
);
|
||||
|
||||
if (bio && bio.length > maxBioLength) {
|
||||
throw InputValidationError.fromCode('bio', ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH, {
|
||||
maxLength: maxBioLength,
|
||||
});
|
||||
}
|
||||
|
||||
let sanitizedBio = bio;
|
||||
if (bio) {
|
||||
sanitizedBio = await EmojiUtils.sanitizeCustomEmojis({
|
||||
content: bio,
|
||||
userId: user.id,
|
||||
webhookId: null,
|
||||
guildId: null,
|
||||
userRepository: this.deps.userAccountRepository,
|
||||
guildRepository: this.deps.guildRepository,
|
||||
limitConfigService: this.deps.limitConfigService,
|
||||
});
|
||||
}
|
||||
|
||||
updates.bio = sanitizedBio;
|
||||
}
|
||||
}
|
||||
|
||||
private async processPronounsUpdate(params: {
|
||||
user: User;
|
||||
pronouns: string | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<void> {
|
||||
const {user, pronouns, updates} = params;
|
||||
|
||||
if (pronouns !== user.pronouns) {
|
||||
getMetricsService().counter({name: 'fluxer.users.pronouns_updated'});
|
||||
const pronounsRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `pronouns_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
|
||||
if (!pronounsRateLimit.allowed) {
|
||||
const minutes = Math.ceil((pronounsRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.fromCode('pronouns', ValidationErrorCodes.PRONOUNS_CHANGED_TOO_MANY_TIMES, {
|
||||
minutes,
|
||||
});
|
||||
}
|
||||
|
||||
updates.pronouns = pronouns;
|
||||
}
|
||||
}
|
||||
|
||||
private async processAccentColorUpdate(params: {
|
||||
user: User;
|
||||
accentColor: number | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<void> {
|
||||
const {user, accentColor, updates} = params;
|
||||
|
||||
if (accentColor !== user.accentColor) {
|
||||
getMetricsService().counter({name: 'fluxer.users.accent_color_updated'});
|
||||
const accentColorRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `accent_color_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
|
||||
if (!accentColorRateLimit.allowed) {
|
||||
const minutes = Math.ceil((accentColorRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.fromCode('accent_color', ValidationErrorCodes.ACCENT_COLOR_CHANGED_TOO_MANY_TIMES, {
|
||||
minutes,
|
||||
});
|
||||
}
|
||||
|
||||
updates.accent_color = accentColor;
|
||||
}
|
||||
}
|
||||
|
||||
private async processAvatarUpdate(params: {
|
||||
user: User;
|
||||
avatar: string | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<PreparedAssetUpload | null> {
|
||||
return await withBusinessSpan(
|
||||
'fluxer.user.avatar_update',
|
||||
'fluxer.users.avatars_updated',
|
||||
{
|
||||
user_id: params.user.id.toString(),
|
||||
avatar_type: params.avatar ? 'custom' : 'default',
|
||||
},
|
||||
async () => {
|
||||
const {user, avatar, updates} = params;
|
||||
|
||||
if (avatar === null) {
|
||||
updates.avatar_hash = null;
|
||||
updates.avatar_color = null;
|
||||
if (user.avatarHash) {
|
||||
return await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
previousHash: user.avatarHash,
|
||||
base64Image: null,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `avatar_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
|
||||
if (!avatarRateLimit.allowed) {
|
||||
const minutes = Math.ceil((avatarRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.fromCode('avatar', ValidationErrorCodes.AVATAR_CHANGED_TOO_MANY_TIMES, {minutes});
|
||||
}
|
||||
|
||||
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
previousHash: user.avatarHash,
|
||||
base64Image: avatar,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasAnimatedAvatar = resolveLimitSafe(
|
||||
this.deps.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_animated_avatar',
|
||||
0,
|
||||
);
|
||||
|
||||
if (prepared.isAnimated && hasAnimatedAvatar === 0) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
|
||||
throw InputValidationError.fromCode('avatar', ValidationErrorCodes.ANIMATED_AVATARS_REQUIRE_PREMIUM);
|
||||
}
|
||||
|
||||
if (prepared.imageBuffer) {
|
||||
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
|
||||
if (derivedColor !== user.avatarColor) {
|
||||
updates.avatar_color = derivedColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (prepared.newHash !== user.avatarHash) {
|
||||
updates.avatar_hash = prepared.newHash;
|
||||
return prepared;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async processBannerUpdate(params: {
|
||||
user: User;
|
||||
banner: string | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<PreparedAssetUpload | null> {
|
||||
const {user, banner, updates} = params;
|
||||
|
||||
if (banner === null) {
|
||||
updates.banner_color = null;
|
||||
}
|
||||
|
||||
getMetricsService().counter({name: 'fluxer.users.banner_updated'});
|
||||
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasAnimatedBanner = resolveLimitSafe(
|
||||
this.deps.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_animated_banner',
|
||||
0,
|
||||
);
|
||||
|
||||
if (banner && hasAnimatedBanner === 0) {
|
||||
throw InputValidationError.fromCode('banner', ValidationErrorCodes.BANNERS_REQUIRE_PREMIUM);
|
||||
}
|
||||
|
||||
const bannerRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `banner_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
|
||||
if (!bannerRateLimit.allowed) {
|
||||
const minutes = Math.ceil((bannerRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.fromCode('banner', ValidationErrorCodes.BANNER_CHANGED_TOO_MANY_TIMES, {minutes});
|
||||
}
|
||||
|
||||
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
previousHash: user.bannerHash,
|
||||
base64Image: banner,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
|
||||
if (prepared.isAnimated && hasAnimatedBanner === 0) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
|
||||
throw InputValidationError.fromCode('banner', ValidationErrorCodes.ANIMATED_AVATARS_REQUIRE_PREMIUM);
|
||||
}
|
||||
|
||||
if (banner !== null && prepared.imageBuffer) {
|
||||
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
|
||||
if (derivedColor !== user.bannerColor) {
|
||||
updates.banner_color = derivedColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (prepared.newHash !== user.bannerHash) {
|
||||
updates.banner_hash = prepared.newHash;
|
||||
return prepared;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private processPremiumBadgeFlags(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
|
||||
const {user, data, updates} = params;
|
||||
let flagsUpdated = false;
|
||||
let newFlags = user.flags;
|
||||
|
||||
if (data.premium_badge_hidden !== undefined) {
|
||||
if (data.premium_badge_hidden) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_HIDDEN;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_HIDDEN;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_badge_masked !== undefined) {
|
||||
if (data.premium_badge_masked) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_MASKED;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_MASKED;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_badge_timestamp_hidden !== undefined) {
|
||||
if (data.premium_badge_timestamp_hidden) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_badge_sequence_hidden !== undefined) {
|
||||
if (data.premium_badge_sequence_hidden) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_enabled_override !== undefined) {
|
||||
if (!(user.flags & UserFlags.STAFF)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
if (data.premium_enabled_override) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_ENABLED_OVERRIDE;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_ENABLED_OVERRIDE;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (flagsUpdated) {
|
||||
updates.flags = newFlags;
|
||||
}
|
||||
}
|
||||
|
||||
private processPremiumOnboardingDismissal(params: {
|
||||
user: User;
|
||||
data: UserUpdateRequest;
|
||||
updates: UserFieldUpdates;
|
||||
}): void {
|
||||
const {data, updates} = params;
|
||||
|
||||
if (data.has_dismissed_premium_onboarding !== undefined) {
|
||||
if (data.has_dismissed_premium_onboarding) {
|
||||
updates.premium_onboarding_dismissed_at = new Date();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processGiftInventoryRead(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
|
||||
const {user, data, updates} = params;
|
||||
|
||||
if (data.has_unread_gift_inventory === false) {
|
||||
updates.gift_inventory_client_seq = user.giftInventoryServerSeq;
|
||||
}
|
||||
}
|
||||
|
||||
private processUsedMobileClient(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
|
||||
const {user, data, updates} = params;
|
||||
|
||||
if (data.used_mobile_client !== undefined) {
|
||||
let newFlags = updates.flags ?? user.flags;
|
||||
if (data.used_mobile_client) {
|
||||
newFlags = newFlags | UserFlags.USED_MOBILE_CLIENT;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.USED_MOBILE_CLIENT;
|
||||
}
|
||||
updates.flags = newFlags;
|
||||
}
|
||||
}
|
||||
}
|
||||
324
packages/api/src/user/services/UserAccountRequestService.tsx
Normal file
324
packages/api/src/user/services/UserAccountRequestService.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
|
||||
import {requireSudoMode, type SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {createChannelID, createGuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {EmailChangeService} from '@fluxer/api/src/user/services/EmailChangeService';
|
||||
import type {UserService} from '@fluxer/api/src/user/services/UserService';
|
||||
import {mapUserToPartialResponseWithCache} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {createPremiumClearPatch, shouldStripExpiredPremium} from '@fluxer/api/src/user/UserHelpers';
|
||||
import {
|
||||
mapGuildMemberToProfileResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapUserToProfileResponse,
|
||||
} from '@fluxer/api/src/user/UserMappers';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError';
|
||||
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
|
||||
import type {ConnectionResponse} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
|
||||
import type {UserUpdateWithVerificationRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {Context} from 'hono';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
|
||||
|
||||
type UserUpdatePayload = Omit<
|
||||
UserUpdateWithVerificationRequestData,
|
||||
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
|
||||
>;
|
||||
|
||||
interface UserProfileParams {
|
||||
currentUserId: UserID;
|
||||
targetUserId: UserID;
|
||||
guildId?: bigint;
|
||||
withMutualFriends?: boolean;
|
||||
withMutualGuilds?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
export class UserAccountRequestService {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly authMfaService: AuthMfaService,
|
||||
private readonly emailChangeService: EmailChangeService,
|
||||
private readonly userService: UserService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly mediaService: IMediaService,
|
||||
) {}
|
||||
|
||||
getCurrentUserResponse(params: {
|
||||
authTokenType?: 'session' | 'bearer' | 'bot' | 'admin_api_key';
|
||||
oauthBearerScopes?: Set<string> | null;
|
||||
user?: User;
|
||||
}): UserPrivateResponse {
|
||||
const tokenType = params.authTokenType;
|
||||
|
||||
if (tokenType === 'bearer') {
|
||||
const bearerUser = params.user;
|
||||
if (!bearerUser) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
this.enforceUserAccess(bearerUser);
|
||||
const includeEmail = params.oauthBearerScopes?.has('email') ?? false;
|
||||
const response = mapUserToPrivateResponse(bearerUser);
|
||||
if (!includeEmail) {
|
||||
response.email = null;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const user = params.user;
|
||||
if (user) {
|
||||
this.enforceUserAccess(user);
|
||||
return mapUserToPrivateResponse(user);
|
||||
}
|
||||
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
async updateCurrentUser(params: {
|
||||
ctx: Context<HonoEnv>;
|
||||
user: User;
|
||||
body: UserUpdateWithVerificationRequestData;
|
||||
authSession: AuthSession;
|
||||
}): Promise<UserPrivateResponse> {
|
||||
const {ctx, user, body, authSession} = params;
|
||||
const oldEmail = user.email;
|
||||
const {
|
||||
mfa_method: _mfaMethod,
|
||||
mfa_code: _mfaCode,
|
||||
webauthn_response: _webauthnResponse,
|
||||
webauthn_challenge: _webauthnChallenge,
|
||||
email_token: emailToken,
|
||||
...userUpdateDataRest
|
||||
} = body;
|
||||
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
|
||||
if (userUpdateData.email !== undefined) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_MUST_BE_CHANGED_VIA_TOKEN);
|
||||
}
|
||||
const emailTokenProvided = emailToken !== undefined;
|
||||
const isUnclaimed = user.isUnclaimedAccount();
|
||||
if (!isUnclaimed && userUpdateData.new_password !== undefined && !userUpdateData.password) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
|
||||
}
|
||||
if (isUnclaimed) {
|
||||
const allowed = new Set(['username', 'discriminator', 'new_password']);
|
||||
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
|
||||
if (disallowedField) {
|
||||
throw InputValidationError.fromCode(
|
||||
disallowedField,
|
||||
ValidationErrorCodes.UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
let emailFromToken: string | null = null;
|
||||
let emailVerifiedViaToken = false;
|
||||
|
||||
const needsVerification = this.requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
|
||||
let sudoResult: SudoVerificationResult | null = null;
|
||||
if (needsVerification) {
|
||||
sudoResult = await requireSudoMode(ctx, user, body, this.authService, this.authMfaService);
|
||||
}
|
||||
|
||||
if (emailTokenProvided && emailToken) {
|
||||
emailFromToken = await this.emailChangeService.consumeToken(user.id, emailToken);
|
||||
userUpdateData = {...userUpdateData, email: emailFromToken};
|
||||
emailVerifiedViaToken = true;
|
||||
}
|
||||
|
||||
const updatedUser = await this.userService.update({
|
||||
user,
|
||||
oldAuthSession: authSession,
|
||||
data: userUpdateData,
|
||||
request: ctx.req.raw,
|
||||
sudoContext: sudoResult ?? undefined,
|
||||
emailVerifiedViaToken,
|
||||
});
|
||||
|
||||
if (emailFromToken && oldEmail && updatedUser.email && oldEmail.toLowerCase() !== updatedUser.email.toLowerCase()) {
|
||||
try {
|
||||
await this.authService.issueEmailRevertToken(updatedUser, oldEmail, updatedUser.email);
|
||||
} catch (error) {
|
||||
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
|
||||
}
|
||||
}
|
||||
return mapUserToPrivateResponse(updatedUser);
|
||||
}
|
||||
|
||||
async preloadMessages(params: {
|
||||
userId: UserID;
|
||||
channels: ReadonlyArray<bigint>;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const channelIds = params.channels.map((channelId) => createChannelID(channelId));
|
||||
const messages = await this.userService.preloadDMMessages({
|
||||
userId: params.userId,
|
||||
channelIds,
|
||||
});
|
||||
|
||||
const mappingPromises = Object.entries(messages).map(async ([channelId, message]) => {
|
||||
const mappedMessage = message
|
||||
? await mapMessageToResponse({
|
||||
message,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
mediaService: this.mediaService,
|
||||
currentUserId: params.userId,
|
||||
})
|
||||
: null;
|
||||
return [channelId, mappedMessage] as const;
|
||||
});
|
||||
|
||||
const mappedEntries = await Promise.all(mappingPromises);
|
||||
return Object.fromEntries(mappedEntries);
|
||||
}
|
||||
|
||||
async getUserProfile(params: UserProfileParams): Promise<UserProfileFullResponse> {
|
||||
const guildId = params.guildId ? createGuildID(params.guildId) : undefined;
|
||||
const profile = await this.userService.getUserProfile({
|
||||
userId: params.currentUserId,
|
||||
targetId: params.targetUserId,
|
||||
guildId,
|
||||
withMutualFriends: params.withMutualFriends,
|
||||
withMutualGuilds: params.withMutualGuilds,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
|
||||
let profileUser = profile.user;
|
||||
let premiumType = profile.premiumType;
|
||||
let premiumSince = profile.premiumSince;
|
||||
let premiumLifetimeSequence = profile.premiumLifetimeSequence;
|
||||
|
||||
if (shouldStripExpiredPremium(profileUser)) {
|
||||
try {
|
||||
const sanitizedUser = await this.userRepository.patchUpsert(
|
||||
profileUser.id,
|
||||
createPremiumClearPatch(),
|
||||
profileUser.toRow(),
|
||||
);
|
||||
if (sanitizedUser) {
|
||||
profileUser = sanitizedUser;
|
||||
profile.user = sanitizedUser;
|
||||
premiumType = undefined;
|
||||
premiumSince = undefined;
|
||||
premiumLifetimeSequence = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{userId: profileUser.id.toString(), error},
|
||||
'Failed to sanitize expired premium fields before returning profile',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userProfile = mapUserToProfileResponse(profileUser);
|
||||
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null);
|
||||
|
||||
const mutualFriends = profile.mutualFriends
|
||||
? await Promise.all(
|
||||
profile.mutualFriends.map((user) =>
|
||||
mapUserToPartialResponseWithCache({
|
||||
user,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const connectedAccounts = profile.connections ? this.mapConnectionsToResponse(profile.connections) : undefined;
|
||||
|
||||
return {
|
||||
user: await mapUserToPartialResponseWithCache({
|
||||
user: profileUser,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
}),
|
||||
user_profile: userProfile,
|
||||
guild_member: profile.guildMember ?? undefined,
|
||||
guild_member_profile: guildMemberProfile ?? undefined,
|
||||
premium_type: premiumType,
|
||||
premium_since: premiumSince?.toISOString(),
|
||||
premium_lifetime_sequence: premiumLifetimeSequence,
|
||||
mutual_friends: mutualFriends,
|
||||
mutual_guilds: profile.mutualGuilds,
|
||||
connected_accounts: connectedAccounts,
|
||||
};
|
||||
}
|
||||
|
||||
checkTagAvailability(params: {currentUser: User; username: string; discriminator: number}): boolean {
|
||||
const currentUser = params.currentUser;
|
||||
const discriminator = params.discriminator;
|
||||
if (
|
||||
params.username.toLowerCase() === currentUser.username.toLowerCase() &&
|
||||
discriminator === currentUser.discriminator
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private enforceUserAccess(user: User): void {
|
||||
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
|
||||
throw new AccountSuspiciousActivityError(user.suspiciousActivityFlags);
|
||||
}
|
||||
}
|
||||
|
||||
private requiresSensitiveUserVerification(user: User, data: UserUpdatePayload, emailTokenProvided: boolean): boolean {
|
||||
const isUnclaimed = user.isUnclaimedAccount();
|
||||
const usernameChanged = data.username !== undefined && data.username !== user.username;
|
||||
const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator;
|
||||
const emailChanged = data.email !== undefined && data.email !== user.email;
|
||||
const newPasswordProvided = data.new_password !== undefined;
|
||||
|
||||
if (isUnclaimed) {
|
||||
return usernameChanged || discriminatorChanged;
|
||||
}
|
||||
|
||||
return usernameChanged || discriminatorChanged || emailTokenProvided || emailChanged || newPasswordProvided;
|
||||
}
|
||||
|
||||
private mapConnectionsToResponse(connections: Array<UserConnectionRow>): Array<ConnectionResponse> {
|
||||
return connections
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((connection) => ({
|
||||
id: connection.connection_id,
|
||||
type: connection.connection_type,
|
||||
name: connection.name,
|
||||
verified: connection.verified,
|
||||
visibility_flags: connection.visibility_flags,
|
||||
sort_order: connection.sort_order,
|
||||
}));
|
||||
}
|
||||
}
|
||||
305
packages/api/src/user/services/UserAccountSecurityService.tsx
Normal file
305
packages/api/src/user/services/UserAccountSecurityService.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {userHasMfa} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
|
||||
interface UserUpdateMetadata {
|
||||
invalidateAuthSessions?: boolean;
|
||||
}
|
||||
|
||||
type UserFieldUpdates = Partial<UserRow>;
|
||||
|
||||
interface UserAccountSecurityServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
authService: AuthService;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
rateLimitService: IRateLimitService;
|
||||
limitConfigService: LimitConfigService;
|
||||
}
|
||||
|
||||
export class UserAccountSecurityService {
|
||||
constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
|
||||
|
||||
async processSecurityUpdates(params: {
|
||||
user: User;
|
||||
data: UserUpdateRequest;
|
||||
sudoContext?: SudoVerificationResult;
|
||||
}): Promise<{updates: UserFieldUpdates; metadata: UserUpdateMetadata}> {
|
||||
const {user, data, sudoContext} = params;
|
||||
const updates: UserFieldUpdates = {
|
||||
password_hash: user.passwordHash,
|
||||
username: user.username,
|
||||
discriminator: user.discriminator,
|
||||
global_name: user.isBot ? null : user.globalName,
|
||||
email: user.email,
|
||||
};
|
||||
const metadata: UserUpdateMetadata = {
|
||||
invalidateAuthSessions: false,
|
||||
};
|
||||
|
||||
const isUnclaimedAccount = user.isUnclaimedAccount();
|
||||
const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext?.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
|
||||
const rawEmail = data.email?.trim();
|
||||
const normalizedEmail = rawEmail?.toLowerCase();
|
||||
|
||||
const hasPasswordRequiredChanges =
|
||||
(data.username !== undefined && data.username !== user.username) ||
|
||||
(data.discriminator !== undefined && data.discriminator !== user.discriminator) ||
|
||||
(data.email !== undefined && normalizedEmail !== user.email?.toLowerCase()) ||
|
||||
data.new_password !== undefined;
|
||||
|
||||
const requiresVerification = hasPasswordRequiredChanges && !isUnclaimedAccount;
|
||||
if (requiresVerification && !identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
|
||||
if (isUnclaimedAccount && data.new_password) {
|
||||
updates.password_hash = await this.hashNewPassword(data.new_password);
|
||||
updates.password_last_changed_at = new Date();
|
||||
metadata.invalidateAuthSessions = false;
|
||||
} else if (data.new_password) {
|
||||
if (!data.password) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
|
||||
}
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
updates.password_hash = await this.hashNewPassword(data.new_password);
|
||||
updates.password_last_changed_at = new Date();
|
||||
metadata.invalidateAuthSessions = true;
|
||||
}
|
||||
|
||||
if (data.username !== undefined) {
|
||||
const {newUsername, newDiscriminator} = await this.updateUsername({
|
||||
user,
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
});
|
||||
updates.username = newUsername;
|
||||
updates.discriminator = newDiscriminator;
|
||||
} else if (data.discriminator !== undefined) {
|
||||
updates.discriminator = await this.updateDiscriminator({user, discriminator: data.discriminator});
|
||||
}
|
||||
|
||||
if (user.isBot) {
|
||||
updates.global_name = null;
|
||||
} else if (data.global_name !== undefined) {
|
||||
if (data.global_name !== user.globalName) {
|
||||
getMetricsService().counter({name: 'fluxer.users.display_name_updated'});
|
||||
}
|
||||
updates.global_name = data.global_name;
|
||||
}
|
||||
|
||||
if (rawEmail) {
|
||||
if (normalizedEmail && normalizedEmail !== user.email?.toLowerCase()) {
|
||||
const existing = await this.deps.userAccountRepository.findByEmail(normalizedEmail);
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
|
||||
}
|
||||
}
|
||||
|
||||
updates.email = rawEmail;
|
||||
}
|
||||
|
||||
return {updates, metadata};
|
||||
}
|
||||
|
||||
async invalidateAndRecreateSessions({
|
||||
user,
|
||||
oldAuthSession,
|
||||
request,
|
||||
}: {
|
||||
user: User;
|
||||
oldAuthSession: AuthSession;
|
||||
request: Request;
|
||||
}): Promise<void> {
|
||||
await this.deps.authService.terminateAllUserSessions(user.id);
|
||||
|
||||
const [newToken, newAuthSession] = await this.deps.authService.createAuthSession({user, request});
|
||||
const oldAuthSessionIdHash = uint8ArrayToBase64(oldAuthSession.sessionIdHash, {urlSafe: true});
|
||||
|
||||
await this.deps.authService.dispatchAuthSessionChange({
|
||||
userId: user.id,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash: uint8ArrayToBase64(newAuthSession.sessionIdHash, {urlSafe: true}),
|
||||
newToken,
|
||||
});
|
||||
}
|
||||
|
||||
private async hashNewPassword(newPassword: string): Promise<string> {
|
||||
if (await this.deps.authService.isPasswordPwned(newPassword)) {
|
||||
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
|
||||
}
|
||||
return await this.deps.authService.hashPassword(newPassword);
|
||||
}
|
||||
|
||||
private async updateUsername({
|
||||
user,
|
||||
username,
|
||||
requestedDiscriminator,
|
||||
}: {
|
||||
user: User;
|
||||
username: string;
|
||||
requestedDiscriminator?: number;
|
||||
}): Promise<{newUsername: string; newDiscriminator: number}> {
|
||||
const normalizedRequestedDiscriminator =
|
||||
requestedDiscriminator == null ? undefined : Number(requestedDiscriminator);
|
||||
|
||||
if (
|
||||
user.username.toLowerCase() === username.toLowerCase() &&
|
||||
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
|
||||
) {
|
||||
return {
|
||||
newUsername: username,
|
||||
newDiscriminator: user.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
const rateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `username_change:${user.id}`,
|
||||
maxAttempts: 5,
|
||||
windowMs: ms('1 hour'),
|
||||
});
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
const minutes = Math.ceil((rateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.fromCode('username', ValidationErrorCodes.USERNAME_CHANGED_TOO_MANY_TIMES, {
|
||||
minutes,
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasCustomDiscriminator = resolveLimitSafe(
|
||||
this.deps.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_custom_discriminator',
|
||||
0,
|
||||
);
|
||||
|
||||
if (
|
||||
hasCustomDiscriminator === 0 &&
|
||||
user.username === username &&
|
||||
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
|
||||
) {
|
||||
return {
|
||||
newUsername: user.username,
|
||||
newDiscriminator: user.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCustomDiscriminator === 0) {
|
||||
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username,
|
||||
requestedDiscriminator: undefined,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw InputValidationError.fromCode(
|
||||
'username',
|
||||
ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
newUsername: username,
|
||||
newDiscriminator: discriminatorResult.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
const discriminatorToUse = normalizedRequestedDiscriminator ?? user.discriminator;
|
||||
if (discriminatorToUse === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) {
|
||||
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR);
|
||||
}
|
||||
|
||||
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username,
|
||||
requestedDiscriminator: discriminatorToUse,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw InputValidationError.fromCode(
|
||||
'username',
|
||||
discriminatorToUse !== undefined
|
||||
? ValidationErrorCodes.TAG_ALREADY_TAKEN
|
||||
: ValidationErrorCodes.TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
newUsername: username,
|
||||
newDiscriminator: discriminatorResult.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
private async updateDiscriminator({user, discriminator}: {user: User; discriminator: number}): Promise<number> {
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const hasCustomDiscriminator = resolveLimitSafe(
|
||||
this.deps.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'feature_custom_discriminator',
|
||||
0,
|
||||
);
|
||||
|
||||
if (hasCustomDiscriminator === 0) {
|
||||
throw InputValidationError.fromCode(
|
||||
'discriminator',
|
||||
ValidationErrorCodes.CHANGING_DISCRIMINATOR_REQUIRES_PREMIUM,
|
||||
);
|
||||
}
|
||||
if (discriminator === 0 && user.premiumType !== UserPremiumTypes.LIFETIME) {
|
||||
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.VISIONARY_REQUIRED_FOR_DISCRIMINATOR);
|
||||
}
|
||||
|
||||
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username: user.username,
|
||||
requestedDiscriminator: discriminator,
|
||||
user,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available) {
|
||||
throw InputValidationError.fromCode('discriminator', ValidationErrorCodes.TAG_ALREADY_TAKEN);
|
||||
}
|
||||
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
353
packages/api/src/user/services/UserAccountService.tsx
Normal file
353
packages/api/src/user/services/UserAccountService.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import {GuildMemberSearchIndexService} from '@fluxer/api/src/guild/services/member/GuildMemberSearchIndexService';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
|
||||
import {UserAccountLifecycleService} from '@fluxer/api/src/user/services/UserAccountLifecycleService';
|
||||
import {UserAccountLookupService} from '@fluxer/api/src/user/services/UserAccountLookupService';
|
||||
import {UserAccountNotesService} from '@fluxer/api/src/user/services/UserAccountNotesService';
|
||||
import {UserAccountProfileService} from '@fluxer/api/src/user/services/UserAccountProfileService';
|
||||
import {UserAccountSecurityService} from '@fluxer/api/src/user/services/UserAccountSecurityService';
|
||||
import {UserAccountSettingsService} from '@fluxer/api/src/user/services/UserAccountSettingsService';
|
||||
import {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import {createPremiumClearPatch} from '@fluxer/api/src/user/UserHelpers';
|
||||
import {hasPartialUserFieldsChanged} from '@fluxer/api/src/user/UserMappers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {
|
||||
UserGuildSettingsUpdateRequest,
|
||||
UserSettingsUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
|
||||
interface UpdateUserParams {
|
||||
user: User;
|
||||
oldAuthSession: AuthSession;
|
||||
data: UserUpdateRequest;
|
||||
request: Request;
|
||||
sudoContext?: SudoVerificationResult;
|
||||
emailVerifiedViaToken?: boolean;
|
||||
}
|
||||
|
||||
export class UserAccountService {
|
||||
private readonly lookupService: UserAccountLookupService;
|
||||
private readonly profileService: UserAccountProfileService;
|
||||
private readonly securityService: UserAccountSecurityService;
|
||||
private readonly settingsService: UserAccountSettingsService;
|
||||
private readonly notesService: UserAccountNotesService;
|
||||
private readonly lifecycleService: UserAccountLifecycleService;
|
||||
private readonly updatePropagator: UserAccountUpdatePropagator;
|
||||
private readonly guildRepository: IGuildRepositoryAggregate;
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
|
||||
constructor(
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
userSettingsRepository: IUserSettingsRepository,
|
||||
userRelationshipRepository: IUserRelationshipRepository,
|
||||
userChannelRepository: IUserChannelRepository,
|
||||
authService: AuthService,
|
||||
userCacheService: UserCacheService,
|
||||
guildService: GuildService,
|
||||
gatewayService: IGatewayService,
|
||||
entityAssetService: EntityAssetService,
|
||||
mediaService: IMediaService,
|
||||
packService: PackService,
|
||||
emailService: IEmailService,
|
||||
rateLimitService: IRateLimitService,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
discriminatorService: IDiscriminatorService,
|
||||
kvDeletionQueue: KVAccountDeletionQueueService,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
connectionRepository: IConnectionRepository,
|
||||
readonly limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.guildRepository = guildRepository;
|
||||
this.searchIndexService = new GuildMemberSearchIndexService();
|
||||
|
||||
this.updatePropagator = new UserAccountUpdatePropagator({
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
userRepository: userAccountRepository,
|
||||
});
|
||||
|
||||
this.lookupService = new UserAccountLookupService({
|
||||
userAccountRepository,
|
||||
userRelationshipRepository,
|
||||
userChannelRepository,
|
||||
guildRepository,
|
||||
guildService,
|
||||
discriminatorService,
|
||||
connectionRepository,
|
||||
});
|
||||
|
||||
this.profileService = new UserAccountProfileService({
|
||||
userAccountRepository,
|
||||
guildRepository,
|
||||
entityAssetService,
|
||||
rateLimitService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
limitConfigService,
|
||||
});
|
||||
|
||||
this.securityService = new UserAccountSecurityService({
|
||||
userAccountRepository,
|
||||
authService,
|
||||
discriminatorService,
|
||||
rateLimitService,
|
||||
limitConfigService,
|
||||
});
|
||||
|
||||
this.settingsService = new UserAccountSettingsService({
|
||||
userAccountRepository,
|
||||
userSettingsRepository,
|
||||
updatePropagator: this.updatePropagator,
|
||||
guildRepository,
|
||||
packService,
|
||||
limitConfigService,
|
||||
});
|
||||
|
||||
this.notesService = new UserAccountNotesService({
|
||||
userAccountRepository,
|
||||
userRelationshipRepository,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.lifecycleService = new UserAccountLifecycleService({
|
||||
userAccountRepository,
|
||||
guildRepository,
|
||||
authService,
|
||||
emailService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
kvDeletionQueue,
|
||||
});
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.lookupService.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.lookupService.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async getUserProfile(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId?: GuildID;
|
||||
withMutualFriends?: boolean;
|
||||
withMutualGuilds?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}) {
|
||||
return this.lookupService.getUserProfile(params);
|
||||
}
|
||||
|
||||
async generateUniqueDiscriminator(username: string): Promise<number> {
|
||||
return this.lookupService.generateUniqueDiscriminator(username);
|
||||
}
|
||||
|
||||
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
|
||||
return this.lookupService.checkUsernameDiscriminatorAvailability(params);
|
||||
}
|
||||
|
||||
async update(params: UpdateUserParams): Promise<User> {
|
||||
const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params;
|
||||
|
||||
const profileResult = await this.profileService.processProfileUpdates({user, data});
|
||||
const securityResult = await this.securityService.processSecurityUpdates({user, data, sudoContext});
|
||||
|
||||
const updates = {
|
||||
...securityResult.updates,
|
||||
...profileResult.updates,
|
||||
};
|
||||
const metadata = {
|
||||
...securityResult.metadata,
|
||||
...profileResult.metadata,
|
||||
};
|
||||
|
||||
const emailChanged = data.email !== undefined;
|
||||
if (emailChanged) {
|
||||
updates.email_verified = !!emailVerifiedViaToken;
|
||||
}
|
||||
|
||||
let updatedUser: User;
|
||||
try {
|
||||
updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
|
||||
} catch (error) {
|
||||
await this.profileService.rollbackAssetChanges(profileResult);
|
||||
Logger.error({error, userId: user.id}, 'User update failed, rolled back asset uploads');
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'user_requested',
|
||||
actorUserId: user.id,
|
||||
});
|
||||
|
||||
await this.profileService.commitAssetChanges(profileResult).catch((error) => {
|
||||
Logger.error({error, userId: user.id}, 'Failed to commit asset changes after successful DB update');
|
||||
});
|
||||
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.updatePropagator.updateUserCache(updatedUser);
|
||||
}
|
||||
|
||||
const nameChanged =
|
||||
user.username !== updatedUser.username ||
|
||||
user.discriminator !== updatedUser.discriminator ||
|
||||
user.globalName !== updatedUser.globalName;
|
||||
if (nameChanged) {
|
||||
void this.reindexGuildMembersForUser(updatedUser);
|
||||
}
|
||||
|
||||
if (metadata.invalidateAuthSessions) {
|
||||
await this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request});
|
||||
}
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
private async reindexGuildMembersForUser(updatedUser: User): Promise<void> {
|
||||
try {
|
||||
const guildIds = await this.userAccountRepository.getUserGuildIds(updatedUser.id);
|
||||
for (const guildId of guildIds) {
|
||||
const guild = await this.guildRepository.findUnique(guildId);
|
||||
if (!guild?.membersIndexedAt) {
|
||||
continue;
|
||||
}
|
||||
const member = await this.guildRepository.getMember(guildId, updatedUser.id);
|
||||
if (member) {
|
||||
void this.searchIndexService.updateMember(member, updatedUser);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({userId: updatedUser.id.toString(), error}, 'Failed to reindex guild members after user update');
|
||||
}
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings> {
|
||||
return this.settingsService.findSettings(userId);
|
||||
}
|
||||
|
||||
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
|
||||
return this.settingsService.updateSettings(params);
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return this.settingsService.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async updateGuildSettings(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID | null;
|
||||
data: UserGuildSettingsUpdateRequest;
|
||||
}): Promise<UserGuildSettings> {
|
||||
return this.settingsService.updateGuildSettings(params);
|
||||
}
|
||||
|
||||
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
|
||||
return this.notesService.getUserNote(params);
|
||||
}
|
||||
|
||||
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
|
||||
return this.notesService.getUserNotes(userId);
|
||||
}
|
||||
|
||||
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
|
||||
return this.notesService.setUserNote(params);
|
||||
}
|
||||
|
||||
async selfDisable(userId: UserID): Promise<void> {
|
||||
return this.lifecycleService.selfDisable(userId);
|
||||
}
|
||||
|
||||
async selfDelete(userId: UserID): Promise<void> {
|
||||
return this.lifecycleService.selfDelete(userId);
|
||||
}
|
||||
|
||||
async resetCurrentUserPremiumState(user: User): Promise<void> {
|
||||
const updates = {
|
||||
...createPremiumClearPatch(),
|
||||
premium_lifetime_sequence: null,
|
||||
stripe_subscription_id: null,
|
||||
stripe_customer_id: null,
|
||||
has_ever_purchased: null,
|
||||
first_refund_at: null,
|
||||
gift_inventory_server_seq: null,
|
||||
gift_inventory_client_seq: null,
|
||||
flags: user.flags & ~UserFlags.PREMIUM_ENABLED_OVERRIDE,
|
||||
};
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.updatePropagator.updateUserCache(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserUpdate(user);
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserGuildSettingsUpdate({
|
||||
userId,
|
||||
settings,
|
||||
}: {
|
||||
userId: UserID;
|
||||
settings: UserGuildSettings;
|
||||
}): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserNoteUpdate(params);
|
||||
}
|
||||
}
|
||||
339
packages/api/src/user/services/UserAccountSettingsService.tsx
Normal file
339
packages/api/src/user/services/UserAccountSettingsService.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createChannelID, createGuildID, type GuildID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ChannelOverride, UserGuildSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
|
||||
import {CustomStatusValidator} from '@fluxer/api/src/user/services/CustomStatusValidator';
|
||||
import type {UserAccountUpdatePropagator} from '@fluxer/api/src/user/services/UserAccountUpdatePropagator';
|
||||
import {
|
||||
DEFAULT_GUILD_FOLDER_ICON,
|
||||
FriendSourceFlags,
|
||||
GroupDmAddPermissionFlags,
|
||||
IncomingCallFlags,
|
||||
UNCATEGORIZED_FOLDER_ID,
|
||||
UserNotificationSettings,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {ValidationError} from '@fluxer/errors/src/ValidationError';
|
||||
import type {
|
||||
UserGuildSettingsUpdateRequest,
|
||||
UserSettingsUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
|
||||
interface UserAccountSettingsServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
userSettingsRepository: IUserSettingsRepository;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
packService: PackService;
|
||||
limitConfigService: LimitConfigService;
|
||||
}
|
||||
|
||||
export class UserAccountSettingsService {
|
||||
private readonly customStatusValidator: CustomStatusValidator;
|
||||
|
||||
constructor(private readonly deps: UserAccountSettingsServiceDeps) {
|
||||
this.customStatusValidator = new CustomStatusValidator(
|
||||
this.deps.userAccountRepository,
|
||||
this.deps.guildRepository,
|
||||
this.deps.packService,
|
||||
this.deps.limitConfigService,
|
||||
);
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings> {
|
||||
const userSettings = await this.deps.userSettingsRepository.findSettings(userId);
|
||||
if (!userSettings) throw new UnknownUserError();
|
||||
return userSettings;
|
||||
}
|
||||
|
||||
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
|
||||
const {userId, data} = params;
|
||||
const currentSettings = await this.deps.userSettingsRepository.findSettings(userId);
|
||||
if (!currentSettings) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedRowData = {...currentSettings.toRow(), user_id: userId};
|
||||
const localeChanged = data.locale !== undefined && data.locale !== currentSettings.locale;
|
||||
|
||||
if (data.status !== undefined) updatedRowData.status = data.status;
|
||||
if (data.status_resets_at !== undefined) updatedRowData.status_resets_at = data.status_resets_at;
|
||||
if (data.status_resets_to !== undefined) updatedRowData.status_resets_to = data.status_resets_to;
|
||||
if (data.theme !== undefined) {
|
||||
if (data.theme !== currentSettings.theme) {
|
||||
getMetricsService().counter({
|
||||
name: 'fluxer.users.theme_changed',
|
||||
dimensions: {
|
||||
new_theme: data.theme,
|
||||
old_theme: currentSettings.theme,
|
||||
},
|
||||
});
|
||||
}
|
||||
updatedRowData.theme = data.theme;
|
||||
}
|
||||
if (data.locale !== undefined) updatedRowData.locale = data.locale;
|
||||
if (data.custom_status !== undefined) {
|
||||
if (data.custom_status === null) {
|
||||
updatedRowData.custom_status = null;
|
||||
} else {
|
||||
const validated = await this.customStatusValidator.validate(userId, data.custom_status);
|
||||
updatedRowData.custom_status = {
|
||||
text: validated.text,
|
||||
expires_at: validated.expiresAt,
|
||||
emoji_id: validated.emojiId,
|
||||
emoji_name: validated.emojiName,
|
||||
emoji_animated: validated.emojiAnimated,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (data.flags !== undefined) updatedRowData.friend_source_flags = data.flags;
|
||||
if (data.restricted_guilds !== undefined) {
|
||||
updatedRowData.restricted_guilds = data.restricted_guilds
|
||||
? new Set(data.restricted_guilds.map(createGuildID))
|
||||
: null;
|
||||
}
|
||||
if (data.bot_restricted_guilds !== undefined) {
|
||||
updatedRowData.bot_restricted_guilds = data.bot_restricted_guilds
|
||||
? new Set(data.bot_restricted_guilds.map(createGuildID))
|
||||
: null;
|
||||
}
|
||||
if (data.default_guilds_restricted !== undefined) {
|
||||
updatedRowData.default_guilds_restricted = data.default_guilds_restricted;
|
||||
}
|
||||
if (data.bot_default_guilds_restricted !== undefined) {
|
||||
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
|
||||
}
|
||||
if (data.inline_attachment_media !== undefined) {
|
||||
updatedRowData.inline_attachment_media = data.inline_attachment_media;
|
||||
}
|
||||
if (data.inline_embed_media !== undefined) updatedRowData.inline_embed_media = data.inline_embed_media;
|
||||
if (data.gif_auto_play !== undefined) updatedRowData.gif_auto_play = data.gif_auto_play;
|
||||
if (data.render_embeds !== undefined) updatedRowData.render_embeds = data.render_embeds;
|
||||
if (data.render_reactions !== undefined) updatedRowData.render_reactions = data.render_reactions;
|
||||
if (data.animate_emoji !== undefined) updatedRowData.animate_emoji = data.animate_emoji;
|
||||
if (data.animate_stickers !== undefined) updatedRowData.animate_stickers = data.animate_stickers;
|
||||
if (data.render_spoilers !== undefined) updatedRowData.render_spoilers = data.render_spoilers;
|
||||
if (data.message_display_compact !== undefined) {
|
||||
updatedRowData.message_display_compact = data.message_display_compact;
|
||||
}
|
||||
if (data.friend_source_flags !== undefined) {
|
||||
updatedRowData.friend_source_flags = this.normalizeFriendSourceFlags(data.friend_source_flags);
|
||||
}
|
||||
if (data.incoming_call_flags !== undefined) {
|
||||
updatedRowData.incoming_call_flags = this.normalizeIncomingCallFlags(data.incoming_call_flags);
|
||||
}
|
||||
if (data.group_dm_add_permission_flags !== undefined) {
|
||||
updatedRowData.group_dm_add_permission_flags = this.normalizeGroupDmAddPermissionFlags(
|
||||
data.group_dm_add_permission_flags,
|
||||
);
|
||||
}
|
||||
if (data.guild_folders !== undefined) {
|
||||
const mappedFolders = data.guild_folders.map((folder) => ({
|
||||
folder_id: folder.id,
|
||||
name: folder.name ?? null,
|
||||
color: folder.color ?? 0x000000,
|
||||
flags: folder.flags ?? 0,
|
||||
icon: folder.icon ?? DEFAULT_GUILD_FOLDER_ICON,
|
||||
guild_ids: folder.guild_ids.map(createGuildID),
|
||||
}));
|
||||
const hasUncategorized = mappedFolders.some((folder) => folder.folder_id === UNCATEGORIZED_FOLDER_ID);
|
||||
if (!hasUncategorized) {
|
||||
mappedFolders.unshift({
|
||||
folder_id: UNCATEGORIZED_FOLDER_ID,
|
||||
name: null,
|
||||
color: 0x000000,
|
||||
flags: 0,
|
||||
icon: DEFAULT_GUILD_FOLDER_ICON,
|
||||
guild_ids: [],
|
||||
});
|
||||
}
|
||||
updatedRowData.guild_folders = mappedFolders;
|
||||
}
|
||||
if (data.afk_timeout !== undefined) updatedRowData.afk_timeout = data.afk_timeout;
|
||||
if (data.time_format !== undefined) updatedRowData.time_format = data.time_format;
|
||||
if (data.developer_mode !== undefined) updatedRowData.developer_mode = data.developer_mode;
|
||||
if (data.trusted_domains !== undefined) {
|
||||
const domainsSet = new Set(data.trusted_domains);
|
||||
if (domainsSet.has('*') && domainsSet.size > 1) {
|
||||
throw ValidationError.fromField(
|
||||
'trusted_domains',
|
||||
'INVALID_TRUSTED_DOMAINS',
|
||||
'Cannot combine wildcard (*) with specific domains',
|
||||
);
|
||||
}
|
||||
updatedRowData.trusted_domains = domainsSet.size > 0 ? domainsSet : null;
|
||||
}
|
||||
if (data.default_hide_muted_channels !== undefined) {
|
||||
updatedRowData.default_hide_muted_channels = data.default_hide_muted_channels;
|
||||
}
|
||||
|
||||
await this.deps.userSettingsRepository.upsertSettings(updatedRowData);
|
||||
const updatedSettings = await this.findSettings(userId);
|
||||
await this.deps.updatePropagator.dispatchUserSettingsUpdate({userId, settings: updatedSettings});
|
||||
|
||||
if (localeChanged) {
|
||||
const user = await this.deps.userAccountRepository.findUnique(userId);
|
||||
if (user) {
|
||||
const updatedUser = await this.deps.userAccountRepository.patchUpsert(
|
||||
userId,
|
||||
{locale: data.locale},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async updateGuildSettings(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID | null;
|
||||
data: UserGuildSettingsUpdateRequest;
|
||||
}): Promise<UserGuildSettings> {
|
||||
const {userId, guildId, data} = params;
|
||||
const currentSettings = await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
|
||||
const resolvedGuildId = guildId ?? createGuildID(0n);
|
||||
const baseRow: UserGuildSettingsRow = currentSettings
|
||||
? {
|
||||
...currentSettings.toRow(),
|
||||
user_id: userId,
|
||||
guild_id: resolvedGuildId,
|
||||
}
|
||||
: {
|
||||
user_id: userId,
|
||||
guild_id: resolvedGuildId,
|
||||
message_notifications: UserNotificationSettings.INHERIT,
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
mobile_push: false,
|
||||
suppress_everyone: false,
|
||||
suppress_roles: false,
|
||||
hide_muted_channels: false,
|
||||
channel_overrides: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const updatedRowData: UserGuildSettingsRow = {...baseRow};
|
||||
|
||||
if (data.message_notifications !== undefined) updatedRowData.message_notifications = data.message_notifications;
|
||||
if (data.muted !== undefined) updatedRowData.muted = data.muted;
|
||||
if (data.mute_config !== undefined) {
|
||||
updatedRowData.mute_config = data.mute_config
|
||||
? {
|
||||
end_time: data.mute_config.end_time ?? null,
|
||||
selected_time_window: data.mute_config.selected_time_window,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (data.mobile_push !== undefined) updatedRowData.mobile_push = data.mobile_push;
|
||||
if (data.suppress_everyone !== undefined) updatedRowData.suppress_everyone = data.suppress_everyone;
|
||||
if (data.suppress_roles !== undefined) updatedRowData.suppress_roles = data.suppress_roles;
|
||||
if (data.hide_muted_channels !== undefined) updatedRowData.hide_muted_channels = data.hide_muted_channels;
|
||||
if (data.channel_overrides !== undefined) {
|
||||
if (data.channel_overrides) {
|
||||
const channelOverrides = new Map<ChannelID, ChannelOverride>();
|
||||
for (const [channelIdStr, override] of Object.entries(data.channel_overrides)) {
|
||||
const channelId = createChannelID(BigInt(channelIdStr));
|
||||
channelOverrides.set(channelId, {
|
||||
collapsed: override.collapsed,
|
||||
message_notifications: override.message_notifications,
|
||||
muted: override.muted,
|
||||
mute_config: override.mute_config
|
||||
? {
|
||||
end_time: override.mute_config.end_time ?? null,
|
||||
selected_time_window: override.mute_config.selected_time_window,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
updatedRowData.channel_overrides = channelOverrides.size > 0 ? channelOverrides : null;
|
||||
} else {
|
||||
updatedRowData.channel_overrides = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSettings = await this.deps.userSettingsRepository.upsertGuildSettings(updatedRowData);
|
||||
await this.deps.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings: updatedSettings});
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
private normalizeFriendSourceFlags(flags: number): number {
|
||||
let normalizedFlags = flags;
|
||||
|
||||
if ((normalizedFlags & FriendSourceFlags.NO_RELATION) === FriendSourceFlags.NO_RELATION) {
|
||||
const hasMutualFriends =
|
||||
(normalizedFlags & FriendSourceFlags.MUTUAL_FRIENDS) === FriendSourceFlags.MUTUAL_FRIENDS;
|
||||
const hasMutualGuilds = (normalizedFlags & FriendSourceFlags.MUTUAL_GUILDS) === FriendSourceFlags.MUTUAL_GUILDS;
|
||||
|
||||
if (!hasMutualFriends || !hasMutualGuilds) {
|
||||
normalizedFlags &= ~FriendSourceFlags.NO_RELATION;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedFlags;
|
||||
}
|
||||
|
||||
private normalizeIncomingCallFlags(flags: number): number {
|
||||
let normalizedFlags = flags;
|
||||
|
||||
const modifierFlags = flags & IncomingCallFlags.SILENT_EVERYONE;
|
||||
|
||||
if ((normalizedFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
|
||||
normalizedFlags = IncomingCallFlags.FRIENDS_ONLY | modifierFlags;
|
||||
}
|
||||
|
||||
if ((normalizedFlags & IncomingCallFlags.NOBODY) === IncomingCallFlags.NOBODY) {
|
||||
normalizedFlags = IncomingCallFlags.NOBODY | modifierFlags;
|
||||
}
|
||||
|
||||
return normalizedFlags;
|
||||
}
|
||||
|
||||
private normalizeGroupDmAddPermissionFlags(flags: number): number {
|
||||
let normalizedFlags = flags;
|
||||
|
||||
if ((normalizedFlags & GroupDmAddPermissionFlags.FRIENDS_ONLY) === GroupDmAddPermissionFlags.FRIENDS_ONLY) {
|
||||
normalizedFlags = GroupDmAddPermissionFlags.FRIENDS_ONLY;
|
||||
}
|
||||
|
||||
if ((normalizedFlags & GroupDmAddPermissionFlags.NOBODY) === GroupDmAddPermissionFlags.NOBODY) {
|
||||
normalizedFlags = GroupDmAddPermissionFlags.NOBODY;
|
||||
}
|
||||
|
||||
if ((normalizedFlags & GroupDmAddPermissionFlags.EVERYONE) === GroupDmAddPermissionFlags.EVERYONE) {
|
||||
normalizedFlags = GroupDmAddPermissionFlags.EVERYONE;
|
||||
}
|
||||
|
||||
return normalizedFlags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import {BaseUserUpdatePropagator} from '@fluxer/api/src/user/services/BaseUserUpdatePropagator';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
|
||||
interface UserAccountUpdatePropagatorDeps {
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
mediaService: IMediaService;
|
||||
userRepository: IUserAccountRepository;
|
||||
}
|
||||
|
||||
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
constructor(private readonly deps: UserAccountUpdatePropagatorDeps) {
|
||||
super({
|
||||
userCacheService: deps.userCacheService,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
const guildIds = await this.deps.userRepository.getUserGuildIds(userId);
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_SETTINGS_UPDATE',
|
||||
data: mapUserSettingsToResponse({settings, memberGuildIds: guildIds}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchUserGuildSettingsUpdate({
|
||||
userId,
|
||||
settings,
|
||||
}: {
|
||||
userId: UserID;
|
||||
settings: UserGuildSettings;
|
||||
}): Promise<void> {
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_GUILD_SETTINGS_UPDATE',
|
||||
data: mapUserGuildSettingsToResponse(settings),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
const {userId, targetId, note} = params;
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_NOTE_UPDATE',
|
||||
data: {id: targetId.toString(), note},
|
||||
});
|
||||
}
|
||||
}
|
||||
204
packages/api/src/user/services/UserAuthRequestService.tsx
Normal file
204
packages/api/src/user/services/UserAuthRequestService.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
|
||||
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import type {UserService} from '@fluxer/api/src/user/services/UserService';
|
||||
import type {
|
||||
DisableTotpRequest,
|
||||
EnableMfaTotpRequest,
|
||||
MfaBackupCodesRequest,
|
||||
MfaBackupCodesResponse,
|
||||
PhoneAddRequest,
|
||||
PhoneSendVerificationRequest,
|
||||
PhoneVerifyRequest,
|
||||
PhoneVerifyResponse,
|
||||
SudoMfaMethodsResponse,
|
||||
WebAuthnChallengeResponse,
|
||||
WebAuthnCredentialListResponse,
|
||||
WebAuthnCredentialUpdateRequest,
|
||||
WebAuthnRegisterRequest,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
|
||||
interface UserAuthWithSudoRequest<T> {
|
||||
user: User;
|
||||
data: T;
|
||||
sudoContext: SudoVerificationResult;
|
||||
}
|
||||
|
||||
interface UserAuthRequest<T> {
|
||||
user: User;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface UserAuthPhoneTokenRequest {
|
||||
user: User;
|
||||
data: PhoneAddRequest;
|
||||
}
|
||||
|
||||
interface UserAuthWebAuthnUpdateRequest {
|
||||
user: User;
|
||||
credentialId: string;
|
||||
data: WebAuthnCredentialUpdateRequest;
|
||||
}
|
||||
|
||||
interface UserAuthWebAuthnRegisterRequest {
|
||||
user: User;
|
||||
data: WebAuthnRegisterRequest;
|
||||
}
|
||||
|
||||
interface UserAuthWebAuthnDeleteRequest {
|
||||
user: User;
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
export class UserAuthRequestService {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private authMfaService: AuthMfaService,
|
||||
private userService: UserService,
|
||||
private userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async enableTotp({
|
||||
user,
|
||||
data,
|
||||
sudoContext,
|
||||
}: UserAuthWithSudoRequest<EnableMfaTotpRequest>): Promise<MfaBackupCodesResponse> {
|
||||
const backupCodes = await this.userService.enableMfaTotp({
|
||||
user,
|
||||
secret: data.secret,
|
||||
code: data.code,
|
||||
sudoContext,
|
||||
});
|
||||
return this.toBackupCodesResponse(backupCodes);
|
||||
}
|
||||
|
||||
async disableTotp({user, data, sudoContext}: UserAuthWithSudoRequest<DisableTotpRequest>): Promise<void> {
|
||||
await this.userService.disableMfaTotp({
|
||||
user,
|
||||
code: data.code,
|
||||
sudoContext,
|
||||
password: data.password,
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupCodes({
|
||||
user,
|
||||
data,
|
||||
sudoContext,
|
||||
}: UserAuthWithSudoRequest<MfaBackupCodesRequest>): Promise<MfaBackupCodesResponse> {
|
||||
const backupCodes = await this.userService.getMfaBackupCodes({
|
||||
user,
|
||||
regenerate: data.regenerate,
|
||||
sudoContext,
|
||||
password: data.password,
|
||||
});
|
||||
return this.toBackupCodesResponse(backupCodes);
|
||||
}
|
||||
|
||||
async sendPhoneVerificationCode({user, data}: UserAuthRequest<PhoneSendVerificationRequest>): Promise<void> {
|
||||
await this.authService.sendPhoneVerificationCode(data.phone, user.id);
|
||||
}
|
||||
|
||||
async verifyPhoneCode({user, data}: UserAuthRequest<PhoneVerifyRequest>): Promise<PhoneVerifyResponse> {
|
||||
const phoneToken = await this.authService.verifyPhoneCode(data.phone, data.code, user.id);
|
||||
return {phone_token: phoneToken};
|
||||
}
|
||||
|
||||
async addPhoneToAccount({user, data}: UserAuthPhoneTokenRequest): Promise<void> {
|
||||
await this.authService.addPhoneToAccount(user.id, data.phone_token);
|
||||
}
|
||||
|
||||
async removePhoneFromAccount(user: User): Promise<void> {
|
||||
await this.authService.removePhoneFromAccount(user.id);
|
||||
}
|
||||
|
||||
async enableSmsMfa(user: User): Promise<void> {
|
||||
await this.authService.enableSmsMfa(user.id);
|
||||
}
|
||||
|
||||
async disableSmsMfa(user: User): Promise<void> {
|
||||
await this.authService.disableSmsMfa(user.id);
|
||||
}
|
||||
|
||||
async forgetAuthorizedIps(user: User): Promise<void> {
|
||||
await this.userRepository.deleteAllAuthorizedIps(user.id);
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(user: User): Promise<WebAuthnCredentialListResponse> {
|
||||
const credentials = await this.userRepository.listWebAuthnCredentials(user.id);
|
||||
return credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async generateWebAuthnRegistrationOptions(user: User): Promise<WebAuthnChallengeResponse> {
|
||||
const options = await this.authService.generateWebAuthnRegistrationOptions(user.id);
|
||||
return this.toWebAuthnChallengeResponse(options);
|
||||
}
|
||||
|
||||
async registerWebAuthnCredential({user, data}: UserAuthWebAuthnRegisterRequest): Promise<void> {
|
||||
await this.authService.verifyWebAuthnRegistration(user.id, data.response, data.challenge, data.name);
|
||||
}
|
||||
|
||||
async renameWebAuthnCredential({user, credentialId, data}: UserAuthWebAuthnUpdateRequest): Promise<void> {
|
||||
await this.authService.renameWebAuthnCredential(user.id, credentialId, data.name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential({user, credentialId}: UserAuthWebAuthnDeleteRequest): Promise<void> {
|
||||
await this.authService.deleteWebAuthnCredential(user.id, credentialId);
|
||||
}
|
||||
|
||||
async listSudoMfaMethods(user: User): Promise<SudoMfaMethodsResponse> {
|
||||
return this.authMfaService.getAvailableMfaMethods(user.id);
|
||||
}
|
||||
|
||||
async sendSudoSmsCode(user: User): Promise<void> {
|
||||
await this.authService.sendSmsMfaCode(user.id);
|
||||
}
|
||||
|
||||
async getSudoWebAuthnOptions(user: User): Promise<WebAuthnChallengeResponse> {
|
||||
const options = await this.authMfaService.generateWebAuthnOptionsForSudo(user.id);
|
||||
return this.toWebAuthnChallengeResponse(options);
|
||||
}
|
||||
|
||||
private toWebAuthnChallengeResponse(options: {challenge: string}): WebAuthnChallengeResponse {
|
||||
const response: Record<string, unknown> & {challenge: string} = {
|
||||
...options,
|
||||
challenge: options.challenge,
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
private toBackupCodesResponse(backupCodes: Array<{code: string; consumed: boolean}>): MfaBackupCodesResponse {
|
||||
return {
|
||||
backup_codes: backupCodes.map((code) => ({
|
||||
code: code.code,
|
||||
consumed: code.consumed,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
207
packages/api/src/user/services/UserAuthService.tsx
Normal file
207
packages/api/src/user/services/UserAuthService.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {userHasMfa} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import {createEmailVerificationToken} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
|
||||
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import * as RandomUtils from '@fluxer/api/src/utils/RandomUtils';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {MfaNotDisabledError} from '@fluxer/errors/src/domains/auth/MfaNotDisabledError';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
|
||||
export class UserAuthService {
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userAuthRepository: IUserAuthRepository,
|
||||
private authService: AuthService,
|
||||
private emailService: IEmailService,
|
||||
private gatewayService: IGatewayService,
|
||||
private botMfaMirrorService?: BotMfaMirrorService,
|
||||
) {}
|
||||
|
||||
async enableMfaTotp(params: {
|
||||
user: User;
|
||||
secret: string;
|
||||
code: string;
|
||||
sudoContext: SudoVerificationResult;
|
||||
}): Promise<Array<MfaBackupCode>> {
|
||||
const {user, secret, code, sudoContext} = params;
|
||||
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
if (user.totpSecret) throw new MfaNotDisabledError();
|
||||
|
||||
const userId = user.id;
|
||||
if (!(await this.authService.verifyMfaCode({userId: user.id, mfaSecret: secret, code}))) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.TOTP);
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
totp_secret: secret,
|
||||
authenticator_types: authenticatorTypes,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
const newBackupCodes = this.authService.generateBackupCodes();
|
||||
const mfaBackupCodes = await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
if (updatedUser) {
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
return mfaBackupCodes;
|
||||
}
|
||||
|
||||
async disableMfaTotp(params: {user: User; code: string; sudoContext: SudoVerificationResult}): Promise<void> {
|
||||
const {user, code, sudoContext} = params;
|
||||
if (!user.totpSecret) throw new MfaNotEnabledError();
|
||||
|
||||
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this.authService.verifyMfaCode({
|
||||
userId: user.id,
|
||||
mfaSecret: user.totpSecret,
|
||||
code,
|
||||
allowBackup: true,
|
||||
}))
|
||||
) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.TOTP);
|
||||
const hasSms = authenticatorTypes.has(UserAuthenticatorTypes.SMS);
|
||||
if (hasSms) {
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.SMS);
|
||||
}
|
||||
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
totp_secret: null,
|
||||
authenticator_types: authenticatorTypes,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.userAuthRepository.clearMfaBackupCodes(userId);
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
|
||||
async getMfaBackupCodes(params: {
|
||||
user: User;
|
||||
regenerate: boolean;
|
||||
sudoContext: SudoVerificationResult;
|
||||
}): Promise<Array<MfaBackupCode>> {
|
||||
const {user, regenerate, sudoContext} = params;
|
||||
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
|
||||
if (regenerate) {
|
||||
return this.regenerateMfaBackupCodes(user);
|
||||
}
|
||||
|
||||
return await this.userAuthRepository.listMfaBackupCodes(user.id);
|
||||
}
|
||||
|
||||
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
|
||||
const userId = user.id;
|
||||
const newBackupCodes = this.authService.generateBackupCodes();
|
||||
await this.userAuthRepository.clearMfaBackupCodes(userId);
|
||||
return await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
|
||||
}
|
||||
|
||||
async verifyEmail(token: string): Promise<boolean> {
|
||||
const emailToken = await this.userAuthRepository.getEmailVerificationToken(token);
|
||||
if (!emailToken) {
|
||||
return false;
|
||||
}
|
||||
const user = await this.userAccountRepository.findUnique(emailToken.userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(
|
||||
emailToken.userId,
|
||||
{
|
||||
email: emailToken.email,
|
||||
email_verified: true,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.userAuthRepository.deleteEmailVerificationToken(token);
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
return true;
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<boolean> {
|
||||
if (user.emailVerified) {
|
||||
return true;
|
||||
}
|
||||
const email = user.email;
|
||||
if (!email) {
|
||||
return false;
|
||||
}
|
||||
const verificationToken = createEmailVerificationToken(RandomUtils.randomString(64));
|
||||
await this.userAuthRepository.createEmailVerificationToken({
|
||||
token_: verificationToken,
|
||||
user_id: user.id,
|
||||
email,
|
||||
});
|
||||
await this.emailService.sendEmailVerification(email, user.username, verificationToken, user.locale);
|
||||
return true;
|
||||
}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
}
|
||||
86
packages/api/src/user/services/UserChannelRequestService.tsx
Normal file
86
packages/api/src/user/services/UserChannelRequestService.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {UserService} from '@fluxer/api/src/user/services/UserService';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
|
||||
interface UserChannelListParams {
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface UserChannelCreateParams {
|
||||
userId: UserID;
|
||||
data: CreatePrivateChannelRequest;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface UserChannelPinParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}
|
||||
|
||||
export class UserChannelRequestService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
) {}
|
||||
|
||||
async listPrivateChannels(params: UserChannelListParams): Promise<Array<ChannelResponse>> {
|
||||
const channels = await this.userService.getPrivateChannels(params.userId);
|
||||
return Promise.all(
|
||||
channels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: params.userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async createPrivateChannel(params: UserChannelCreateParams): Promise<ChannelResponse> {
|
||||
const channel = await this.userService.createOrOpenDMChannel({
|
||||
userId: params.userId,
|
||||
data: params.data,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
return mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: params.userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async pinChannel(params: UserChannelPinParams): Promise<void> {
|
||||
await this.userService.pinDmChannel({userId: params.userId, channelId: params.channelId});
|
||||
}
|
||||
|
||||
async unpinChannel(params: UserChannelPinParams): Promise<void> {
|
||||
await this.userService.unpinDmChannel({userId: params.userId, channelId: params.channelId});
|
||||
}
|
||||
}
|
||||
547
packages/api/src/user/services/UserChannelService.tsx
Normal file
547
packages/api/src/user/services/UserChannelService.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createChannelID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import {dispatchMessageCreate} from '@fluxer/api/src/channel/services/group_dm/GroupDmHelpers';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {MAX_GROUP_DM_RECIPIENTS, MAX_GROUP_DMS_PER_USER} from '@fluxer/constants/src/LimitConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError';
|
||||
import {MaxGroupDmRecipientsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmRecipientsError';
|
||||
import {MaxGroupDmsError} from '@fluxer/errors/src/domains/channel/MaxGroupDmsError';
|
||||
import {UnclaimedAccountCannotSendDirectMessagesError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotSendDirectMessagesError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
|
||||
export class UserChannelService {
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userChannelRepository: IUserChannelRepository,
|
||||
private userRelationshipRepository: IUserRelationshipRepository,
|
||||
private channelService: ChannelService,
|
||||
private channelRepository: IChannelRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private userPermissionUtils: UserPermissionUtils,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
return await this.userChannelRepository.listPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async createOrOpenDMChannel({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: CreatePrivateChannelRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
if (data.recipients !== undefined) {
|
||||
return await this.createGroupDMChannel({
|
||||
userId,
|
||||
recipients: data.recipients,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.recipient_id) {
|
||||
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.RECIPIENT_IDS_CANNOT_BE_EMPTY);
|
||||
}
|
||||
const recipientId = createUserID(data.recipient_id);
|
||||
if (userId === recipientId) {
|
||||
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.CANNOT_DM_YOURSELF);
|
||||
}
|
||||
const targetUser = await this.userAccountRepository.findUnique(recipientId);
|
||||
if (!targetUser) throw new UnknownUserError();
|
||||
|
||||
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
|
||||
if (existingChannel) {
|
||||
return await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
|
||||
}
|
||||
await this.validateDmPermission(userId, recipientId, targetUser);
|
||||
const channel = await this.createNewDMChannel({userId, recipientId, userCacheService, requestCache});
|
||||
return channel;
|
||||
}
|
||||
|
||||
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
|
||||
}
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
const newPinnedDMs = await this.userChannelRepository.addPinnedDm(userId, channelId);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: userId,
|
||||
event: 'USER_PINNED_DMS_UPDATE',
|
||||
data: newPinnedDMs.map(String),
|
||||
});
|
||||
}
|
||||
|
||||
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw InputValidationError.fromCode('channel_id', ValidationErrorCodes.CHANNEL_MUST_BE_DM_OR_GROUP_DM);
|
||||
}
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
const newPinnedDMs = await this.userChannelRepository.removePinnedDm(userId, channelId);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: userId,
|
||||
event: 'USER_PINNED_DMS_UPDATE',
|
||||
data: newPinnedDMs.map(String),
|
||||
});
|
||||
}
|
||||
|
||||
async preloadDMMessages(params: {
|
||||
userId: UserID;
|
||||
channelIds: Array<ChannelID>;
|
||||
}): Promise<Record<string, Message | null>> {
|
||||
const {userId, channelIds} = params;
|
||||
if (channelIds.length > 100) {
|
||||
throw InputValidationError.fromCode('channels', ValidationErrorCodes.CANNOT_PRELOAD_MORE_THAN_100_CHANNELS);
|
||||
}
|
||||
|
||||
const results: Record<string, Message | null> = {};
|
||||
const fetchPromises = channelIds.map(async (channelId) => {
|
||||
try {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
return;
|
||||
}
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
return;
|
||||
}
|
||||
const messages = await this.channelService.getMessages({
|
||||
userId,
|
||||
channelId,
|
||||
limit: 1,
|
||||
before: undefined,
|
||||
after: undefined,
|
||||
around: undefined,
|
||||
});
|
||||
results[channelId.toString()] = messages[0] ?? null;
|
||||
} catch {
|
||||
results[channelId.toString()] = null;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(fetchPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
async getExistingDmForUsers(userId: UserID, recipientId: UserID): Promise<Channel | null> {
|
||||
return await this.userChannelRepository.findExistingDmState(userId, recipientId);
|
||||
}
|
||||
|
||||
async ensureDmOpenForBothUsers({
|
||||
userId,
|
||||
recipientId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipientId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
|
||||
|
||||
if (existingChannel) {
|
||||
const [isUserOpen, isRecipientOpen] = await Promise.all([
|
||||
this.userChannelRepository.isDmChannelOpen(userId, existingChannel.id),
|
||||
this.userChannelRepository.isDmChannelOpen(recipientId, existingChannel.id),
|
||||
]);
|
||||
|
||||
if (!isUserOpen) {
|
||||
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
|
||||
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
if (!isRecipientOpen) {
|
||||
await this.userChannelRepository.openDmForUser(recipientId, existingChannel.id);
|
||||
await this.dispatchChannelCreate({
|
||||
userId: recipientId,
|
||||
channel: existingChannel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
return existingChannel;
|
||||
}
|
||||
|
||||
return await this.createNewDmForBothUsers({userId, recipientId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async reopenDmForBothUsers({
|
||||
userId,
|
||||
recipientId,
|
||||
existingChannel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipientId: UserID;
|
||||
existingChannel: Channel;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
|
||||
await this.reopenExistingDMChannel({
|
||||
userId: recipientId,
|
||||
existingChannel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async createNewDmForBothUsers({
|
||||
userId,
|
||||
recipientId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipientId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const newChannel = await this.createNewDMChannel({
|
||||
userId,
|
||||
recipientId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.userChannelRepository.openDmForUser(recipientId, newChannel.id);
|
||||
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
private async reopenExistingDMChannel({
|
||||
userId,
|
||||
existingChannel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
existingChannel: Channel;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
|
||||
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
|
||||
return existingChannel;
|
||||
}
|
||||
|
||||
private async createNewDMChannel({
|
||||
userId,
|
||||
recipientId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipientId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const channelId = createChannelID(await this.snowflakeService.generate());
|
||||
const newChannel = await this.userChannelRepository.createDmChannelAndState(userId, recipientId, channelId);
|
||||
await this.userChannelRepository.openDmForUser(userId, channelId);
|
||||
await this.dispatchChannelCreate({userId, channel: newChannel, userCacheService, requestCache});
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
private async createGroupDMChannel({
|
||||
userId,
|
||||
recipients,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipients: Array<bigint>;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const fallbackRecipientLimit = MAX_GROUP_DM_RECIPIENTS;
|
||||
const recipientLimit = this.resolveLimitForUser(
|
||||
await this.userAccountRepository.findUnique(userId),
|
||||
'max_group_dm_recipients',
|
||||
fallbackRecipientLimit,
|
||||
);
|
||||
if (recipients.length > recipientLimit) {
|
||||
throw new MaxGroupDmRecipientsError(recipientLimit);
|
||||
}
|
||||
|
||||
const recipientIds = recipients.map(createUserID);
|
||||
const uniqueRecipientIds = new Set(recipientIds);
|
||||
if (uniqueRecipientIds.size !== recipientIds.length) {
|
||||
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.DUPLICATE_RECIPIENTS_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
if (uniqueRecipientIds.has(userId)) {
|
||||
throw InputValidationError.fromCode('recipients', ValidationErrorCodes.CANNOT_ADD_YOURSELF_TO_GROUP_DM);
|
||||
}
|
||||
|
||||
const usersToCheck = new Set<UserID>([userId, ...recipientIds]);
|
||||
await this.ensureUsersWithinGroupDmLimit(usersToCheck);
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
const targetUser = await this.userAccountRepository.findUnique(recipientId);
|
||||
if (!targetUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const friendship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
recipientId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (!friendship) {
|
||||
throw new NotFriendsWithUserError();
|
||||
}
|
||||
|
||||
await this.userPermissionUtils.validateGroupDmAddPermissions({userId, targetId: recipientId});
|
||||
}
|
||||
|
||||
const channelId = createChannelID(await this.snowflakeService.generate());
|
||||
const allRecipients = new Set([userId, ...recipientIds]);
|
||||
|
||||
const channelData = {
|
||||
channel_id: channelId,
|
||||
guild_id: null,
|
||||
type: ChannelTypes.GROUP_DM,
|
||||
name: null,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: 0,
|
||||
owner_id: userId,
|
||||
recipient_ids: allRecipients,
|
||||
nsfw: false,
|
||||
rate_limit_per_user: 0,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const newChannel = await this.channelRepository.upsert(channelData);
|
||||
|
||||
for (const recipientId of allRecipients) {
|
||||
await this.userChannelRepository.openDmForUser(recipientId, channelId);
|
||||
}
|
||||
|
||||
const systemMessages: Array<Message> = [];
|
||||
for (const recipientId of recipientIds) {
|
||||
const messageId = createMessageID(await this.snowflakeService.generate());
|
||||
const message = await this.channelRepository.upsertMessage({
|
||||
channel_id: channelId,
|
||||
bucket: BucketUtils.makeBucket(messageId),
|
||||
message_id: messageId,
|
||||
author_id: userId,
|
||||
type: MessageTypes.RECIPIENT_ADD,
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: null,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
mention_everyone: false,
|
||||
mention_users: new Set([recipientId]),
|
||||
mention_roles: null,
|
||||
mention_channels: null,
|
||||
attachments: null,
|
||||
embeds: null,
|
||||
sticker_items: null,
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
systemMessages.push(message);
|
||||
}
|
||||
|
||||
for (const recipientId of allRecipients) {
|
||||
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
for (const message of systemMessages) {
|
||||
await this.dispatchSystemMessage({
|
||||
channel: newChannel,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
private async dispatchSystemMessage({
|
||||
channel,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
userCacheService,
|
||||
gatewayService: this.gatewayService,
|
||||
mediaService: this.mediaService,
|
||||
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchChannelCreate({
|
||||
userId,
|
||||
channel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channel: Channel;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_CREATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureUsersWithinGroupDmLimit(userIds: Iterable<UserID>): Promise<void> {
|
||||
for (const userId of userIds) {
|
||||
await this.ensureUserWithinGroupDmLimit(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserWithinGroupDmLimit(userId: UserID): Promise<void> {
|
||||
const summaries = await this.userChannelRepository.listPrivateChannelSummaries(userId);
|
||||
const openGroupDms = summaries.filter((summary) => summary.open && summary.isGroupDm).length;
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
const fallbackLimit = MAX_GROUP_DMS_PER_USER;
|
||||
const limit = this.resolveLimitForUser(user ?? null, 'max_group_dms_per_user', fallbackLimit);
|
||||
if (openGroupDms >= limit) {
|
||||
throw new MaxGroupDmsError(limit);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateDmPermission(userId: UserID, recipientId: UserID, _recipientUser?: User | null): Promise<void> {
|
||||
const senderUser = await this.userAccountRepository.findUnique(userId);
|
||||
if (senderUser?.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotSendDirectMessagesError();
|
||||
}
|
||||
|
||||
const userBlockedRecipient = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
recipientId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (userBlockedRecipient) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const recipientBlockedUser = await this.userRelationshipRepository.getRelationship(
|
||||
recipientId,
|
||||
userId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (recipientBlockedUser) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const friendship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
recipientId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (friendship) return;
|
||||
|
||||
const hasMutualGuilds = await this.userPermissionUtils.checkMutualGuildsAsync({
|
||||
userId,
|
||||
targetId: recipientId,
|
||||
});
|
||||
if (hasMutualGuilds) return;
|
||||
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
|
||||
const ctx = createLimitMatchContext({user});
|
||||
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
|
||||
}
|
||||
}
|
||||
118
packages/api/src/user/services/UserContactChangeLogService.tsx
Normal file
118
packages/api/src/user/services/UserContactChangeLogService.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserContactChangeLogRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {UserContactChangeLogRepository} from '@fluxer/api/src/user/repositories/UserContactChangeLogRepository';
|
||||
|
||||
export type ContactChangeReason = 'user_requested' | 'admin_action';
|
||||
|
||||
interface RecordDiffParams {
|
||||
oldUser: User | null;
|
||||
newUser: User;
|
||||
reason: ContactChangeReason;
|
||||
actorUserId: UserID | null;
|
||||
eventAt?: Date;
|
||||
}
|
||||
|
||||
interface ListLogsParams {
|
||||
userId: UserID;
|
||||
limit?: number;
|
||||
beforeEventId?: string;
|
||||
}
|
||||
|
||||
export class UserContactChangeLogService {
|
||||
private readonly DEFAULT_LIMIT = 50;
|
||||
|
||||
constructor(private readonly repo: UserContactChangeLogRepository) {}
|
||||
|
||||
async recordDiff(params: RecordDiffParams): Promise<void> {
|
||||
const {oldUser, newUser, reason, actorUserId, eventAt} = params;
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
|
||||
const oldEmail = oldUser?.email?.toLowerCase() ?? null;
|
||||
const newEmail = newUser.email?.toLowerCase() ?? null;
|
||||
if (oldEmail !== newEmail) {
|
||||
tasks.push(
|
||||
this.repo.insertLog({
|
||||
userId: newUser.id,
|
||||
field: 'email',
|
||||
oldValue: oldEmail,
|
||||
newValue: newEmail,
|
||||
reason,
|
||||
actorUserId,
|
||||
eventAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const oldPhone = oldUser?.phone ?? null;
|
||||
const newPhone = newUser.phone ?? null;
|
||||
if (oldPhone !== newPhone) {
|
||||
tasks.push(
|
||||
this.repo.insertLog({
|
||||
userId: newUser.id,
|
||||
field: 'phone',
|
||||
oldValue: oldPhone,
|
||||
newValue: newPhone,
|
||||
reason,
|
||||
actorUserId,
|
||||
eventAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const oldTag = oldUser ? this.buildFluxerTag(oldUser) : null;
|
||||
const newTag = this.buildFluxerTag(newUser);
|
||||
if (oldTag !== newTag) {
|
||||
tasks.push(
|
||||
this.repo.insertLog({
|
||||
userId: newUser.id,
|
||||
field: 'fluxer_tag',
|
||||
oldValue: oldTag,
|
||||
newValue: newTag,
|
||||
reason,
|
||||
actorUserId,
|
||||
eventAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
async listLogs(params: ListLogsParams): Promise<Array<UserContactChangeLogRow>> {
|
||||
const {userId, beforeEventId} = params;
|
||||
const limit = params.limit ?? this.DEFAULT_LIMIT;
|
||||
return this.repo.listLogs({userId, limit, beforeEventId});
|
||||
}
|
||||
|
||||
private buildFluxerTag(user: User | null): string | null {
|
||||
if (!user) return null;
|
||||
const discriminator = user.discriminator?.toString() ?? '';
|
||||
if (!user.username || discriminator === '') {
|
||||
return null;
|
||||
}
|
||||
const paddedDiscriminator = discriminator.padStart(4, '0');
|
||||
return `${user.username}#${paddedDiscriminator}`;
|
||||
}
|
||||
}
|
||||
181
packages/api/src/user/services/UserContentRequestService.tsx
Normal file
181
packages/api/src/user/services/UserContentRequestService.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {SavedMessageEntry} from '@fluxer/api/src/user/services/UserContentService';
|
||||
import type {UserService} from '@fluxer/api/src/user/services/UserService';
|
||||
import type {MessageListResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {
|
||||
HarvestCreationResponseSchema,
|
||||
HarvestDownloadUrlResponse,
|
||||
HarvestStatusResponseSchema,
|
||||
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
|
||||
import type {
|
||||
SavedMessageEntryListResponse,
|
||||
SavedMessageEntryResponse,
|
||||
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {z} from 'zod';
|
||||
|
||||
type HarvestCreationResponse = z.infer<typeof HarvestCreationResponseSchema>;
|
||||
type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
|
||||
type HarvestLatestResponse = HarvestStatusResponse | null;
|
||||
|
||||
interface UserMentionsParams {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
roles: boolean;
|
||||
everyone: boolean;
|
||||
guilds: boolean;
|
||||
before?: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface UserMentionDeleteParams {
|
||||
userId: UserID;
|
||||
messageId: MessageID;
|
||||
}
|
||||
|
||||
interface SavedMessagesParams {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface SaveMessageParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface UnsaveMessageParams {
|
||||
userId: UserID;
|
||||
messageId: MessageID;
|
||||
}
|
||||
|
||||
interface HarvestRequestParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface HarvestStatusParams {
|
||||
userId: UserID;
|
||||
harvestId: bigint;
|
||||
}
|
||||
|
||||
interface HarvestDownloadParams {
|
||||
userId: UserID;
|
||||
harvestId: bigint;
|
||||
storageService: IStorageService;
|
||||
}
|
||||
|
||||
export class UserContentRequestService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly mediaService: IMediaService,
|
||||
) {}
|
||||
|
||||
async listMentions(params: UserMentionsParams): Promise<MessageListResponse> {
|
||||
const messages = await this.userService.getRecentMentions({
|
||||
userId: params.userId,
|
||||
limit: params.limit,
|
||||
everyone: params.everyone,
|
||||
roles: params.roles,
|
||||
guilds: params.guilds,
|
||||
before: params.before,
|
||||
});
|
||||
return Promise.all(
|
||||
messages.map((message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: params.userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
mediaService: this.mediaService,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteMention(params: UserMentionDeleteParams): Promise<void> {
|
||||
await this.userService.deleteRecentMention({userId: params.userId, messageId: params.messageId});
|
||||
}
|
||||
|
||||
async listSavedMessages(params: SavedMessagesParams): Promise<SavedMessageEntryListResponse> {
|
||||
const entries = await this.userService.getSavedMessages({userId: params.userId, limit: params.limit});
|
||||
return Promise.all(entries.map((entry) => this.mapSavedMessageEntry(params.userId, entry, params.requestCache)));
|
||||
}
|
||||
|
||||
async saveMessage(params: SaveMessageParams): Promise<void> {
|
||||
await this.userService.saveMessage({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
messageId: params.messageId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async unsaveMessage(params: UnsaveMessageParams): Promise<void> {
|
||||
await this.userService.unsaveMessage({userId: params.userId, messageId: params.messageId});
|
||||
}
|
||||
|
||||
async requestHarvest(params: HarvestRequestParams): Promise<HarvestCreationResponse> {
|
||||
return this.userService.requestDataHarvest(params.userId);
|
||||
}
|
||||
|
||||
async getLatestHarvest(params: HarvestRequestParams): Promise<HarvestLatestResponse> {
|
||||
return this.userService.getLatestHarvest(params.userId);
|
||||
}
|
||||
|
||||
async getHarvestStatus(params: HarvestStatusParams): Promise<HarvestStatusResponse> {
|
||||
return this.userService.getHarvestStatus(params.userId, params.harvestId);
|
||||
}
|
||||
|
||||
async getHarvestDownloadUrl(params: HarvestDownloadParams): Promise<HarvestDownloadUrlResponse> {
|
||||
return this.userService.getHarvestDownloadUrl(params.userId, params.harvestId, params.storageService);
|
||||
}
|
||||
|
||||
private async mapSavedMessageEntry(
|
||||
userId: UserID,
|
||||
entry: SavedMessageEntry,
|
||||
requestCache: RequestCache,
|
||||
): Promise<SavedMessageEntryResponse> {
|
||||
return {
|
||||
id: entry.messageId.toString(),
|
||||
channel_id: entry.channelId.toString(),
|
||||
message_id: entry.messageId.toString(),
|
||||
status: entry.status,
|
||||
message: entry.message
|
||||
? await mapMessageToResponse({
|
||||
message: entry.message,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
482
packages/api/src/user/services/UserContentService.tsx
Normal file
482
packages/api/src/user/services/UserContentService.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import {mapMessageToResponse} from '@fluxer/api/src/channel/MessageMappers';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {PushSubscriptionRow} from '@fluxer/api/src/database/types/UserTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {KVBulkMessageDeletionQueueService} from '@fluxer/api/src/infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
|
||||
import {BaseUserUpdatePropagator} from '@fluxer/api/src/user/services/BaseUserUpdatePropagator';
|
||||
import {UserHarvest, type UserHarvestResponse} from '@fluxer/api/src/user/UserHarvestModel';
|
||||
import {UserHarvestRepository} from '@fluxer/api/src/user/UserHarvestRepository';
|
||||
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
|
||||
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
|
||||
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
|
||||
import {HarvestOnCooldownError} from '@fluxer/errors/src/domains/moderation/HarvestOnCooldownError';
|
||||
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export interface SavedMessageEntry {
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
status: SavedMessageStatus;
|
||||
message: Message | null;
|
||||
}
|
||||
|
||||
export class UserContentService {
|
||||
private readonly updatePropagator: BaseUserUpdatePropagator;
|
||||
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userContentRepository: IUserContentRepository,
|
||||
userCacheService: UserCacheService,
|
||||
private channelService: ChannelService,
|
||||
private channelRepository: IChannelRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
private workerService: IWorkerService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
|
||||
private limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.updatePropagator = new BaseUserUpdatePropagator({
|
||||
userCacheService,
|
||||
gatewayService: this.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async getRecentMentions(params: {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
everyone: boolean;
|
||||
roles: boolean;
|
||||
guilds: boolean;
|
||||
before?: MessageID;
|
||||
}): Promise<Array<Message>> {
|
||||
const {userId, limit, everyone, roles, guilds, before} = params;
|
||||
const mentions = await this.userContentRepository.listRecentMentions(
|
||||
userId,
|
||||
everyone,
|
||||
roles,
|
||||
guilds,
|
||||
limit,
|
||||
before,
|
||||
);
|
||||
const messagePromises = mentions.map(async (mention) => {
|
||||
try {
|
||||
return await this.channelService.getMessage({
|
||||
userId,
|
||||
channelId: mention.channelId,
|
||||
messageId: mention.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownMessageError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const messageResults = await Promise.all(messagePromises);
|
||||
const messages = messageResults.filter((message): message is Message => message != null);
|
||||
return messages.sort((a, b) => (b.id > a.id ? 1 : -1));
|
||||
}
|
||||
|
||||
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
const recentMention = await this.userContentRepository.getRecentMention(userId, messageId);
|
||||
if (!recentMention) return;
|
||||
await this.userContentRepository.deleteRecentMention(recentMention);
|
||||
await this.dispatchRecentMentionDelete({userId, messageId});
|
||||
}
|
||||
|
||||
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
|
||||
const savedMessages = await this.userContentRepository.listSavedMessages(userId, limit);
|
||||
|
||||
const messagePromises = savedMessages.map(async (savedMessage) => {
|
||||
let message: Message | null = null;
|
||||
let status: SavedMessageStatus = 'available';
|
||||
|
||||
try {
|
||||
message = await this.channelService.getMessage({
|
||||
userId,
|
||||
channelId: savedMessage.channelId,
|
||||
messageId: savedMessage.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownMessageError) {
|
||||
await this.userContentRepository.deleteSavedMessage(userId, savedMessage.messageId);
|
||||
return null;
|
||||
}
|
||||
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
|
||||
status = 'missing_permissions';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelId: savedMessage.channelId,
|
||||
messageId: savedMessage.messageId,
|
||||
status,
|
||||
message,
|
||||
};
|
||||
});
|
||||
|
||||
const messageResults = await Promise.all(messagePromises);
|
||||
const results = messageResults.filter((result): result is NonNullable<typeof result> => result != null);
|
||||
|
||||
return results.sort((a, b) => (b.messageId > a.messageId ? 1 : a.messageId > b.messageId ? -1 : 0));
|
||||
}
|
||||
|
||||
async saveMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const savedMessages = await this.userContentRepository.listSavedMessages(userId, 1000);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const maxBookmarks = resolveLimitSafe(
|
||||
this.limitConfigService.getConfigSnapshot(),
|
||||
ctx,
|
||||
'max_bookmarks',
|
||||
MAX_BOOKMARKS_NON_PREMIUM,
|
||||
);
|
||||
|
||||
if (savedMessages.length >= maxBookmarks) {
|
||||
throw new MaxBookmarksError({maxBookmarks});
|
||||
}
|
||||
|
||||
await this.channelService.getChannelAuthenticated({userId, channelId});
|
||||
const message = await this.channelService.getMessage({userId, channelId, messageId});
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
await this.userContentRepository.createSavedMessage(userId, channelId, messageId);
|
||||
await this.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
await this.userContentRepository.deleteSavedMessage(userId, messageId);
|
||||
await this.dispatchSavedMessageDelete({userId, messageId});
|
||||
}
|
||||
|
||||
async registerPushSubscription(params: {
|
||||
userId: UserID;
|
||||
endpoint: string;
|
||||
keys: {p256dh: string; auth: string};
|
||||
userAgent?: string;
|
||||
}): Promise<PushSubscription> {
|
||||
const {userId, endpoint, keys, userAgent} = params;
|
||||
|
||||
const subscriptionId = crypto.createHash('sha256').update(endpoint).digest('hex').substring(0, 32);
|
||||
|
||||
const data: PushSubscriptionRow = {
|
||||
user_id: userId,
|
||||
subscription_id: subscriptionId,
|
||||
endpoint,
|
||||
p256dh_key: keys.p256dh,
|
||||
auth_key: keys.auth,
|
||||
user_agent: userAgent ?? null,
|
||||
};
|
||||
|
||||
return await this.userContentRepository.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return await this.userContentRepository.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
await this.userContentRepository.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async requestDataHarvest(userId: UserID): Promise<{
|
||||
harvest_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
}> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) throw new UnknownUserError();
|
||||
|
||||
if (!Config.dev.testModeEnabled) {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const latestHarvest = await harvestRepository.findLatestByUserId(userId);
|
||||
|
||||
if (latestHarvest?.requestedAt) {
|
||||
const sevenDaysAgo = new Date(Date.now() - ms('7 days'));
|
||||
if (latestHarvest.requestedAt > sevenDaysAgo) {
|
||||
const retryAfter = new Date(latestHarvest.requestedAt.getTime() + ms('7 days'));
|
||||
throw new HarvestOnCooldownError({retryAfter});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const harvestId = await this.snowflakeService.generate();
|
||||
const harvest = new UserHarvest({
|
||||
user_id: userId,
|
||||
harvest_id: harvestId,
|
||||
requested_at: new Date(),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
failed_at: null,
|
||||
storage_key: null,
|
||||
file_size: null,
|
||||
progress_percent: 0,
|
||||
progress_step: 'Queued',
|
||||
error_message: null,
|
||||
download_url_expires_at: null,
|
||||
});
|
||||
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
await harvestRepository.create(harvest);
|
||||
|
||||
await this.workerService.addJob('harvestUserData', {
|
||||
userId: userId.toString(),
|
||||
harvestId: harvestId.toString(),
|
||||
});
|
||||
|
||||
return {
|
||||
harvest_id: harvest.harvestId.toString(),
|
||||
status: harvest.getStatus(),
|
||||
created_at: harvest.requestedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
||||
if (!harvest) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
return harvest.toResponse();
|
||||
}
|
||||
|
||||
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findLatestByUserId(userId);
|
||||
return harvest ? harvest.toResponse() : null;
|
||||
}
|
||||
|
||||
async getHarvestDownloadUrl(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
storageService: IStorageService,
|
||||
): Promise<{download_url: string; expires_at: string}> {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
||||
|
||||
if (!harvest) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
|
||||
if (!harvest.completedAt || !harvest.storageKey) {
|
||||
throw new HarvestNotReadyError();
|
||||
}
|
||||
|
||||
if (harvest.failedAt) {
|
||||
throw new HarvestFailedError();
|
||||
}
|
||||
|
||||
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
|
||||
throw new HarvestExpiredError();
|
||||
}
|
||||
|
||||
const ZIP_EXPIRY_MS = ms('7 days');
|
||||
const downloadUrl = await storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: harvest.storageKey,
|
||||
expiresIn: ZIP_EXPIRY_MS / 1000,
|
||||
});
|
||||
|
||||
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_MS);
|
||||
|
||||
return {
|
||||
download_url: downloadUrl,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
|
||||
const {userId, delayMs = ms('1 day')} = params;
|
||||
const scheduledAt = new Date(Date.now() + delayMs);
|
||||
|
||||
const user = await this.userAccountRepository.findUniqueAssert(userId);
|
||||
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
||||
|
||||
const counts = await this.countBulkDeletionTargets(userId, scheduledAt.getTime());
|
||||
Logger.debug(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
channelCount: counts.channelCount,
|
||||
messageCount: counts.messageCount,
|
||||
scheduledAt: scheduledAt.toISOString(),
|
||||
},
|
||||
'Scheduling bulk message deletion',
|
||||
);
|
||||
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
pending_bulk_message_deletion_at: scheduledAt,
|
||||
pending_bulk_message_deletion_channel_count: counts.channelCount,
|
||||
pending_bulk_message_deletion_message_count: counts.messageCount,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await this.bulkMessageDeletionQueue.scheduleDeletion(userId, scheduledAt);
|
||||
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
|
||||
Logger.debug({userId: userId.toString()}, 'Canceling pending bulk message deletion');
|
||||
const user = await this.userAccountRepository.findUniqueAssert(userId);
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
|
||||
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
||||
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
}
|
||||
|
||||
private async countBulkDeletionTargets(
|
||||
userId: UserID,
|
||||
cutoffMs: number,
|
||||
): Promise<{
|
||||
channelCount: number;
|
||||
messageCount: number;
|
||||
}> {
|
||||
const CHUNK_SIZE = 200;
|
||||
let lastMessageId: MessageID | undefined;
|
||||
const channels = new Set<string>();
|
||||
let messageCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messageRefs = await this.channelRepository.listMessagesByAuthor(userId, CHUNK_SIZE, lastMessageId);
|
||||
if (messageRefs.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messageRefs) {
|
||||
if (snowflakeToDate(messageId).getTime() > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
channels.add(channelId.toString());
|
||||
messageCount++;
|
||||
}
|
||||
|
||||
lastMessageId = messageRefs[messageRefs.length - 1].messageId;
|
||||
|
||||
if (messageRefs.length < CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelCount: channels.size,
|
||||
messageCount,
|
||||
};
|
||||
}
|
||||
|
||||
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RECENT_MENTION_DELETE',
|
||||
data: {message_id: messageId.toString()},
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageCreate({
|
||||
userId,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
message: Message;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'SAVED_MESSAGE_CREATE',
|
||||
data: await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'SAVED_MESSAGE_DELETE',
|
||||
data: {message_id: messageId.toString()},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
|
||||
export class UserDeletionEligibilityService {
|
||||
private readonly INACTIVITY_WARNING_TTL_DAYS = 30;
|
||||
private readonly INACTIVITY_WARNING_PREFIX = 'inactivity_warning_sent';
|
||||
|
||||
constructor(private kvClient: IKVProvider) {}
|
||||
|
||||
async isEligibleForInactivityDeletion(user: User): Promise<boolean> {
|
||||
if (user.isBot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.isSystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isAppStoreReviewer(user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.pendingDeletionAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.lastActiveAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inactivityThresholdMs = this.getInactivityThresholdMs();
|
||||
const timeSinceLastActiveMs = Date.now() - user.lastActiveAt.getTime();
|
||||
|
||||
if (timeSinceLastActiveMs < inactivityThresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async isEligibleForWarningEmail(user: User): Promise<boolean> {
|
||||
const isEligibleForDeletion = await this.isEligibleForInactivityDeletion(user);
|
||||
if (!isEligibleForDeletion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const alreadySentWarning = await this.hasWarningSent(user.id);
|
||||
if (alreadySentWarning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async markWarningSent(userId: UserID): Promise<void> {
|
||||
const key = this.getWarningKey(userId);
|
||||
const ttlSeconds = seconds(`${this.INACTIVITY_WARNING_TTL_DAYS + 5} days`);
|
||||
const timestamp = Date.now().toString();
|
||||
|
||||
await this.kvClient.setex(key, ttlSeconds, timestamp);
|
||||
}
|
||||
|
||||
async hasWarningSent(userId: UserID): Promise<boolean> {
|
||||
const key = this.getWarningKey(userId);
|
||||
const exists = await this.kvClient.exists(key);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
async getWarningSentTimestamp(userId: UserID): Promise<number | null> {
|
||||
const key = this.getWarningKey(userId);
|
||||
const value = await this.kvClient.get(key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = parseInt(value, 10);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
async hasWarningGracePeriodExpired(userId: UserID): Promise<boolean> {
|
||||
const timestamp = await this.getWarningSentTimestamp(userId);
|
||||
if (timestamp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeSinceWarningMs = Date.now() - timestamp;
|
||||
const gracePeriodMs = this.INACTIVITY_WARNING_TTL_DAYS * ms('1 day');
|
||||
|
||||
return timeSinceWarningMs >= gracePeriodMs;
|
||||
}
|
||||
|
||||
private getInactivityThresholdMs(): number {
|
||||
const thresholdDays = Config.inactivityDeletionThresholdDays ?? 365 * 2;
|
||||
return thresholdDays * ms('1 day');
|
||||
}
|
||||
|
||||
private getWarningKey(userId: UserID): string {
|
||||
return `${this.INACTIVITY_WARNING_PREFIX}:${userId}`;
|
||||
}
|
||||
|
||||
private isAppStoreReviewer(user: User): boolean {
|
||||
return (user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
|
||||
}
|
||||
}
|
||||
519
packages/api/src/user/services/UserDeletionService.tsx
Normal file
519
packages/api/src/user/services/UserDeletionService.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMessageID, createUserID, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import {mapChannelToResponse} from '@fluxer/api/src/channel/ChannelMappers';
|
||||
import type {ChannelRepository} from '@fluxer/api/src/channel/ChannelRepository';
|
||||
import type {FavoriteMemeRepository} from '@fluxer/api/src/favorite_meme/FavoriteMemeRepository';
|
||||
import type {GuildRepository} from '@fluxer/api/src/guild/repositories/GuildRepository';
|
||||
import type {IPurgeQueue} from '@fluxer/api/src/infrastructure/CloudflarePurgeQueue';
|
||||
import type {DiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {createRequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {ApplicationRepository} from '@fluxer/api/src/oauth/repositories/ApplicationRepository';
|
||||
import type {OAuth2TokenRepository} from '@fluxer/api/src/oauth/repositories/OAuth2TokenRepository';
|
||||
import type {UserRepository} from '@fluxer/api/src/user/repositories/UserRepository';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
DELETED_USER_DISCRIMINATOR,
|
||||
DELETED_USER_GLOBAL_NAME,
|
||||
DELETED_USER_USERNAME,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
import {ms} from 'itty-time';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
const CHUNK_SIZE = 100;
|
||||
|
||||
export interface UserDeletionDependencies {
|
||||
userRepository: UserRepository;
|
||||
guildRepository: GuildRepository;
|
||||
channelRepository: ChannelRepository;
|
||||
favoriteMemeRepository: FavoriteMemeRepository;
|
||||
oauth2TokenRepository: OAuth2TokenRepository;
|
||||
storageService: IStorageService;
|
||||
purgeQueue: IPurgeQueue;
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
snowflakeService: SnowflakeService;
|
||||
discriminatorService: DiscriminatorService;
|
||||
stripe: Stripe | null;
|
||||
applicationRepository: ApplicationRepository;
|
||||
workerService: IWorkerService;
|
||||
}
|
||||
|
||||
export async function processUserDeletion(
|
||||
userId: UserID,
|
||||
deletionReasonCode: number,
|
||||
deps: UserDeletionDependencies,
|
||||
): Promise<void> {
|
||||
const {
|
||||
userRepository,
|
||||
guildRepository,
|
||||
channelRepository,
|
||||
favoriteMemeRepository,
|
||||
oauth2TokenRepository,
|
||||
storageService,
|
||||
purgeQueue,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
snowflakeService,
|
||||
stripe,
|
||||
applicationRepository,
|
||||
workerService,
|
||||
} = deps;
|
||||
|
||||
Logger.debug({userId, deletionReasonCode}, 'Starting user account deletion');
|
||||
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
Logger.warn({userId}, 'User not found, skipping deletion');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.stripeSubscriptionId && stripe) {
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
Logger.debug(
|
||||
{userId, subscriptionId: user.stripeSubscriptionId, attempt},
|
||||
'Canceling active Stripe subscription',
|
||||
);
|
||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId, {
|
||||
invoice_now: false,
|
||||
prorate: false,
|
||||
});
|
||||
Logger.debug({userId, subscriptionId: user.stripeSubscriptionId}, 'Stripe subscription cancelled successfully');
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const isLastAttempt = attempt === MAX_RETRIES - 1;
|
||||
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
userId,
|
||||
subscriptionId: user.stripeSubscriptionId,
|
||||
attempt: attempt + 1,
|
||||
maxRetries: MAX_RETRIES,
|
||||
willRetry: !isLastAttempt,
|
||||
},
|
||||
isLastAttempt
|
||||
? 'Failed to cancel Stripe subscription after all retries'
|
||||
: 'Failed to cancel Stripe subscription, retrying with exponential backoff',
|
||||
);
|
||||
|
||||
if (!isLastAttempt) {
|
||||
const backoffDelay = ms('1 second') * 2 ** attempt + Math.random() * 500;
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
const error = new Error(
|
||||
`Failed to cancel Stripe subscription ${user.stripeSubscriptionId} for user ${userId} after ${MAX_RETRIES} attempts. User deletion halted to prevent billing issues.`,
|
||||
{cause: lastError},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const deletedUserId = createUserID(await snowflakeService.generate());
|
||||
Logger.debug({userId, deletedUserId}, 'Creating dedicated deleted user record');
|
||||
|
||||
await userRepository.create({
|
||||
user_id: deletedUserId,
|
||||
username: DELETED_USER_USERNAME,
|
||||
discriminator: DELETED_USER_DISCRIMINATOR,
|
||||
global_name: DELETED_USER_GLOBAL_NAME,
|
||||
bot: false,
|
||||
system: true,
|
||||
email: null,
|
||||
email_verified: null,
|
||||
email_bounced: null,
|
||||
phone: null,
|
||||
password_hash: null,
|
||||
password_last_changed_at: null,
|
||||
totp_secret: null,
|
||||
authenticator_types: null,
|
||||
avatar_hash: null,
|
||||
avatar_color: null,
|
||||
banner_hash: null,
|
||||
banner_color: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
date_of_birth: null,
|
||||
locale: null,
|
||||
flags: UserFlags.DELETED,
|
||||
premium_type: null,
|
||||
premium_since: null,
|
||||
premium_until: null,
|
||||
premium_will_cancel: null,
|
||||
premium_billing_cycle: null,
|
||||
premium_lifetime_sequence: null,
|
||||
stripe_subscription_id: null,
|
||||
stripe_customer_id: null,
|
||||
has_ever_purchased: null,
|
||||
suspicious_activity_flags: null,
|
||||
terms_agreed_at: null,
|
||||
privacy_agreed_at: null,
|
||||
last_active_at: null,
|
||||
last_active_ip: null,
|
||||
temp_banned_until: null,
|
||||
pending_deletion_at: null,
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
acls: null,
|
||||
traits: null,
|
||||
first_refund_at: null,
|
||||
gift_inventory_server_seq: null,
|
||||
gift_inventory_client_seq: null,
|
||||
premium_onboarding_dismissed_at: null,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await userRepository.deleteUserSecondaryIndices(deletedUserId);
|
||||
|
||||
Logger.debug({userId}, 'Leaving all guilds');
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
try {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
Logger.debug({userId, guildId}, 'Member not found in guild, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (member.avatarHash) {
|
||||
try {
|
||||
const key = `guilds/${guildId}/users/${userId}/avatars/${member.avatarHash}`;
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, key);
|
||||
await purgeQueue.addUrls([`${Config.endpoints.media}/${key}`]);
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, guildId, avatarHash: member.avatarHash}, 'Failed to delete guild member avatar');
|
||||
}
|
||||
}
|
||||
|
||||
if (member.bannerHash) {
|
||||
try {
|
||||
const key = `guilds/${guildId}/users/${userId}/banners/${member.bannerHash}`;
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, key);
|
||||
await purgeQueue.addUrls([`${Config.endpoints.media}/${key}`]);
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, guildId, bannerHash: member.bannerHash}, 'Failed to delete guild member banner');
|
||||
}
|
||||
}
|
||||
|
||||
await guildRepository.deleteMember(guildId, userId);
|
||||
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (guild) {
|
||||
const guildRow = guild.toRow();
|
||||
await guildRepository.upsert({
|
||||
...guildRow,
|
||||
member_count: Math.max(0, guild.memberCount - 1),
|
||||
});
|
||||
}
|
||||
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_REMOVE',
|
||||
data: {user: {id: userId.toString()}},
|
||||
});
|
||||
|
||||
await gatewayService.leaveGuild({userId, guildId});
|
||||
|
||||
Logger.debug({userId, guildId}, 'Left guild successfully');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, guildId}, 'Failed to leave guild');
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({userId}, 'Leaving all group DMs');
|
||||
|
||||
const allPrivateChannels = await userRepository.listPrivateChannels(userId);
|
||||
const groupDmChannels = allPrivateChannels.filter((channel) => channel.type === ChannelTypes.GROUP_DM);
|
||||
|
||||
for (const channel of groupDmChannels) {
|
||||
try {
|
||||
const updatedRecipientIds = new Set<UserID>(channel.recipientIds);
|
||||
updatedRecipientIds.delete(userId);
|
||||
|
||||
let newOwnerId = channel.ownerId;
|
||||
if (userId === channel.ownerId && updatedRecipientIds.size > 0) {
|
||||
newOwnerId = Array.from(updatedRecipientIds)[0];
|
||||
}
|
||||
|
||||
if (updatedRecipientIds.size === 0) {
|
||||
await channelRepository.delete(channel.id);
|
||||
await userRepository.closeDmForUser(userId, channel.id);
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService,
|
||||
requestCache: createRequestCache(),
|
||||
});
|
||||
|
||||
await gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: channelResponse,
|
||||
});
|
||||
|
||||
Logger.debug({userId, channelId: channel.id}, 'Deleted empty group DM');
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedNicknames = new Map(channel.nicknames);
|
||||
updatedNicknames.delete(userId.toString());
|
||||
|
||||
await channelRepository.upsert({
|
||||
...channel.toRow(),
|
||||
owner_id: newOwnerId,
|
||||
recipient_ids: updatedRecipientIds,
|
||||
nicks: updatedNicknames.size > 0 ? updatedNicknames : null,
|
||||
});
|
||||
|
||||
await userRepository.closeDmForUser(userId, channel.id);
|
||||
|
||||
const messageId = createMessageID(await snowflakeService.generate());
|
||||
|
||||
await channelRepository.upsertMessage({
|
||||
channel_id: channel.id,
|
||||
bucket: BucketUtils.makeBucket(messageId),
|
||||
message_id: messageId,
|
||||
author_id: userId,
|
||||
type: MessageTypes.RECIPIENT_REMOVE,
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: null,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
mention_everyone: false,
|
||||
mention_users: new Set([userId]),
|
||||
mention_roles: null,
|
||||
mention_channels: null,
|
||||
attachments: null,
|
||||
embeds: null,
|
||||
sticker_items: null,
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const recipientUserResponse = await userCacheService.getUserPartialResponse(userId, createRequestCache());
|
||||
|
||||
for (const recId of updatedRecipientIds) {
|
||||
await gatewayService.dispatchPresence({
|
||||
userId: recId,
|
||||
event: 'CHANNEL_RECIPIENT_REMOVE',
|
||||
data: {
|
||||
channel_id: channel.id.toString(),
|
||||
user: recipientUserResponse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService,
|
||||
requestCache: createRequestCache(),
|
||||
});
|
||||
|
||||
await gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: channelResponse,
|
||||
});
|
||||
|
||||
Logger.debug({userId, channelId: channel.id}, 'Left group DM successfully');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, channelId: channel.id}, 'Failed to leave group DM');
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({userId}, 'Anonymizing user messages');
|
||||
|
||||
let lastMessageId: MessageID | undefined;
|
||||
let processedCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messagesToAnonymize = await channelRepository.listMessagesByAuthor(userId, CHUNK_SIZE, lastMessageId);
|
||||
|
||||
if (messagesToAnonymize.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messagesToAnonymize) {
|
||||
await channelRepository.anonymizeMessage(channelId, messageId, deletedUserId);
|
||||
}
|
||||
|
||||
processedCount += messagesToAnonymize.length;
|
||||
lastMessageId = messagesToAnonymize[messagesToAnonymize.length - 1].messageId;
|
||||
|
||||
Logger.debug({userId, processedCount, chunkSize: messagesToAnonymize.length}, 'Anonymized message chunk');
|
||||
|
||||
if (messagesToAnonymize.length < CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({userId, totalProcessed: processedCount}, 'Completed message anonymization');
|
||||
|
||||
Logger.debug({userId}, 'Deleting S3 objects');
|
||||
|
||||
if (user.avatarHash) {
|
||||
try {
|
||||
await storageService.deleteAvatar({prefix: 'avatars', key: `${userId}/${user.avatarHash}`});
|
||||
await purgeQueue.addUrls([`${Config.endpoints.media}/avatars/${userId}/${user.avatarHash}`]);
|
||||
Logger.debug({userId, avatarHash: user.avatarHash}, 'Deleted avatar');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId}, 'Failed to delete avatar');
|
||||
}
|
||||
}
|
||||
|
||||
if (user.bannerHash) {
|
||||
try {
|
||||
await storageService.deleteAvatar({prefix: 'banners', key: `${userId}/${user.bannerHash}`});
|
||||
await purgeQueue.addUrls([`${Config.endpoints.media}/banners/${userId}/${user.bannerHash}`]);
|
||||
Logger.debug({userId, bannerHash: user.bannerHash}, 'Deleted banner');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId}, 'Failed to delete banner');
|
||||
}
|
||||
}
|
||||
|
||||
const favoriteMemes = await favoriteMemeRepository.findByUserId(userId);
|
||||
for (const meme of favoriteMemes) {
|
||||
try {
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, meme.storageKey);
|
||||
Logger.debug({userId, memeId: meme.id}, 'Deleted favorite meme');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, memeId: meme.id}, 'Failed to delete favorite meme');
|
||||
}
|
||||
}
|
||||
|
||||
await favoriteMemeRepository.deleteAllByUserId(userId);
|
||||
|
||||
Logger.debug({userId}, 'Deleting OAuth tokens');
|
||||
|
||||
await Promise.all([
|
||||
oauth2TokenRepository.deleteAllAccessTokensForUser(userId),
|
||||
oauth2TokenRepository.deleteAllRefreshTokensForUser(userId),
|
||||
]);
|
||||
|
||||
Logger.debug({userId}, 'Deleting owned developer applications and bots');
|
||||
try {
|
||||
const applications = await applicationRepository.listApplicationsByOwner(userId);
|
||||
for (const application of applications) {
|
||||
await workerService.addJob('applicationProcessDeletion', {
|
||||
applicationId: application.applicationId.toString(),
|
||||
});
|
||||
}
|
||||
Logger.debug({userId, applicationCount: applications.length}, 'Scheduled application deletions');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId}, 'Failed to schedule application deletions');
|
||||
}
|
||||
|
||||
Logger.debug({userId}, 'Deleting user data');
|
||||
|
||||
await Promise.all([
|
||||
userRepository.deleteUserSettings(userId),
|
||||
userRepository.deleteAllUserGuildSettings(userId),
|
||||
userRepository.deleteAllRelationships(userId),
|
||||
userRepository.deleteAllNotes(userId),
|
||||
userRepository.deleteAllReadStates(userId),
|
||||
userRepository.deleteAllSavedMessages(userId),
|
||||
userRepository.deleteAllAuthSessions(userId),
|
||||
userRepository.deleteAllMfaBackupCodes(userId),
|
||||
userRepository.deleteAllWebAuthnCredentials(userId),
|
||||
userRepository.deleteAllPushSubscriptions(userId),
|
||||
userRepository.deleteAllRecentMentions(userId),
|
||||
userRepository.deleteAllAuthorizedIps(userId),
|
||||
userRepository.deletePinnedDmsByUserId(userId),
|
||||
]);
|
||||
|
||||
await userRepository.deleteUserSecondaryIndices(userId);
|
||||
|
||||
const userForAnonymization = await userRepository.findUniqueAssert(userId);
|
||||
|
||||
Logger.debug({userId}, 'Anonymizing user record');
|
||||
|
||||
const anonymisedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
username: DELETED_USER_USERNAME,
|
||||
discriminator: DELETED_USER_DISCRIMINATOR,
|
||||
global_name: DELETED_USER_GLOBAL_NAME,
|
||||
email: null,
|
||||
email_verified: false,
|
||||
phone: null,
|
||||
password_hash: null,
|
||||
totp_secret: null,
|
||||
avatar_hash: null,
|
||||
banner_hash: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
date_of_birth: null,
|
||||
flags: UserFlags.DELETED,
|
||||
premium_type: null,
|
||||
premium_since: null,
|
||||
premium_until: null,
|
||||
stripe_customer_id: null,
|
||||
stripe_subscription_id: null,
|
||||
pending_deletion_at: null,
|
||||
authenticator_types: new Set(),
|
||||
},
|
||||
userForAnonymization.toRow(),
|
||||
);
|
||||
await userCacheService.setUserPartialResponseFromUser(anonymisedUser);
|
||||
|
||||
Logger.debug({userId, deletionReasonCode}, 'User account anonymization completed successfully');
|
||||
getMetricsService().counter({
|
||||
name: 'user.deletion',
|
||||
dimensions: {
|
||||
reason_code: deletionReasonCode.toString(),
|
||||
source: 'worker',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {UserService} from '@fluxer/api/src/user/services/UserService';
|
||||
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {mapRelationshipToResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {
|
||||
FriendRequestByTagRequest,
|
||||
RelationshipNicknameUpdateRequest,
|
||||
RelationshipTypePutRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
interface RelationshipListParams {
|
||||
userId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface RelationshipSendByTagParams {
|
||||
userId: UserID;
|
||||
data: FriendRequestByTagRequest;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface RelationshipSendParams {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface RelationshipUpdateTypeParams {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
data: RelationshipTypePutRequest;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface RelationshipDeleteParams {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
}
|
||||
|
||||
interface RelationshipNicknameParams {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
data: RelationshipNicknameUpdateRequest;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
export class UserRelationshipRequestService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
) {}
|
||||
|
||||
async listRelationships(params: RelationshipListParams): Promise<Array<RelationshipResponse>> {
|
||||
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
|
||||
const relationships = await this.userService.getRelationships(params.userId);
|
||||
return Promise.all(
|
||||
relationships.map((relationship) => mapRelationshipToResponse({relationship, userPartialResolver})),
|
||||
);
|
||||
}
|
||||
|
||||
async sendFriendRequestByTag(params: RelationshipSendByTagParams): Promise<RelationshipResponse> {
|
||||
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
|
||||
const relationship = await this.userService.sendFriendRequestByTag({
|
||||
userId: params.userId,
|
||||
data: params.data,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
return mapRelationshipToResponse({relationship, userPartialResolver});
|
||||
}
|
||||
|
||||
async sendFriendRequest(params: RelationshipSendParams): Promise<RelationshipResponse> {
|
||||
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
|
||||
const relationship = await this.userService.sendFriendRequest({
|
||||
userId: params.userId,
|
||||
targetId: params.targetId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
return mapRelationshipToResponse({relationship, userPartialResolver});
|
||||
}
|
||||
|
||||
async updateRelationshipType(params: RelationshipUpdateTypeParams): Promise<RelationshipResponse> {
|
||||
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
|
||||
if (params.data?.type === RelationshipTypes.BLOCKED) {
|
||||
const relationship = await this.userService.blockUser({
|
||||
userId: params.userId,
|
||||
targetId: params.targetId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
return mapRelationshipToResponse({relationship, userPartialResolver});
|
||||
}
|
||||
const relationship = await this.userService.acceptFriendRequest({
|
||||
userId: params.userId,
|
||||
targetId: params.targetId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
return mapRelationshipToResponse({relationship, userPartialResolver});
|
||||
}
|
||||
|
||||
async removeRelationship(params: RelationshipDeleteParams): Promise<void> {
|
||||
await this.userService.removeRelationship({userId: params.userId, targetId: params.targetId});
|
||||
}
|
||||
|
||||
async updateNickname(params: RelationshipNicknameParams): Promise<RelationshipResponse> {
|
||||
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
|
||||
const relationship = await this.userService.updateFriendNickname({
|
||||
userId: params.userId,
|
||||
targetId: params.targetId,
|
||||
nickname: params.data.nickname ?? null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
return mapRelationshipToResponse({relationship, userPartialResolver});
|
||||
}
|
||||
|
||||
private createUserPartialResolver(requestCache: RequestCache) {
|
||||
return (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService: this.userCacheService, requestCache});
|
||||
}
|
||||
}
|
||||
554
packages/api/src/user/services/UserRelationshipService.tsx
Normal file
554
packages/api/src/user/services/UserRelationshipService.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {Relationship} from '@fluxer/api/src/models/Relationship';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
import {getCachedUserPartialResponse} from '@fluxer/api/src/user/UserCacheHelpers';
|
||||
import {mapRelationshipToResponse} from '@fluxer/api/src/user/UserMappers';
|
||||
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {MAX_RELATIONSHIPS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {RelationshipTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {BotsCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/oauth/BotsCannotSendFriendRequestsError';
|
||||
import {AlreadyFriendsError} from '@fluxer/errors/src/domains/user/AlreadyFriendsError';
|
||||
import {CannotSendFriendRequestToBlockedUserError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToBlockedUserError';
|
||||
import {CannotSendFriendRequestToSelfError} from '@fluxer/errors/src/domains/user/CannotSendFriendRequestToSelfError';
|
||||
import {FriendRequestBlockedError} from '@fluxer/errors/src/domains/user/FriendRequestBlockedError';
|
||||
import {InvalidDiscriminatorError} from '@fluxer/errors/src/domains/user/InvalidDiscriminatorError';
|
||||
import {MaxRelationshipsError} from '@fluxer/errors/src/domains/user/MaxRelationshipsError';
|
||||
import {NoUsersWithFluxertagError} from '@fluxer/errors/src/domains/user/NoUsersWithFluxertagError';
|
||||
import {UnclaimedAccountCannotAcceptFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotAcceptFriendRequestsError';
|
||||
import {UnclaimedAccountCannotSendFriendRequestsError} from '@fluxer/errors/src/domains/user/UnclaimedAccountCannotSendFriendRequestsError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {FriendRequestByTagRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
|
||||
export class UserRelationshipService {
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userRelationshipRepository: IUserRelationshipRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private userPermissionUtils: UserPermissionUtils,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {}
|
||||
|
||||
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
|
||||
return await this.userRelationshipRepository.listRelationships(userId);
|
||||
}
|
||||
|
||||
async sendFriendRequestByTag({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: FriendRequestByTagRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const {username, discriminator} = data;
|
||||
const discrimValue = discriminator;
|
||||
if (!Number.isInteger(discrimValue) || discrimValue < 0 || discrimValue > 9999) {
|
||||
throw new InvalidDiscriminatorError();
|
||||
}
|
||||
const targetUser = await this.userAccountRepository.findByUsernameDiscriminator(username, discrimValue);
|
||||
if (!targetUser) {
|
||||
throw new NoUsersWithFluxertagError();
|
||||
}
|
||||
if (this.isDeletedUser(targetUser)) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
const existingRelationship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetUser.id,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (existingRelationship) {
|
||||
throw new AlreadyFriendsError();
|
||||
}
|
||||
return this.sendFriendRequest({userId, targetId: targetUser.id, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async sendFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const targetUser = await this.validateFriendRequest({userId, targetId});
|
||||
const pendingIncoming = await this.userRelationshipRepository.getRelationship(
|
||||
targetId,
|
||||
userId,
|
||||
RelationshipTypes.OUTGOING_REQUEST,
|
||||
);
|
||||
if (pendingIncoming) {
|
||||
return this.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
const existingFriendship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.OUTGOING_REQUEST,
|
||||
);
|
||||
if (existingFriendship || existingOutgoingRequest) {
|
||||
const relationships = await this.userRelationshipRepository.listRelationships(userId);
|
||||
const relationship = relationships.find((r) => r.targetUserId === targetId);
|
||||
if (relationship) {
|
||||
return relationship;
|
||||
}
|
||||
}
|
||||
await this.validateRelationshipCounts({userId, targetId});
|
||||
const requestRelationship = await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
|
||||
const targetIsFriendlyBot =
|
||||
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
|
||||
const manualApprovalFlag = UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL;
|
||||
const manualApprovalRequired = targetUser.isBot && (targetUser.flags & manualApprovalFlag) === manualApprovalFlag;
|
||||
|
||||
if (targetIsFriendlyBot && !manualApprovalRequired) {
|
||||
const finalFriendship = await this.acceptFriendRequest({
|
||||
userId: targetId,
|
||||
targetId: userId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
return finalFriendship;
|
||||
}
|
||||
|
||||
return requestRelationship;
|
||||
}
|
||||
|
||||
async acceptFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (this.isDeletedUser(user)) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
if (user?.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotAcceptFriendRequestsError();
|
||||
}
|
||||
const requesterUser = await this.userAccountRepository.findUnique(targetId);
|
||||
if (!requesterUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (this.isDeletedUser(requesterUser)) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
|
||||
const incomingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.INCOMING_REQUEST,
|
||||
);
|
||||
if (!incomingRequest) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
await this.validateRelationshipCounts({userId, targetId});
|
||||
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST);
|
||||
|
||||
const now = new Date();
|
||||
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.FRIEND,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: targetId,
|
||||
target_user_id: userId,
|
||||
type: RelationshipTypes.FRIEND,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
await this.dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship: userRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.dispatchRelationshipUpdate({
|
||||
userId: targetId,
|
||||
relationship: targetRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return userRelationship;
|
||||
}
|
||||
|
||||
async blockUser({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const targetUser = await this.userAccountRepository.findUnique(targetId);
|
||||
if (!targetUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const existingBlocked = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (existingBlocked) {
|
||||
return existingBlocked;
|
||||
}
|
||||
|
||||
const existingFriend = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
const existingIncomingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.INCOMING_REQUEST,
|
||||
);
|
||||
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.OUTGOING_REQUEST,
|
||||
);
|
||||
|
||||
if (existingFriend) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
} else if (existingOutgoingRequest) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
} else if (existingIncomingRequest) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const blockRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.BLOCKED,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship: blockRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return blockRelationship;
|
||||
}
|
||||
|
||||
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
const [friend, incoming, outgoing, blocked] = await Promise.all([
|
||||
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND),
|
||||
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST),
|
||||
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST),
|
||||
this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED),
|
||||
]);
|
||||
|
||||
const existingRelationship = friend || incoming || outgoing || blocked;
|
||||
if (!existingRelationship) throw new UnknownUserError();
|
||||
const relationshipType = existingRelationship.type;
|
||||
if (relationshipType === RelationshipTypes.INCOMING_REQUEST || relationshipType === RelationshipTypes.BLOCKED) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
|
||||
await this.dispatchRelationshipRemove({
|
||||
userId,
|
||||
targetId: targetId.toString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
|
||||
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
return;
|
||||
}
|
||||
if (relationshipType === RelationshipTypes.FRIEND) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
return;
|
||||
}
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
|
||||
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
||||
}
|
||||
|
||||
async updateFriendNickname({
|
||||
userId,
|
||||
targetId,
|
||||
nickname,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
nickname: string | null;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const relationship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (!relationship) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.FRIEND,
|
||||
nickname,
|
||||
since: relationship.since ?? new Date(),
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship: updatedRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return updatedRelationship;
|
||||
}
|
||||
|
||||
private async validateFriendRequest({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<User> {
|
||||
if (userId === targetId) {
|
||||
throw new CannotSendFriendRequestToSelfError();
|
||||
}
|
||||
|
||||
const requesterUser = await this.userAccountRepository.findUnique(userId);
|
||||
if (!requesterUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (this.isDeletedUser(requesterUser)) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
if (requesterUser?.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotSendFriendRequestsError();
|
||||
}
|
||||
if (requesterUser?.isBot) {
|
||||
throw new BotsCannotSendFriendRequestsError();
|
||||
}
|
||||
|
||||
const targetUser = await this.userAccountRepository.findUnique(targetId);
|
||||
if (!targetUser) throw new UnknownUserError();
|
||||
if (this.isDeletedUser(targetUser)) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
|
||||
const targetIsFriendlyBot =
|
||||
targetUser.isBot && (targetUser.flags & UserFlags.FRIENDLY_BOT) === UserFlags.FRIENDLY_BOT;
|
||||
if (targetUser.isBot && !targetIsFriendlyBot) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
const requesterBlockedTarget = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (requesterBlockedTarget) {
|
||||
throw new CannotSendFriendRequestToBlockedUserError();
|
||||
}
|
||||
const targetBlockedRequester = await this.userRelationshipRepository.getRelationship(
|
||||
targetId,
|
||||
userId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (targetBlockedRequester) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
await this.userPermissionUtils.validateFriendSourcePermissions({userId, targetId});
|
||||
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
private async validateRelationshipCounts({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
const targetUser = await this.userAccountRepository.findUnique(targetId);
|
||||
|
||||
if (!user?.isBot) {
|
||||
const userLimit = this.resolveLimitForUser(user ?? null, 'max_relationships', MAX_RELATIONSHIPS);
|
||||
const hasReachedLimit = await this.userRelationshipRepository.hasReachedRelationshipLimit(userId, userLimit);
|
||||
if (hasReachedLimit) {
|
||||
throw new MaxRelationshipsError(userLimit);
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUser?.isBot) {
|
||||
const targetLimit = this.resolveLimitForUser(targetUser ?? null, 'max_relationships', MAX_RELATIONSHIPS);
|
||||
const hasReachedLimit = await this.userRelationshipRepository.hasReachedRelationshipLimit(targetId, targetLimit);
|
||||
if (hasReachedLimit) {
|
||||
throw new MaxRelationshipsError(targetLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const now = new Date();
|
||||
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.OUTGOING_REQUEST,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: targetId,
|
||||
target_user_id: userId,
|
||||
type: RelationshipTypes.INCOMING_REQUEST,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
await this.dispatchRelationshipCreate({userId, relationship: userRelationship, userCacheService, requestCache});
|
||||
await this.dispatchRelationshipCreate({
|
||||
userId: targetId,
|
||||
relationship: targetRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
return userRelationship;
|
||||
}
|
||||
|
||||
async dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const userPartialResolver = (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RELATIONSHIP_ADD',
|
||||
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const userPartialResolver = (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RELATIONSHIP_UPDATE',
|
||||
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RELATIONSHIP_REMOVE',
|
||||
data: {id: targetId},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
|
||||
const ctx = createLimitMatchContext({user});
|
||||
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
|
||||
}
|
||||
|
||||
private isDeletedUser(user: User | null | undefined): boolean {
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (user.flags & UserFlags.DELETED) === UserFlags.DELETED;
|
||||
}
|
||||
}
|
||||
622
packages/api/src/user/services/UserService.tsx
Normal file
622
packages/api/src/user/services/UserService.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '@fluxer/api/src/auth/services/SudoVerificationService';
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService';
|
||||
import type {IConnectionRepository} from '@fluxer/api/src/connection/IConnectionRepository';
|
||||
import type {UserConnectionRow} from '@fluxer/api/src/database/types/ConnectionTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {GuildService} from '@fluxer/api/src/guild/services/GuildService';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
|
||||
import type {KVBulkMessageDeletionQueueService} from '@fluxer/api/src/infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
|
||||
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {Channel} from '@fluxer/api/src/models/Channel';
|
||||
import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
|
||||
import type {Message} from '@fluxer/api/src/models/Message';
|
||||
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
|
||||
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
|
||||
import type {Relationship} from '@fluxer/api/src/models/Relationship';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
|
||||
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
|
||||
import type {PackService} from '@fluxer/api/src/pack/PackService';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
|
||||
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
|
||||
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
|
||||
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
|
||||
import {UserAccountService} from '@fluxer/api/src/user/services/UserAccountService';
|
||||
import {UserAuthService} from '@fluxer/api/src/user/services/UserAuthService';
|
||||
import {UserChannelService} from '@fluxer/api/src/user/services/UserChannelService';
|
||||
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
|
||||
import type {SavedMessageEntry} from '@fluxer/api/src/user/services/UserContentService';
|
||||
import {UserContentService} from '@fluxer/api/src/user/services/UserContentService';
|
||||
import {UserRelationshipService} from '@fluxer/api/src/user/services/UserRelationshipService';
|
||||
import type {UserHarvestResponse} from '@fluxer/api/src/user/UserHarvestModel';
|
||||
import type {UserPermissionUtils} from '@fluxer/api/src/utils/UserPermissionUtils';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {
|
||||
CreatePrivateChannelRequest,
|
||||
FriendRequestByTagRequest,
|
||||
UserGuildSettingsUpdateRequest,
|
||||
UserSettingsUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
|
||||
|
||||
interface UpdateUserParams {
|
||||
user: User;
|
||||
oldAuthSession: AuthSession;
|
||||
data: UserUpdateRequest;
|
||||
request: Request;
|
||||
sudoContext?: SudoVerificationResult;
|
||||
emailVerifiedViaToken?: boolean;
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
private accountService: UserAccountService;
|
||||
private authService: UserAuthService;
|
||||
private relationshipService: UserRelationshipService;
|
||||
private channelService: UserChannelService;
|
||||
private contentService: UserContentService;
|
||||
|
||||
constructor(
|
||||
userAccountRepository: IUserAccountRepository,
|
||||
userSettingsRepository: IUserSettingsRepository,
|
||||
userAuthRepository: IUserAuthRepository,
|
||||
userRelationshipRepository: IUserRelationshipRepository,
|
||||
userChannelRepository: IUserChannelRepository,
|
||||
userContentRepository: IUserContentRepository,
|
||||
authService: AuthService,
|
||||
userCacheService: UserCacheService,
|
||||
channelService: ChannelService,
|
||||
channelRepository: IChannelRepository,
|
||||
guildService: GuildService,
|
||||
gatewayService: IGatewayService,
|
||||
entityAssetService: EntityAssetService,
|
||||
mediaService: IMediaService,
|
||||
packService: PackService,
|
||||
emailService: IEmailService,
|
||||
snowflakeService: SnowflakeService,
|
||||
discriminatorService: IDiscriminatorService,
|
||||
rateLimitService: IRateLimitService,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
workerService: IWorkerService,
|
||||
userPermissionUtils: UserPermissionUtils,
|
||||
kvDeletionQueue: KVAccountDeletionQueueService,
|
||||
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
|
||||
botMfaMirrorService: BotMfaMirrorService,
|
||||
contactChangeLogService: UserContactChangeLogService,
|
||||
connectionRepository: IConnectionRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.accountService = new UserAccountService(
|
||||
userAccountRepository,
|
||||
userSettingsRepository,
|
||||
userRelationshipRepository,
|
||||
userChannelRepository,
|
||||
authService,
|
||||
userCacheService,
|
||||
guildService,
|
||||
gatewayService,
|
||||
entityAssetService,
|
||||
mediaService,
|
||||
packService,
|
||||
emailService,
|
||||
rateLimitService,
|
||||
guildRepository,
|
||||
discriminatorService,
|
||||
kvDeletionQueue,
|
||||
contactChangeLogService,
|
||||
connectionRepository,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.authService = new UserAuthService(
|
||||
userAccountRepository,
|
||||
userAuthRepository,
|
||||
authService,
|
||||
emailService,
|
||||
gatewayService,
|
||||
botMfaMirrorService,
|
||||
);
|
||||
|
||||
this.relationshipService = new UserRelationshipService(
|
||||
userAccountRepository,
|
||||
userRelationshipRepository,
|
||||
gatewayService,
|
||||
userPermissionUtils,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.channelService = new UserChannelService(
|
||||
userAccountRepository,
|
||||
userChannelRepository,
|
||||
userRelationshipRepository,
|
||||
channelService,
|
||||
channelRepository,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
userPermissionUtils,
|
||||
limitConfigService,
|
||||
);
|
||||
|
||||
this.contentService = new UserContentService(
|
||||
userAccountRepository,
|
||||
userContentRepository,
|
||||
userCacheService,
|
||||
channelService,
|
||||
channelRepository,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
workerService,
|
||||
snowflakeService,
|
||||
bulkMessageDeletionQueue,
|
||||
limitConfigService,
|
||||
);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return await this.accountService.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return await this.accountService.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async getUserProfile(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId?: GuildID;
|
||||
withMutualFriends?: boolean;
|
||||
withMutualGuilds?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<{
|
||||
user: User;
|
||||
guildMember?: GuildMemberResponse | null;
|
||||
guildMemberDomain?: GuildMember | null;
|
||||
premiumType?: number;
|
||||
premiumSince?: Date;
|
||||
premiumLifetimeSequence?: number;
|
||||
mutualFriends?: Array<User>;
|
||||
mutualGuilds?: Array<{id: string; nick: string | null}>;
|
||||
connections?: Array<UserConnectionRow>;
|
||||
}> {
|
||||
return await this.accountService.getUserProfile(params);
|
||||
}
|
||||
|
||||
async update(params: UpdateUserParams): Promise<User> {
|
||||
return await this.accountService.update(params);
|
||||
}
|
||||
|
||||
async generateUniqueDiscriminator(username: string): Promise<number> {
|
||||
return await this.accountService.generateUniqueDiscriminator(username);
|
||||
}
|
||||
|
||||
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
|
||||
return await this.accountService.checkUsernameDiscriminatorAvailability(params);
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings> {
|
||||
return await this.accountService.findSettings(userId);
|
||||
}
|
||||
|
||||
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
|
||||
return await this.accountService.updateSettings(params);
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return await this.accountService.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async updateGuildSettings(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID | null;
|
||||
data: UserGuildSettingsUpdateRequest;
|
||||
}): Promise<UserGuildSettings> {
|
||||
return await this.accountService.updateGuildSettings(params);
|
||||
}
|
||||
|
||||
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
|
||||
return await this.accountService.getUserNote(params);
|
||||
}
|
||||
|
||||
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
|
||||
return await this.accountService.getUserNotes(userId);
|
||||
}
|
||||
|
||||
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
|
||||
return await this.accountService.setUserNote(params);
|
||||
}
|
||||
|
||||
async selfDisable(userId: UserID): Promise<void> {
|
||||
return await this.accountService.selfDisable(userId);
|
||||
}
|
||||
|
||||
async selfDelete(userId: UserID): Promise<void> {
|
||||
return await this.accountService.selfDelete(userId);
|
||||
}
|
||||
|
||||
async resetCurrentUserPremiumState(user: User): Promise<void> {
|
||||
return await this.accountService.resetCurrentUserPremiumState(user);
|
||||
}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
return await this.accountService.dispatchUserUpdate(user);
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
return await this.accountService.dispatchUserSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserGuildSettingsUpdate({
|
||||
userId,
|
||||
settings,
|
||||
}: {
|
||||
userId: UserID;
|
||||
settings: UserGuildSettings;
|
||||
}): Promise<void> {
|
||||
return await this.accountService.dispatchUserGuildSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
return await this.accountService.dispatchUserNoteUpdate(params);
|
||||
}
|
||||
|
||||
async enableMfaTotp(params: {
|
||||
user: User;
|
||||
secret: string;
|
||||
code: string;
|
||||
sudoContext: SudoVerificationResult;
|
||||
}): Promise<Array<MfaBackupCode>> {
|
||||
return await this.authService.enableMfaTotp(params);
|
||||
}
|
||||
|
||||
async disableMfaTotp(params: {
|
||||
user: User;
|
||||
code: string;
|
||||
sudoContext: SudoVerificationResult;
|
||||
password?: string;
|
||||
}): Promise<void> {
|
||||
return await this.authService.disableMfaTotp(params);
|
||||
}
|
||||
|
||||
async getMfaBackupCodes(params: {
|
||||
user: User;
|
||||
regenerate: boolean;
|
||||
sudoContext: SudoVerificationResult;
|
||||
password?: string;
|
||||
}): Promise<Array<MfaBackupCode>> {
|
||||
return await this.authService.getMfaBackupCodes(params);
|
||||
}
|
||||
|
||||
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
|
||||
return await this.authService.regenerateMfaBackupCodes(user);
|
||||
}
|
||||
|
||||
async verifyEmail(token: string): Promise<boolean> {
|
||||
return await this.authService.verifyEmail(token);
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<boolean> {
|
||||
return await this.authService.resendVerificationEmail(user);
|
||||
}
|
||||
|
||||
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
|
||||
return await this.relationshipService.getRelationships(userId);
|
||||
}
|
||||
|
||||
async sendFriendRequestByTag({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: FriendRequestByTagRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.sendFriendRequestByTag({userId, data, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async sendFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.sendFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async acceptFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const relationship = await this.relationshipService.acceptFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
await this.channelService.ensureDmOpenForBothUsers({
|
||||
userId,
|
||||
recipientId: targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
async blockUser({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.blockUser({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async updateFriendNickname({
|
||||
userId,
|
||||
targetId,
|
||||
nickname,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
nickname: string | null;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.updateFriendNickname({
|
||||
userId,
|
||||
targetId,
|
||||
nickname,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
return await this.relationshipService.removeRelationship({userId, targetId});
|
||||
}
|
||||
|
||||
async dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.relationshipService.dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.relationshipService.dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
|
||||
return await this.relationshipService.dispatchRelationshipRemove({userId, targetId});
|
||||
}
|
||||
|
||||
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
return await this.channelService.getPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async createOrOpenDMChannel({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: CreatePrivateChannelRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
return await this.channelService.createOrOpenDMChannel({userId, data, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
return await this.channelService.pinDmChannel({userId, channelId});
|
||||
}
|
||||
|
||||
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
return await this.channelService.unpinDmChannel({userId, channelId});
|
||||
}
|
||||
|
||||
async preloadDMMessages(params: {
|
||||
userId: UserID;
|
||||
channelIds: Array<ChannelID>;
|
||||
}): Promise<Record<string, Message | null>> {
|
||||
return await this.channelService.preloadDMMessages(params);
|
||||
}
|
||||
|
||||
async getRecentMentions(params: {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
everyone: boolean;
|
||||
roles: boolean;
|
||||
guilds: boolean;
|
||||
before?: MessageID;
|
||||
}): Promise<Array<Message>> {
|
||||
return await this.contentService.getRecentMentions(params);
|
||||
}
|
||||
|
||||
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.deleteRecentMention({userId, messageId});
|
||||
}
|
||||
|
||||
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
|
||||
return await this.contentService.getSavedMessages({userId, limit});
|
||||
}
|
||||
|
||||
async saveMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.contentService.saveMessage({userId, channelId, messageId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.unsaveMessage({userId, messageId});
|
||||
}
|
||||
|
||||
async registerPushSubscription(params: {
|
||||
userId: UserID;
|
||||
endpoint: string;
|
||||
keys: {p256dh: string; auth: string};
|
||||
userAgent?: string;
|
||||
}): Promise<PushSubscription> {
|
||||
return await this.contentService.registerPushSubscription(params);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return await this.contentService.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return await this.contentService.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async requestDataHarvest(userId: UserID): Promise<{
|
||||
harvest_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
}> {
|
||||
return await this.contentService.requestDataHarvest(userId);
|
||||
}
|
||||
|
||||
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
|
||||
return await this.contentService.getHarvestStatus(userId, harvestId);
|
||||
}
|
||||
|
||||
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
|
||||
return await this.contentService.getLatestHarvest(userId);
|
||||
}
|
||||
|
||||
async getHarvestDownloadUrl(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
storageService: IStorageService,
|
||||
): Promise<{download_url: string; expires_at: string}> {
|
||||
return await this.contentService.getHarvestDownloadUrl(userId, harvestId, storageService);
|
||||
}
|
||||
|
||||
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
|
||||
return await this.contentService.requestBulkMessageDeletion(params);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
|
||||
return await this.contentService.cancelBulkMessageDeletion(userId);
|
||||
}
|
||||
|
||||
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.dispatchRecentMentionDelete({userId, messageId});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageCreate({
|
||||
userId,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
message: Message;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.contentService.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.dispatchSavedMessageDelete({userId, messageId});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {deleteAccount, expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Account Delete Auto Cancel on Login', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('logging in after account deletion cancels the deletion', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await deleteAccount(harness, account.token, account.password);
|
||||
|
||||
const login = await loginAccount(harness, account);
|
||||
expect(login.token).not.toBe('');
|
||||
|
||||
const data = await expectDataExists(harness, account.userId);
|
||||
expect(data.hasSelfDeletedFlag).toBe(false);
|
||||
expect(data.pendingDeletionAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {deleteAccount, expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Account Deletion Grace Period', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('account deletion enters grace period and allows login', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await deleteAccount(harness, account.token, account.password);
|
||||
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
|
||||
const dataBeforeLogin = await expectDataExists(harness, account.userId);
|
||||
expect(dataBeforeLogin.hasSelfDeletedFlag).toBe(true);
|
||||
expect(dataBeforeLogin.pendingDeletionAt).not.toBeNull();
|
||||
|
||||
const loginAfterDelete = await loginAccount(harness, account);
|
||||
expect(loginAfterDelete.token).not.toBe('');
|
||||
|
||||
const dataAfterLogin = await expectDataExists(harness, account.userId);
|
||||
expect(dataAfterLogin.hasSelfDeletedFlag).toBe(false);
|
||||
expect(dataAfterLogin.pendingDeletionAt).toBeNull();
|
||||
});
|
||||
|
||||
test('account deletion is blocked while user owns guilds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createGuild(harness, account.token, 'Owned Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/delete')
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'USER_OWNS_GUILDS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted, sendMessage} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
deleteAccount,
|
||||
setPendingDeletionAt,
|
||||
triggerDeletionWorker,
|
||||
waitForDeletionCompletion,
|
||||
} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Account Delete Message Pagination', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('account deletion anonymizes messages beyond chunk size', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Message Pagination Guild');
|
||||
|
||||
let channelId = guild.system_channel_id;
|
||||
if (!channelId) {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'general');
|
||||
channelId = channel.id;
|
||||
}
|
||||
|
||||
const chunkSize = 100;
|
||||
const extraMessages = 5;
|
||||
const totalMessages = chunkSize + extraMessages;
|
||||
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
await sendMessage(harness, account.token, channelId, `Message ${i + 1}`);
|
||||
}
|
||||
|
||||
const newOwner = await createTestAccount(harness);
|
||||
const invite = await createChannelInvite(harness, account.token, channelId);
|
||||
await acceptInvite(harness, newOwner.token, invite.code);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/transfer-ownership`)
|
||||
.body({
|
||||
new_owner_id: newOwner.userId,
|
||||
password: account.password,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await deleteAccount(harness, account.token, account.password);
|
||||
|
||||
const past = new Date();
|
||||
past.setMinutes(past.getMinutes() - 1);
|
||||
await setPendingDeletionAt(harness, account.userId, past);
|
||||
|
||||
await triggerDeletionWorker(harness);
|
||||
await waitForDeletionCompletion(harness, account.userId);
|
||||
|
||||
const messages = await createBuilder<Array<MessageResponse>>(harness, newOwner.token)
|
||||
.get(`/channels/${channelId}/messages?limit=100`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const anonymizedMessages = messages.filter((message) => message.content.startsWith('Message '));
|
||||
expect(anonymizedMessages.length).toBeGreaterThan(0);
|
||||
for (const message of anonymizedMessages) {
|
||||
expect(message.author.id).not.toBe(account.userId);
|
||||
expect(message.author.username).toBe('DeletedUser');
|
||||
expect(message.author.discriminator).toBe('0000');
|
||||
}
|
||||
|
||||
const countJson = await createBuilderWithoutAuth<{count: number}>(harness)
|
||||
.get(`/test/users/${account.userId}/messages/count`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(countJson.count).toBe(0);
|
||||
}, 60_000);
|
||||
});
|
||||
58
packages/api/src/user/tests/AccountDeletePermanent.test.tsx
Normal file
58
packages/api/src/user/tests/AccountDeletePermanent.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {createFriendship} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
|
||||
import {
|
||||
deleteAccount,
|
||||
setPendingDeletionAt,
|
||||
triggerDeletionWorker,
|
||||
waitForDeletionCompletion,
|
||||
} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {beforeEach, describe, test} from 'vitest';
|
||||
|
||||
describe('Account Delete Permanent', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('permanent account deletion removes user data', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, account, friend);
|
||||
|
||||
await deleteAccount(harness, account.token, account.password);
|
||||
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
|
||||
const past = new Date();
|
||||
past.setMinutes(past.getMinutes() - 1);
|
||||
await setPendingDeletionAt(harness, account.userId, past);
|
||||
|
||||
await triggerDeletionWorker(harness);
|
||||
|
||||
await waitForDeletionCompletion(harness, account.userId);
|
||||
});
|
||||
});
|
||||
48
packages/api/src/user/tests/AccountDisable.test.tsx
Normal file
48
packages/api/src/user/tests/AccountDisable.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {disableAccount, expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Account Disable', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('disabling account preserves data and allows re-login', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await disableAccount(harness, account.token, account.password);
|
||||
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
|
||||
const login = await loginAccount(harness, account);
|
||||
expect(login.token).not.toBe('');
|
||||
|
||||
const data = await expectDataExists(harness, account.userId);
|
||||
expect(data.emailCleared).toBe(false);
|
||||
expect(data.passwordCleared).toBe(false);
|
||||
});
|
||||
});
|
||||
291
packages/api/src/user/tests/FavoriteMemeLimits.test.tsx
Normal file
291
packages/api/src/user/tests/FavoriteMemeLimits.test.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createTestAccountForAttachmentTests,
|
||||
setupTestGuildAndChannel,
|
||||
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
createFavoriteMemeFromMessage,
|
||||
createMessageWithImageAttachment,
|
||||
listFavoriteMemes,
|
||||
} from '@fluxer/api/src/user/tests/FavoriteMemeTestUtils';
|
||||
import {MAX_FAVORITE_MEME_TAGS, MAX_FAVORITE_MEMES_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Favorite Meme Limits', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should enforce maximum favorite memes limit for non-premium users', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
for (let i = 0; i < MAX_FAVORITE_MEMES_NON_PREMIUM; i++) {
|
||||
const message = await createMessageWithImageAttachment(
|
||||
harness,
|
||||
account.token,
|
||||
channel.id,
|
||||
i % 2 === 0 ? 'yeah.png' : 'thisisfine.gif',
|
||||
);
|
||||
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: `Meme ${i + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
const memes = await listFavoriteMemes(harness, account.token);
|
||||
expect(memes.length).toBe(MAX_FAVORITE_MEMES_NON_PREMIUM);
|
||||
|
||||
const extraMessage = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${extraMessage.id}/memes`)
|
||||
.body({
|
||||
attachment_id: extraMessage.attachments[0].id,
|
||||
name: 'One Too Many',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
}, 10000);
|
||||
|
||||
test('should enforce maximum tags per meme limit', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
const tooManyTags = Array.from({length: MAX_FAVORITE_MEME_TAGS + 1}, (_, i) => `tag${i + 1}`);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Too Many Tags',
|
||||
tags: tooManyTags,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow exactly max tags per meme', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
const maxTags = Array.from({length: MAX_FAVORITE_MEME_TAGS}, (_, i) => `tag${i + 1}`);
|
||||
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Max Tags Meme',
|
||||
tags: maxTags,
|
||||
});
|
||||
|
||||
expect(meme.tags.length).toBe(MAX_FAVORITE_MEME_TAGS);
|
||||
});
|
||||
|
||||
test('should enforce tag limit on update', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Initial Tags',
|
||||
tags: ['tag1', 'tag2'],
|
||||
});
|
||||
|
||||
const tooManyTags = Array.from({length: MAX_FAVORITE_MEME_TAGS + 1}, (_, i) => `newtag${i + 1}`);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/users/@me/memes/${meme.id}`)
|
||||
.body({
|
||||
tags: tooManyTags,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject saving same attachment twice', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Original',
|
||||
});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Duplicate',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow different media content', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message1 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'yeah.png');
|
||||
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message1.id, {
|
||||
attachment_id: message1.attachments[0].id,
|
||||
name: 'PNG Meme',
|
||||
});
|
||||
|
||||
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'thisisfine.gif');
|
||||
const meme2 = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message2.id, {
|
||||
attachment_id: message2.attachments[0].id,
|
||||
name: 'GIF Meme',
|
||||
});
|
||||
|
||||
expect(meme2.id).toBeTruthy();
|
||||
expect(meme2.filename).toBe('thisisfine.gif');
|
||||
});
|
||||
|
||||
test('should return error for invalid attachment id', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: '999999999999999999',
|
||||
name: 'Invalid Attachment',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error for invalid embed index', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
embed_index: 99,
|
||||
name: 'Invalid Embed',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error for message without media', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const {sendMessage} = await import('@fluxer/api/src/message/tests/MessageTestUtils');
|
||||
const textMessage = await sendMessage(harness, account.token, channel.id, 'No media here');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${textMessage.id}/memes`)
|
||||
.body({
|
||||
embed_index: 0,
|
||||
name: 'No Media',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error for unknown message', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/999999999999999999/memes`)
|
||||
.body({
|
||||
attachment_id: '123',
|
||||
name: 'Unknown Message',
|
||||
})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error for inaccessible channel', async () => {
|
||||
const account1 = await createTestAccountForAttachmentTests(harness);
|
||||
const account2 = await createTestAccountForAttachmentTests(harness);
|
||||
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account1);
|
||||
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account2.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Inaccessible',
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow accessing other users memes', async () => {
|
||||
const account1 = await createTestAccountForAttachmentTests(harness);
|
||||
const account2 = await createTestAccountForAttachmentTests(harness);
|
||||
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account1);
|
||||
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account1.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Private Meme',
|
||||
});
|
||||
|
||||
await createBuilder(harness, account2.token)
|
||||
.get(`/users/@me/memes/${meme.id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow updating other users memes', async () => {
|
||||
const account1 = await createTestAccountForAttachmentTests(harness);
|
||||
const account2 = await createTestAccountForAttachmentTests(harness);
|
||||
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account1);
|
||||
const message = await createMessageWithImageAttachment(harness, account1.token, channel.id);
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account1.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Private Meme',
|
||||
});
|
||||
|
||||
await createBuilder(harness, account2.token)
|
||||
.patch(`/users/@me/memes/${meme.id}`)
|
||||
.body({
|
||||
name: 'Stolen Meme',
|
||||
})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
358
packages/api/src/user/tests/FavoriteMemeOperations.test.tsx
Normal file
358
packages/api/src/user/tests/FavoriteMemeOperations.test.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createTestAccountForAttachmentTests,
|
||||
setupTestGuildAndChannel,
|
||||
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
createFavoriteMemeFromMessage,
|
||||
createMessageWithImageAttachment,
|
||||
deleteFavoriteMeme,
|
||||
getFavoriteMeme,
|
||||
listFavoriteMemes,
|
||||
updateFavoriteMeme,
|
||||
} from '@fluxer/api/src/user/tests/FavoriteMemeTestUtils';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Favorite Meme Operations', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should create favorite meme from message attachment', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'My Favorite Meme',
|
||||
});
|
||||
|
||||
expect(meme.id).toBeTruthy();
|
||||
expect(meme.name).toBe('My Favorite Meme');
|
||||
expect(meme.user_id).toBe(account.userId);
|
||||
expect(meme.filename).toBe('yeah.png');
|
||||
expect(meme.content_type).toBe('image/png');
|
||||
});
|
||||
|
||||
test('should create favorite meme with alt text and tags', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Tagged Meme',
|
||||
alt_text: 'A funny image',
|
||||
tags: ['funny', 'reaction'],
|
||||
});
|
||||
|
||||
expect(meme.name).toBe('Tagged Meme');
|
||||
expect(meme.alt_text).toBe('A funny image');
|
||||
expect(meme.tags).toEqual(['funny', 'reaction']);
|
||||
});
|
||||
|
||||
test('should list favorite memes', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message1 = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message1.id, {
|
||||
attachment_id: message1.attachments[0].id,
|
||||
name: 'First Meme',
|
||||
});
|
||||
|
||||
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'thisisfine.gif');
|
||||
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message2.id, {
|
||||
attachment_id: message2.attachments[0].id,
|
||||
name: 'Second Meme',
|
||||
});
|
||||
|
||||
const memes = await listFavoriteMemes(harness, account.token);
|
||||
|
||||
expect(memes.length).toBe(2);
|
||||
expect(memes.some((m) => m.name === 'First Meme')).toBe(true);
|
||||
expect(memes.some((m) => m.name === 'Second Meme')).toBe(true);
|
||||
});
|
||||
|
||||
test('should get single favorite meme by id', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Get Me',
|
||||
});
|
||||
|
||||
const meme = await getFavoriteMeme(harness, account.token, created.id);
|
||||
|
||||
expect(meme.id).toBe(created.id);
|
||||
expect(meme.name).toBe('Get Me');
|
||||
});
|
||||
|
||||
test('should update favorite meme name', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Original Name',
|
||||
});
|
||||
|
||||
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
|
||||
name: 'New Name',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('New Name');
|
||||
});
|
||||
|
||||
test('should update favorite meme alt text', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Meme Name',
|
||||
});
|
||||
|
||||
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
|
||||
alt_text: 'Updated description',
|
||||
});
|
||||
|
||||
expect(updated.alt_text).toBe('Updated description');
|
||||
});
|
||||
|
||||
test('should clear favorite meme alt text by setting to null', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Meme',
|
||||
alt_text: 'Has alt text',
|
||||
});
|
||||
|
||||
expect(created.alt_text).toBe('Has alt text');
|
||||
|
||||
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
|
||||
alt_text: null,
|
||||
});
|
||||
|
||||
expect(updated.alt_text).toBeNull();
|
||||
});
|
||||
|
||||
test('should update favorite meme tags', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Tagged',
|
||||
tags: ['old-tag'],
|
||||
});
|
||||
|
||||
const updated = await updateFavoriteMeme(harness, account.token, created.id, {
|
||||
tags: ['new-tag', 'another-tag'],
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual(['new-tag', 'another-tag']);
|
||||
});
|
||||
|
||||
test('should delete favorite meme', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Delete Me',
|
||||
});
|
||||
|
||||
await deleteFavoriteMeme(harness, account.token, created.id);
|
||||
|
||||
const memes = await listFavoriteMemes(harness, account.token);
|
||||
expect(memes.find((m) => m.id === created.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return 404 for unknown meme id', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get('/users/@me/memes/999999999999999999')
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require name when creating from message', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require either attachment_id or embed_index', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
name: 'Test Meme',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should validate name length max 100 characters', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'a'.repeat(101),
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should validate alt_text length max 500 characters', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Valid Name',
|
||||
alt_text: 'a'.repeat(501),
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should validate tag length max 30 characters', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Valid Name',
|
||||
tags: ['a'.repeat(31)],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject empty tags', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages/${message.id}/memes`)
|
||||
.body({
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Tagged Meme',
|
||||
tags: ['valid', '', ' ', 'another'],
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should delete idempotently', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const created = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Delete Twice',
|
||||
});
|
||||
|
||||
await deleteFavoriteMeme(harness, account.token, created.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/memes/${created.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return empty list when no memes exist', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
|
||||
const memes = await listFavoriteMemes(harness, account.token);
|
||||
|
||||
expect(memes).toEqual([]);
|
||||
});
|
||||
|
||||
test('should include url in meme response', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'URL Test',
|
||||
});
|
||||
|
||||
expect(meme.url).toBeTruthy();
|
||||
expect(meme.url).toContain(meme.attachment_id);
|
||||
expect(meme.url).toContain(meme.filename);
|
||||
});
|
||||
});
|
||||
135
packages/api/src/user/tests/FavoriteMemeTestUtils.tsx
Normal file
135
packages/api/src/user/tests/FavoriteMemeTestUtils.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {loadFixture, sendMessageWithAttachments} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {FavoriteMemeResponse} from '@fluxer/schema/src/domains/meme/MemeSchemas';
|
||||
|
||||
export interface MessageWithAttachment {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
url?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function listFavoriteMemes(harness: ApiTestHarness, token: string): Promise<Array<FavoriteMemeResponse>> {
|
||||
return createBuilder<Array<FavoriteMemeResponse>>(harness, token).get('/users/@me/memes').execute();
|
||||
}
|
||||
|
||||
export async function getFavoriteMeme(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
memeId: string,
|
||||
): Promise<FavoriteMemeResponse> {
|
||||
return createBuilder<FavoriteMemeResponse>(harness, token).get(`/users/@me/memes/${memeId}`).execute();
|
||||
}
|
||||
|
||||
export async function createFavoriteMemeFromMessage(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
data: {
|
||||
attachment_id?: string;
|
||||
embed_index?: number;
|
||||
name: string;
|
||||
alt_text?: string;
|
||||
tags?: Array<string>;
|
||||
},
|
||||
): Promise<FavoriteMemeResponse> {
|
||||
return createBuilder<FavoriteMemeResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/messages/${messageId}/memes`)
|
||||
.body(data)
|
||||
.expect(HTTP_STATUS.CREATED)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function createFavoriteMemeFromUrl(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
data: {
|
||||
url: string;
|
||||
name?: string;
|
||||
alt_text?: string;
|
||||
tags?: Array<string>;
|
||||
klipy_slug?: string;
|
||||
},
|
||||
): Promise<FavoriteMemeResponse> {
|
||||
return createBuilder<FavoriteMemeResponse>(harness, token)
|
||||
.post('/users/@me/memes')
|
||||
.body(data)
|
||||
.expect(HTTP_STATUS.CREATED)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function updateFavoriteMeme(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
memeId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
alt_text?: string | null;
|
||||
tags?: Array<string>;
|
||||
},
|
||||
): Promise<FavoriteMemeResponse> {
|
||||
return createBuilder<FavoriteMemeResponse>(harness, token).patch(`/users/@me/memes/${memeId}`).body(data).execute();
|
||||
}
|
||||
|
||||
export async function deleteFavoriteMeme(harness: ApiTestHarness, token: string, memeId: string): Promise<void> {
|
||||
await createBuilder<void>(harness, token)
|
||||
.delete(`/users/@me/memes/${memeId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function createMessageWithImageAttachment(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
filename = 'yeah.png',
|
||||
): Promise<MessageWithAttachment> {
|
||||
const fileData = loadFixture(filename);
|
||||
|
||||
const {response, json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
token,
|
||||
channelId,
|
||||
{
|
||||
content: 'Test message with attachment',
|
||||
attachments: [{id: 0, filename}],
|
||||
},
|
||||
[{index: 0, filename, data: fileData}],
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to create message with attachment: ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
channel_id: channelId,
|
||||
attachments: json.attachments ?? [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {
|
||||
expectHarvestDownloadFailsWithError,
|
||||
fetchHarvestDownload,
|
||||
markHarvestCompleted,
|
||||
requestHarvest,
|
||||
} from '@fluxer/api/src/user/tests/HarvestTestUtils';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
describe('Harvest Download Expiration Boundary', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('download succeeds before expiration and fails after', async () => {
|
||||
vi.useRealTimers();
|
||||
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const {harvest_id} = await requestHarvest(harness, account.token);
|
||||
|
||||
const baseTime = Date.now();
|
||||
const expirationTime = new Date(baseTime + 5000);
|
||||
await markHarvestCompleted(account.userId, harvest_id, expirationTime);
|
||||
|
||||
const download = await fetchHarvestDownload(harness, account.token, harvest_id);
|
||||
expect(download.download_url).not.toBe('');
|
||||
expect(download.expires_at).not.toBe('');
|
||||
|
||||
vi.useFakeTimers({now: baseTime + 7000});
|
||||
|
||||
await expectHarvestDownloadFailsWithError(harness, account.token, harvest_id, 'HARVEST_EXPIRED');
|
||||
});
|
||||
});
|
||||
46
packages/api/src/user/tests/HarvestDownloadExpired.test.tsx
Normal file
46
packages/api/src/user/tests/HarvestDownloadExpired.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {
|
||||
expectHarvestDownloadFailsWithError,
|
||||
markHarvestCompleted,
|
||||
requestHarvest,
|
||||
} from '@fluxer/api/src/user/tests/HarvestTestUtils';
|
||||
import {beforeEach, describe, test} from 'vitest';
|
||||
|
||||
describe('Harvest Download Expired', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('download fails when harvest has expired', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const {harvest_id} = await requestHarvest(harness, account.token);
|
||||
|
||||
const expiredTime = new Date(Date.now() - 60 * 60 * 1000);
|
||||
await markHarvestCompleted(account.userId, harvest_id, expiredTime);
|
||||
|
||||
await expectHarvestDownloadFailsWithError(harness, account.token, harvest_id, 'HARVEST_EXPIRED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {fetchHarvestDownload, markHarvestCompleted, requestHarvest} from '@fluxer/api/src/user/tests/HarvestTestUtils';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Harvest Download Not Expired', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('download succeeds when harvest has not expired', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const {harvest_id} = await requestHarvest(harness, account.token);
|
||||
|
||||
const validTime = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000);
|
||||
await markHarvestCompleted(account.userId, harvest_id, validTime);
|
||||
|
||||
const download = await fetchHarvestDownload(harness, account.token, harvest_id);
|
||||
expect(download.download_url).not.toBe('');
|
||||
expect(download.expires_at).not.toBe('');
|
||||
});
|
||||
});
|
||||
111
packages/api/src/user/tests/HarvestTestUtils.tsx
Normal file
111
packages/api/src/user/tests/HarvestTestUtils.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {UserHarvestRepository} from '@fluxer/api/src/user/UserHarvestRepository';
|
||||
import type {
|
||||
HarvestDownloadUrlResponse,
|
||||
HarvestStatusResponseSchema,
|
||||
} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
|
||||
import {expect} from 'vitest';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
|
||||
export interface HarvestRequestResponse {
|
||||
harvest_id: string;
|
||||
}
|
||||
|
||||
export async function requestHarvest(harness: ApiTestHarness, token: string): Promise<HarvestRequestResponse> {
|
||||
return createBuilder<HarvestRequestResponse>(harness, token).post('/users/@me/harvest').execute();
|
||||
}
|
||||
|
||||
export async function fetchHarvestStatus(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
harvestId: string,
|
||||
): Promise<HarvestStatusResponse> {
|
||||
return createBuilder<HarvestStatusResponse>(harness, token).get(`/users/@me/harvest/${harvestId}`).execute();
|
||||
}
|
||||
|
||||
export async function fetchHarvestDownload(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
harvestId: string,
|
||||
): Promise<HarvestDownloadUrlResponse> {
|
||||
return createBuilder<HarvestDownloadUrlResponse>(harness, token)
|
||||
.get(`/users/@me/harvest/${harvestId}/download`)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function setHarvestExpiration(
|
||||
harness: ApiTestHarness,
|
||||
userId: string,
|
||||
harvestId: string,
|
||||
expiresAt: string,
|
||||
): Promise<void> {
|
||||
await createBuilderWithoutAuth<void>(harness)
|
||||
.post(`/test/users/${userId}/harvest/${harvestId}/set-expiration`)
|
||||
.body({expires_at: expiresAt})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function waitForHarvestCompletion(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
harvestId: string,
|
||||
timeoutMs = 90000,
|
||||
pollIntervalMs = 1000,
|
||||
): Promise<HarvestStatusResponse> {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const status = await fetchHarvestStatus(harness, token, harvestId);
|
||||
if (status.completed_at !== null && status.download_url_expires_at !== null) {
|
||||
return status;
|
||||
}
|
||||
if (status.failed_at !== null) {
|
||||
throw new Error(`Harvest failed: ${status.error_message ?? 'unknown error'}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
throw new Error('Harvest did not complete within timeout');
|
||||
}
|
||||
|
||||
export async function expectHarvestDownloadFailsWithError(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
harvestId: string,
|
||||
expectedCode: string,
|
||||
): Promise<void> {
|
||||
const {json} = await createBuilder<Record<string, unknown>>(harness, token)
|
||||
.get(`/users/@me/harvest/${harvestId}/download`)
|
||||
.expect(400)
|
||||
.executeWithResponse();
|
||||
|
||||
const errorResponse = json as {code: string; message: string};
|
||||
expect(errorResponse.code).toBe(expectedCode);
|
||||
}
|
||||
|
||||
export async function markHarvestCompleted(userId: string, harvestId: string, expiresAt: Date): Promise<void> {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const userIdTyped = createUserID(BigInt(userId));
|
||||
const harvestIdTyped = BigInt(harvestId);
|
||||
await harvestRepository.markAsCompleted(userIdTyped, harvestIdTyped, `test/${harvestId}.zip`, 1024n, expiresAt);
|
||||
}
|
||||
206
packages/api/src/user/tests/InactivityDeletion.test.tsx
Normal file
206
packages/api/src/user/tests/InactivityDeletion.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {expectDataExists} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface InactivityCheckResult {
|
||||
warnings_sent: number;
|
||||
deletions_scheduled: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
async function setUserActivity(harness: ApiTestHarness, userId: string, date: Date): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${userId}/set-last-active-at`)
|
||||
.body({timestamp: date.toISOString()})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function setBotFlag(harness: ApiTestHarness, userId: string, isBot: boolean): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness).post(`/test/users/${userId}/set-bot-flag`).body({is_bot: isBot}).execute();
|
||||
}
|
||||
|
||||
async function setSystemFlag(harness: ApiTestHarness, userId: string, isSystem: boolean): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${userId}/set-system-flag`)
|
||||
.body({is_system: isSystem})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function processInactivityDeletions(harness: ApiTestHarness): Promise<InactivityCheckResult> {
|
||||
return createBuilderWithoutAuth<InactivityCheckResult>(harness)
|
||||
.post('/test/worker/process-inactivity-deletions')
|
||||
.body({})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Inactivity Deletion', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('bot user should never be scheduled for inactivity deletion', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await setBotFlag(harness, account.userId, true);
|
||||
|
||||
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
|
||||
await setUserActivity(harness, account.userId, threeYearsAgo);
|
||||
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result.deletions_scheduled).toBe(0);
|
||||
expect(result.warnings_sent).toBe(0);
|
||||
|
||||
const dataStatus = await expectDataExists(harness, account.userId);
|
||||
expect(dataStatus.userExists).toBe(true);
|
||||
expect(dataStatus.hasSelfDeletedFlag).toBe(false);
|
||||
});
|
||||
|
||||
test('system user should never be scheduled for inactivity deletion', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await setSystemFlag(harness, account.userId, true);
|
||||
|
||||
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
|
||||
await setUserActivity(harness, account.userId, threeYearsAgo);
|
||||
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result.deletions_scheduled).toBe(0);
|
||||
expect(result.warnings_sent).toBe(0);
|
||||
|
||||
const dataStatus = await expectDataExists(harness, account.userId);
|
||||
expect(dataStatus.userExists).toBe(true);
|
||||
expect(dataStatus.hasSelfDeletedFlag).toBe(false);
|
||||
});
|
||||
|
||||
test('recently active user should not receive warning', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
await setUserActivity(harness, account.userId, oneMonthAgo);
|
||||
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result.warnings_sent).toBe(0);
|
||||
expect(result.deletions_scheduled).toBe(0);
|
||||
});
|
||||
|
||||
test('inactive user should receive warning email', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
|
||||
await setUserActivity(harness, account.userId, threeYearsAgo);
|
||||
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result.warnings_sent).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('warning email should be idempotent', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
|
||||
await setUserActivity(harness, account.userId, threeYearsAgo);
|
||||
|
||||
const firstResult = await processInactivityDeletions(harness);
|
||||
const firstWarnings = firstResult.warnings_sent;
|
||||
|
||||
const secondResult = await processInactivityDeletions(harness);
|
||||
|
||||
expect(secondResult.warnings_sent).toBeLessThanOrEqual(firstWarnings);
|
||||
});
|
||||
|
||||
test('user without activity data should not be deleted', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result.deletions_scheduled).toBe(0);
|
||||
|
||||
const dataStatus = await expectDataExists(harness, account.userId);
|
||||
expect(dataStatus.userExists).toBe(true);
|
||||
});
|
||||
|
||||
test('user already pending deletion should be skipped', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
|
||||
await setUserActivity(harness, account.userId, threeYearsAgo);
|
||||
|
||||
const pendingDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${account.userId}/set-pending-deletion`)
|
||||
.body({pending_deletion_at: pendingDate.toISOString()})
|
||||
.execute();
|
||||
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result.deletions_scheduled).toBe(0);
|
||||
|
||||
const dataStatus = await expectDataExists(harness, account.userId);
|
||||
expect(dataStatus.userExists).toBe(true);
|
||||
});
|
||||
|
||||
test('processing should handle multiple users', async () => {
|
||||
const account1 = await createTestAccount(harness);
|
||||
const account2 = await createTestAccount(harness);
|
||||
const account3 = await createTestAccount(harness);
|
||||
|
||||
await setBotFlag(harness, account1.userId, true);
|
||||
await setSystemFlag(harness, account2.userId, true);
|
||||
|
||||
const threeYearsAgo = new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000);
|
||||
await setUserActivity(harness, account1.userId, threeYearsAgo);
|
||||
await setUserActivity(harness, account2.userId, threeYearsAgo);
|
||||
await setUserActivity(harness, account3.userId, threeYearsAgo);
|
||||
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result.errors).toBe(0);
|
||||
|
||||
const data1 = await expectDataExists(harness, account1.userId);
|
||||
const data2 = await expectDataExists(harness, account2.userId);
|
||||
|
||||
expect(data1.hasSelfDeletedFlag).toBe(false);
|
||||
expect(data2.hasSelfDeletedFlag).toBe(false);
|
||||
});
|
||||
|
||||
test('should return processing statistics', async () => {
|
||||
const result = await processInactivityDeletions(harness);
|
||||
|
||||
expect(result).toHaveProperty('warnings_sent');
|
||||
expect(result).toHaveProperty('deletions_scheduled');
|
||||
expect(result).toHaveProperty('errors');
|
||||
expect(typeof result.warnings_sent).toBe('number');
|
||||
expect(typeof result.deletions_scheduled).toBe('number');
|
||||
expect(typeof result.errors).toBe('number');
|
||||
});
|
||||
});
|
||||
385
packages/api/src/user/tests/PasswordChangeFlow.test.tsx
Normal file
385
packages/api/src/user/tests/PasswordChangeFlow.test.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createTestAccount,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
loginUser,
|
||||
type TestAccount,
|
||||
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_CREDENTIALS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface PasswordChangeStartResult {
|
||||
ticket: string;
|
||||
code_expires_at: string;
|
||||
resend_available_at: string | null;
|
||||
}
|
||||
|
||||
interface PasswordChangeVerifyResult {
|
||||
verification_proof: string;
|
||||
}
|
||||
|
||||
async function startPasswordChange(harness: ApiTestHarness, token: string): Promise<PasswordChangeStartResult> {
|
||||
return createBuilder<PasswordChangeStartResult>(harness, token)
|
||||
.post('/users/@me/password-change/start')
|
||||
.body({})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function verifyPasswordChangeCode(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
ticket: string,
|
||||
code: string,
|
||||
): Promise<PasswordChangeVerifyResult> {
|
||||
return createBuilder<PasswordChangeVerifyResult>(harness, token)
|
||||
.post('/users/@me/password-change/verify')
|
||||
.body({ticket, code})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function completePasswordChange(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
ticket: string,
|
||||
verificationProof: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.post('/users/@me/password-change/complete')
|
||||
.body({ticket, verification_proof: verificationProof, new_password: newPassword})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function getVerificationCode(harness: ApiTestHarness, email: string): Promise<string> {
|
||||
const emails = await listTestEmails(harness, {recipient: email});
|
||||
const record = findLastTestEmail(emails, 'password_change_verification');
|
||||
if (!record) {
|
||||
throw new Error(`No password change verification email found for ${email}`);
|
||||
}
|
||||
return record.metadata.code;
|
||||
}
|
||||
|
||||
async function runFullVerification(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
): Promise<{ticket: string; verificationProof: string}> {
|
||||
const startResult = await startPasswordChange(harness, account.token);
|
||||
const code = await getVerificationCode(harness, account.email);
|
||||
const verifyResult = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
|
||||
return {ticket: startResult.ticket, verificationProof: verifyResult.verification_proof};
|
||||
}
|
||||
|
||||
describe('PasswordChangeFlow', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
test('sends verification email and returns ticket', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const result = await startPasswordChange(harness, account.token);
|
||||
|
||||
expect(result.ticket).toBeDefined();
|
||||
expect(typeof result.ticket).toBe('string');
|
||||
expect(result.code_expires_at).toBeDefined();
|
||||
expect(result.resend_available_at).toBeDefined();
|
||||
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const verificationEmail = findLastTestEmail(emails, 'password_change_verification');
|
||||
expect(verificationEmail).not.toBeNull();
|
||||
expect(verificationEmail!.metadata.code).toBeDefined();
|
||||
});
|
||||
|
||||
test('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/password-change/start')
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resend', () => {
|
||||
test('rejects during cooldown period', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const startResult = await startPasswordChange(harness, account.token);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/password-change/resend')
|
||||
.body({ticket: startResult.ticket})
|
||||
.expect(429)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('rejects with invalid ticket', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/password-change/resend')
|
||||
.body({ticket: 'invalid-ticket-id'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/password-change/resend')
|
||||
.body({ticket: 'some-ticket'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
test('returns verification_proof with correct code', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const startResult = await startPasswordChange(harness, account.token);
|
||||
const code = await getVerificationCode(harness, account.email);
|
||||
|
||||
const verifyResult = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
|
||||
|
||||
expect(verifyResult.verification_proof).toBeDefined();
|
||||
expect(typeof verifyResult.verification_proof).toBe('string');
|
||||
});
|
||||
|
||||
test('rejects with incorrect code', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const startResult = await startPasswordChange(harness, account.token);
|
||||
|
||||
const {json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/password-change/verify')
|
||||
.body({ticket: startResult.ticket, code: 'XXXX-YYYY'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
|
||||
const body = json as {errors?: Array<{code: string; path: string}>};
|
||||
expect(body.errors?.[0]?.code).toBe('INVALID_VERIFICATION_CODE');
|
||||
});
|
||||
|
||||
test('rejects with invalid ticket', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/password-change/verify')
|
||||
.body({ticket: 'nonexistent-ticket', code: 'ABCD-1234'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('returns same proof on repeated verification with correct code', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const startResult = await startPasswordChange(harness, account.token);
|
||||
const code = await getVerificationCode(harness, account.email);
|
||||
|
||||
const firstVerify = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
|
||||
const secondVerify = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
|
||||
|
||||
expect(firstVerify.verification_proof).toBe(secondVerify.verification_proof);
|
||||
});
|
||||
|
||||
test('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/password-change/verify')
|
||||
.body({ticket: 'some-ticket', code: 'ABCD-1234'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
test('succeeds with correct proof and passwords', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {ticket, verificationProof} = await runFullVerification(harness, account);
|
||||
|
||||
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
|
||||
});
|
||||
|
||||
test('succeeds when the user does not have a password set', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {ticket, verificationProof} = await runFullVerification(harness, account);
|
||||
|
||||
await createBuilderWithoutAuth(harness).post(`/test/users/${account.userId}/unclaim`).body({}).execute();
|
||||
|
||||
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
|
||||
});
|
||||
|
||||
test('rejects with invalid verification_proof', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {ticket} = await runFullVerification(harness, account);
|
||||
|
||||
const {json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/password-change/complete')
|
||||
.body({
|
||||
ticket,
|
||||
verification_proof: 'fake-proof-value',
|
||||
new_password: TEST_CREDENTIALS.ALT_PASSWORD_1,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
|
||||
const body = json as {errors?: Array<{code: string; path: string}>};
|
||||
expect(body.errors?.[0]?.code).toBe('INVALID_PROOF_TOKEN');
|
||||
});
|
||||
|
||||
test('rejects when ticket has not been verified', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const startResult = await startPasswordChange(harness, account.token);
|
||||
|
||||
const {json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/password-change/complete')
|
||||
.body({
|
||||
ticket: startResult.ticket,
|
||||
verification_proof: 'some-proof',
|
||||
new_password: TEST_CREDENTIALS.ALT_PASSWORD_1,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
|
||||
const body = json as {errors?: Array<{code: string; path: string}>};
|
||||
expect(body.errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
|
||||
});
|
||||
|
||||
test('rejects reusing a completed ticket', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {ticket, verificationProof} = await runFullVerification(harness, account);
|
||||
|
||||
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
|
||||
|
||||
const freshLogin = await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: TEST_CREDENTIALS.ALT_PASSWORD_1,
|
||||
});
|
||||
if ('mfa' in freshLogin) {
|
||||
throw new Error('Expected non-MFA login');
|
||||
}
|
||||
const newToken = (freshLogin as {token: string}).token;
|
||||
|
||||
await createBuilder(harness, newToken)
|
||||
.post('/users/@me/password-change/complete')
|
||||
.body({
|
||||
ticket,
|
||||
verification_proof: verificationProof,
|
||||
new_password: TEST_CREDENTIALS.ALT_PASSWORD_2,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('invalidates existing sessions after password change', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const originalToken = account.token;
|
||||
|
||||
const {ticket, verificationProof} = await runFullVerification(harness, account);
|
||||
|
||||
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
|
||||
|
||||
const {response} = await createBuilder(harness, originalToken)
|
||||
.get('/users/@me')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.executeRaw();
|
||||
expect(response.status).toBe(HTTP_STATUS.UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('user can log in with new password after change', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {ticket, verificationProof} = await runFullVerification(harness, account);
|
||||
|
||||
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
|
||||
|
||||
const login = await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: TEST_CREDENTIALS.ALT_PASSWORD_1,
|
||||
});
|
||||
expect('token' in login).toBe(true);
|
||||
expect('mfa' in login).toBe(false);
|
||||
});
|
||||
|
||||
test('old password no longer works after change', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {ticket, verificationProof} = await runFullVerification(harness, account);
|
||||
|
||||
await completePasswordChange(harness, account.token, ticket, verificationProof, TEST_CREDENTIALS.ALT_PASSWORD_1);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: TEST_CREDENTIALS.STRONG_PASSWORD})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/password-change/complete')
|
||||
.body({
|
||||
ticket: 'some-ticket',
|
||||
verification_proof: 'some-proof',
|
||||
new_password: 'new-password',
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('full flow', () => {
|
||||
test('start, verify, and complete password change end-to-end', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const startResult = await startPasswordChange(harness, account.token);
|
||||
expect(startResult.ticket).toBeDefined();
|
||||
expect(startResult.code_expires_at).toBeDefined();
|
||||
|
||||
const code = await getVerificationCode(harness, account.email);
|
||||
expect(code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
|
||||
|
||||
const verifyResult = await verifyPasswordChangeCode(harness, account.token, startResult.ticket, code);
|
||||
expect(verifyResult.verification_proof).toBeDefined();
|
||||
|
||||
await completePasswordChange(
|
||||
harness,
|
||||
account.token,
|
||||
startResult.ticket,
|
||||
verifyResult.verification_proof,
|
||||
TEST_CREDENTIALS.ALT_PASSWORD_1,
|
||||
);
|
||||
|
||||
const login = await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: TEST_CREDENTIALS.ALT_PASSWORD_1,
|
||||
});
|
||||
expect('token' in login).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
193
packages/api/src/user/tests/ProfileImageUpload.test.tsx
Normal file
193
packages/api/src/user/tests/ProfileImageUpload.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {getGifDataUrl, getPngDataUrl} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
grantPremium,
|
||||
type UserProfileUpdateResult,
|
||||
updateAvatar,
|
||||
updateBanner,
|
||||
} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const PREMIUM_TYPE_SUBSCRIPTION = 2;
|
||||
const AVATAR_MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
code: string;
|
||||
errors?: Array<{path?: string; code?: string}>;
|
||||
}
|
||||
|
||||
function getTooLargeImageDataUrl(): string {
|
||||
const largeData = 'A'.repeat(AVATAR_MAX_SIZE + 10000);
|
||||
const base64 = Buffer.from(largeData).toString('base64');
|
||||
return getPngDataUrl(base64);
|
||||
}
|
||||
|
||||
describe('Profile Image Upload', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('User Avatar', () => {
|
||||
it('allows uploading valid PNG avatar', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const result = await updateAvatar(harness, account.token, getPngDataUrl());
|
||||
|
||||
expect(result.avatar).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows uploading valid GIF avatar with premium', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
|
||||
|
||||
const result = await updateAvatar(harness, account.token, getGifDataUrl());
|
||||
|
||||
expect(result.avatar).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects animated avatar without premium', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
await createBuilder<UserProfileUpdateResult>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({avatar: getGifDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects avatar that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
await createBuilder<UserProfileUpdateResult>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({avatar: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows clearing avatar by setting to null', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
await updateAvatar(harness, account.token, getPngDataUrl());
|
||||
|
||||
const cleared = await updateAvatar(harness, account.token, null);
|
||||
|
||||
expect(cleared.avatar).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces old avatar when uploading new one', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const first = await updateAvatar(harness, account.token, getPngDataUrl());
|
||||
expect(first.avatar).toBeTruthy();
|
||||
const firstHash = first.avatar;
|
||||
|
||||
const second = await updateAvatar(harness, account.token, getPngDataUrl());
|
||||
expect(second.avatar).toBeTruthy();
|
||||
|
||||
expect(second.avatar).toBe(firstHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Banner', () => {
|
||||
it('rejects banner upload without premium', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({banner: getPngDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(json.errors?.[0]?.code).toBe('BANNERS_REQUIRE_PREMIUM');
|
||||
});
|
||||
|
||||
it('allows banner upload with premium', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
|
||||
|
||||
const result = await updateBanner(harness, account.token, getPngDataUrl());
|
||||
|
||||
expect(result.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows uploading GIF banner with premium', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
|
||||
|
||||
const result = await updateBanner(harness, account.token, getGifDataUrl());
|
||||
|
||||
expect(result.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects banner that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
|
||||
|
||||
await createBuilder<UserProfileUpdateResult>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({banner: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows clearing banner by setting to null', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
await grantPremium(harness, account.userId, PREMIUM_TYPE_SUBSCRIPTION);
|
||||
|
||||
await updateBanner(harness, account.token, getPngDataUrl());
|
||||
|
||||
const cleared = await updateBanner(harness, account.token, null);
|
||||
|
||||
expect(cleared.banner).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
195
packages/api/src/user/tests/PushSubscriptionLifecycle.test.tsx
Normal file
195
packages/api/src/user/tests/PushSubscriptionLifecycle.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
deletePushSubscription,
|
||||
listPushSubscriptions,
|
||||
subscribePush,
|
||||
} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Push Subscription Lifecycle', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('subscribe returns a 32-character hex subscription id', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const result = await subscribePush(harness, account.token, 'https://push.example.com/endpoint-1');
|
||||
|
||||
expect(result.subscription_id).toBeDefined();
|
||||
expect(result.subscription_id).toMatch(/^[a-f0-9]{32}$/);
|
||||
});
|
||||
|
||||
test('same endpoint produces the same subscription id', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const endpoint = 'https://push.example.com/deterministic';
|
||||
|
||||
const first = await subscribePush(harness, account.token, endpoint);
|
||||
const second = await subscribePush(harness, account.token, endpoint);
|
||||
|
||||
expect(first.subscription_id).toBe(second.subscription_id);
|
||||
});
|
||||
|
||||
test('different endpoints produce different subscription ids', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await subscribePush(harness, account.token, 'https://push.example.com/endpoint-a');
|
||||
const second = await subscribePush(harness, account.token, 'https://push.example.com/endpoint-b');
|
||||
|
||||
expect(first.subscription_id).not.toBe(second.subscription_id);
|
||||
});
|
||||
|
||||
test('list subscriptions returns empty array initially', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const result = await listPushSubscriptions(harness, account.token);
|
||||
|
||||
expect(result.subscriptions).toEqual([]);
|
||||
});
|
||||
|
||||
test('list subscriptions returns registered subscriptions', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const endpoint = 'https://push.example.com/list-test';
|
||||
const userAgent = 'TestBrowser/1.0';
|
||||
|
||||
const subscribed = await subscribePush(harness, account.token, endpoint, {userAgent});
|
||||
const result = await listPushSubscriptions(harness, account.token);
|
||||
|
||||
expect(result.subscriptions).toHaveLength(1);
|
||||
expect(result.subscriptions[0].subscription_id).toBe(subscribed.subscription_id);
|
||||
expect(result.subscriptions[0].user_agent).toBe(userAgent);
|
||||
});
|
||||
|
||||
test('list subscriptions returns multiple subscriptions', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await subscribePush(harness, account.token, 'https://push.example.com/multi-1');
|
||||
const second = await subscribePush(harness, account.token, 'https://push.example.com/multi-2');
|
||||
|
||||
const result = await listPushSubscriptions(harness, account.token);
|
||||
expect(result.subscriptions).toHaveLength(2);
|
||||
|
||||
const ids = result.subscriptions.map((s) => s.subscription_id).sort();
|
||||
expect(ids).toContain(first.subscription_id);
|
||||
expect(ids).toContain(second.subscription_id);
|
||||
});
|
||||
|
||||
test('subscription without user_agent returns null', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await subscribePush(harness, account.token, 'https://push.example.com/no-ua');
|
||||
|
||||
const result = await listPushSubscriptions(harness, account.token);
|
||||
expect(result.subscriptions).toHaveLength(1);
|
||||
expect(result.subscriptions[0].user_agent).toBeNull();
|
||||
});
|
||||
|
||||
test('delete subscription removes it from the list', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const subscribed = await subscribePush(harness, account.token, 'https://push.example.com/delete-test');
|
||||
await deletePushSubscription(harness, account.token, subscribed.subscription_id);
|
||||
|
||||
const result = await listPushSubscriptions(harness, account.token);
|
||||
expect(result.subscriptions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('delete one subscription does not affect others', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await subscribePush(harness, account.token, 'https://push.example.com/keep');
|
||||
const second = await subscribePush(harness, account.token, 'https://push.example.com/remove');
|
||||
|
||||
await deletePushSubscription(harness, account.token, second.subscription_id);
|
||||
|
||||
const result = await listPushSubscriptions(harness, account.token);
|
||||
expect(result.subscriptions).toHaveLength(1);
|
||||
expect(result.subscriptions[0].subscription_id).toBe(first.subscription_id);
|
||||
});
|
||||
|
||||
test('subscriptions are isolated between users', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await subscribePush(harness, alice.token, 'https://push.example.com/alice');
|
||||
await subscribePush(harness, bob.token, 'https://push.example.com/bob');
|
||||
|
||||
const aliceSubs = await listPushSubscriptions(harness, alice.token);
|
||||
const bobSubs = await listPushSubscriptions(harness, bob.token);
|
||||
|
||||
expect(aliceSubs.subscriptions).toHaveLength(1);
|
||||
expect(bobSubs.subscriptions).toHaveLength(1);
|
||||
expect(aliceSubs.subscriptions[0].subscription_id).not.toBe(bobSubs.subscriptions[0].subscription_id);
|
||||
});
|
||||
|
||||
test('subscribe rejects invalid endpoint', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/push/subscribe')
|
||||
.body({
|
||||
endpoint: 'not-a-url',
|
||||
keys: {p256dh: 'key', auth: 'auth'},
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('subscribe rejects missing keys', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/push/subscribe')
|
||||
.body({
|
||||
endpoint: 'https://push.example.com/missing-keys',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('subscribe requires authentication', async () => {
|
||||
await createBuilder(harness, '')
|
||||
.post('/users/@me/push/subscribe')
|
||||
.body({
|
||||
endpoint: 'https://push.example.com/no-auth',
|
||||
keys: {p256dh: 'key', auth: 'auth'},
|
||||
})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('list subscriptions requires authentication', async () => {
|
||||
await createBuilder(harness, '')
|
||||
.get('/users/@me/push/subscriptions')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('delete subscription requires authentication', async () => {
|
||||
await createBuilder(harness, '')
|
||||
.delete('/users/@me/push/subscriptions/abc123')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
assertRelationshipId,
|
||||
assertRelationshipType,
|
||||
listRelationships,
|
||||
sendFriendRequest,
|
||||
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('RelationshipAcceptAfterPrivacyChangeAllowsExistingRequest', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('accepting friend request after privacy change allows existing request', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
|
||||
await createBuilder(harness, bob.token)
|
||||
.patch('/users/@me/settings')
|
||||
.body({friend_source_flags: 3})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const {json: accepted} = await acceptFriendRequest(harness, bob.token, alice.userId);
|
||||
assertRelationshipId(accepted, alice.userId);
|
||||
assertRelationshipType(accepted, RelationshipTypes.FRIEND);
|
||||
|
||||
const {json: aliceRels} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRels).toHaveLength(1);
|
||||
assertRelationshipId(aliceRels[0]!, bob.userId);
|
||||
assertRelationshipType(aliceRels[0]!, RelationshipTypes.FRIEND);
|
||||
});
|
||||
});
|
||||
165
packages/api/src/user/tests/RelationshipBlocking.test.tsx
Normal file
165
packages/api/src/user/tests/RelationshipBlocking.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createFriendship} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
assertRelationshipId,
|
||||
assertRelationshipType,
|
||||
blockUser,
|
||||
listRelationships,
|
||||
removeRelationship,
|
||||
sendFriendRequest,
|
||||
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('RelationshipBlocking', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('blocking a user removes existing friendship', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, alice, bob);
|
||||
|
||||
const {json: aliceRelsBeforeBlock} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRelsBeforeBlock).toHaveLength(1);
|
||||
assertRelationshipType(aliceRelsBeforeBlock[0]!, RelationshipTypes.FRIEND);
|
||||
|
||||
const {json: blocked} = await blockUser(harness, alice.token, bob.userId);
|
||||
assertRelationshipId(blocked, bob.userId);
|
||||
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
|
||||
|
||||
const {json: aliceRelsAfterBlock} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRelsAfterBlock).toHaveLength(1);
|
||||
assertRelationshipType(aliceRelsAfterBlock[0]!, RelationshipTypes.BLOCKED);
|
||||
|
||||
const {json: bobRelsAfterBlock} = await listRelationships(harness, bob.token);
|
||||
expect(bobRelsAfterBlock).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('blocked user cannot send friend request', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await blockUser(harness, alice.token, bob.userId);
|
||||
|
||||
await createBuilder(harness, bob.token)
|
||||
.post(`/users/@me/relationships/${alice.userId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('blocking ignores incoming friend requests without notifying sender', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
assertRelationshipId(outgoing, bob.userId);
|
||||
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
|
||||
|
||||
const {json: blocked} = await blockUser(harness, bob.token, alice.userId);
|
||||
assertRelationshipId(blocked, alice.userId);
|
||||
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
|
||||
|
||||
const {json: aliceRels} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRels).toHaveLength(1);
|
||||
assertRelationshipType(aliceRels[0]!, RelationshipTypes.OUTGOING_REQUEST);
|
||||
assertRelationshipId(aliceRels[0]!, bob.userId);
|
||||
});
|
||||
|
||||
test('accept friend request after privacy setting change allows existing request', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
|
||||
await createBuilder(harness, bob.token)
|
||||
.patch('/users/@me/settings')
|
||||
.body({friend_source_flags: 3})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const {json: accepted} = await acceptFriendRequest(harness, bob.token, alice.userId);
|
||||
assertRelationshipId(accepted, alice.userId);
|
||||
assertRelationshipType(accepted, RelationshipTypes.FRIEND);
|
||||
|
||||
const {json: aliceRels} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRels).toHaveLength(1);
|
||||
assertRelationshipId(aliceRels[0]!, bob.userId);
|
||||
assertRelationshipType(aliceRels[0]!, RelationshipTypes.FRIEND);
|
||||
});
|
||||
|
||||
test('unblock user works correctly', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
const {json: blocked} = await blockUser(harness, alice.token, bob.userId);
|
||||
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
|
||||
|
||||
const {json: aliceRelsBeforeUnblock} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRelsBeforeUnblock).toHaveLength(1);
|
||||
assertRelationshipType(aliceRelsBeforeUnblock[0]!, RelationshipTypes.BLOCKED);
|
||||
|
||||
await removeRelationship(harness, alice.token, bob.userId);
|
||||
|
||||
const {json: aliceRelsAfterUnblock} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRelsAfterUnblock).toHaveLength(0);
|
||||
|
||||
const {json: outgoing} = await sendFriendRequest(harness, bob.token, alice.userId);
|
||||
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
|
||||
});
|
||||
|
||||
test('cannot send DM to blocked user', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, alice.token);
|
||||
|
||||
await createFriendship(harness, alice, bob);
|
||||
|
||||
const channel = await createDmChannel(harness, alice.token, bob.userId);
|
||||
|
||||
await blockUser(harness, alice.token, bob.userId);
|
||||
|
||||
await createBuilder(harness, alice.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'hello'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
assertRelationshipId,
|
||||
assertRelationshipType,
|
||||
blockUser,
|
||||
listRelationships,
|
||||
removeRelationship,
|
||||
sendFriendRequest,
|
||||
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('RelationshipBlockingBehaviors', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
describe('Friend Request Blocking', () => {
|
||||
test('friend request to blocker is generic blocked error', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await blockUser(harness, bob.token, alice.userId);
|
||||
|
||||
await createBuilder(harness, alice.token)
|
||||
.post(`/users/@me/relationships/${bob.userId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'FRIEND_REQUEST_BLOCKED')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification Behavior', () => {
|
||||
test('blocking ignores incoming request without notifying sender', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
assertRelationshipId(outgoing, bob.userId);
|
||||
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
|
||||
|
||||
const {json: blocked} = await blockUser(harness, bob.token, alice.userId);
|
||||
assertRelationshipId(blocked, alice.userId);
|
||||
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
|
||||
|
||||
const {json: aliceRels} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRels).toHaveLength(1);
|
||||
expect(aliceRels[0]!.type).toBe(RelationshipTypes.OUTGOING_REQUEST);
|
||||
assertRelationshipId(aliceRels[0]!, bob.userId);
|
||||
});
|
||||
|
||||
test('ignoring incoming request does not notify sender', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
assertRelationshipId(outgoing, bob.userId);
|
||||
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
|
||||
|
||||
await removeRelationship(harness, bob.token, alice.userId);
|
||||
|
||||
const {json: aliceRels} = await listRelationships(harness, alice.token);
|
||||
expect(aliceRels).toHaveLength(1);
|
||||
expect(aliceRels[0]!.type).toBe(RelationshipTypes.OUTGOING_REQUEST);
|
||||
assertRelationshipId(aliceRels[0]!, bob.userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outgoing Request Handling', () => {
|
||||
test('blocking with outgoing request withdraws the request and notifies target', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
const {json: outgoing} = await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
assertRelationshipId(outgoing, bob.userId);
|
||||
assertRelationshipType(outgoing, RelationshipTypes.OUTGOING_REQUEST);
|
||||
|
||||
const {json: blocked} = await blockUser(harness, alice.token, bob.userId);
|
||||
assertRelationshipId(blocked, bob.userId);
|
||||
assertRelationshipType(blocked, RelationshipTypes.BLOCKED);
|
||||
|
||||
const {json: bobRels} = await listRelationships(harness, bob.token);
|
||||
expect(bobRels).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
listRelationships,
|
||||
sendFriendRequest,
|
||||
updateFriendNickname,
|
||||
} from '@fluxer/api/src/user/tests/RelationshipTestUtils';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('RelationshipNicknameUpdate', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
test('update friend nickname', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
|
||||
await acceptFriendRequest(harness, bob.token, alice.userId);
|
||||
|
||||
const {json: updated} = await updateFriendNickname(harness, alice.token, bob.userId, 'Bestie Bob');
|
||||
expect(updated.nickname).toBe('Bestie Bob');
|
||||
|
||||
const {json: aliceRels} = await listRelationships(harness, alice.token);
|
||||
const bobRel = aliceRels.find((r) => r.id === bob.userId);
|
||||
expect(bobRel?.nickname).toBe('Bestie Bob');
|
||||
|
||||
const {json: bobRels} = await listRelationships(harness, bob.token);
|
||||
const aliceRel = bobRels.find((r) => r.id === alice.userId);
|
||||
expect(aliceRel?.nickname).toBeNull();
|
||||
});
|
||||
|
||||
test('remove friend nickname', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
|
||||
await sendFriendRequest(harness, alice.token, bob.userId);
|
||||
await acceptFriendRequest(harness, bob.token, alice.userId);
|
||||
|
||||
await updateFriendNickname(harness, alice.token, bob.userId, 'Bobby');
|
||||
|
||||
const {json: updated} = await updateFriendNickname(harness, alice.token, bob.userId, null);
|
||||
expect(updated.nickname).toBeNull();
|
||||
|
||||
const {json: aliceRels} = await listRelationships(harness, alice.token);
|
||||
const bobRel = aliceRels.find((r) => r.id === bob.userId);
|
||||
expect(bobRel?.nickname).toBeNull();
|
||||
});
|
||||
});
|
||||
138
packages/api/src/user/tests/RelationshipTestUtils.tsx
Normal file
138
packages/api/src/user/tests/RelationshipTestUtils.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createFriendship as channelCreateFriendship} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
export async function sendFriendRequest(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
targetId: string,
|
||||
): Promise<{response: Response; json: RelationshipResponse}> {
|
||||
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
|
||||
.post(`/users/@me/relationships/${targetId}`)
|
||||
.executeWithResponse();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected 200, got ${response.status}`);
|
||||
}
|
||||
return {response, json};
|
||||
}
|
||||
|
||||
export async function sendFriendRequestByTag(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
username: string,
|
||||
discriminator: string,
|
||||
): Promise<{response: Response; json: RelationshipResponse}> {
|
||||
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
|
||||
.post('/users/@me/relationships')
|
||||
.body({username, discriminator})
|
||||
.executeWithResponse();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected 200, got ${response.status}`);
|
||||
}
|
||||
return {response, json};
|
||||
}
|
||||
|
||||
export async function acceptFriendRequest(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
targetId: string,
|
||||
): Promise<{response: Response; json: RelationshipResponse}> {
|
||||
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
|
||||
.put(`/users/@me/relationships/${targetId}`)
|
||||
.body({})
|
||||
.executeWithResponse();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected 200, got ${response.status}`);
|
||||
}
|
||||
return {response, json};
|
||||
}
|
||||
|
||||
export async function blockUser(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
targetId: string,
|
||||
): Promise<{response: Response; json: RelationshipResponse}> {
|
||||
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
|
||||
.put(`/users/@me/relationships/${targetId}`)
|
||||
.body({type: RelationshipTypes.BLOCKED})
|
||||
.executeWithResponse();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected 200, got ${response.status}`);
|
||||
}
|
||||
return {response, json};
|
||||
}
|
||||
|
||||
export async function removeRelationship(harness: ApiTestHarness, token: string, targetId: string): Promise<void> {
|
||||
await createBuilder<void>(harness, token).delete(`/users/@me/relationships/${targetId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function listRelationships(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
): Promise<{response: Response; json: Array<RelationshipResponse>}> {
|
||||
const {response, json} = await createBuilder<Array<RelationshipResponse>>(harness, token)
|
||||
.get('/users/@me/relationships')
|
||||
.executeWithResponse();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected 200, got ${response.status}`);
|
||||
}
|
||||
return {response, json};
|
||||
}
|
||||
|
||||
export async function updateFriendNickname(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
targetId: string,
|
||||
nickname: string | null,
|
||||
): Promise<{response: Response; json: RelationshipResponse}> {
|
||||
const {response, json} = await createBuilder<RelationshipResponse>(harness, token)
|
||||
.patch(`/users/@me/relationships/${targetId}`)
|
||||
.body({nickname})
|
||||
.executeWithResponse();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected 200, got ${response.status}`);
|
||||
}
|
||||
return {response, json};
|
||||
}
|
||||
|
||||
export function assertRelationshipType(relationship: RelationshipResponse, expectedType: number): void {
|
||||
expect(relationship.type).toBe(expectedType);
|
||||
}
|
||||
|
||||
export function assertRelationshipId(relationship: RelationshipResponse, expectedId: string): void {
|
||||
expect(relationship.id).toBe(expectedId);
|
||||
}
|
||||
|
||||
export function findRelationship(relations: Array<RelationshipResponse>, userId: string): RelationshipResponse | null {
|
||||
return relations.find((r) => r.id === userId) ?? null;
|
||||
}
|
||||
|
||||
export const createFriendship = channelCreateFriendship;
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, createUniqueUsername, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
getChannel,
|
||||
sendChannelMessage,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {fetchUserNote, grantPremium, setUserNote} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Unclaimed Account Restrictions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
describe('Messaging Restrictions', () => {
|
||||
test('unclaimed account cannot send guild messages', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, channel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await unclaimAccount(harness, member.userId);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'Hello world'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_SEND_MESSAGES')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('unclaimed account can receive DMs', async () => {
|
||||
const receiver = await createTestAccount(harness);
|
||||
|
||||
await unclaimAccount(harness, receiver.userId);
|
||||
|
||||
await createBuilder(harness, receiver.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
test('unclaimed account cannot add reactions', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, channel.id);
|
||||
await acceptInvite(harness, user.token, invite.code);
|
||||
|
||||
const message = await sendChannelMessage(harness, owner.token, channel.id, 'React to this');
|
||||
|
||||
await unclaimAccount(harness, user.userId);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.put(`/channels/${channel.id}/messages/${message.id}/reactions/${encodeURIComponent('👍')}/@me`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_ADD_REACTIONS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Friend Request Restrictions', () => {
|
||||
test('unclaimed account cannot send friend request', async () => {
|
||||
const unclaimedAccount = await createTestAccount(harness);
|
||||
const targetAccount = await createTestAccount(harness);
|
||||
|
||||
await unclaimAccount(harness, unclaimedAccount.userId);
|
||||
|
||||
await createBuilder(harness, unclaimedAccount.token)
|
||||
.post(`/users/@me/relationships/${targetAccount.userId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_SEND_FRIEND_REQUESTS')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('unclaimed account cannot accept friend request', async () => {
|
||||
const senderAccount = await createTestAccount(harness);
|
||||
const receiverAccount = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, senderAccount.token)
|
||||
.post(`/users/@me/relationships/${receiverAccount.userId}`)
|
||||
.execute();
|
||||
|
||||
await unclaimAccount(harness, receiverAccount.userId);
|
||||
|
||||
await createBuilder(harness, receiverAccount.token)
|
||||
.put(`/users/@me/relationships/${senderAccount.userId}`)
|
||||
.body({type: 1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_ACCEPT_FRIEND_REQUESTS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Allowed Operations', () => {
|
||||
test('unclaimed account can use personal notes', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
|
||||
await unclaimAccount(harness, user.userId);
|
||||
|
||||
const noteContent = 'Note from unclaimed account';
|
||||
await setUserNote(harness, user.token, target.userId, noteContent);
|
||||
|
||||
const {json} = await fetchUserNote(harness, user.token, target.userId);
|
||||
const note = json as {note: string | null};
|
||||
expect(note.note).toBe(noteContent);
|
||||
});
|
||||
|
||||
test('unclaimed account can change username', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const newUsername = createUniqueUsername('unclaimed_update');
|
||||
|
||||
await unclaimAccount(harness, account.userId);
|
||||
|
||||
const updated = await createBuilder<{username: string; discriminator: string}>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({username: newUsername})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.username).toBe(newUsername);
|
||||
});
|
||||
|
||||
test('unclaimed premium account can change discriminator', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
|
||||
await unclaimAccount(harness, account.userId);
|
||||
|
||||
const updated = await createBuilder<{username: string; discriminator: string}>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({discriminator: '0067'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.discriminator).toBe('0067');
|
||||
});
|
||||
|
||||
test('unclaimed premium account can change username and discriminator together', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const newUsername = createUniqueUsername('unclaimed_combo');
|
||||
await grantPremium(harness, account.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
|
||||
await unclaimAccount(harness, account.userId);
|
||||
|
||||
const updated = await createBuilder<{username: string; discriminator: string}>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({username: newUsername, discriminator: '0068'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.username).toBe(newUsername);
|
||||
expect(updated.discriminator).toBe('0068');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Profile Update Restrictions', () => {
|
||||
test('unclaimed account rejects unsupported profile fields', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, account.userId);
|
||||
|
||||
const {response, text} = await createBuilder(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({bio: 'not allowed'})
|
||||
.executeRaw();
|
||||
|
||||
expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST);
|
||||
expect(text).toContain('UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account Deletion', () => {
|
||||
test('unclaimed account can be deleted without password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await unclaimAccount(harness, account.userId);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/delete')
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
130
packages/api/src/user/tests/UserAccountAndSettings.test.tsx
Normal file
130
packages/api/src/user/tests/UserAccountAndSettings.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {
|
||||
checkUsernameDiscriminatorAvailability,
|
||||
fetchUser,
|
||||
fetchUserProfile,
|
||||
preloadMessages,
|
||||
setUserNote,
|
||||
updateGuildSettings,
|
||||
updateUserProfile,
|
||||
} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('User Account And Settings', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('user can update profile and settings', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const newGlobal = `Integration ${Date.now()}`;
|
||||
const newBio = 'Integration tests ensure user endpoints behave';
|
||||
|
||||
const updated = await updateUserProfile(harness, account.token, {
|
||||
global_name: newGlobal,
|
||||
bio: newBio,
|
||||
});
|
||||
expect(updated.json.global_name).toBe(newGlobal);
|
||||
expect(updated.json.bio).toBe(newBio);
|
||||
|
||||
const checkTagResult = await checkUsernameDiscriminatorAvailability(
|
||||
harness,
|
||||
updated.json.username,
|
||||
updated.json.discriminator,
|
||||
account.token,
|
||||
);
|
||||
expect(checkTagResult.json.taken).toBe(false);
|
||||
|
||||
const user = await fetchUser(harness, account.userId, account.token);
|
||||
expect(user.json.id).toBe(account.userId);
|
||||
|
||||
const profile = await fetchUserProfile(harness, account.userId, account.token);
|
||||
expect(profile.json.user.id).toBe(account.userId);
|
||||
|
||||
const guildSettings = await updateGuildSettings(harness, account.token, {
|
||||
suppress_everyone: true,
|
||||
});
|
||||
const settings = guildSettings.json as Record<string, unknown>;
|
||||
expect(settings.suppress_everyone).toBe(true);
|
||||
|
||||
const target = await createTestAccount(harness);
|
||||
await setUserNote(harness, account.token, target.userId, 'Great tester');
|
||||
|
||||
const preload = await preloadMessages(harness, account.token, []);
|
||||
const preloadData = preload.json as Record<string, unknown>;
|
||||
expect(Object.keys(preloadData).length).toBe(0);
|
||||
});
|
||||
|
||||
test('reject getting nonexistent user', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get(`/users/${TEST_IDS.NONEXISTENT_USER}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('reject getting nonexistent user profile', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get(`/users/${TEST_IDS.NONEXISTENT_USER}/profile`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('check-tag with missing username returns 400', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get('/users/check-tag?discriminator=1234')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('check-tag with missing discriminator returns 400', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get('/users/check-tag?username=testuser')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('check-tag with invalid discriminator returns 400', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get('/users/check-tag?username=test&discriminator=invalid')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
122
packages/api/src/user/tests/UserAuthorizedIps.test.tsx
Normal file
122
packages/api/src/user/tests/UserAuthorizedIps.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
clearTestEmails,
|
||||
createTestAccount,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, test} from 'vitest';
|
||||
|
||||
interface LoginResponse {
|
||||
user_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
async function seedAuthorizedIp(params: {
|
||||
harness: ApiTestHarness;
|
||||
email: string;
|
||||
password: string;
|
||||
ip: string;
|
||||
}): Promise<void> {
|
||||
const {harness, email, password, ip} = params;
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email, password})
|
||||
.header('x-forwarded-for', ip)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.IP_AUTHORIZATION_REQUIRED)
|
||||
.execute();
|
||||
|
||||
const emails = await listTestEmails(harness, {recipient: email});
|
||||
const ipEmail = findLastTestEmail(emails, 'ip_authorization');
|
||||
if (!ipEmail?.metadata?.token) {
|
||||
throw new Error('Missing IP authorization email token');
|
||||
}
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/authorize-ip')
|
||||
.body({token: ipEmail.metadata.token})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('User authorised IPs', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await clearTestEmails(harness);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('requires sudo to forget authorised IPs', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete('/users/@me/authorized-ips')
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.SUDO_MODE_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('forgetting authorised IPs forces email verification on next login', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const ip = '203.0.113.42';
|
||||
|
||||
await seedAuthorizedIp({
|
||||
harness,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
ip,
|
||||
});
|
||||
|
||||
const login = await createBuilderWithoutAuth<LoginResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.header('x-forwarded-for', ip)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, login.token)
|
||||
.delete('/users/@me/authorized-ips')
|
||||
.body({password: account.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.header('x-forwarded-for', ip)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.IP_AUTHORIZATION_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
449
packages/api/src/user/tests/UserChannelService.test.tsx
Normal file
449
packages/api/src/user/tests/UserChannelService.test.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {authorizeBot, createTestBotAccount} from '@fluxer/api/src/bot/tests/BotTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
blockUser,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
createGuild,
|
||||
getChannel,
|
||||
type MinimalChannelResponse,
|
||||
sendChannelMessage,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface PrivateChannelsResponse extends Array<MinimalChannelResponse> {}
|
||||
|
||||
describe('UserChannelService', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('DM channel creation', () => {
|
||||
test('can create DM with a friend', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
expect(channel.id).toBeDefined();
|
||||
expect(channel.type).toBe(ChannelTypes.DM);
|
||||
});
|
||||
|
||||
test('bot can create DM when it shares a guild with the recipient', async () => {
|
||||
const botAccount = await createTestBotAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, botAccount.ownerToken, 'Bot DM mutual guild');
|
||||
const systemChannel = await getChannel(harness, botAccount.ownerToken, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, botAccount.ownerToken, systemChannel.id);
|
||||
await acceptInvite(harness, recipient.token, invite.code);
|
||||
await authorizeBot(harness, botAccount.ownerToken, botAccount.appId, ['bot'], guild.id, '0');
|
||||
|
||||
const channel = await createBuilder<MinimalChannelResponse>(harness, `Bot ${botAccount.botToken}`)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: recipient.userId})
|
||||
.execute();
|
||||
|
||||
expect(channel.id).toBeDefined();
|
||||
expect(channel.type).toBe(ChannelTypes.DM);
|
||||
});
|
||||
|
||||
test('cannot create DM with yourself', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: user.userId})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot create DM with unknown user', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: '999999999999999999'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_USER')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot create DM with blocked user', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Test Community');
|
||||
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: user2.userId})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('unclaimed account cannot create DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Test Community');
|
||||
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
await unclaimAccount(harness, user1.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: user2.userId})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('reopening existing DM returns same channel', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel1 = await createDmChannel(harness, user1.token, user2.userId);
|
||||
const channel2 = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
expect(channel1.id).toBe(channel2.id);
|
||||
});
|
||||
|
||||
test('reopening existing DM works after blocking user', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const channel1 = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await blockUser(harness, user1, user2.userId);
|
||||
|
||||
const channel2 = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
expect(channel2.id).toBe(channel1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group DM creation', () => {
|
||||
test('can create group DM with friends', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend1 = await createTestAccount(harness);
|
||||
const friend2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend1);
|
||||
await createFriendship(harness, owner, friend2);
|
||||
|
||||
const channel = await createGroupDmChannel(harness, owner.token, [friend1.userId, friend2.userId]);
|
||||
|
||||
expect(channel.id).toBeDefined();
|
||||
expect(channel.type).toBe(ChannelTypes.GROUP_DM);
|
||||
expect(channel.owner_id).toBe(owner.userId);
|
||||
expect(channel.recipients.length).toBe(2);
|
||||
});
|
||||
|
||||
test('cannot create group DM with non-friend', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
const stranger = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipients: [friend.userId, stranger.userId]})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot add yourself to group DM recipients', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipients: [friend.userId, owner.userId]})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot add duplicate recipients to group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipients: [friend.userId, friend.userId]})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot create group DM with unknown user', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipients: [friend.userId, '999999999999999999']})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_USER')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('private channel listing', () => {
|
||||
test('lists all private channels for user', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
await createDmChannel(harness, user1.token, user2.userId);
|
||||
await createDmChannel(harness, user1.token, user3.userId);
|
||||
|
||||
const channels = await createBuilder<PrivateChannelsResponse>(harness, user1.token)
|
||||
.get('/users/@me/channels')
|
||||
.execute();
|
||||
|
||||
expect(channels.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DM pinning', () => {
|
||||
test('can pin and unpin DM channel', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.put(`/users/@me/channels/${channel.id}/pin`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.delete(`/users/@me/channels/${channel.id}/pin`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot pin non-DM channel', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.put('/users/@me/channels/999999999999999999/pin')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('preload DM messages', () => {
|
||||
test('can preload messages for multiple DM channels', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
|
||||
const channel1 = await createDmChannel(harness, user1.token, user2.userId);
|
||||
const channel2 = await createDmChannel(harness, user1.token, user3.userId);
|
||||
|
||||
await sendChannelMessage(harness, user1.token, channel1.id, 'Hello user2');
|
||||
await sendChannelMessage(harness, user1.token, channel2.id, 'Hello user3');
|
||||
|
||||
const result = await createBuilder<Record<string, unknown>>(harness, user1.token)
|
||||
.post('/users/@me/preload-messages')
|
||||
.body({channels: [channel1.id, channel2.id]})
|
||||
.execute();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test('cannot preload more than 100 channels', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const tooManyChannels = Array.from({length: 101}, (_, i) => i.toString());
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.post('/users/@me/preload-messages')
|
||||
.body({channels: tooManyChannels})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sending messages in DMs', () => {
|
||||
test('can send message in DM to friend', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
const message = await sendChannelMessage(harness, user1.token, channel.id, 'Hello!');
|
||||
|
||||
expect(message.id).toBeDefined();
|
||||
expect(message.content).toBe('Hello!');
|
||||
});
|
||||
|
||||
test('cannot send message to user who blocked you', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
|
||||
const channel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user2.token)
|
||||
.put(`/users/@me/relationships/${user1.userId}`)
|
||||
.body({type: 2})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'Hello!'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('group DM recipient management', () => {
|
||||
test('owner can add friend to group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend1 = await createTestAccount(harness);
|
||||
const friend2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend1);
|
||||
await createFriendship(harness, owner, friend2);
|
||||
|
||||
const channel = await createGroupDmChannel(harness, owner.token, [friend1.userId]);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${channel.id}/recipients/${friend2.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('owner can remove recipient from group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend1 = await createTestAccount(harness);
|
||||
const friend2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend1);
|
||||
await createFriendship(harness, owner, friend2);
|
||||
|
||||
const channel = await createGroupDmChannel(harness, owner.token, [friend1.userId, friend2.userId]);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/channels/${channel.id}/recipients/${friend2.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('cannot add non-friend to group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const friend = await createTestAccount(harness);
|
||||
const stranger = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, friend);
|
||||
|
||||
const channel = await createGroupDmChannel(harness, owner.token, [friend.userId]);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${channel.id}/recipients/${stranger.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('non-owner cannot remove other recipients', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member1 = await createTestAccount(harness);
|
||||
const member2 = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, member1);
|
||||
await createFriendship(harness, owner, member2);
|
||||
|
||||
const channel = await createGroupDmChannel(harness, owner.token, [member1.userId, member2.userId]);
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.delete(`/channels/${channel.id}/recipients/${member2.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member can leave group DM', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await createFriendship(harness, owner, member);
|
||||
|
||||
const channel = await createGroupDmChannel(harness, owner.token, [member.userId]);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/channels/${channel.id}/recipients/${member.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user