initial commit
This commit is contained in:
22
fluxer_api/src/user/IUserRepository.ts
Normal file
22
fluxer_api/src/user/IUserRepository.ts
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 './repositories/IUserRepositoryAggregate';
|
||||
|
||||
export interface IUserRepository extends IUserRepositoryAggregate {}
|
||||
65
fluxer_api/src/user/UserCacheHelpers.ts
Normal file
65
fluxer_api/src/user/UserCacheHelpers.ts
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 '~/BrandedTypes';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {UserPartialResponse} from './UserModel';
|
||||
import {mapUserToPartialResponse} from './UserModel';
|
||||
|
||||
export async function getCachedUserPartialResponse(params: {
|
||||
userId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
const {userId, userCacheService, requestCache} = params;
|
||||
return await userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
}
|
||||
|
||||
export async function getCachedUserPartialResponses(params: {
|
||||
userIds: Array<UserID>;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Map<UserID, UserPartialResponse>> {
|
||||
const {userIds, userCacheService, requestCache} = params;
|
||||
return await userCacheService.getUserPartialResponses(userIds, requestCache);
|
||||
}
|
||||
|
||||
export async function mapUserToPartialResponseWithCache(params: {
|
||||
user: User;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<UserPartialResponse> {
|
||||
const {user, userCacheService, requestCache} = params;
|
||||
const cached = requestCache.userPartials.get(user.id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const response = mapUserToPartialResponse(user);
|
||||
requestCache.userPartials.set(user.id, response);
|
||||
const cacheKey = `user:partial:${user.id}`;
|
||||
Promise.resolve(userCacheService.cacheService.set(cacheKey, response, 300)).catch(() => {});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function invalidateUserCache(params: {userId: UserID; userCacheService: UserCacheService}): Promise<void> {
|
||||
const {userId, userCacheService} = params;
|
||||
await userCacheService.invalidateUserCache(userId);
|
||||
}
|
||||
20
fluxer_api/src/user/UserController.ts
Normal file
20
fluxer_api/src/user/UserController.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export {UserController} from './controllers/UserController';
|
||||
96
fluxer_api/src/user/UserHarvestModel.ts
Normal file
96
fluxer_api/src/user/UserHarvestModel.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import type {UserHarvestRow} from '~/database/CassandraTypes';
|
||||
|
||||
export class UserHarvest {
|
||||
userId: UserID;
|
||||
harvestId: bigint;
|
||||
requestedAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
storageKey: string | null;
|
||||
fileSize: bigint | null;
|
||||
progressPercent: number;
|
||||
progressStep: string | null;
|
||||
errorMessage: string | null;
|
||||
downloadUrlExpiresAt: Date | null;
|
||||
|
||||
constructor(row: UserHarvestRow) {
|
||||
this.userId = row.user_id;
|
||||
this.harvestId = row.harvest_id;
|
||||
this.requestedAt = row.requested_at;
|
||||
this.startedAt = row.started_at ?? null;
|
||||
this.completedAt = row.completed_at ?? null;
|
||||
this.failedAt = row.failed_at ?? null;
|
||||
this.storageKey = row.storage_key ?? null;
|
||||
this.fileSize = row.file_size ?? null;
|
||||
this.progressPercent = row.progress_percent;
|
||||
this.progressStep = row.progress_step ?? null;
|
||||
this.errorMessage = row.error_message ?? null;
|
||||
this.downloadUrlExpiresAt = row.download_url_expires_at ?? null;
|
||||
}
|
||||
|
||||
toRow(): UserHarvestRow {
|
||||
return {
|
||||
user_id: this.userId,
|
||||
harvest_id: this.harvestId,
|
||||
requested_at: this.requestedAt,
|
||||
started_at: this.startedAt,
|
||||
completed_at: this.completedAt,
|
||||
failed_at: this.failedAt,
|
||||
storage_key: this.storageKey,
|
||||
file_size: this.fileSize,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
toResponse(): {
|
||||
harvest_id: string;
|
||||
requested_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
failed_at: string | null;
|
||||
file_size: string | null;
|
||||
progress_percent: number;
|
||||
progress_step: string | null;
|
||||
error_message: string | null;
|
||||
download_url_expires_at: string | null;
|
||||
} {
|
||||
return {
|
||||
harvest_id: this.harvestId.toString(),
|
||||
requested_at: this.requestedAt.toISOString(),
|
||||
started_at: this.startedAt?.toISOString() ?? null,
|
||||
completed_at: this.completedAt?.toISOString() ?? null,
|
||||
failed_at: this.failedAt?.toISOString() ?? null,
|
||||
file_size: this.fileSize?.toString() ?? null,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type UserHarvestResponse = ReturnType<UserHarvest['toResponse']>;
|
||||
158
fluxer_api/src/user/UserHarvestRepository.ts
Normal file
158
fluxer_api/src/user/UserHarvestRepository.ts
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 '~/BrandedTypes';
|
||||
import {Db, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {UserHarvestRow} from '~/database/CassandraTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import {UserHarvests} from '~/Tables';
|
||||
import {UserHarvest} from './UserHarvestModel';
|
||||
|
||||
const FIND_HARVEST_CQL = UserHarvests.selectCql({
|
||||
where: [UserHarvests.where.eq('user_id'), UserHarvests.where.eq('harvest_id')],
|
||||
});
|
||||
|
||||
const createFindUserHarvestsQuery = (limit: number) =>
|
||||
UserHarvests.selectCql({
|
||||
where: UserHarvests.where.eq('user_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
const FIND_LATEST_HARVEST_CQL = UserHarvests.selectCql({
|
||||
where: UserHarvests.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class UserHarvestRepository {
|
||||
async create(harvest: UserHarvest): Promise<void> {
|
||||
await upsertOne(UserHarvests.upsertAll(harvest.toRow()));
|
||||
Logger.debug({userId: harvest.userId, harvestId: harvest.harvestId}, 'Created harvest record');
|
||||
}
|
||||
|
||||
async update(harvest: UserHarvest): Promise<void> {
|
||||
const row = harvest.toRow();
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: row.user_id, harvest_id: row.harvest_id},
|
||||
{
|
||||
started_at: Db.set(row.started_at),
|
||||
completed_at: Db.set(row.completed_at),
|
||||
failed_at: Db.set(row.failed_at),
|
||||
storage_key: Db.set(row.storage_key),
|
||||
file_size: Db.set(row.file_size),
|
||||
progress_percent: Db.set(row.progress_percent),
|
||||
progress_step: Db.set(row.progress_step),
|
||||
error_message: Db.set(row.error_message),
|
||||
download_url_expires_at: Db.set(row.download_url_expires_at),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId: harvest.userId, harvestId: harvest.harvestId}, 'Updated harvest record');
|
||||
}
|
||||
|
||||
async findByUserAndHarvestId(userId: UserID, harvestId: bigint): Promise<UserHarvest | null> {
|
||||
const row = await fetchOne<UserHarvestRow>(FIND_HARVEST_CQL, {
|
||||
user_id: userId,
|
||||
harvest_id: harvestId,
|
||||
});
|
||||
return row ? new UserHarvest(row) : null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: UserID, limit: number = 10): Promise<Array<UserHarvest>> {
|
||||
const rows = await fetchMany<UserHarvestRow>(createFindUserHarvestsQuery(limit), {
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.map((row) => new UserHarvest(row));
|
||||
}
|
||||
|
||||
async findLatestByUserId(userId: UserID): Promise<UserHarvest | null> {
|
||||
const row = await fetchOne<UserHarvestRow>(FIND_LATEST_HARVEST_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return row ? new UserHarvest(row) : null;
|
||||
}
|
||||
|
||||
async updateProgress(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
progressPercent: number,
|
||||
progressStep: string,
|
||||
): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId, harvestId, progressPercent, progressStep}, 'Updated harvest progress');
|
||||
}
|
||||
|
||||
async markAsStarted(userId: UserID, harvestId: bigint): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set('Starting harvest'),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId, harvestId}, 'Marked harvest as started');
|
||||
}
|
||||
|
||||
async markAsCompleted(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
storageKey: string,
|
||||
fileSize: bigint,
|
||||
downloadUrlExpiresAt: Date,
|
||||
): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.debug({userId, harvestId, storageKey, fileSize}, 'Marked harvest as completed');
|
||||
}
|
||||
|
||||
async markAsFailed(userId: UserID, harvestId: bigint, errorMessage: string): Promise<void> {
|
||||
await upsertOne(
|
||||
UserHarvests.patchByPk(
|
||||
{user_id: userId, harvest_id: harvestId},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
},
|
||||
),
|
||||
);
|
||||
Logger.error({userId, harvestId, errorMessage}, 'Marked harvest as failed');
|
||||
}
|
||||
}
|
||||
57
fluxer_api/src/user/UserHelpers.ts
Normal file
57
fluxer_api/src/user/UserHelpers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Config} from '~/Config';
|
||||
import {UserFlags} from '~/Constants';
|
||||
|
||||
interface PremiumCheckable {
|
||||
premiumType: number | null;
|
||||
premiumUntil: Date | null;
|
||||
premiumWillCancel: boolean;
|
||||
flags: bigint;
|
||||
}
|
||||
|
||||
const GRACE_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function checkIsPremium(user: PremiumCheckable): boolean {
|
||||
if (Config.instance.selfHosted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((user.flags & UserFlags.PREMIUM_ENABLED_OVERRIDE) !== 0n) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user.premiumType == null || user.premiumType <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.premiumUntil == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const untilMs = user.premiumUntil.getTime();
|
||||
|
||||
if (user.premiumWillCancel) {
|
||||
return nowMs <= untilMs;
|
||||
}
|
||||
|
||||
return nowMs <= untilMs + GRACE_MS;
|
||||
}
|
||||
303
fluxer_api/src/user/UserMappers.ts
Normal file
303
fluxer_api/src/user/UserMappers.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, type UserID} from '~/BrandedTypes';
|
||||
import {PUBLIC_USER_FLAGS, SuspiciousActivityFlags, UserFlags} from '~/Constants';
|
||||
import type {
|
||||
BetaCode,
|
||||
GuildChannelOverride,
|
||||
GuildMember,
|
||||
MuteConfiguration,
|
||||
Relationship,
|
||||
User,
|
||||
UserGuildSettings,
|
||||
UserSettings,
|
||||
} from '~/Models';
|
||||
import {isUserAdult} from '~/utils/AgeUtils';
|
||||
import type {
|
||||
BetaCodeResponse,
|
||||
RelationshipResponse,
|
||||
UserGuildSettingsResponse,
|
||||
UserPartialResponse,
|
||||
UserPrivateResponse,
|
||||
UserProfileResponse,
|
||||
UserSettingsResponse,
|
||||
} from './UserTypes';
|
||||
|
||||
export const mapUserToPartialResponse = (user: User): UserPartialResponse => {
|
||||
const isBot = user.isBot;
|
||||
const isPremium = user.isPremium();
|
||||
|
||||
let avatarHash = user.avatarHash;
|
||||
if (avatarHash?.startsWith('a_') && !isPremium && !isBot) {
|
||||
avatarHash = avatarHash.substring(2);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
discriminator: user.discriminator.toString().padStart(4, '0'),
|
||||
global_name: user.globalName,
|
||||
avatar: avatarHash,
|
||||
avatar_color: user.avatarColor,
|
||||
bot: isBot || undefined,
|
||||
system: user.isSystem || undefined,
|
||||
flags: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS),
|
||||
};
|
||||
};
|
||||
|
||||
export const hasPartialUserFieldsChanged = (oldUser: User, newUser: User): boolean => {
|
||||
const oldPartial = mapUserToPartialResponse(oldUser);
|
||||
const newPartial = mapUserToPartialResponse(newUser);
|
||||
|
||||
return (
|
||||
oldPartial.username !== newPartial.username ||
|
||||
oldPartial.discriminator !== newPartial.discriminator ||
|
||||
oldPartial.global_name !== newPartial.global_name ||
|
||||
oldPartial.avatar !== newPartial.avatar ||
|
||||
oldPartial.avatar_color !== newPartial.avatar_color ||
|
||||
oldPartial.bot !== newPartial.bot ||
|
||||
oldPartial.system !== newPartial.system ||
|
||||
oldPartial.flags !== newPartial.flags
|
||||
);
|
||||
};
|
||||
|
||||
export const mapUserToPrivateResponse = (user: User): UserPrivateResponse => {
|
||||
const isPremium = user.isPremium();
|
||||
|
||||
let requiredActions: Array<string> | undefined;
|
||||
if (user.suspiciousActivityFlags != null && user.suspiciousActivityFlags > 0) {
|
||||
const actions: Array<string> = [];
|
||||
for (const [key, value] of Object.entries(SuspiciousActivityFlags)) {
|
||||
if (user.suspiciousActivityFlags & value) {
|
||||
actions.push(key);
|
||||
}
|
||||
}
|
||||
if (actions.length > 0) {
|
||||
requiredActions = actions;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...mapUserToPartialResponse(user),
|
||||
acls: Array.from(user.acls),
|
||||
email: user.email ?? null,
|
||||
phone: user.phone ?? null,
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
accent_color: user.accentColor,
|
||||
banner: isPremium ? user.bannerHash : null,
|
||||
banner_color: isPremium ? user.bannerColor : null,
|
||||
mfa_enabled: (user.authenticatorTypes?.size ?? 0) > 0,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : undefined,
|
||||
verified: user.emailVerified,
|
||||
premium_type: isPremium ? user.premiumType : 0,
|
||||
premium_since: isPremium ? (user.premiumSince?.toISOString() ?? null) : null,
|
||||
premium_until: user.premiumUntil?.toISOString() ?? null,
|
||||
premium_will_cancel: user.premiumWillCancel ?? false,
|
||||
premium_billing_cycle: user.premiumBillingCycle || null,
|
||||
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
|
||||
premium_badge_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_HIDDEN),
|
||||
premium_badge_masked: !!(user.flags & UserFlags.PREMIUM_BADGE_MASKED),
|
||||
premium_badge_timestamp_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN),
|
||||
premium_badge_sequence_hidden: !!(user.flags & UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN),
|
||||
premium_purchase_disabled: !!(user.flags & UserFlags.PREMIUM_PURCHASE_DISABLED),
|
||||
premium_enabled_override: !!(user.flags & UserFlags.PREMIUM_ENABLED_OVERRIDE),
|
||||
password_last_changed_at: user.passwordLastChangedAt?.toISOString() ?? null,
|
||||
required_actions: requiredActions ?? null,
|
||||
nsfw_allowed: isUserAdult(user.dateOfBirth),
|
||||
pending_manual_verification: !!(user.flags & UserFlags.PENDING_MANUAL_VERIFICATION),
|
||||
has_dismissed_premium_onboarding:
|
||||
user.premiumSince != null &&
|
||||
user.premiumOnboardingDismissedAt != null &&
|
||||
user.premiumOnboardingDismissedAt >= user.premiumSince,
|
||||
has_ever_purchased: user.hasEverPurchased,
|
||||
has_unread_gift_inventory:
|
||||
user.giftInventoryServerSeq != null &&
|
||||
(user.giftInventoryClientSeq == null || user.giftInventoryClientSeq < user.giftInventoryServerSeq),
|
||||
unread_gift_inventory_count:
|
||||
user.giftInventoryServerSeq != null ? user.giftInventoryServerSeq - (user.giftInventoryClientSeq ?? 0) : 0,
|
||||
used_mobile_client: !!(user.flags & UserFlags.USED_MOBILE_CLIENT),
|
||||
pending_bulk_message_deletion:
|
||||
user.pendingBulkMessageDeletionAt != null
|
||||
? {
|
||||
scheduled_at: user.pendingBulkMessageDeletionAt.toISOString(),
|
||||
channel_count: user.pendingBulkMessageDeletionChannelCount ?? 0,
|
||||
message_count: user.pendingBulkMessageDeletionMessageCount ?? 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapUserToProfileResponse = (user: User): UserProfileResponse => ({
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
banner: user.isPremium() ? user.bannerHash : null,
|
||||
banner_color: user.isPremium() ? user.bannerColor : null,
|
||||
accent_color: user.accentColor,
|
||||
});
|
||||
|
||||
export const mapUserToOAuthResponse = (user: User, opts?: {includeEmail?: boolean}) => {
|
||||
const includeEmail = opts?.includeEmail && !!user.email;
|
||||
return {
|
||||
sub: user.id.toString(),
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
discriminator: user.discriminator.toString().padStart(4, '0'),
|
||||
avatar: user.avatarHash,
|
||||
verified: user.emailVerified ?? false,
|
||||
email: includeEmail ? user.email : null,
|
||||
flags: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS),
|
||||
public_flags: Number((user.flags ?? 0n) & PUBLIC_USER_FLAGS),
|
||||
global_name: user.globalName ?? null,
|
||||
bot: user.isBot || false,
|
||||
system: user.isSystem || false,
|
||||
acls: Array.from(user.acls),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapGuildMemberToProfileResponse = (
|
||||
guildMember: GuildMember | null | undefined,
|
||||
): UserProfileResponse | null => {
|
||||
if (!guildMember) return null;
|
||||
|
||||
return {
|
||||
bio: guildMember.bio,
|
||||
pronouns: guildMember.pronouns,
|
||||
banner: guildMember.bannerHash,
|
||||
accent_color: guildMember.accentColor,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapUserSettingsToResponse = (params: {settings: UserSettings}): UserSettingsResponse => {
|
||||
const {settings} = params;
|
||||
return {
|
||||
status: settings.status,
|
||||
status_resets_at: settings.statusResetsAt?.toISOString() ?? null,
|
||||
status_resets_to: settings.statusResetsTo,
|
||||
theme: settings.theme,
|
||||
guild_positions: settings.guildPositions?.map(String) ?? [],
|
||||
locale: settings.locale,
|
||||
restricted_guilds: [...settings.restrictedGuilds].map(String),
|
||||
default_guilds_restricted: settings.defaultGuildsRestricted,
|
||||
inline_attachment_media: settings.inlineAttachmentMedia,
|
||||
inline_embed_media: settings.inlineEmbedMedia,
|
||||
gif_auto_play: settings.gifAutoPlay,
|
||||
render_embeds: settings.renderEmbeds,
|
||||
render_reactions: settings.renderReactions,
|
||||
animate_emoji: settings.animateEmoji,
|
||||
animate_stickers: settings.animateStickers,
|
||||
render_spoilers: settings.renderSpoilers,
|
||||
message_display_compact: settings.compactMessageDisplay,
|
||||
friend_source_flags: settings.friendSourceFlags,
|
||||
incoming_call_flags: settings.incomingCallFlags,
|
||||
group_dm_add_permission_flags: settings.groupDmAddPermissionFlags,
|
||||
guild_folders:
|
||||
settings.guildFolders?.map((folder) => ({
|
||||
id: folder.folderId,
|
||||
name: folder.name,
|
||||
color: folder.color,
|
||||
guild_ids: folder.guildIds.map(String),
|
||||
})) ?? [],
|
||||
custom_status: settings.customStatus
|
||||
? {
|
||||
text: settings.customStatus.text,
|
||||
expires_at: settings.customStatus.expiresAt?.toISOString(),
|
||||
emoji_id: settings.customStatus.emojiId?.toString(),
|
||||
emoji_name: settings.customStatus.emojiName,
|
||||
emoji_animated: settings.customStatus.emojiAnimated,
|
||||
}
|
||||
: null,
|
||||
afk_timeout: settings.afkTimeout,
|
||||
time_format: settings.timeFormat,
|
||||
developer_mode: settings.developerMode,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapRelationshipToResponse = async (params: {
|
||||
relationship: Relationship;
|
||||
userPartialResolver: (userId: UserID) => Promise<UserPartialResponse>;
|
||||
}): Promise<RelationshipResponse> => {
|
||||
const {relationship, userPartialResolver} = params;
|
||||
const userPartial = await userPartialResolver(relationship.targetUserId);
|
||||
return {
|
||||
id: relationship.targetUserId.toString(),
|
||||
type: relationship.type,
|
||||
user: userPartial,
|
||||
since: relationship.since?.toISOString(),
|
||||
nickname: relationship.nickname,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapBetaCodeToResponse = async (params: {
|
||||
betaCode: BetaCode;
|
||||
userPartialResolver: (userId: UserID) => Promise<UserPartialResponse>;
|
||||
}): Promise<BetaCodeResponse> => {
|
||||
const {betaCode, userPartialResolver} = params;
|
||||
return {
|
||||
code: betaCode.code,
|
||||
created_at: betaCode.createdAt.toISOString(),
|
||||
redeemed_at: betaCode.redeemedAt?.toISOString() || null,
|
||||
redeemer: betaCode.redeemerId ? await userPartialResolver(betaCode.redeemerId) : null,
|
||||
};
|
||||
};
|
||||
|
||||
const mapMuteConfigToResponse = (
|
||||
muteConfig: MuteConfiguration | null,
|
||||
): {end_time: string | null; selected_time_window: number} | null =>
|
||||
muteConfig
|
||||
? {
|
||||
end_time: muteConfig.endTime?.toISOString() ?? null,
|
||||
selected_time_window: muteConfig.selectedTimeWindow ?? 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
const mapChannelOverrideToResponse = (
|
||||
override: GuildChannelOverride,
|
||||
): {
|
||||
collapsed: boolean;
|
||||
message_notifications: number;
|
||||
muted: boolean;
|
||||
mute_config: {end_time: string | null; selected_time_window: number} | null;
|
||||
} => ({
|
||||
collapsed: override.collapsed,
|
||||
message_notifications: override.messageNotifications ?? 0,
|
||||
muted: override.muted,
|
||||
mute_config: mapMuteConfigToResponse(override.muteConfig),
|
||||
});
|
||||
|
||||
export const mapUserGuildSettingsToResponse = (settings: UserGuildSettings): UserGuildSettingsResponse => ({
|
||||
guild_id: settings.guildId === createGuildID(0n) ? null : settings.guildId.toString(),
|
||||
message_notifications: settings.messageNotifications ?? 0,
|
||||
muted: settings.muted,
|
||||
mute_config: mapMuteConfigToResponse(settings.muteConfig),
|
||||
mobile_push: settings.mobilePush,
|
||||
suppress_everyone: settings.suppressEveryone,
|
||||
suppress_roles: settings.suppressRoles,
|
||||
hide_muted_channels: settings.hideMutedChannels,
|
||||
channel_overrides: settings.channelOverrides.size
|
||||
? Object.fromEntries(
|
||||
Array.from(settings.channelOverrides.entries()).map(([channelId, override]) => [
|
||||
channelId.toString(),
|
||||
mapChannelOverrideToResponse(override),
|
||||
]),
|
||||
)
|
||||
: null,
|
||||
version: settings.version,
|
||||
});
|
||||
21
fluxer_api/src/user/UserModel.ts
Normal file
21
fluxer_api/src/user/UserModel.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export * from './UserMappers';
|
||||
export * from './UserTypes';
|
||||
20
fluxer_api/src/user/UserRepository.ts
Normal file
20
fluxer_api/src/user/UserRepository.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export {UserRepository} from './repositories/UserRepository';
|
||||
20
fluxer_api/src/user/UserService.ts
Normal file
20
fluxer_api/src/user/UserService.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export {UserService} from './services/UserService';
|
||||
390
fluxer_api/src/user/UserTypes.ts
Normal file
390
fluxer_api/src/user/UserTypes.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import {
|
||||
AVATAR_MAX_SIZE,
|
||||
Locales,
|
||||
MAX_GUILDS_PREMIUM,
|
||||
StatusTypes,
|
||||
ThemeTypes,
|
||||
UserNotificationSettings,
|
||||
} from '~/Constants';
|
||||
import type {MessageResponse} from '~/channel/ChannelModel';
|
||||
import {
|
||||
ColorType,
|
||||
createBase64StringType,
|
||||
createStringType,
|
||||
DateTimeType,
|
||||
DiscriminatorType,
|
||||
EmailType,
|
||||
GlobalNameType,
|
||||
Int32Type,
|
||||
Int64Type,
|
||||
PasswordType,
|
||||
UsernameType,
|
||||
z,
|
||||
} from '~/Schema';
|
||||
|
||||
export const UserPartialResponse = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
discriminator: z.string(),
|
||||
global_name: z.string().nullish(),
|
||||
avatar: z.string().nullish(),
|
||||
avatar_color: z.number().int().nullish(),
|
||||
bot: z.boolean().optional(),
|
||||
system: z.boolean().optional(),
|
||||
flags: z.number().int(),
|
||||
});
|
||||
|
||||
export type UserPartialResponse = z.infer<typeof UserPartialResponse>;
|
||||
|
||||
export const UserPrivateResponse = z.object({
|
||||
...UserPartialResponse.shape,
|
||||
banner: z.string().nullish(),
|
||||
banner_color: z.number().int().nullish(),
|
||||
accent_color: z.number().int().nullish(),
|
||||
acls: z.array(z.string()),
|
||||
email: z.string().nullish(),
|
||||
phone: z.string().nullish(),
|
||||
bio: z.string().nullish(),
|
||||
pronouns: z.string().nullish(),
|
||||
mfa_enabled: z.boolean(),
|
||||
authenticator_types: z.array(z.number().int()).optional(),
|
||||
verified: z.boolean(),
|
||||
premium_type: z.number().int().nullish(),
|
||||
premium_since: z.iso.datetime().nullish(),
|
||||
premium_until: z.iso.datetime().nullish(),
|
||||
premium_will_cancel: z.boolean(),
|
||||
premium_billing_cycle: z.string().nullish(),
|
||||
premium_lifetime_sequence: z.number().int().nullish(),
|
||||
premium_badge_hidden: z.boolean(),
|
||||
premium_badge_masked: z.boolean(),
|
||||
premium_badge_timestamp_hidden: z.boolean(),
|
||||
premium_badge_sequence_hidden: z.boolean(),
|
||||
premium_purchase_disabled: z.boolean(),
|
||||
premium_enabled_override: z.boolean(),
|
||||
password_last_changed_at: z.iso.datetime().nullish(),
|
||||
required_actions: z.array(z.string()).nullable(),
|
||||
nsfw_allowed: z.boolean(),
|
||||
pending_manual_verification: z.boolean(),
|
||||
has_dismissed_premium_onboarding: z.boolean(),
|
||||
has_ever_purchased: z.boolean(),
|
||||
has_unread_gift_inventory: z.boolean(),
|
||||
unread_gift_inventory_count: z.number().int(),
|
||||
used_mobile_client: z.boolean(),
|
||||
pending_bulk_message_deletion: z
|
||||
.object({
|
||||
scheduled_at: z.iso.datetime(),
|
||||
channel_count: z.number().int(),
|
||||
message_count: z.number().int(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type UserPrivateResponse = z.infer<typeof UserPrivateResponse>;
|
||||
|
||||
export const UserProfileResponse = z.object({
|
||||
bio: z.string().nullish(),
|
||||
pronouns: z.string().nullish(),
|
||||
banner: z.string().nullish(),
|
||||
banner_color: z.number().int().nullish(),
|
||||
accent_color: z.number().int().nullish(),
|
||||
});
|
||||
|
||||
export type UserProfileResponse = z.infer<typeof UserProfileResponse>;
|
||||
|
||||
export const UserUpdateRequest = z
|
||||
.object({
|
||||
username: UsernameType,
|
||||
discriminator: DiscriminatorType,
|
||||
global_name: GlobalNameType.nullish(),
|
||||
email: EmailType,
|
||||
new_password: PasswordType,
|
||||
password: PasswordType,
|
||||
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
bio: createStringType(1, 320).nullish(),
|
||||
pronouns: createStringType(1, 40).nullish(),
|
||||
accent_color: ColorType.nullish(),
|
||||
premium_badge_hidden: z.boolean(),
|
||||
premium_badge_masked: z.boolean(),
|
||||
premium_badge_timestamp_hidden: z.boolean(),
|
||||
premium_badge_sequence_hidden: z.boolean(),
|
||||
premium_enabled_override: z.boolean(),
|
||||
has_dismissed_premium_onboarding: z.boolean(),
|
||||
has_unread_gift_inventory: z.boolean(),
|
||||
used_mobile_client: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type UserUpdateRequest = z.infer<typeof UserUpdateRequest>;
|
||||
|
||||
export type SavedMessageStatus = 'available' | 'missing_permissions';
|
||||
|
||||
export interface SavedMessageEntryResponse {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
message_id: string;
|
||||
status: SavedMessageStatus;
|
||||
message: MessageResponse | null;
|
||||
}
|
||||
|
||||
const GuildFolderResponse = z.object({
|
||||
id: z.number().int().nullish(),
|
||||
name: z.string().nullish(),
|
||||
color: z.number().int().nullish(),
|
||||
guild_ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const CustomStatusResponse = z.object({
|
||||
text: z.string().nullish(),
|
||||
expires_at: z.iso.datetime().nullish(),
|
||||
emoji_id: z.string().nullish(),
|
||||
emoji_name: z.string().nullish(),
|
||||
emoji_animated: z.boolean(),
|
||||
});
|
||||
|
||||
export type CustomStatusResponse = z.infer<typeof CustomStatusResponse>;
|
||||
|
||||
const isUnicodeEmoji = (value: string): boolean => {
|
||||
const regex = emojiRegex();
|
||||
const match = value.match(regex);
|
||||
return Boolean(match && match[0] === value);
|
||||
};
|
||||
|
||||
export const CustomStatusPayload = z
|
||||
.object({
|
||||
text: createStringType(1, 128).nullish(),
|
||||
expires_at: DateTimeType.nullish(),
|
||||
emoji_id: Int64Type.nullish(),
|
||||
emoji_name: createStringType(1, 32).nullish(),
|
||||
})
|
||||
.transform((value) => {
|
||||
if (value.emoji_id != null) {
|
||||
return {...value, emoji_name: undefined};
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.refine((value) => value.emoji_name == null || isUnicodeEmoji(value.emoji_name), {
|
||||
message: 'Emoji name must be a valid Unicode emoji',
|
||||
path: ['emoji_name'],
|
||||
});
|
||||
|
||||
export const UserSettingsResponse = z.object({
|
||||
status: z.string(),
|
||||
status_resets_at: z.iso.datetime().nullish(),
|
||||
status_resets_to: z.string().nullish(),
|
||||
theme: z.string(),
|
||||
guild_positions: z.array(z.string()),
|
||||
locale: z.string(),
|
||||
restricted_guilds: z.array(z.string()),
|
||||
default_guilds_restricted: z.boolean(),
|
||||
inline_attachment_media: z.boolean(),
|
||||
inline_embed_media: z.boolean(),
|
||||
gif_auto_play: z.boolean(),
|
||||
render_embeds: z.boolean(),
|
||||
render_reactions: z.boolean(),
|
||||
animate_emoji: z.boolean(),
|
||||
animate_stickers: z.number().int(),
|
||||
render_spoilers: z.number().int(),
|
||||
message_display_compact: z.boolean(),
|
||||
friend_source_flags: z.number().int(),
|
||||
incoming_call_flags: z.number().int(),
|
||||
group_dm_add_permission_flags: z.number().int(),
|
||||
guild_folders: z.array(GuildFolderResponse),
|
||||
custom_status: CustomStatusResponse.nullish(),
|
||||
afk_timeout: z.number().int(),
|
||||
time_format: z.number().int(),
|
||||
developer_mode: z.boolean(),
|
||||
});
|
||||
|
||||
export type UserSettingsResponse = z.infer<typeof UserSettingsResponse>;
|
||||
|
||||
export const UserSettingsUpdateRequest = z
|
||||
.object({
|
||||
flags: z.number().int(),
|
||||
status: z.enum(Object.values(StatusTypes)),
|
||||
status_resets_at: DateTimeType.nullish(),
|
||||
status_resets_to: z.enum(Object.values(StatusTypes)).nullish(),
|
||||
theme: z.enum(Object.values(ThemeTypes)),
|
||||
guild_positions: z
|
||||
.array(Int64Type)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`),
|
||||
locale: z.enum(Object.values(Locales)),
|
||||
restricted_guilds: z
|
||||
.array(Int64Type)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`),
|
||||
default_guilds_restricted: z.boolean(),
|
||||
inline_attachment_media: z.boolean(),
|
||||
inline_embed_media: z.boolean(),
|
||||
gif_auto_play: z.boolean(),
|
||||
render_embeds: z.boolean(),
|
||||
render_reactions: z.boolean(),
|
||||
animate_emoji: z.boolean(),
|
||||
animate_stickers: z.number().int().min(0).max(2),
|
||||
render_spoilers: z.number().int().min(0).max(2),
|
||||
message_display_compact: z.boolean(),
|
||||
friend_source_flags: Int32Type,
|
||||
incoming_call_flags: Int32Type,
|
||||
group_dm_add_permission_flags: Int32Type,
|
||||
guild_folders: z
|
||||
.array(
|
||||
z.object({
|
||||
id: Int32Type,
|
||||
name: createStringType(1, 32),
|
||||
color: ColorType.nullish().default(0x000000),
|
||||
guild_ids: z
|
||||
.array(Int64Type)
|
||||
.transform((ids) => [...new Set(ids)])
|
||||
.refine((ids) => ids.length <= MAX_GUILDS_PREMIUM, `Maximum ${MAX_GUILDS_PREMIUM} guilds allowed`),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
custom_status: CustomStatusPayload.nullish(),
|
||||
afk_timeout: z.number().int().min(60).max(600),
|
||||
time_format: z.number().int().min(0).max(2),
|
||||
developer_mode: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type UserSettingsUpdateRequest = z.infer<typeof UserSettingsUpdateRequest>;
|
||||
|
||||
export const RelationshipResponse = z.object({
|
||||
id: z.string(),
|
||||
type: z.number().int(),
|
||||
user: UserPartialResponse,
|
||||
since: z.iso.datetime().optional(),
|
||||
nickname: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type RelationshipResponse = z.infer<typeof RelationshipResponse>;
|
||||
|
||||
export const RelationshipNicknameUpdateRequest = z.object({
|
||||
nickname: createStringType(1, 32)
|
||||
.nullish()
|
||||
.transform((value) => (value == null ? null : value.trim() || null))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type RelationshipNicknameUpdateRequest = z.infer<typeof RelationshipNicknameUpdateRequest>;
|
||||
|
||||
export const CreatePrivateChannelRequest = z
|
||||
.object({
|
||||
recipient_id: Int64Type.optional(),
|
||||
recipients: z.array(Int64Type).max(9).optional(),
|
||||
})
|
||||
.refine((data) => (data.recipient_id && !data.recipients) || (!data.recipient_id && data.recipients), {
|
||||
message: 'Either recipient_id or recipients must be provided, but not both',
|
||||
});
|
||||
|
||||
export type CreatePrivateChannelRequest = z.infer<typeof CreatePrivateChannelRequest>;
|
||||
|
||||
export const FriendRequestByTagRequest = z.object({
|
||||
username: UsernameType,
|
||||
discriminator: createStringType(1, 4)
|
||||
.refine((value) => /^\d{1,4}$/.test(value), 'Discriminator must be 1-4 digits')
|
||||
.optional()
|
||||
.default('0'),
|
||||
});
|
||||
|
||||
export type FriendRequestByTagRequest = z.infer<typeof FriendRequestByTagRequest>;
|
||||
|
||||
export const BetaCodeResponse = z.object({
|
||||
code: z.string(),
|
||||
created_at: z.iso.datetime(),
|
||||
redeemed_at: z.iso.datetime().nullish(),
|
||||
redeemer: UserPartialResponse.nullish(),
|
||||
});
|
||||
|
||||
export type BetaCodeResponse = z.infer<typeof BetaCodeResponse>;
|
||||
|
||||
const MessageNotificationsType = z.union([
|
||||
z.literal(UserNotificationSettings.ALL_MESSAGES),
|
||||
z.literal(UserNotificationSettings.ONLY_MENTIONS),
|
||||
z.literal(UserNotificationSettings.NO_MESSAGES),
|
||||
z.literal(UserNotificationSettings.INHERIT),
|
||||
]);
|
||||
|
||||
const MuteConfigType = z
|
||||
.object({
|
||||
end_time: z.coerce.date().nullish(),
|
||||
selected_time_window: z.number().int(),
|
||||
})
|
||||
.nullish();
|
||||
|
||||
const MuteConfigResponseType = z
|
||||
.object({
|
||||
end_time: z.iso.datetime().nullish(),
|
||||
selected_time_window: z.number().int(),
|
||||
})
|
||||
.nullish();
|
||||
|
||||
const ChannelOverrideType = z.object({
|
||||
collapsed: z.boolean(),
|
||||
message_notifications: MessageNotificationsType,
|
||||
muted: z.boolean(),
|
||||
mute_config: MuteConfigType,
|
||||
});
|
||||
|
||||
const ChannelOverrideResponseType = z.object({
|
||||
collapsed: z.boolean(),
|
||||
message_notifications: z.number().int(),
|
||||
muted: z.boolean(),
|
||||
mute_config: MuteConfigResponseType,
|
||||
});
|
||||
|
||||
export const UserGuildSettingsUpdateRequest = z
|
||||
.object({
|
||||
message_notifications: MessageNotificationsType,
|
||||
muted: z.boolean(),
|
||||
mute_config: MuteConfigType,
|
||||
mobile_push: z.boolean(),
|
||||
suppress_everyone: z.boolean(),
|
||||
suppress_roles: z.boolean(),
|
||||
hide_muted_channels: z.boolean(),
|
||||
channel_overrides: z
|
||||
.record(
|
||||
Int64Type.transform((val) => val.toString()),
|
||||
ChannelOverrideType,
|
||||
)
|
||||
.nullish(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type UserGuildSettingsUpdateRequest = z.infer<typeof UserGuildSettingsUpdateRequest>;
|
||||
|
||||
export const UserGuildSettingsResponse = z.object({
|
||||
guild_id: z.string().nullish(),
|
||||
message_notifications: z.number().int(),
|
||||
muted: z.boolean(),
|
||||
mute_config: MuteConfigResponseType,
|
||||
mobile_push: z.boolean(),
|
||||
suppress_everyone: z.boolean(),
|
||||
suppress_roles: z.boolean(),
|
||||
hide_muted_channels: z.boolean(),
|
||||
channel_overrides: z.record(z.string(), ChannelOverrideResponseType).nullish(),
|
||||
version: z.number().int(),
|
||||
});
|
||||
|
||||
export type UserGuildSettingsResponse = z.infer<typeof UserGuildSettingsResponse>;
|
||||
711
fluxer_api/src/user/controllers/UserAccountController.ts
Normal file
711
fluxer_api/src/user/controllers/UserAccountController.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoApp, HonoEnv} from '~/App';
|
||||
import {requireSudoMode, type SudoVerificationResult} from '~/auth/services/SudoVerificationService';
|
||||
import {createChannelID, createGuildID, createUserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {
|
||||
AccountSuspiciousActivityError,
|
||||
InputValidationError,
|
||||
MissingAccessError,
|
||||
UnauthorizedError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {User} from '~/Models';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {
|
||||
createStringType,
|
||||
DiscriminatorType,
|
||||
Int64Type,
|
||||
QueryBooleanType,
|
||||
SudoVerificationSchema,
|
||||
URLType,
|
||||
UsernameType,
|
||||
z,
|
||||
} from '~/Schema';
|
||||
import {getCachedUserPartialResponse, mapUserToPartialResponseWithCache} from '~/user/UserCacheHelpers';
|
||||
import {
|
||||
mapGuildMemberToProfileResponse,
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToOAuthResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapUserToProfileResponse,
|
||||
UserGuildSettingsUpdateRequest,
|
||||
UserSettingsUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
} from '~/user/UserModel';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const EmailTokenType = createStringType(1, 256);
|
||||
|
||||
const UserUpdateWithVerificationRequest = UserUpdateRequest.merge(
|
||||
z.object({
|
||||
email_token: EmailTokenType.optional(),
|
||||
}),
|
||||
)
|
||||
.merge(SudoVerificationSchema)
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.email !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Email must be changed via email_token',
|
||||
path: ['email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type UserUpdateWithVerificationRequestData = z.infer<typeof UserUpdateWithVerificationRequest>;
|
||||
type UserUpdatePayload = Omit<
|
||||
UserUpdateWithVerificationRequestData,
|
||||
'mfa_method' | 'mfa_code' | 'webauthn_response' | 'webauthn_challenge' | 'email_token'
|
||||
>;
|
||||
|
||||
const requiresSensitiveUserVerification = (
|
||||
user: User,
|
||||
data: UserUpdateRequest,
|
||||
emailTokenProvided: boolean,
|
||||
): boolean => {
|
||||
const isUnclaimed = !user.passwordHash;
|
||||
const usernameChanged = data.username !== undefined && data.username !== user.username;
|
||||
const discriminatorChanged = data.discriminator !== undefined && data.discriminator !== user.discriminator;
|
||||
const emailChanged = data.email !== undefined && data.email !== user.email;
|
||||
const newPasswordProvided = data.new_password !== undefined;
|
||||
|
||||
if (isUnclaimed) {
|
||||
return usernameChanged || discriminatorChanged;
|
||||
}
|
||||
|
||||
return usernameChanged || discriminatorChanged || emailTokenProvided || emailChanged || newPasswordProvided;
|
||||
};
|
||||
|
||||
const EmailChangeTicketSchema = z.object({
|
||||
ticket: createStringType(),
|
||||
});
|
||||
|
||||
const EmailChangeCodeSchema = EmailChangeTicketSchema.extend({
|
||||
code: createStringType(),
|
||||
});
|
||||
|
||||
const EmailChangeRequestNewSchema = EmailChangeTicketSchema.extend({
|
||||
new_email: createStringType(),
|
||||
original_proof: createStringType(),
|
||||
});
|
||||
|
||||
const EmailChangeVerifyNewSchema = EmailChangeCodeSchema.extend({
|
||||
original_proof: createStringType(),
|
||||
});
|
||||
|
||||
export const UserAccountController = (app: HonoApp) => {
|
||||
const enforceUserAccess = (user: User): void => {
|
||||
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
|
||||
throw new AccountSuspiciousActivityError(user.suspiciousActivityFlags);
|
||||
}
|
||||
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) !== 0n) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreloadMessages = async (ctx: Context<HonoEnv>, channels: ReadonlyArray<bigint>) => {
|
||||
const channelIds = channels.map(createChannelID);
|
||||
|
||||
const messages = await ctx.get('userService').preloadDMMessages({
|
||||
userId: ctx.get('user').id,
|
||||
channelIds,
|
||||
});
|
||||
|
||||
const mappingPromises = Object.entries(messages).map(async ([channelId, message]) => {
|
||||
const mappedMessage = message
|
||||
? await mapMessageToResponse({
|
||||
message,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
mediaService: ctx.get('mediaService'),
|
||||
currentUserId: ctx.get('user').id,
|
||||
})
|
||||
: null;
|
||||
return [channelId, mappedMessage] as const;
|
||||
});
|
||||
|
||||
const mappedEntries = await Promise.all(mappingPromises);
|
||||
const mappedMessages = Object.fromEntries(mappedEntries);
|
||||
|
||||
return ctx.json(mappedMessages);
|
||||
};
|
||||
|
||||
app.get('/users/@me', RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_GET), async (ctx) => {
|
||||
const tokenType = ctx.get('authTokenType');
|
||||
|
||||
if (tokenType === 'bearer') {
|
||||
const scopes = ctx.get('oauthBearerScopes');
|
||||
const bearerUser = ctx.get('user');
|
||||
if (!scopes || !bearerUser) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
enforceUserAccess(bearerUser);
|
||||
const includeEmail = scopes.has('email');
|
||||
return ctx.json(mapUserToOAuthResponse(bearerUser, {includeEmail}));
|
||||
}
|
||||
|
||||
const maybeUser = ctx.get('user');
|
||||
if (maybeUser) {
|
||||
enforceUserAccess(maybeUser);
|
||||
return ctx.json(mapUserToPrivateResponse(maybeUser));
|
||||
}
|
||||
|
||||
throw new UnauthorizedError();
|
||||
});
|
||||
|
||||
app.patch(
|
||||
'/users/@me',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_UPDATE_SELF),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', UserUpdateWithVerificationRequest),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const oldEmail = user.email;
|
||||
const rawBody: UserUpdateWithVerificationRequestData = ctx.req.valid('json');
|
||||
const {
|
||||
mfa_method: _mfaMethod,
|
||||
mfa_code: _mfaCode,
|
||||
webauthn_response: _webauthnResponse,
|
||||
webauthn_challenge: _webauthnChallenge,
|
||||
email_token: emailToken,
|
||||
...userUpdateDataRest
|
||||
} = rawBody;
|
||||
let userUpdateData: UserUpdatePayload = userUpdateDataRest;
|
||||
if (userUpdateData.email !== undefined) {
|
||||
throw InputValidationError.create('email', 'Email must be changed via email_token');
|
||||
}
|
||||
const emailTokenProvided = emailToken !== undefined;
|
||||
const isUnclaimed = !user.passwordHash;
|
||||
if (isUnclaimed) {
|
||||
const allowed = new Set(['new_password']);
|
||||
const disallowedField = Object.keys(userUpdateData).find((key) => !allowed.has(key));
|
||||
if (disallowedField) {
|
||||
throw InputValidationError.create(
|
||||
disallowedField,
|
||||
'Unclaimed accounts can only set a new email via email_token and a new password',
|
||||
);
|
||||
}
|
||||
}
|
||||
let emailFromToken: string | null = null;
|
||||
let emailVerifiedViaToken = false;
|
||||
|
||||
const needsVerification = requiresSensitiveUserVerification(user, userUpdateData, emailTokenProvided);
|
||||
let sudoResult: SudoVerificationResult | null = null;
|
||||
if (needsVerification) {
|
||||
sudoResult = await requireSudoMode(ctx, user, rawBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
}
|
||||
|
||||
if (emailTokenProvided && emailToken) {
|
||||
emailFromToken = await ctx.get('emailChangeService').consumeToken(user.id, emailToken);
|
||||
userUpdateData = {...userUpdateData, email: emailFromToken};
|
||||
emailVerifiedViaToken = true;
|
||||
}
|
||||
|
||||
const updatedUser = await ctx.get('userService').update({
|
||||
user,
|
||||
oldAuthSession: ctx.get('authSession'),
|
||||
data: userUpdateData,
|
||||
request: ctx.req.raw,
|
||||
sudoContext: sudoResult ?? undefined,
|
||||
emailVerifiedViaToken,
|
||||
});
|
||||
|
||||
if (
|
||||
emailFromToken &&
|
||||
oldEmail &&
|
||||
updatedUser.email &&
|
||||
oldEmail.toLowerCase() !== updatedUser.email.toLowerCase()
|
||||
) {
|
||||
try {
|
||||
await ctx.get('authService').issueEmailRevertToken(updatedUser, oldEmail, updatedUser.email);
|
||||
} catch (error) {
|
||||
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
|
||||
}
|
||||
}
|
||||
return ctx.json(mapUserToPrivateResponse(updatedUser));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/start',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_START),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({}).optional()),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const result = await ctx.get('emailChangeService').start(user);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/resend-original',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_RESEND_ORIGINAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeTicketSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await ctx.get('emailChangeService').resendOriginal(user, body.ticket);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/verify-original',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_VERIFY_ORIGINAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeCodeSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await ctx.get('emailChangeService').verifyOriginal(user, body.ticket, body.code);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/request-new',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_REQUEST_NEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeRequestNewSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await ctx
|
||||
.get('emailChangeService')
|
||||
.requestNewEmail(user, body.ticket, body.new_email, body.original_proof);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/resend-new',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_RESEND_NEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeTicketSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await ctx.get('emailChangeService').resendNew(user, body.ticket);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/email-change/verify-new',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_EMAIL_CHANGE_VERIFY_NEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', EmailChangeVerifyNewSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
const emailToken = await ctx
|
||||
.get('emailChangeService')
|
||||
.verifyNew(user, body.ticket, body.code, body.original_proof);
|
||||
return ctx.json({email_token: emailToken});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/check-tag',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHECK_TAG),
|
||||
LoginRequired,
|
||||
Validator('query', z.object({username: UsernameType, discriminator: DiscriminatorType})),
|
||||
async (ctx) => {
|
||||
const {username, discriminator} = ctx.req.valid('query');
|
||||
const currentUser = ctx.get('user');
|
||||
if (
|
||||
username.toLowerCase() === currentUser.username.toLowerCase() &&
|
||||
discriminator === currentUser.discriminator
|
||||
) {
|
||||
return ctx.json({taken: false});
|
||||
}
|
||||
const taken = await ctx.get('userService').checkUsernameDiscriminatorAvailability({username, discriminator});
|
||||
return ctx.json({taken});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GET),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userResponse = await getCachedUserPartialResponse({
|
||||
userId: createUserID(ctx.req.valid('param').user_id),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(userResponse);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/:target_id/profile',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GET_PROFILE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({target_id: Int64Type})),
|
||||
Validator(
|
||||
'query',
|
||||
z.object({
|
||||
guild_id: Int64Type.optional(),
|
||||
with_mutual_friends: QueryBooleanType,
|
||||
with_mutual_guilds: QueryBooleanType,
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {target_id} = ctx.req.valid('param');
|
||||
const {guild_id, with_mutual_friends, with_mutual_guilds} = ctx.req.valid('query');
|
||||
const currentUserId = ctx.get('user').id;
|
||||
const targetUserId = createUserID(target_id);
|
||||
const guildId = guild_id ? createGuildID(guild_id) : undefined;
|
||||
|
||||
const profile = await ctx.get('userService').getUserProfile({
|
||||
userId: currentUserId,
|
||||
targetId: targetUserId,
|
||||
guildId,
|
||||
withMutualFriends: with_mutual_friends,
|
||||
withMutualGuilds: with_mutual_guilds,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
|
||||
const userProfile = mapUserToProfileResponse(profile.user);
|
||||
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null);
|
||||
|
||||
const mutualFriends = profile.mutualFriends
|
||||
? await Promise.all(
|
||||
profile.mutualFriends.map((user) =>
|
||||
mapUserToPartialResponseWithCache({
|
||||
user,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ctx.json({
|
||||
user: await mapUserToPartialResponseWithCache({
|
||||
user: profile.user,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
user_profile: userProfile,
|
||||
guild_member: profile.guildMember ?? undefined,
|
||||
guild_member_profile: guildMemberProfile ?? undefined,
|
||||
premium_type: profile.premiumType,
|
||||
premium_since: profile.premiumSince?.toISOString(),
|
||||
premium_lifetime_sequence: profile.premiumLifetimeSequence,
|
||||
mutual_friends: mutualFriends,
|
||||
mutual_guilds: profile.mutualGuilds,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_GET),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const settings = await ctx.get('userService').findSettings(ctx.get('user').id);
|
||||
return ctx.json(mapUserSettingsToResponse({settings}));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SETTINGS_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', UserSettingsUpdateRequest),
|
||||
async (ctx) => {
|
||||
const updatedSettings = await ctx.get('userService').updateSettings({
|
||||
userId: ctx.get('user').id,
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.json(
|
||||
mapUserSettingsToResponse({
|
||||
settings: updatedSettings,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/notes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const notes = await ctx.get('userService').getUserNotes(ctx.get('user').id);
|
||||
return ctx.json(notes);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/notes/:target_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({target_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const note = await ctx.get('userService').getUserNote({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').target_id),
|
||||
});
|
||||
if (!note) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
return ctx.json(note);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/notes/:target_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_NOTES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({target_id: Int64Type})),
|
||||
Validator('json', z.object({note: createStringType(1, 256).nullish()})),
|
||||
async (ctx) => {
|
||||
const {target_id} = ctx.req.valid('param');
|
||||
const {note} = ctx.req.valid('json');
|
||||
await ctx.get('userService').setUserNote({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(target_id),
|
||||
note: note ?? null,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/guilds/@me/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GUILD_SETTINGS_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', UserGuildSettingsUpdateRequest),
|
||||
async (ctx) => {
|
||||
const settings = await ctx.get('userService').updateGuildSettings({
|
||||
userId: ctx.get('user').id,
|
||||
guildId: null,
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.json(mapUserGuildSettingsToResponse(settings));
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/guilds/:guild_id/settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_GUILD_SETTINGS_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({guild_id: Int64Type})),
|
||||
Validator('json', UserGuildSettingsUpdateRequest),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const settings = await ctx.get('userService').updateGuildSettings({
|
||||
userId: ctx.get('user').id,
|
||||
guildId: createGuildID(guild_id),
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.json(mapUserGuildSettingsToResponse(settings));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_ACCOUNT_DISABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const userService = ctx.get('userService');
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await userService.selfDisable(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_ACCOUNT_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const userService = ctx.get('userService');
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await userService.selfDelete(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/push/subscribe',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_SUBSCRIBE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
endpoint: URLType,
|
||||
keys: z.object({
|
||||
p256dh: createStringType(1, 1024),
|
||||
auth: createStringType(1, 1024),
|
||||
}),
|
||||
user_agent: createStringType(1, 1024).optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {endpoint, keys, user_agent} = ctx.req.valid('json');
|
||||
const subscription = await ctx.get('userService').registerPushSubscription({
|
||||
userId: ctx.get('user').id,
|
||||
endpoint,
|
||||
keys,
|
||||
userAgent: user_agent,
|
||||
});
|
||||
return ctx.json({subscription_id: subscription.subscriptionId});
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/push/subscriptions',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const subscriptions = await ctx.get('userService').listPushSubscriptions(ctx.get('user').id);
|
||||
return ctx.json({
|
||||
subscriptions: subscriptions.map((sub) => ({
|
||||
subscription_id: sub.subscriptionId,
|
||||
user_agent: sub.userAgent,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/push/subscriptions/:subscription_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PUSH_UNSUBSCRIBE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({subscription_id: createStringType(1, 256)})),
|
||||
async (ctx) => {
|
||||
const {subscription_id} = ctx.req.valid('param');
|
||||
await ctx.get('userService').deletePushSubscription(ctx.get('user').id, subscription_id);
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/preload-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PRELOAD_MESSAGES),
|
||||
LoginRequired,
|
||||
Validator('json', z.object({channels: z.array(Int64Type).max(100)})),
|
||||
async (ctx) => handlePreloadMessages(ctx, ctx.req.valid('json').channels),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/channels/messages/preload',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_PRELOAD_MESSAGES),
|
||||
LoginRequired,
|
||||
Validator('json', z.object({channels: z.array(Int64Type).max(100)})),
|
||||
async (ctx) => handlePreloadMessages(ctx, ctx.req.valid('json').channels),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/messages/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userService').requestBulkMessageDeletion({userId: user.id});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/messages/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
await ctx.get('userService').cancelBulkMessageDeletion(user.id);
|
||||
return ctx.json({success: true});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/messages/delete/test',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BULK_MESSAGE_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
if (!(user.flags & UserFlags.STAFF)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
await ctx.get('userService').requestBulkMessageDeletion({
|
||||
userId: user.id,
|
||||
delayMs: 60 * 1000,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
331
fluxer_api/src/user/controllers/UserAuthController.ts
Normal file
331
fluxer_api/src/user/controllers/UserAuthController.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {HonoApp} from '~/App';
|
||||
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, PasswordType, PhoneNumberType, SudoVerificationSchema, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const DisableTotpSchema = z
|
||||
.object({code: createStringType(), password: PasswordType.optional()})
|
||||
.merge(SudoVerificationSchema);
|
||||
|
||||
const MfaBackupCodesSchema = z
|
||||
.object({regenerate: z.boolean(), password: PasswordType.optional()})
|
||||
.merge(SudoVerificationSchema);
|
||||
|
||||
export const UserAuthController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/users/@me/mfa/totp/enable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_ENABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({secret: createStringType(), code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {secret, code} = ctx.req.valid('json');
|
||||
const backupCodes = await ctx.get('userService').enableMfaTotp({
|
||||
user: ctx.get('user'),
|
||||
secret,
|
||||
code,
|
||||
});
|
||||
return ctx.json({
|
||||
backup_codes: backupCodes.map((bc) => ({
|
||||
code: bc.code,
|
||||
consumed: bc.consumed,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/totp/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_TOTP_DISABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', DisableTotpSchema),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('userService').disableMfaTotp({
|
||||
user,
|
||||
code: body.code,
|
||||
sudoContext: sudoResult,
|
||||
password: body.password,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/backup-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', MfaBackupCodesSchema),
|
||||
async (ctx) => {
|
||||
const body = ctx.req.valid('json');
|
||||
const user = ctx.get('user');
|
||||
const sudoResult = await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
const backupCodes = await ctx.get('userService').getMfaBackupCodes({
|
||||
user,
|
||||
regenerate: body.regenerate,
|
||||
sudoContext: sudoResult,
|
||||
password: body.password,
|
||||
});
|
||||
return ctx.json({
|
||||
backup_codes: backupCodes.map((bc) => ({
|
||||
code: bc.code,
|
||||
consumed: bc.consumed,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone/send-verification',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({phone: PhoneNumberType})),
|
||||
async (ctx) => {
|
||||
const {phone} = ctx.req.valid('json');
|
||||
await ctx.get('authService').sendPhoneVerificationCode(phone, ctx.get('user').id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone/verify',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_VERIFY_CODE),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({phone: PhoneNumberType, code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {phone, code} = ctx.req.valid('json');
|
||||
const phoneToken = await ctx.get('authService').verifyPhoneCode(phone, code, ctx.get('user').id);
|
||||
return ctx.json({phone_token: phoneToken});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_ADD),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', z.object({phone_token: createStringType()}).merge(SudoVerificationSchema)),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {phone_token, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').addPhoneToAccount(user.id, phone_token);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_REMOVE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').removePhoneFromAccount(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/sms/enable',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_ENABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await ctx.get('authService').enableSmsMfa(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/sms/disable',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_SMS_DISABLE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').disableSmsMfa(user.id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/mfa/webauthn/credentials',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const credentials = await ctx.get('userRepository').listWebAuthnCredentials(ctx.get('user').id);
|
||||
return ctx.json(
|
||||
credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
name: cred.name,
|
||||
created_at: cred.createdAt.toISOString(),
|
||||
last_used_at: cred.lastUsedAt?.toISOString() ?? null,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/credentials/registration-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTRATION_OPTIONS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
const options = await ctx.get('authService').generateWebAuthnRegistrationOptions(user.id);
|
||||
return ctx.json(options);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/mfa/webauthn/credentials',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_REGISTER),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator(
|
||||
'json',
|
||||
z
|
||||
.object({
|
||||
response: z.custom<RegistrationResponseJSON>(),
|
||||
challenge: createStringType(),
|
||||
name: createStringType(1, 100),
|
||||
})
|
||||
.merge(SudoVerificationSchema),
|
||||
),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {response, challenge, name, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'), {
|
||||
issueSudoToken: false,
|
||||
});
|
||||
await ctx.get('authService').verifyWebAuthnRegistration(user.id, response, challenge, name);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({credential_id: createStringType()})),
|
||||
Validator('json', z.object({name: createStringType(1, 100)}).merge(SudoVerificationSchema)),
|
||||
SudoModeMiddleware,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {credential_id} = ctx.req.valid('param');
|
||||
const {name, ...sudoBody} = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, sudoBody, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').renameWebAuthnCredential(user.id, credential_id, name);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/mfa/webauthn/credentials/:credential_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({credential_id: createStringType()})),
|
||||
SudoModeMiddleware,
|
||||
Validator('json', SudoVerificationSchema),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {credential_id} = ctx.req.valid('param');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').deleteWebAuthnCredential(user.id, credential_id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/sudo/mfa-methods',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_MFA_METHODS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const methods = await ctx.get('authMfaService').getAvailableMfaMethods(ctx.get('user').id);
|
||||
return ctx.json(methods);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/sudo/mfa/sms/send',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_SMS_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
await ctx.get('authService').sendSmsMfaCode(ctx.get('user').id);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/sudo/webauthn/authentication-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_WEBAUTHN_OPTIONS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const options = await ctx.get('authMfaService').generateWebAuthnOptionsForSudo(ctx.get('user').id);
|
||||
return ctx.json(options);
|
||||
},
|
||||
);
|
||||
};
|
||||
111
fluxer_api/src/user/controllers/UserChannelController.ts
Normal file
111
fluxer_api/src/user/controllers/UserChannelController.ts
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 type {HonoApp} from '~/App';
|
||||
import {createChannelID} from '~/BrandedTypes';
|
||||
import {mapChannelToResponse} from '~/channel/ChannelModel';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {CreatePrivateChannelRequest} from '~/user/UserModel';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const UserChannelController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channels = await ctx.get('userService').getPrivateChannels(userId);
|
||||
const responses = await Promise.all(
|
||||
channels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return ctx.json(responses);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/channels',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', CreatePrivateChannelRequest),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channel = await ctx.get('userService').createOrOpenDMChannel({
|
||||
userId,
|
||||
data: ctx.req.valid('json'),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(
|
||||
await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/channels/:channel_id/pin',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('userService').pinDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/channels/:channel_id/pin',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
await ctx.get('userService').unpinDmChannel({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
266
fluxer_api/src/user/controllers/UserContentController.ts
Normal file
266
fluxer_api/src/user/controllers/UserContentController.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createChannelID, createMessageID, type UserID} from '~/BrandedTypes';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createQueryIntegerType, createStringType, Int64Type, QueryBooleanType, z} from '~/Schema';
|
||||
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
|
||||
import {mapBetaCodeToResponse} from '~/user/UserModel';
|
||||
import type {SavedMessageEntryResponse} from '~/user/UserTypes';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const createUserPartialResolver =
|
||||
(userCacheService: UserCacheService, requestCache: RequestCache) => (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
|
||||
export const UserContentController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/beta-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const userService = ctx.get('userService');
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
|
||||
const [betaCodes, allowanceInfo] = await Promise.all([
|
||||
userService.getBetaCodes(userId),
|
||||
userService.getBetaCodeAllowanceInfo(userId),
|
||||
]);
|
||||
|
||||
const responses = await Promise.all(
|
||||
betaCodes.map((betaCode) => mapBetaCodeToResponse({betaCode, userPartialResolver})),
|
||||
);
|
||||
|
||||
return ctx.json({
|
||||
beta_codes: responses,
|
||||
allowance: allowanceInfo.allowance,
|
||||
next_reset_at: allowanceInfo.nextResetAt?.toISOString() ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/beta-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const betaCode = await ctx.get('userService').createBetaCode(ctx.get('user').id);
|
||||
return ctx.json(await mapBetaCodeToResponse({betaCode, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/beta-codes/:code',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_BETA_CODES_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({code: createStringType()})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').deleteBetaCode({
|
||||
userId: ctx.get('user').id,
|
||||
code: ctx.req.valid('param').code,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/mentions',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator(
|
||||
'query',
|
||||
z.object({
|
||||
limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25}),
|
||||
roles: QueryBooleanType.optional().default(true),
|
||||
everyone: QueryBooleanType.optional().default(true),
|
||||
guilds: QueryBooleanType.optional().default(true),
|
||||
before: Int64Type.optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {limit, roles, everyone, guilds, before} = ctx.req.valid('query');
|
||||
const userId = ctx.get('user').id;
|
||||
const messages = await ctx.get('userService').getRecentMentions({
|
||||
userId,
|
||||
limit,
|
||||
everyone,
|
||||
roles,
|
||||
guilds,
|
||||
before: before ? createMessageID(before) : undefined,
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
messages.map((message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
mediaService: ctx.get('mediaService'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return ctx.json(responses);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/mentions/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MENTIONS_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').deleteRecentMention({
|
||||
userId: ctx.get('user').id,
|
||||
messageId: createMessageID(ctx.req.valid('param').message_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/saved-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('query', z.object({limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25})})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const entries = await ctx.get('userService').getSavedMessages({
|
||||
userId,
|
||||
limit: ctx.req.valid('query').limit,
|
||||
});
|
||||
const responses = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const response: SavedMessageEntryResponse = {
|
||||
id: entry.messageId.toString(),
|
||||
channel_id: entry.channelId.toString(),
|
||||
message_id: entry.messageId.toString(),
|
||||
status: entry.status,
|
||||
message: entry.message
|
||||
? await mapMessageToResponse({
|
||||
message: entry.message,
|
||||
currentUserId: userId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
mediaService: ctx.get('mediaService'),
|
||||
})
|
||||
: null,
|
||||
};
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
return ctx.json(responses, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/saved-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', z.object({channel_id: Int64Type, message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const {channel_id, message_id} = ctx.req.valid('json');
|
||||
await ctx.get('userService').saveMessage({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(channel_id),
|
||||
messageId: createMessageID(message_id),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/saved-messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').unsaveMessage({
|
||||
userId: ctx.get('user').id,
|
||||
messageId: createMessageID(ctx.req.valid('param').message_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/harvest',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_DATA_HARVEST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('userService').requestDataHarvest(ctx.get('user').id);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/latest',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_LATEST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const harvest = await ctx.get('userService').getLatestHarvest(ctx.get('user').id);
|
||||
return ctx.json(harvest, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/:harvestId',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_STATUS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const harvestId = BigInt(ctx.req.param('harvestId'));
|
||||
const harvest = await ctx.get('userService').getHarvestStatus(ctx.get('user').id, harvestId);
|
||||
return ctx.json(harvest, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/harvest/:harvestId/download',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_HARVEST_DOWNLOAD),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const harvestId = BigInt(ctx.req.param('harvestId'));
|
||||
const result = await ctx
|
||||
.get('userService')
|
||||
.getHarvestDownloadUrl(ctx.get('user').id, harvestId, ctx.get('storageService'));
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
};
|
||||
35
fluxer_api/src/user/controllers/UserController.ts
Normal file
35
fluxer_api/src/user/controllers/UserController.ts
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 '~/App';
|
||||
import {UserAccountController} from './UserAccountController';
|
||||
import {UserAuthController} from './UserAuthController';
|
||||
import {UserChannelController} from './UserChannelController';
|
||||
import {UserContentController} from './UserContentController';
|
||||
import {UserRelationshipController} from './UserRelationshipController';
|
||||
import {UserScheduledMessageController} from './UserScheduledMessageController';
|
||||
|
||||
export const UserController = (app: HonoApp) => {
|
||||
UserAccountController(app);
|
||||
UserAuthController(app);
|
||||
UserRelationshipController(app);
|
||||
UserChannelController(app);
|
||||
UserContentController(app);
|
||||
UserScheduledMessageController(app);
|
||||
};
|
||||
160
fluxer_api/src/user/controllers/UserRelationshipController.ts
Normal file
160
fluxer_api/src/user/controllers/UserRelationshipController.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {RelationshipTypes} from '~/Constants';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
|
||||
import {
|
||||
FriendRequestByTagRequest,
|
||||
mapRelationshipToResponse,
|
||||
RelationshipNicknameUpdateRequest,
|
||||
} from '~/user/UserModel';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const createUserPartialResolver =
|
||||
(userCacheService: UserCacheService, requestCache: RequestCache) => (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
|
||||
export const UserRelationshipController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/relationships',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIPS_LIST),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const relationships = await ctx.get('userService').getRelationships(ctx.get('user').id);
|
||||
const responses = await Promise.all(
|
||||
relationships.map((relationship) => mapRelationshipToResponse({relationship, userPartialResolver})),
|
||||
);
|
||||
return ctx.json(responses);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/relationships',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', FriendRequestByTagRequest),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const relationship = await ctx.get('userService').sendFriendRequestByTag({
|
||||
userId: ctx.get('user').id,
|
||||
data: ctx.req.valid('json'),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_SEND),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const relationship = await ctx.get('userService').sendFriendRequest({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_FRIEND_REQUEST_ACCEPT),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
Validator('json', z.object({type: z.number().optional()}).optional()),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const body = ctx.req.valid('json');
|
||||
const targetId = createUserID(ctx.req.valid('param').user_id);
|
||||
if (body?.type === RelationshipTypes.BLOCKED) {
|
||||
const relationship = await ctx.get('userService').blockUser({
|
||||
userId: ctx.get('user').id,
|
||||
targetId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
} else {
|
||||
const relationship = await ctx.get('userService').acceptFriendRequest({
|
||||
userId: ctx.get('user').id,
|
||||
targetId,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_DELETE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
await ctx.get('userService').removeRelationship({
|
||||
userId: ctx.get('user').id,
|
||||
targetId: createUserID(ctx.req.valid('param').user_id),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/relationships/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_RELATIONSHIP_UPDATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({user_id: Int64Type})),
|
||||
Validator('json', RelationshipNicknameUpdateRequest),
|
||||
async (ctx) => {
|
||||
const userPartialResolver = createUserPartialResolver(ctx.get('userCacheService'), ctx.get('requestCache'));
|
||||
const targetId = createUserID(ctx.req.valid('param').user_id);
|
||||
const requestBody = ctx.req.valid('json');
|
||||
const relationship = await ctx.get('userService').updateFriendNickname({
|
||||
userId: ctx.get('user').id,
|
||||
targetId,
|
||||
nickname: requestBody.nickname ?? null,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.json(await mapRelationshipToResponse({relationship, userPartialResolver}));
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoApp, HonoEnv} from '~/App';
|
||||
import {createMessageID} from '~/BrandedTypes';
|
||||
import {parseScheduledMessageInput} from '~/channel/controllers/ScheduledMessageParsing';
|
||||
import {UnknownMessageError} from '~/Errors';
|
||||
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const UserScheduledMessageController = (app: HonoApp) => {
|
||||
app.get(
|
||||
'/users/@me/scheduled-messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
async (ctx: Context<HonoEnv>) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessages = await scheduledMessageService.listScheduledMessages(userId);
|
||||
|
||||
return ctx.json(
|
||||
scheduledMessages.map((message) => message.toResponse()),
|
||||
200,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_READ),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({scheduled_message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageId = createMessageID(BigInt(ctx.req.valid('param').scheduled_message_id));
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessage = await scheduledMessageService.getScheduledMessage(userId, scheduledMessageId);
|
||||
|
||||
if (!scheduledMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({scheduled_message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const scheduledMessageId = createMessageID(BigInt(ctx.req.valid('param').scheduled_message_id));
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
await scheduledMessageService.cancelScheduledMessage(userId, scheduledMessageId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/users/@me/scheduled-messages/:scheduled_message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_SAVED_MESSAGES_WRITE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', z.object({scheduled_message_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const scheduledMessageId = createMessageID(BigInt(ctx.req.valid('param').scheduled_message_id));
|
||||
|
||||
const existingMessage = await scheduledMessageService.getScheduledMessage(user.id, scheduledMessageId);
|
||||
if (!existingMessage) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
const channelId = existingMessage.channelId;
|
||||
|
||||
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
|
||||
ctx,
|
||||
userId: user.id,
|
||||
channelId,
|
||||
});
|
||||
|
||||
const scheduledMessage = await scheduledMessageService.updateScheduledMessage({
|
||||
user,
|
||||
channelId,
|
||||
data: message,
|
||||
scheduledLocalAt,
|
||||
timezone,
|
||||
scheduledMessageId,
|
||||
existing: existingMessage,
|
||||
});
|
||||
|
||||
return ctx.json(scheduledMessage.toResponse(), 200);
|
||||
},
|
||||
);
|
||||
};
|
||||
121
fluxer_api/src/user/repositories/BetaCodeRepository.ts
Normal file
121
fluxer_api/src/user/repositories/BetaCodeRepository.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createBetaCode, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {BetaCodeByCodeRow, BetaCodeRow} from '~/database/CassandraTypes';
|
||||
import {BetaCode} from '~/Models';
|
||||
import {BetaCodes, BetaCodesByCode} from '~/Tables';
|
||||
|
||||
const FETCH_BETA_CODES_BY_CREATOR_QUERY = BetaCodes.selectCql({
|
||||
where: BetaCodes.where.eq('creator_id'),
|
||||
});
|
||||
|
||||
const FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY = BetaCodes.selectCql({
|
||||
where: [BetaCodes.where.eq('creator_id'), BetaCodes.where.eq('code')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_BETA_CODE_BY_CODE_QUERY = BetaCodesByCode.selectCql({
|
||||
where: BetaCodesByCode.where.eq('code'),
|
||||
});
|
||||
|
||||
const FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY = BetaCodes.selectCql({
|
||||
columns: ['code'],
|
||||
where: BetaCodes.where.eq('creator_id', 'user_id'),
|
||||
});
|
||||
|
||||
export class BetaCodeRepository {
|
||||
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
|
||||
const betaCodes = await fetchMany<BetaCodeRow>(FETCH_BETA_CODES_BY_CREATOR_QUERY, {
|
||||
creator_id: creatorId,
|
||||
});
|
||||
return betaCodes.map((betaCode) => new BetaCode(betaCode));
|
||||
}
|
||||
|
||||
async getBetaCode(code: string): Promise<BetaCode | null> {
|
||||
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
|
||||
if (!betaCodeByCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const betaCode = await fetchOne<BetaCodeRow>(FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY, {
|
||||
creator_id: betaCodeByCode.creator_id,
|
||||
code: betaCodeByCode.code,
|
||||
});
|
||||
|
||||
return betaCode ? new BetaCode(betaCode) : null;
|
||||
}
|
||||
|
||||
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(BetaCodes.upsertAll(betaCode));
|
||||
batch.addPrepared(
|
||||
BetaCodesByCode.upsertAll({
|
||||
code: betaCode.code,
|
||||
creator_id: betaCode.creator_id,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
return new BetaCode(betaCode);
|
||||
}
|
||||
|
||||
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
|
||||
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
|
||||
if (!betaCodeByCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
BetaCodes.patchByPk(
|
||||
{
|
||||
creator_id: betaCodeByCode.creator_id,
|
||||
code: betaCodeByCode.code,
|
||||
},
|
||||
{
|
||||
redeemer_id: Db.set(redeemerId),
|
||||
redeemed_at: Db.set(redeemedAt),
|
||||
},
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(BetaCodes.deleteByPk({creator_id: creatorId, code: createBetaCode(code)}));
|
||||
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(code), creator_id: creatorId}));
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllBetaCodes(userId: UserID): Promise<void> {
|
||||
const codes = await fetchMany<{code: string}>(FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const betaCode of codes) {
|
||||
batch.addPrepared(BetaCodes.deleteByPk({creator_id: userId, code: createBetaCode(betaCode.code)}));
|
||||
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(betaCode.code), creator_id: userId}));
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
160
fluxer_api/src/user/repositories/GiftCodeRepository.ts
Normal file
160
fluxer_api/src/user/repositories/GiftCodeRepository.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {GiftCodeRow} from '~/database/CassandraTypes';
|
||||
import {GiftCode} from '~/Models';
|
||||
import {GiftCodes, GiftCodesByCreator, GiftCodesByPaymentIntent, GiftCodesByRedeemer} from '~/Tables';
|
||||
|
||||
const FETCH_GIFT_CODES_BY_CREATOR_QUERY = GiftCodesByCreator.selectCql({
|
||||
columns: ['code'],
|
||||
where: GiftCodesByCreator.where.eq('created_by_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY = GiftCodesByPaymentIntent.selectCql({
|
||||
columns: ['code'],
|
||||
where: GiftCodesByPaymentIntent.where.eq('stripe_payment_intent_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GIFT_CODE_QUERY = GiftCodes.selectCql({
|
||||
where: GiftCodes.where.eq('code'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class GiftCodeRepository {
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(GiftCodes.upsertAll(data));
|
||||
batch.addPrepared(
|
||||
GiftCodesByCreator.upsertAll({
|
||||
created_by_user_id: data.created_by_user_id,
|
||||
code: data.code,
|
||||
}),
|
||||
);
|
||||
|
||||
if (data.stripe_payment_intent_id) {
|
||||
batch.addPrepared(
|
||||
GiftCodesByPaymentIntent.upsertAll({
|
||||
stripe_payment_intent_id: data.stripe_payment_intent_id,
|
||||
code: data.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
const row = await fetchOne<GiftCodeRow>(FETCH_GIFT_CODE_QUERY, {code});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GiftCode(row);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
const row = await fetchOne<{code: string}>(FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY, {
|
||||
stripe_payment_intent_id: paymentIntentId,
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.findGiftCode(row.code);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
const rows = await fetchMany<{code: string}>(FETCH_GIFT_CODES_BY_CREATOR_QUERY, {
|
||||
created_by_user_id: userId,
|
||||
});
|
||||
|
||||
const giftCodes = await Promise.all(rows.map((row) => this.findGiftCode(row.code)));
|
||||
|
||||
return giftCodes.filter((gc) => gc !== null) as Array<GiftCode>;
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
const redeemedAt = new Date();
|
||||
|
||||
const q = GiftCodes.patchByPkIf(
|
||||
{code},
|
||||
{
|
||||
redeemed_by_user_id: Db.set(userId),
|
||||
redeemed_at: Db.set(redeemedAt),
|
||||
},
|
||||
{col: 'redeemed_by_user_id', expectedParam: 'expected_redeemer', expectedValue: null},
|
||||
);
|
||||
|
||||
const result = await executeConditional(q);
|
||||
|
||||
if (result.applied) {
|
||||
await upsertOne(
|
||||
GiftCodesByRedeemer.upsertAll({
|
||||
redeemed_by_user_id: userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const patch: Record<string, ReturnType<typeof Db.set>> = {};
|
||||
if (data.redeemed_at !== undefined) {
|
||||
patch.redeemed_at = Db.set(data.redeemed_at);
|
||||
}
|
||||
if (data.redeemed_by_user_id !== undefined) {
|
||||
patch.redeemed_by_user_id = Db.set(data.redeemed_by_user_id);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
batch.addPrepared(GiftCodes.patchByPk({code}, patch));
|
||||
}
|
||||
|
||||
if (data.redeemed_by_user_id) {
|
||||
batch.addPrepared(
|
||||
GiftCodesByRedeemer.upsertAll({
|
||||
redeemed_by_user_id: data.redeemed_by_user_id,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
await upsertOne(
|
||||
GiftCodes.patchByPk(
|
||||
{code},
|
||||
{
|
||||
checkout_session_id: Db.set(checkoutSessionId),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
fluxer_api/src/user/repositories/IUserAccountRepository.ts
Normal file
52
fluxer_api/src/user/repositories/IUserAccountRepository.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import type {User} from '~/Models';
|
||||
|
||||
export interface IUserAccountRepository {
|
||||
create(data: UserRow): Promise<User>;
|
||||
upsert(data: UserRow, oldData?: UserRow | null): Promise<User>;
|
||||
patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null>;
|
||||
findUnique(userId: UserID): Promise<User | null>;
|
||||
findUniqueAssert(userId: UserID): Promise<User>;
|
||||
findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null>;
|
||||
findDiscriminatorsByUsername(username: string): Promise<Set<number>>;
|
||||
findByEmail(email: string): Promise<User | null>;
|
||||
findByPhone(phone: string): Promise<User | null>;
|
||||
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null>;
|
||||
findByStripeCustomerId(stripeCustomerId: string): Promise<User | null>;
|
||||
listUsers(userIds: Array<UserID>): Promise<Array<User>>;
|
||||
listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>>;
|
||||
|
||||
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
|
||||
|
||||
addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
|
||||
removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void>;
|
||||
findUsersPendingDeletion(now: Date): Promise<Array<User>>;
|
||||
findUsersPendingDeletionByDate(deletionDate: string): Promise<Array<{user_id: bigint; deletion_reason_code: number}>>;
|
||||
isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean>;
|
||||
scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
|
||||
|
||||
deleteUserSecondaryIndices(userId: UserID): Promise<void>;
|
||||
removeFromAllGuilds(userId: UserID): Promise<void>;
|
||||
|
||||
updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void>;
|
||||
}
|
||||
93
fluxer_api/src/user/repositories/IUserAuthRepository.ts
Normal file
93
fluxer_api/src/user/repositories/IUserAuthRepository.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {PhoneVerificationToken, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {
|
||||
AuthSession,
|
||||
EmailRevertToken,
|
||||
EmailVerificationToken,
|
||||
MfaBackupCode,
|
||||
PasswordResetToken,
|
||||
WebAuthnCredential,
|
||||
} from '~/Models';
|
||||
|
||||
export interface IUserAuthRepository {
|
||||
listAuthSessions(userId: UserID): Promise<Array<AuthSession>>;
|
||||
getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null>;
|
||||
createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession>;
|
||||
updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void>;
|
||||
deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void>;
|
||||
revokeAuthSession(sessionIdHash: Buffer): Promise<void>;
|
||||
deleteAllAuthSessions(userId: UserID): Promise<void>;
|
||||
|
||||
listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>>;
|
||||
createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>>;
|
||||
clearMfaBackupCodes(userId: UserID): Promise<void>;
|
||||
consumeMfaBackupCode(userId: UserID, code: string): Promise<void>;
|
||||
deleteAllMfaBackupCodes(userId: UserID): Promise<void>;
|
||||
|
||||
getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null>;
|
||||
createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken>;
|
||||
deleteEmailVerificationToken(token: string): Promise<void>;
|
||||
|
||||
getPasswordResetToken(token: string): Promise<PasswordResetToken | null>;
|
||||
createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken>;
|
||||
deletePasswordResetToken(token: string): Promise<void>;
|
||||
|
||||
getEmailRevertToken(token: string): Promise<EmailRevertToken | null>;
|
||||
createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken>;
|
||||
deleteEmailRevertToken(token: string): Promise<void>;
|
||||
|
||||
createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void>;
|
||||
getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null>;
|
||||
deletePhoneToken(token: PhoneVerificationToken): Promise<void>;
|
||||
updateUserActivity(userId: UserID, clientIp: string): Promise<void>;
|
||||
checkIpAuthorized(userId: UserID, ip: string): Promise<boolean>;
|
||||
createAuthorizedIp(userId: UserID, ip: string): Promise<void>;
|
||||
createIpAuthorizationToken(userId: UserID, token: string): Promise<void>;
|
||||
authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null>;
|
||||
deleteAllAuthorizedIps(userId: UserID): Promise<void>;
|
||||
|
||||
listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>>;
|
||||
getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null>;
|
||||
createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void>;
|
||||
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
|
||||
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
|
||||
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
|
||||
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
|
||||
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
|
||||
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
|
||||
|
||||
createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void>;
|
||||
deletePendingVerification(userId: UserID): Promise<void>;
|
||||
}
|
||||
49
fluxer_api/src/user/repositories/IUserChannelRepository.ts
Normal file
49
fluxer_api/src/user/repositories/IUserChannelRepository.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {Channel} from '~/Models';
|
||||
|
||||
export interface PrivateChannelSummary {
|
||||
channelId: ChannelID;
|
||||
isGroupDm: boolean;
|
||||
channelType: number | null;
|
||||
lastMessageId: MessageID | null;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface IUserChannelRepository {
|
||||
listPrivateChannels(userId: UserID): Promise<Array<Channel>>;
|
||||
deleteAllPrivateChannels(userId: UserID): Promise<void>;
|
||||
listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>>;
|
||||
|
||||
findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null>;
|
||||
createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel>;
|
||||
isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean>;
|
||||
openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void>;
|
||||
closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void>;
|
||||
|
||||
getPinnedDms(userId: UserID): Promise<Array<ChannelID>>;
|
||||
getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>>;
|
||||
addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
|
||||
removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
|
||||
deletePinnedDmsByUserId(userId: UserID): Promise<void>;
|
||||
|
||||
deleteAllReadStates(userId: UserID): Promise<void>;
|
||||
}
|
||||
91
fluxer_api/src/user/repositories/IUserContentRepository.ts
Normal file
91
fluxer_api/src/user/repositories/IUserContentRepository.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
BetaCodeRow,
|
||||
GiftCodeRow,
|
||||
PaymentBySubscriptionRow,
|
||||
PaymentRow,
|
||||
PushSubscriptionRow,
|
||||
RecentMentionRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
|
||||
|
||||
export interface IUserContentRepository {
|
||||
getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null>;
|
||||
listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean,
|
||||
includeRole: boolean,
|
||||
includeGuilds: boolean,
|
||||
limit: number,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>>;
|
||||
createRecentMention(mention: RecentMentionRow): Promise<RecentMention>;
|
||||
createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void>;
|
||||
deleteRecentMention(mention: RecentMention): Promise<void>;
|
||||
deleteAllRecentMentions(userId: UserID): Promise<void>;
|
||||
|
||||
listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>>;
|
||||
createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage>;
|
||||
deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void>;
|
||||
deleteAllSavedMessages(userId: UserID): Promise<void>;
|
||||
|
||||
listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>>;
|
||||
getBetaCode(code: string): Promise<BetaCode | null>;
|
||||
upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode>;
|
||||
updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void>;
|
||||
deleteBetaCode(code: string, creatorId: UserID): Promise<void>;
|
||||
deleteAllBetaCodes(userId: UserID): Promise<void>;
|
||||
|
||||
createGiftCode(data: GiftCodeRow): Promise<void>;
|
||||
findGiftCode(code: string): Promise<GiftCode | null>;
|
||||
findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null>;
|
||||
findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>>;
|
||||
redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}>;
|
||||
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
|
||||
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
|
||||
|
||||
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
|
||||
createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription>;
|
||||
deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void>;
|
||||
getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>>;
|
||||
deleteAllPushSubscriptions(userId: UserID): Promise<void>;
|
||||
|
||||
createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void>;
|
||||
updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}>;
|
||||
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
|
||||
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
|
||||
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
|
||||
|
||||
listVisionarySlots(): Promise<Array<VisionarySlot>>;
|
||||
expandVisionarySlots(byCount: number): Promise<void>;
|
||||
shrinkVisionarySlots(toCount: number): Promise<void>;
|
||||
reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
|
||||
unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import type {RelationshipRow} from '~/database/CassandraTypes';
|
||||
import type {Relationship, UserNote} from '~/Models';
|
||||
|
||||
export interface IUserRelationshipRepository {
|
||||
listRelationships(sourceUserId: UserID): Promise<Array<Relationship>>;
|
||||
getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null>;
|
||||
upsertRelationship(relationship: RelationshipRow): Promise<Relationship>;
|
||||
deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void>;
|
||||
deleteAllRelationships(userId: UserID): Promise<void>;
|
||||
|
||||
getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null>;
|
||||
getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>>;
|
||||
upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote>;
|
||||
clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void>;
|
||||
deleteAllNotes(userId: UserID): Promise<void>;
|
||||
}
|
||||
33
fluxer_api/src/user/repositories/IUserRepositoryAggregate.ts
Normal file
33
fluxer_api/src/user/repositories/IUserRepositoryAggregate.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IUserAccountRepository} from './IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from './IUserAuthRepository';
|
||||
import type {IUserChannelRepository} from './IUserChannelRepository';
|
||||
import type {IUserContentRepository} from './IUserContentRepository';
|
||||
import type {IUserRelationshipRepository} from './IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from './IUserSettingsRepository';
|
||||
|
||||
export interface IUserRepositoryAggregate
|
||||
extends IUserAccountRepository,
|
||||
IUserAuthRepository,
|
||||
IUserSettingsRepository,
|
||||
IUserRelationshipRepository,
|
||||
IUserChannelRepository,
|
||||
IUserContentRepository {}
|
||||
34
fluxer_api/src/user/repositories/IUserSettingsRepository.ts
Normal file
34
fluxer_api/src/user/repositories/IUserSettingsRepository.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
|
||||
import type {UserGuildSettings, UserSettings} from '~/Models';
|
||||
|
||||
export interface IUserSettingsRepository {
|
||||
findSettings(userId: UserID): Promise<UserSettings | null>;
|
||||
upsertSettings(settings: UserSettingsRow): Promise<UserSettings>;
|
||||
deleteUserSettings(userId: UserID): Promise<void>;
|
||||
|
||||
findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null>;
|
||||
findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>>;
|
||||
upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings>;
|
||||
deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void>;
|
||||
deleteAllUserGuildSettings(userId: UserID): Promise<void>;
|
||||
}
|
||||
205
fluxer_api/src/user/repositories/PaymentRepository.ts
Normal file
205
fluxer_api/src/user/repositories/PaymentRepository.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {PaymentBySubscriptionRow, PaymentRow} from '~/database/CassandraTypes';
|
||||
import {Payment} from '~/Models';
|
||||
import {Payments, PaymentsByPaymentIntent, PaymentsBySubscription, PaymentsByUser} from '~/Tables';
|
||||
|
||||
const FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY = Payments.selectCql({
|
||||
where: Payments.where.eq('checkout_session_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY = PaymentsByPaymentIntent.selectCql({
|
||||
columns: ['checkout_session_id'],
|
||||
where: PaymentsByPaymentIntent.where.eq('payment_intent_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY = PaymentsBySubscription.selectCql({
|
||||
where: PaymentsBySubscription.where.eq('subscription_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENTS_BY_USER_QUERY = PaymentsByUser.selectCql({
|
||||
columns: ['checkout_session_id'],
|
||||
where: PaymentsByUser.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENTS_BY_IDS_QUERY = Payments.selectCql({
|
||||
where: Payments.where.in('checkout_session_id', 'checkout_session_ids'),
|
||||
});
|
||||
|
||||
export class PaymentRepository {
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const paymentRow: PaymentRow = {
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
user_id: data.user_id,
|
||||
price_id: data.price_id,
|
||||
product_type: data.product_type,
|
||||
status: data.status,
|
||||
is_gift: data.is_gift,
|
||||
created_at: data.created_at,
|
||||
stripe_customer_id: null,
|
||||
payment_intent_id: null,
|
||||
subscription_id: null,
|
||||
invoice_id: null,
|
||||
amount_cents: 0,
|
||||
currency: '',
|
||||
gift_code: null,
|
||||
completed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
batch.addPrepared(Payments.upsertAll(paymentRow));
|
||||
|
||||
batch.addPrepared(
|
||||
PaymentsByUser.upsertAll({
|
||||
user_id: data.user_id,
|
||||
created_at: data.created_at,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
const checkoutSessionId = data.checkout_session_id;
|
||||
|
||||
const result = await executeVersionedUpdate(
|
||||
() =>
|
||||
fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
|
||||
checkout_session_id: checkoutSessionId,
|
||||
}),
|
||||
(current) => {
|
||||
type PatchOp = ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>;
|
||||
const patch: Record<string, PatchOp> = {};
|
||||
|
||||
const addField = <K extends keyof PaymentRow>(key: K) => {
|
||||
const newVal = data[key];
|
||||
const oldVal = current?.[key];
|
||||
if (newVal === null) {
|
||||
if (current && oldVal !== null && oldVal !== undefined) {
|
||||
patch[key] = Db.clear();
|
||||
}
|
||||
} else if (newVal !== undefined) {
|
||||
patch[key] = Db.set(newVal);
|
||||
}
|
||||
};
|
||||
|
||||
addField('stripe_customer_id');
|
||||
addField('payment_intent_id');
|
||||
addField('subscription_id');
|
||||
addField('invoice_id');
|
||||
addField('amount_cents');
|
||||
addField('currency');
|
||||
addField('status');
|
||||
addField('gift_code');
|
||||
addField('completed_at');
|
||||
|
||||
return {
|
||||
pk: {checkout_session_id: checkoutSessionId},
|
||||
patch,
|
||||
};
|
||||
},
|
||||
Payments,
|
||||
{onFailure: 'throw'},
|
||||
);
|
||||
|
||||
if (result.applied) {
|
||||
await this.updatePaymentIndexes(data);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updatePaymentIndexes(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (data.payment_intent_id) {
|
||||
batch.addPrepared(
|
||||
PaymentsByPaymentIntent.upsertAll({
|
||||
payment_intent_id: data.payment_intent_id,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.subscription_id) {
|
||||
const payment = await this.getPaymentByCheckoutSession(data.checkout_session_id);
|
||||
if (payment?.priceId && payment.productType) {
|
||||
batch.addPrepared(
|
||||
PaymentsBySubscription.upsertAll({
|
||||
subscription_id: data.subscription_id,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
user_id: payment.userId,
|
||||
price_id: payment.priceId,
|
||||
product_type: payment.productType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
const result = await fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
|
||||
checkout_session_id: checkoutSessionId,
|
||||
});
|
||||
return result ? new Payment(result) : null;
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
const mapping = await fetchOne<{checkout_session_id: string}>(FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY, {
|
||||
payment_intent_id: paymentIntentId,
|
||||
});
|
||||
if (!mapping) return null;
|
||||
return this.getPaymentByCheckoutSession(mapping.checkout_session_id);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
const result = await fetchOne<PaymentBySubscriptionRow>(FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY, {
|
||||
subscription_id: subscriptionId,
|
||||
});
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
|
||||
const paymentRefs = await fetchMany<{checkout_session_id: string}>(FETCH_PAYMENTS_BY_USER_QUERY, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (paymentRefs.length === 0) return [];
|
||||
const rows = await fetchMany<PaymentRow>(FETCH_PAYMENTS_BY_IDS_QUERY, {
|
||||
checkout_session_ids: paymentRefs.map((r) => r.checkout_session_id),
|
||||
});
|
||||
return rows.map((r) => new Payment(r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
|
||||
import type {PushSubscriptionRow} from '~/database/CassandraTypes';
|
||||
import {PushSubscription} from '~/Models';
|
||||
import {PushSubscriptions} from '~/Tables';
|
||||
|
||||
const FETCH_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
|
||||
where: PushSubscriptions.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
|
||||
where: PushSubscriptions.where.in('user_id', 'user_ids'),
|
||||
});
|
||||
|
||||
export class PushSubscriptionRepository {
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
const rows = await fetchMany<PushSubscriptionRow>(FETCH_PUSH_SUBSCRIPTIONS_CQL, {user_id: userId});
|
||||
return rows.map((row) => new PushSubscription(row));
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
await upsertOne(PushSubscriptions.upsertAll(data));
|
||||
return new PushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
await deleteOneOrMany(PushSubscriptions.deleteByPk({user_id: userId, subscription_id: subscriptionId}));
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const rows = await fetchMany<PushSubscriptionRow>(FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL, {user_ids: userIds});
|
||||
|
||||
const map = new Map<UserID, Array<PushSubscription>>();
|
||||
for (const row of rows) {
|
||||
const sub = new PushSubscription(row);
|
||||
const existing = map.get(row.user_id) ?? [];
|
||||
existing.push(sub);
|
||||
map.set(row.user_id, existing);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
prepared(PushSubscriptions.deleteCql({where: PushSubscriptions.where.eq('user_id')}), {user_id: userId}),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
fluxer_api/src/user/repositories/RecentMentionRepository.ts
Normal file
153
fluxer_api/src/user/repositories/RecentMentionRepository.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, prepared} from '~/database/Cassandra';
|
||||
import type {RecentMentionRow} from '~/database/CassandraTypes';
|
||||
import {RecentMention} from '~/Models';
|
||||
import {RecentMentions, RecentMentionsByGuild} from '~/Tables';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
|
||||
const FETCH_RECENT_MENTION_CQL = RecentMentions.selectCql({
|
||||
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.eq('message_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchRecentMentionsQuery = (limit: number) =>
|
||||
RecentMentions.selectCql({
|
||||
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.lt('message_id', 'before_message_id')],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class RecentMentionRepository {
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
const mention = await fetchOne<RecentMentionRow>(FETCH_RECENT_MENTION_CQL, {
|
||||
user_id: userId,
|
||||
message_id: messageId,
|
||||
});
|
||||
return mention ? new RecentMention(mention) : null;
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean = true,
|
||||
includeRole: boolean = true,
|
||||
includeGuilds: boolean = true,
|
||||
limit: number = 25,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
const fetchLimit = Math.max(limit * 2, 50);
|
||||
const query = createFetchRecentMentionsQuery(fetchLimit);
|
||||
const params: {user_id: UserID; before_message_id: MessageID} = {
|
||||
user_id: userId,
|
||||
before_message_id: before || createMessageID(SnowflakeUtils.getSnowflake()),
|
||||
};
|
||||
const allMentions = await fetchMany<RecentMentionRow>(query, params);
|
||||
const filteredMentions = allMentions.filter((mention) => {
|
||||
if (!includeEveryone && mention.is_everyone) return false;
|
||||
if (!includeRole && mention.is_role) return false;
|
||||
if (!includeGuilds && mention.guild_id != null) return false;
|
||||
return true;
|
||||
});
|
||||
return filteredMentions.slice(0, limit).map((mention) => new RecentMention(mention));
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(RecentMentions.upsertAll(mention));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.insert({
|
||||
user_id: mention.user_id,
|
||||
guild_id: mention.guild_id,
|
||||
message_id: mention.message_id,
|
||||
channel_id: mention.channel_id,
|
||||
is_everyone: mention.is_everyone,
|
||||
is_role: mention.is_role,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
return new RecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
if (mentions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batch = new BatchBuilder();
|
||||
for (const mention of mentions) {
|
||||
batch.addPrepared(RecentMentions.upsertAll(mention));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.insert({
|
||||
user_id: mention.user_id,
|
||||
guild_id: mention.guild_id,
|
||||
message_id: mention.message_id,
|
||||
channel_id: mention.channel_id,
|
||||
is_everyone: mention.is_everyone,
|
||||
is_role: mention.is_role,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(RecentMentions.deleteByPk({user_id: mention.userId, message_id: mention.messageId}));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.deleteByPk({
|
||||
user_id: mention.userId,
|
||||
guild_id: mention.guildId,
|
||||
message_id: mention.messageId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
const mentions = await fetchMany<{guild_id: bigint; message_id: bigint}>(
|
||||
RecentMentions.selectCql({
|
||||
columns: ['guild_id', 'message_id'],
|
||||
where: RecentMentions.where.eq('user_id'),
|
||||
}),
|
||||
{
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
prepared(RecentMentions.deleteCql({where: RecentMentions.where.eq('user_id')}), {user_id: userId}),
|
||||
);
|
||||
|
||||
for (const mention of mentions) {
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.deleteByPk({
|
||||
guild_id: mention.guild_id,
|
||||
user_id: userId,
|
||||
message_id: mention.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
72
fluxer_api/src/user/repositories/SavedMessageRepository.ts
Normal file
72
fluxer_api/src/user/repositories/SavedMessageRepository.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
|
||||
import type {SavedMessageRow} from '~/database/CassandraTypes';
|
||||
import {SavedMessage} from '~/Models';
|
||||
import {SavedMessages} from '~/Tables';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
|
||||
const createFetchSavedMessagesQuery = (limit: number) =>
|
||||
SavedMessages.selectCql({
|
||||
where: [SavedMessages.where.eq('user_id'), SavedMessages.where.lt('message_id', 'before_message_id')],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class SavedMessageRepository {
|
||||
async listSavedMessages(
|
||||
userId: UserID,
|
||||
limit: number = 25,
|
||||
before: MessageID = createMessageID(SnowflakeUtils.getSnowflake()),
|
||||
): Promise<Array<SavedMessage>> {
|
||||
const fetchLimit = Math.max(limit * 2, 50);
|
||||
const savedMessageRows = await fetchMany<SavedMessageRow>(createFetchSavedMessagesQuery(fetchLimit), {
|
||||
user_id: userId,
|
||||
before_message_id: before,
|
||||
});
|
||||
const savedMessages: Array<SavedMessage> = [];
|
||||
for (const savedMessageRow of savedMessageRows) {
|
||||
if (savedMessages.length >= limit) break;
|
||||
savedMessages.push(new SavedMessage(savedMessageRow));
|
||||
}
|
||||
return savedMessages;
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
const savedMessageRow: SavedMessageRow = {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
saved_at: new Date(),
|
||||
};
|
||||
await upsertOne(SavedMessages.upsertAll(savedMessageRow));
|
||||
return new SavedMessage(savedMessageRow);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
await deleteOneOrMany(SavedMessages.deleteByPk({user_id: userId, message_id: messageId}));
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
prepared(SavedMessages.deleteCql({where: SavedMessages.where.eq('user_id')}), {user_id: userId}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MessageID, UserID} from '~/BrandedTypes';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {ScheduledMessageRow} from '~/database/CassandraTypes';
|
||||
import {ScheduledMessage} from '~/models/ScheduledMessage';
|
||||
import {ScheduledMessages} from '~/Tables';
|
||||
|
||||
export class ScheduledMessageRepository {
|
||||
private readonly fetchCql = ScheduledMessages.selectCql({
|
||||
where: [ScheduledMessages.where.eq('user_id')],
|
||||
});
|
||||
|
||||
async listScheduledMessages(userId: UserID, limit: number = 25): Promise<Array<ScheduledMessage>> {
|
||||
const rows = await fetchMany<ScheduledMessageRow>(this.fetchCql, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const messages = rows.map((row) => ScheduledMessage.fromRow(row));
|
||||
return messages.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, limit);
|
||||
}
|
||||
|
||||
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
|
||||
const row = await fetchOne<ScheduledMessageRow>(
|
||||
ScheduledMessages.selectCql({
|
||||
where: [ScheduledMessages.where.eq('user_id'), ScheduledMessages.where.eq('scheduled_message_id')],
|
||||
}),
|
||||
{
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
},
|
||||
);
|
||||
return row ? ScheduledMessage.fromRow(row) : null;
|
||||
}
|
||||
|
||||
async upsertScheduledMessage(message: ScheduledMessage, ttlSeconds: number): Promise<void> {
|
||||
await ScheduledMessages.upsertAllWithTtl(message.toRow(), ttlSeconds);
|
||||
}
|
||||
|
||||
async deleteScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ScheduledMessages.deleteByPk({
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string, ttlSeconds: number): Promise<void> {
|
||||
await ScheduledMessages.patchByPkWithTtl(
|
||||
{
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
},
|
||||
{
|
||||
status: Db.set('invalid'),
|
||||
status_reason: Db.set(reason),
|
||||
invalidated_at: Db.set(new Date()),
|
||||
},
|
||||
ttlSeconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
143
fluxer_api/src/user/repositories/UserAccountRepository.ts
Normal file
143
fluxer_api/src/user/repositories/UserAccountRepository.ts
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 {GuildID, UserID} from '~/BrandedTypes';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import type {User} from '~/Models';
|
||||
import {UserAccountRepository as UserAccountCrudRepository} from './account/UserAccountRepository';
|
||||
import {UserDeletionRepository} from './account/UserDeletionRepository';
|
||||
import {UserGuildRepository} from './account/UserGuildRepository';
|
||||
import {UserLookupRepository} from './account/UserLookupRepository';
|
||||
import type {IUserAccountRepository} from './IUserAccountRepository';
|
||||
|
||||
export class UserAccountRepository implements IUserAccountRepository {
|
||||
private accountRepo: UserAccountCrudRepository;
|
||||
private lookupRepo: UserLookupRepository;
|
||||
private deletionRepo: UserDeletionRepository;
|
||||
private guildRepo: UserGuildRepository;
|
||||
|
||||
constructor() {
|
||||
this.accountRepo = new UserAccountCrudRepository();
|
||||
this.lookupRepo = new UserLookupRepository(this.accountRepo.findUnique.bind(this.accountRepo));
|
||||
this.deletionRepo = new UserDeletionRepository(this.accountRepo.findUnique.bind(this.accountRepo));
|
||||
this.guildRepo = new UserGuildRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.accountRepo.create(data);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.accountRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.accountRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.accountRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.upsert(data, oldData);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
|
||||
return this.accountRepo.patchUpsert(userId, patchData, oldData);
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.deleteUserSecondaryIndices(userId);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByEmail(email);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByPhone(phone);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByStripeCustomerId(stripeCustomerId);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByStripeSubscriptionId(stripeSubscriptionId);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
return this.lookupRepo.findByUsernameDiscriminator(username, discriminator);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
return this.lookupRepo.findDiscriminatorsByUsername(username);
|
||||
}
|
||||
|
||||
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
|
||||
const user = await this.findUnique(userId);
|
||||
return {
|
||||
last_active_at: user?.lastActiveAt ?? null,
|
||||
last_active_ip: user?.lastActiveIp ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.deletionRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
return this.deletionRepo.findUsersPendingDeletion(now);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
return this.deletionRepo.findUsersPendingDeletionByDate(deletionDate);
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
return this.deletionRepo.isUserPendingDeletion(userId, deletionDate);
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
return this.deletionRepo.removePendingDeletion(userId, pendingDeletionAt);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.deletionRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
return this.guildRepo.getUserGuildIds(userId);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
return this.guildRepo.removeFromAllGuilds(userId);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
return this.accountRepo.updateLastActiveAt(params);
|
||||
}
|
||||
}
|
||||
240
fluxer_api/src/user/repositories/UserAuthRepository.ts
Normal file
240
fluxer_api/src/user/repositories/UserAuthRepository.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {PhoneVerificationToken, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {
|
||||
AuthSession,
|
||||
EmailRevertToken,
|
||||
EmailVerificationToken,
|
||||
MfaBackupCode,
|
||||
PasswordResetToken,
|
||||
WebAuthnCredential,
|
||||
} from '~/Models';
|
||||
import {AuthSessionRepository} from './auth/AuthSessionRepository';
|
||||
import {IpAuthorizationRepository} from './auth/IpAuthorizationRepository';
|
||||
import {MfaBackupCodeRepository} from './auth/MfaBackupCodeRepository';
|
||||
import {PendingVerificationRepository} from './auth/PendingVerificationRepository';
|
||||
import {TokenRepository} from './auth/TokenRepository';
|
||||
import {WebAuthnRepository} from './auth/WebAuthnRepository';
|
||||
import type {IUserAccountRepository} from './IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from './IUserAuthRepository';
|
||||
|
||||
export class UserAuthRepository implements IUserAuthRepository {
|
||||
private authSessionRepository: AuthSessionRepository;
|
||||
private mfaBackupCodeRepository: MfaBackupCodeRepository;
|
||||
private tokenRepository: TokenRepository;
|
||||
private ipAuthorizationRepository: IpAuthorizationRepository;
|
||||
private webAuthnRepository: WebAuthnRepository;
|
||||
private pendingVerificationRepository: PendingVerificationRepository;
|
||||
|
||||
constructor(userAccountRepository: IUserAccountRepository) {
|
||||
this.authSessionRepository = new AuthSessionRepository();
|
||||
this.mfaBackupCodeRepository = new MfaBackupCodeRepository();
|
||||
this.tokenRepository = new TokenRepository();
|
||||
this.ipAuthorizationRepository = new IpAuthorizationRepository(userAccountRepository);
|
||||
this.webAuthnRepository = new WebAuthnRepository();
|
||||
this.pendingVerificationRepository = new PendingVerificationRepository();
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
return this.authSessionRepository.listAuthSessions(userId);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
return this.authSessionRepository.getAuthSessionByToken(sessionIdHash);
|
||||
}
|
||||
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
return this.authSessionRepository.createAuthSession(sessionData);
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
const session = await this.getAuthSessionByToken(sessionIdHash);
|
||||
if (!session) return;
|
||||
await this.authSessionRepository.updateAuthSessionLastUsed(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
return this.authSessionRepository.deleteAuthSessions(userId, sessionIdHashes);
|
||||
}
|
||||
|
||||
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
|
||||
const session = await this.getAuthSessionByToken(sessionIdHash);
|
||||
if (!session) return;
|
||||
await this.deleteAuthSessions(session.userId, [sessionIdHash]);
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
return this.authSessionRepository.deleteAllAuthSessions(userId);
|
||||
}
|
||||
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
return this.mfaBackupCodeRepository.listMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
return this.mfaBackupCodeRepository.createMfaBackupCodes(userId, codes);
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.clearMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.consumeMfaBackupCode(userId, code);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.deleteAllMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
return this.tokenRepository.getEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
return this.tokenRepository.createEmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deleteEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
return this.tokenRepository.getPasswordResetToken(token);
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
return this.tokenRepository.createPasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deletePasswordResetToken(token);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
return this.tokenRepository.getEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
return this.tokenRepository.createEmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deleteEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.tokenRepository.createPhoneToken(token, phone, userId);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return this.tokenRepository.getPhoneToken(token);
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
return this.tokenRepository.deletePhoneToken(token);
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
return this.ipAuthorizationRepository.checkIpAuthorized(userId, ip);
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.createAuthorizedIp(userId, ip);
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.createIpAuthorizationToken(userId, token);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.ipAuthorizationRepository.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
return this.ipAuthorizationRepository.getAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
return this.ipAuthorizationRepository.deleteAllAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
return this.webAuthnRepository.listWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
return this.webAuthnRepository.getWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialCounter(userId, credentialId, counter);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
return this.webAuthnRepository.getUserIdByCredentialId(credentialId);
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
return this.webAuthnRepository.deleteAllWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
|
||||
return this.pendingVerificationRepository.createPendingVerification(userId, createdAt, metadata);
|
||||
}
|
||||
|
||||
async deletePendingVerification(userId: UserID): Promise<void> {
|
||||
return this.pendingVerificationRepository.deletePendingVerification(userId);
|
||||
}
|
||||
}
|
||||
369
fluxer_api/src/user/repositories/UserChannelRepository.ts
Normal file
369
fluxer_api/src/user/repositories/UserChannelRepository.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchManyInChunks, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {ChannelRow, DmStateRow, PrivateChannelRow} from '~/database/CassandraTypes';
|
||||
import {Channel} from '~/Models';
|
||||
import {Channels, DmStates, PinnedDms, PrivateChannels, ReadStates} from '~/Tables';
|
||||
import type {IUserChannelRepository, PrivateChannelSummary} from './IUserChannelRepository';
|
||||
|
||||
interface PinnedDmRow {
|
||||
user_id: UserID;
|
||||
channel_id: ChannelID;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
const CHECK_PRIVATE_CHANNEL_CQL = PrivateChannels.selectCql({
|
||||
columns: ['channel_id'],
|
||||
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.eq('channel_id')],
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_CQL = Channels.selectCql({
|
||||
columns: [
|
||||
'channel_id',
|
||||
'guild_id',
|
||||
'type',
|
||||
'name',
|
||||
'topic',
|
||||
'icon_hash',
|
||||
'url',
|
||||
'parent_id',
|
||||
'position',
|
||||
'owner_id',
|
||||
'recipient_ids',
|
||||
'nsfw',
|
||||
'rate_limit_per_user',
|
||||
'bitrate',
|
||||
'user_limit',
|
||||
'rtc_region',
|
||||
'last_message_id',
|
||||
'last_pin_timestamp',
|
||||
'permission_overwrites',
|
||||
'nicks',
|
||||
'soft_deleted',
|
||||
],
|
||||
where: [Channels.where.eq('channel_id'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_DM_STATE_CQL = DmStates.selectCql({
|
||||
where: [DmStates.where.eq('hi_user_id'), DmStates.where.eq('lo_user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PINNED_DMS_CQL = PinnedDms.selectCql({
|
||||
where: PinnedDms.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_PRIVATE_CHANNELS_CQL = PrivateChannels.selectCql({
|
||||
where: PrivateChannels.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_METADATA_CQL = Channels.selectCql({
|
||||
columns: ['channel_id', 'type', 'last_message_id', 'soft_deleted'],
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
});
|
||||
|
||||
const FETCH_CHANNELS_IN_CQL = Channels.selectCql({
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
});
|
||||
|
||||
const sortBySortOrder = (a: PinnedDmRow, b: PinnedDmRow): number => a.sort_order - b.sort_order;
|
||||
|
||||
async function fetchPinnedDms(userId: UserID): Promise<Array<PinnedDmRow>> {
|
||||
return fetchMany<PinnedDmRow>(FETCH_PINNED_DMS_CQL, {user_id: userId});
|
||||
}
|
||||
|
||||
export class UserChannelRepository implements IUserChannelRepository {
|
||||
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
|
||||
const existingDm = pinnedDms.find((dm) => dm.channel_id === channelId);
|
||||
if (existingDm) {
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
const highestSortOrder = pinnedDms.length > 0 ? Math.max(...pinnedDms.map((dm) => dm.sort_order)) : -1;
|
||||
|
||||
await upsertOne(
|
||||
PinnedDms.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
sort_order: highestSortOrder + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const allPinnedDms: Array<PinnedDmRow> = [
|
||||
...pinnedDms,
|
||||
{
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
sort_order: highestSortOrder + 1,
|
||||
},
|
||||
];
|
||||
|
||||
return allPinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PrivateChannels.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
|
||||
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
|
||||
const loUserId = user1Id > user2Id ? user2Id : user1Id;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const channelRow: ChannelRow = {
|
||||
channel_id: channelId,
|
||||
guild_id: null,
|
||||
type: ChannelTypes.DM,
|
||||
name: null,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: null,
|
||||
owner_id: null,
|
||||
recipient_ids: new Set([user1Id, user2Id]),
|
||||
nsfw: null,
|
||||
rate_limit_per_user: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
batch.addPrepared(Channels.upsertAll(channelRow));
|
||||
batch.addPrepared(
|
||||
DmStates.upsertAll({
|
||||
hi_user_id: hiUserId,
|
||||
lo_user_id: loUserId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
PrivateChannels.upsertAll({
|
||||
user_id: user1Id,
|
||||
channel_id: channelId,
|
||||
is_gdm: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
|
||||
return new Channel(channelRow);
|
||||
}
|
||||
|
||||
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PrivateChannels.deleteCql({
|
||||
where: PrivateChannels.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllReadStates(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ReadStates.deleteCql({
|
||||
where: ReadStates.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
|
||||
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
|
||||
const loUserId = user1Id > user2Id ? user2Id : user1Id;
|
||||
|
||||
const dmState = await fetchOne<DmStateRow>(FETCH_DM_STATE_CQL, {
|
||||
hi_user_id: hiUserId,
|
||||
lo_user_id: loUserId,
|
||||
});
|
||||
|
||||
if (!dmState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
|
||||
channel_id: dmState.channel_id,
|
||||
soft_deleted: false,
|
||||
});
|
||||
|
||||
return channel ? new Channel(channel) : null;
|
||||
}
|
||||
|
||||
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder);
|
||||
}
|
||||
|
||||
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
|
||||
const result = await fetchOne<{channel_id: bigint}>(CHECK_PRIVATE_CHANNEL_CQL, {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
|
||||
return result != null;
|
||||
}
|
||||
|
||||
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channelIds = rows.map((row) => row.channel_id);
|
||||
const channelRows = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_IN_CQL, channelIds, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: false,
|
||||
}));
|
||||
|
||||
return channelRows.map((row) => new Channel(row));
|
||||
}
|
||||
|
||||
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
|
||||
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channelIds = rows.map((row) => row.channel_id);
|
||||
const fetchMetadataForSoftDeleted = async (
|
||||
ids: Array<ChannelID>,
|
||||
softDeleted: boolean,
|
||||
): Promise<
|
||||
Array<{
|
||||
channel_id: ChannelID;
|
||||
type: number;
|
||||
last_message_id: MessageID | null;
|
||||
soft_deleted: boolean;
|
||||
}>
|
||||
> => {
|
||||
return fetchManyInChunks(FETCH_CHANNEL_METADATA_CQL, ids, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: softDeleted,
|
||||
}));
|
||||
};
|
||||
|
||||
const channelMap = new Map<
|
||||
ChannelID,
|
||||
{
|
||||
channel_id: ChannelID;
|
||||
type: number;
|
||||
last_message_id: MessageID | null;
|
||||
soft_deleted: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const openChannelRows = await fetchMetadataForSoftDeleted(channelIds, false);
|
||||
for (const row of openChannelRows) {
|
||||
channelMap.set(row.channel_id, row);
|
||||
}
|
||||
|
||||
const missingChannelIds = channelIds.filter((id) => !channelMap.has(id));
|
||||
if (missingChannelIds.length > 0) {
|
||||
const deletedChannelRows = await fetchMetadataForSoftDeleted(missingChannelIds, true);
|
||||
for (const row of deletedChannelRows) {
|
||||
if (!channelMap.has(row.channel_id)) {
|
||||
channelMap.set(row.channel_id, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const channelRow = channelMap.get(row.channel_id);
|
||||
return {
|
||||
channelId: row.channel_id,
|
||||
isGroupDm: row.is_gdm ?? false,
|
||||
channelType: channelRow ? channelRow.type : null,
|
||||
lastMessageId: channelRow ? channelRow.last_message_id : null,
|
||||
open: Boolean(channelRow && !channelRow.soft_deleted),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
|
||||
let resolvedIsGroupDm: boolean;
|
||||
if (isGroupDm !== undefined) {
|
||||
resolvedIsGroupDm = isGroupDm;
|
||||
} else {
|
||||
const channelRow = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
|
||||
channel_id: channelId,
|
||||
soft_deleted: false,
|
||||
});
|
||||
resolvedIsGroupDm = channelRow?.type === ChannelTypes.GROUP_DM;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
PrivateChannels.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
is_gdm: resolvedIsGroupDm,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
await deleteOneOrMany(
|
||||
PinnedDms.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PinnedDms.deleteCql({
|
||||
where: PinnedDms.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {fetchMany, upsertOne} from '~/database/Cassandra';
|
||||
import type {UserContactChangeLogRow} from '~/database/CassandraTypes';
|
||||
import {UserContactChangeLogs} from '~/Tables';
|
||||
|
||||
const createListLogsCql = (limit: number, includeCursor: boolean) =>
|
||||
UserContactChangeLogs.selectCql({
|
||||
where: includeCursor
|
||||
? [UserContactChangeLogs.where.eq('user_id'), UserContactChangeLogs.where.lt('event_id', 'before_event_id')]
|
||||
: UserContactChangeLogs.where.eq('user_id'),
|
||||
orderBy: {col: 'event_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
|
||||
export interface ContactChangeLogListParams {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
beforeEventId?: string;
|
||||
}
|
||||
|
||||
export interface ContactChangeLogInsertParams {
|
||||
userId: UserID;
|
||||
field: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
reason: string;
|
||||
actorUserId: UserID | null;
|
||||
eventAt?: Date;
|
||||
}
|
||||
|
||||
export class UserContactChangeLogRepository {
|
||||
async insertLog(params: ContactChangeLogInsertParams): Promise<void> {
|
||||
const eventAt = params.eventAt ?? new Date();
|
||||
await upsertOne(
|
||||
UserContactChangeLogs.insertWithNow(
|
||||
{
|
||||
user_id: params.userId,
|
||||
field: params.field,
|
||||
old_value: params.oldValue,
|
||||
new_value: params.newValue,
|
||||
reason: params.reason,
|
||||
actor_user_id: params.actorUserId,
|
||||
event_at: eventAt,
|
||||
},
|
||||
'event_id',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listLogs(params: ContactChangeLogListParams): Promise<Array<UserContactChangeLogRow>> {
|
||||
const {userId, limit, beforeEventId} = params;
|
||||
const query = createListLogsCql(limit, !!beforeEventId);
|
||||
const queryParams: {user_id: UserID; before_event_id?: string} = {
|
||||
user_id: userId,
|
||||
};
|
||||
if (beforeEventId) {
|
||||
queryParams.before_event_id = beforeEventId;
|
||||
}
|
||||
const rows = await fetchMany<
|
||||
Omit<UserContactChangeLogRow, 'user_id' | 'actor_user_id'> & {
|
||||
user_id: bigint;
|
||||
actor_user_id: bigint | null;
|
||||
}
|
||||
>(query, queryParams);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
user_id: createUserID(row.user_id),
|
||||
actor_user_id: row.actor_user_id != null ? createUserID(row.actor_user_id) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
231
fluxer_api/src/user/repositories/UserContentRepository.ts
Normal file
231
fluxer_api/src/user/repositories/UserContentRepository.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
BetaCodeRow,
|
||||
GiftCodeRow,
|
||||
PaymentBySubscriptionRow,
|
||||
PaymentRow,
|
||||
PushSubscriptionRow,
|
||||
RecentMentionRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
|
||||
import {BetaCodeRepository} from './BetaCodeRepository';
|
||||
import {GiftCodeRepository} from './GiftCodeRepository';
|
||||
import type {IUserContentRepository} from './IUserContentRepository';
|
||||
import {PaymentRepository} from './PaymentRepository';
|
||||
import {PushSubscriptionRepository} from './PushSubscriptionRepository';
|
||||
import {RecentMentionRepository} from './RecentMentionRepository';
|
||||
import {SavedMessageRepository} from './SavedMessageRepository';
|
||||
import {VisionarySlotRepository} from './VisionarySlotRepository';
|
||||
|
||||
export class UserContentRepository implements IUserContentRepository {
|
||||
private betaCodeRepository: BetaCodeRepository;
|
||||
private giftCodeRepository: GiftCodeRepository;
|
||||
private paymentRepository: PaymentRepository;
|
||||
private pushSubscriptionRepository: PushSubscriptionRepository;
|
||||
private recentMentionRepository: RecentMentionRepository;
|
||||
private savedMessageRepository: SavedMessageRepository;
|
||||
private visionarySlotRepository: VisionarySlotRepository;
|
||||
|
||||
constructor() {
|
||||
this.betaCodeRepository = new BetaCodeRepository();
|
||||
this.giftCodeRepository = new GiftCodeRepository();
|
||||
this.paymentRepository = new PaymentRepository();
|
||||
this.pushSubscriptionRepository = new PushSubscriptionRepository();
|
||||
this.recentMentionRepository = new RecentMentionRepository();
|
||||
this.savedMessageRepository = new SavedMessageRepository();
|
||||
this.visionarySlotRepository = new VisionarySlotRepository();
|
||||
}
|
||||
|
||||
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
|
||||
return this.betaCodeRepository.listBetaCodes(creatorId);
|
||||
}
|
||||
|
||||
async getBetaCode(code: string): Promise<BetaCode | null> {
|
||||
return this.betaCodeRepository.getBetaCode(code);
|
||||
}
|
||||
|
||||
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
|
||||
return this.betaCodeRepository.upsertBetaCode(betaCode);
|
||||
}
|
||||
|
||||
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
|
||||
return this.betaCodeRepository.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
|
||||
}
|
||||
|
||||
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
|
||||
return this.betaCodeRepository.deleteBetaCode(code, creatorId);
|
||||
}
|
||||
|
||||
async deleteAllBetaCodes(userId: UserID): Promise<void> {
|
||||
return this.betaCodeRepository.deleteAllBetaCodes(userId);
|
||||
}
|
||||
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
return this.giftCodeRepository.createGiftCode(data);
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
return this.giftCodeRepository.findGiftCode(code);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
return this.giftCodeRepository.findGiftCodeByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
return this.giftCodeRepository.findGiftCodesByCreator(userId);
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
return this.giftCodeRepository.redeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.giftCodeRepository.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
return this.giftCodeRepository.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
|
||||
}
|
||||
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
return this.paymentRepository.createPayment(data);
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
return this.paymentRepository.updatePayment(data);
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
return this.paymentRepository.getSubscriptionInfo(subscriptionId);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return this.pushSubscriptionRepository.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
return this.pushSubscriptionRepository.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return this.pushSubscriptionRepository.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
return this.pushSubscriptionRepository.getBulkPushSubscriptions(userIds);
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
return this.pushSubscriptionRepository.deleteAllPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
return this.recentMentionRepository.getRecentMention(userId, messageId);
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean = true,
|
||||
includeRole: boolean = true,
|
||||
includeGuilds: boolean = true,
|
||||
limit: number = 25,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
return this.recentMentionRepository.listRecentMentions(
|
||||
userId,
|
||||
includeEveryone,
|
||||
includeRole,
|
||||
includeGuilds,
|
||||
limit,
|
||||
before,
|
||||
);
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
return this.recentMentionRepository.createRecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
return this.recentMentionRepository.createRecentMentions(mentions);
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
return this.recentMentionRepository.deleteRecentMention(mention);
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
return this.recentMentionRepository.deleteAllRecentMentions(userId);
|
||||
}
|
||||
|
||||
async listSavedMessages(userId: UserID, limit: number = 25, before?: MessageID): Promise<Array<SavedMessage>> {
|
||||
return this.savedMessageRepository.listSavedMessages(userId, limit, before);
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
return this.savedMessageRepository.createSavedMessage(userId, channelId, messageId);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
return this.savedMessageRepository.deleteSavedMessage(userId, messageId);
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
return this.savedMessageRepository.deleteAllSavedMessages(userId);
|
||||
}
|
||||
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
return this.visionarySlotRepository.listVisionarySlots();
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
return this.visionarySlotRepository.expandVisionarySlots(byCount);
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
return this.visionarySlotRepository.shrinkVisionarySlots(toCount);
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.visionarySlotRepository.reserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.visionarySlotRepository.unreserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
}
|
||||
215
fluxer_api/src/user/repositories/UserRelationshipRepository.ts
Normal file
215
fluxer_api/src/user/repositories/UserRelationshipRepository.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {NoteRow, RelationshipRow} from '~/database/CassandraTypes';
|
||||
import {Relationship, UserNote} from '~/Models';
|
||||
import {Notes, Relationships} from '~/Tables';
|
||||
import type {IUserRelationshipRepository} from './IUserRelationshipRepository';
|
||||
|
||||
const FETCH_ALL_NOTES_CQL = Notes.selectCql({
|
||||
where: Notes.where.eq('source_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_NOTE_CQL = Notes.selectCql({
|
||||
where: [Notes.where.eq('source_user_id'), Notes.where.eq('target_user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIPS_CQL = Relationships.selectCql({
|
||||
where: Relationships.where.eq('source_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIP_CQL = Relationships.selectCql({
|
||||
where: [
|
||||
Relationships.where.eq('source_user_id'),
|
||||
Relationships.where.eq('target_user_id'),
|
||||
Relationships.where.eq('type'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_ALL_NOTES_FOR_DELETE_QUERY = Notes.selectCql({
|
||||
columns: ['source_user_id', 'target_user_id'],
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIPS_FOR_DELETE_CQL = Relationships.selectCql({
|
||||
columns: ['source_user_id', 'target_user_id', 'type'],
|
||||
where: Relationships.where.eq('source_user_id', 'user_id'),
|
||||
});
|
||||
|
||||
export class UserRelationshipRepository implements IUserRelationshipRepository {
|
||||
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Notes.deleteByPk({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllNotes(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Notes.deleteCql({
|
||||
where: Notes.where.eq('source_user_id', 'user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
const allNotes = await fetchMany<{source_user_id: bigint; target_user_id: bigint}>(
|
||||
FETCH_ALL_NOTES_FOR_DELETE_QUERY,
|
||||
{},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const note of allNotes) {
|
||||
if (note.target_user_id === BigInt(userId)) {
|
||||
batch.addPrepared(
|
||||
Notes.deleteByPk({
|
||||
source_user_id: createUserID(note.source_user_id),
|
||||
target_user_id: createUserID(note.target_user_id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllRelationships(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Relationships.deleteCql({
|
||||
where: Relationships.where.eq('source_user_id', 'user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
const relationships = await fetchMany<{source_user_id: bigint; target_user_id: bigint; type: number}>(
|
||||
FETCH_RELATIONSHIPS_FOR_DELETE_CQL,
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const rel of relationships) {
|
||||
if (rel.target_user_id === BigInt(userId)) {
|
||||
batch.addPrepared(
|
||||
Relationships.deleteByPk({
|
||||
source_user_id: createUserID(rel.source_user_id),
|
||||
target_user_id: createUserID(rel.target_user_id),
|
||||
type: rel.type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Relationships.deleteByPk({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
|
||||
const relationship = await fetchOne<RelationshipRow>(FETCH_RELATIONSHIP_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
type,
|
||||
});
|
||||
return relationship ? new Relationship(relationship) : null;
|
||||
}
|
||||
|
||||
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
|
||||
const note = await fetchOne<NoteRow>(FETCH_NOTE_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
});
|
||||
return note ? new UserNote(note) : null;
|
||||
}
|
||||
|
||||
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
|
||||
const notes = await fetchMany<NoteRow>(FETCH_ALL_NOTES_CQL, {source_user_id: sourceUserId});
|
||||
const noteMap = new Map<UserID, string>();
|
||||
for (const note of notes) {
|
||||
noteMap.set(note.target_user_id, note.note);
|
||||
}
|
||||
return noteMap;
|
||||
}
|
||||
|
||||
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
|
||||
const relationships = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
});
|
||||
return relationships.map((rel) => new Relationship(rel));
|
||||
}
|
||||
|
||||
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
|
||||
const result = await executeVersionedUpdate<RelationshipRow, 'source_user_id' | 'target_user_id' | 'type'>(
|
||||
() =>
|
||||
fetchOne(FETCH_RELATIONSHIP_CQL, {
|
||||
source_user_id: relationship.source_user_id,
|
||||
target_user_id: relationship.target_user_id,
|
||||
type: relationship.type,
|
||||
}),
|
||||
(current) => ({
|
||||
pk: {
|
||||
source_user_id: relationship.source_user_id,
|
||||
target_user_id: relationship.target_user_id,
|
||||
type: relationship.type,
|
||||
},
|
||||
patch: {
|
||||
nickname: Db.set(relationship.nickname),
|
||||
since: Db.set(relationship.since),
|
||||
version: Db.set((current?.version ?? 0) + 1),
|
||||
},
|
||||
}),
|
||||
Relationships,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
return new Relationship({
|
||||
...relationship,
|
||||
version: result.finalVersion ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
|
||||
const result = await executeVersionedUpdate<NoteRow, 'source_user_id' | 'target_user_id'>(
|
||||
() => fetchOne(FETCH_NOTE_CQL, {source_user_id: sourceUserId, target_user_id: targetUserId}),
|
||||
(current) => ({
|
||||
pk: {source_user_id: sourceUserId, target_user_id: targetUserId},
|
||||
patch: {note: Db.set(note), version: Db.set((current?.version ?? 0) + 1)},
|
||||
}),
|
||||
Notes,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
return new UserNote({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
note,
|
||||
version: result.finalVersion ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
660
fluxer_api/src/user/repositories/UserRepository.ts
Normal file
660
fluxer_api/src/user/repositories/UserRepository.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID, PhoneVerificationToken, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
BetaCodeRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
GiftCodeRow,
|
||||
PasswordResetTokenRow,
|
||||
PaymentBySubscriptionRow,
|
||||
PaymentRow,
|
||||
PhoneTokenRow,
|
||||
PushSubscriptionRow,
|
||||
RecentMentionRow,
|
||||
RelationshipRow,
|
||||
UserGuildSettingsRow,
|
||||
UserRow,
|
||||
UserSettingsRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {
|
||||
AuthSession,
|
||||
BetaCode,
|
||||
Channel,
|
||||
EmailRevertToken,
|
||||
EmailVerificationToken,
|
||||
GiftCode,
|
||||
MfaBackupCode,
|
||||
PasswordResetToken,
|
||||
Payment,
|
||||
PushSubscription,
|
||||
ReadState,
|
||||
RecentMention,
|
||||
Relationship,
|
||||
SavedMessage,
|
||||
User,
|
||||
UserGuildSettings,
|
||||
UserNote,
|
||||
UserSettings,
|
||||
VisionarySlot,
|
||||
WebAuthnCredential,
|
||||
} from '~/Models';
|
||||
import {ReadStateRepository} from '~/read_state/ReadStateRepository';
|
||||
import type {PrivateChannelSummary} from './IUserChannelRepository';
|
||||
import type {IUserRepositoryAggregate} from './IUserRepositoryAggregate';
|
||||
import {UserAccountRepository} from './UserAccountRepository';
|
||||
import {UserAuthRepository} from './UserAuthRepository';
|
||||
import {UserChannelRepository} from './UserChannelRepository';
|
||||
import {UserContentRepository} from './UserContentRepository';
|
||||
import {UserRelationshipRepository} from './UserRelationshipRepository';
|
||||
import {UserSettingsRepository} from './UserSettingsRepository';
|
||||
|
||||
export class UserRepository implements IUserRepositoryAggregate {
|
||||
private accountRepo: UserAccountRepository;
|
||||
private settingsRepo: UserSettingsRepository;
|
||||
private authRepo: UserAuthRepository;
|
||||
private relationshipRepo: UserRelationshipRepository;
|
||||
private channelRepo: UserChannelRepository;
|
||||
private contentRepo: UserContentRepository;
|
||||
private readStateRepo: ReadStateRepository;
|
||||
|
||||
constructor() {
|
||||
this.accountRepo = new UserAccountRepository();
|
||||
this.settingsRepo = new UserSettingsRepository();
|
||||
this.authRepo = new UserAuthRepository(this.accountRepo);
|
||||
this.relationshipRepo = new UserRelationshipRepository();
|
||||
this.channelRepo = new UserChannelRepository();
|
||||
this.contentRepo = new UserContentRepository();
|
||||
this.readStateRepo = new ReadStateRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.accountRepo.create(data);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.upsert(data, oldData);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
|
||||
return this.accountRepo.patchUpsert(userId, patchData, oldData);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.accountRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.accountRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
return this.accountRepo.findByUsernameDiscriminator(username, discriminator);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
return this.accountRepo.findDiscriminatorsByUsername(username);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.accountRepo.findByEmail(email);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
return this.accountRepo.findByPhone(phone);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
return this.accountRepo.findByStripeSubscriptionId(stripeSubscriptionId);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
return this.accountRepo.findByStripeCustomerId(stripeCustomerId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.accountRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
return this.accountRepo.getUserGuildIds(userId);
|
||||
}
|
||||
|
||||
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
|
||||
return this.accountRepo.getActivityTracking(userId);
|
||||
}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.accountRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
return this.accountRepo.removePendingDeletion(userId, pendingDeletionAt);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
return this.accountRepo.findUsersPendingDeletion(now);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
return this.accountRepo.findUsersPendingDeletionByDate(deletionDate);
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
return this.accountRepo.isUserPendingDeletion(userId, deletionDate);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.accountRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.deleteUserSecondaryIndices(userId);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.removeFromAllGuilds(userId);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
return this.accountRepo.updateLastActiveAt(params);
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings | null> {
|
||||
return this.settingsRepo.findSettings(userId);
|
||||
}
|
||||
|
||||
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
|
||||
return this.settingsRepo.upsertSettings(settings);
|
||||
}
|
||||
|
||||
async deleteUserSettings(userId: UserID): Promise<void> {
|
||||
return this.settingsRepo.deleteUserSettings(userId);
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return this.settingsRepo.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
|
||||
return this.settingsRepo.findAllGuildSettings(userId);
|
||||
}
|
||||
|
||||
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
|
||||
return this.settingsRepo.upsertGuildSettings(settings);
|
||||
}
|
||||
|
||||
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
|
||||
return this.settingsRepo.deleteGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
|
||||
return this.settingsRepo.deleteAllUserGuildSettings(userId);
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
return this.authRepo.listAuthSessions(userId);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
return this.authRepo.getAuthSessionByToken(sessionIdHash);
|
||||
}
|
||||
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
return this.authRepo.createAuthSession(sessionData);
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
return this.authRepo.updateAuthSessionLastUsed(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
return this.authRepo.deleteAuthSessions(userId, sessionIdHashes);
|
||||
}
|
||||
|
||||
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
|
||||
return this.authRepo.revokeAuthSession(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllAuthSessions(userId);
|
||||
}
|
||||
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
return this.authRepo.listMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
return this.authRepo.createMfaBackupCodes(userId, codes);
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.authRepo.clearMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
return this.authRepo.consumeMfaBackupCode(userId, code);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
return this.authRepo.getEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
return this.authRepo.createEmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
return this.authRepo.deleteEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
return this.authRepo.getPasswordResetToken(token);
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
return this.authRepo.createPasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
return this.authRepo.deletePasswordResetToken(token);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
return this.authRepo.getEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
return this.authRepo.createEmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
return this.authRepo.deleteEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.authRepo.createPhoneToken(token, phone, userId);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return this.authRepo.getPhoneToken(token);
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
return this.authRepo.deletePhoneToken(token);
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
return this.authRepo.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
return this.authRepo.checkIpAuthorized(userId, ip);
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
return this.authRepo.createAuthorizedIp(userId, ip);
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
|
||||
return this.authRepo.createIpAuthorizationToken(userId, token);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.authRepo.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
return this.authRepo.getAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
return this.authRepo.listWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
return this.authRepo.getWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialCounter(userId, credentialId, counter);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
return this.authRepo.getUserIdByCredentialId(credentialId);
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
|
||||
return this.authRepo.createPendingVerification(userId, createdAt, metadata);
|
||||
}
|
||||
|
||||
async deletePendingVerification(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deletePendingVerification(userId);
|
||||
}
|
||||
|
||||
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
|
||||
return this.relationshipRepo.listRelationships(sourceUserId);
|
||||
}
|
||||
|
||||
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
|
||||
return this.relationshipRepo.getRelationship(sourceUserId, targetUserId, type);
|
||||
}
|
||||
|
||||
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
|
||||
return this.relationshipRepo.upsertRelationship(relationship);
|
||||
}
|
||||
|
||||
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
|
||||
return this.relationshipRepo.deleteRelationship(sourceUserId, targetUserId, type);
|
||||
}
|
||||
|
||||
async deleteAllRelationships(userId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.deleteAllRelationships(userId);
|
||||
}
|
||||
|
||||
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
|
||||
return this.relationshipRepo.getUserNote(sourceUserId, targetUserId);
|
||||
}
|
||||
|
||||
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
|
||||
return this.relationshipRepo.getUserNotes(sourceUserId);
|
||||
}
|
||||
|
||||
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
|
||||
return this.relationshipRepo.upsertUserNote(sourceUserId, targetUserId, note);
|
||||
}
|
||||
|
||||
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.clearUserNote(sourceUserId, targetUserId);
|
||||
}
|
||||
|
||||
async deleteAllNotes(userId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.deleteAllNotes(userId);
|
||||
}
|
||||
|
||||
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
return this.channelRepo.listPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
|
||||
return this.channelRepo.listPrivateChannelSummaries(userId);
|
||||
}
|
||||
|
||||
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deleteAllPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
|
||||
return this.channelRepo.findExistingDmState(user1Id, user2Id);
|
||||
}
|
||||
|
||||
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
|
||||
return this.channelRepo.createDmChannelAndState(user1Id, user2Id, channelId);
|
||||
}
|
||||
|
||||
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
|
||||
return this.channelRepo.isDmChannelOpen(userId, channelId);
|
||||
}
|
||||
|
||||
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
|
||||
return this.channelRepo.openDmForUser(userId, channelId, isGroupDm);
|
||||
}
|
||||
|
||||
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
return this.channelRepo.closeDmForUser(userId, channelId);
|
||||
}
|
||||
|
||||
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.getPinnedDms(userId);
|
||||
}
|
||||
|
||||
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
|
||||
return this.channelRepo.getPinnedDmsWithDetails(userId);
|
||||
}
|
||||
|
||||
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.addPinnedDm(userId, channelId);
|
||||
}
|
||||
|
||||
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.removePinnedDm(userId, channelId);
|
||||
}
|
||||
|
||||
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deletePinnedDmsByUserId(userId);
|
||||
}
|
||||
|
||||
async deleteAllReadStates(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deleteAllReadStates(userId);
|
||||
}
|
||||
|
||||
async getReadStates(userId: UserID): Promise<Array<ReadState>> {
|
||||
return this.readStateRepo.listReadStates(userId);
|
||||
}
|
||||
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
return this.contentRepo.getRecentMention(userId, messageId);
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean,
|
||||
includeRole: boolean,
|
||||
includeGuilds: boolean,
|
||||
limit: number,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
return this.contentRepo.listRecentMentions(userId, includeEveryone, includeRole, includeGuilds, limit, before);
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
return this.contentRepo.createRecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
return this.contentRepo.createRecentMentions(mentions);
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
return this.contentRepo.deleteRecentMention(mention);
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllRecentMentions(userId);
|
||||
}
|
||||
|
||||
async listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>> {
|
||||
return this.contentRepo.listSavedMessages(userId, limit, before);
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
return this.contentRepo.createSavedMessage(userId, channelId, messageId);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
return this.contentRepo.deleteSavedMessage(userId, messageId);
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllSavedMessages(userId);
|
||||
}
|
||||
|
||||
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
|
||||
return this.contentRepo.listBetaCodes(creatorId);
|
||||
}
|
||||
|
||||
async getBetaCode(code: string): Promise<BetaCode | null> {
|
||||
return this.contentRepo.getBetaCode(code);
|
||||
}
|
||||
|
||||
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
|
||||
return this.contentRepo.upsertBetaCode(betaCode);
|
||||
}
|
||||
|
||||
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
|
||||
return this.contentRepo.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
|
||||
}
|
||||
|
||||
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteBetaCode(code, creatorId);
|
||||
}
|
||||
|
||||
async deleteAllBetaCodes(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllBetaCodes(userId);
|
||||
}
|
||||
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
return this.contentRepo.createGiftCode(data);
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
return this.contentRepo.findGiftCode(code);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
return this.contentRepo.findGiftCodeByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
return this.contentRepo.findGiftCodesByCreator(userId);
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
return this.contentRepo.redeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.contentRepo.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
return this.contentRepo.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return this.contentRepo.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
return this.contentRepo.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return this.contentRepo.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
return this.contentRepo.getBulkPushSubscriptions(userIds);
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
return this.contentRepo.createPayment(data);
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
return this.contentRepo.updatePayment(data);
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
return this.contentRepo.getSubscriptionInfo(subscriptionId);
|
||||
}
|
||||
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
return this.contentRepo.listVisionarySlots();
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
return this.contentRepo.expandVisionarySlots(byCount);
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
return this.contentRepo.shrinkVisionarySlots(toCount);
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.contentRepo.reserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.contentRepo.unreserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
}
|
||||
117
fluxer_api/src/user/repositories/UserSettingsRepository.ts
Normal file
117
fluxer_api/src/user/repositories/UserSettingsRepository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import {buildPatchFromData, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
|
||||
import {USER_GUILD_SETTINGS_COLUMNS, USER_SETTINGS_COLUMNS} from '~/database/types/UserTypes';
|
||||
import {UserGuildSettings, UserSettings} from '~/Models';
|
||||
import {UserGuildSettings as UserGuildSettingsTable, UserSettings as UserSettingsTable} from '~/Tables';
|
||||
import type {IUserSettingsRepository} from './IUserSettingsRepository';
|
||||
|
||||
const FETCH_USER_SETTINGS_CQL = UserSettingsTable.selectCql({
|
||||
where: UserSettingsTable.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
|
||||
where: [UserGuildSettingsTable.where.eq('user_id'), UserGuildSettingsTable.where.eq('guild_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_ALL_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
|
||||
where: UserGuildSettingsTable.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class UserSettingsRepository implements IUserSettingsRepository {
|
||||
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
UserGuildSettingsTable.deleteCql({
|
||||
where: UserGuildSettingsTable.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
UserGuildSettingsTable.deleteByPk({
|
||||
user_id: userId,
|
||||
guild_id: guildId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteUserSettings(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(UserSettingsTable.deleteByPk({user_id: userId}));
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
const settings = await fetchOne<UserGuildSettingsRow>(FETCH_USER_GUILD_SETTINGS_CQL, {
|
||||
user_id: userId,
|
||||
guild_id: guildId ? guildId : 0n,
|
||||
});
|
||||
return settings ? new UserGuildSettings(settings) : null;
|
||||
}
|
||||
|
||||
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
|
||||
const rows = await fetchMany<UserGuildSettingsRow>(FETCH_ALL_USER_GUILD_SETTINGS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.map((row) => new UserGuildSettings(row));
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings | null> {
|
||||
const settings = await fetchOne<UserSettingsRow>(FETCH_USER_SETTINGS_CQL, {user_id: userId});
|
||||
return settings ? new UserSettings(settings) : null;
|
||||
}
|
||||
|
||||
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
|
||||
const userId = settings.user_id;
|
||||
const guildId = settings.guild_id;
|
||||
|
||||
const result = await executeVersionedUpdate<UserGuildSettingsRow, 'user_id' | 'guild_id'>(
|
||||
() => this.findGuildSettings(userId, guildId).then((s) => s?.toRow() ?? null),
|
||||
(current) => ({
|
||||
pk: {user_id: userId, guild_id: guildId},
|
||||
patch: buildPatchFromData(settings, current, USER_GUILD_SETTINGS_COLUMNS, ['user_id', 'guild_id']),
|
||||
}),
|
||||
UserGuildSettingsTable,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return new UserGuildSettings({...settings, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
|
||||
const userId = settings.user_id;
|
||||
|
||||
const result = await executeVersionedUpdate<UserSettingsRow, 'user_id'>(
|
||||
() => this.findSettings(userId).then((s) => s?.toRow() ?? null),
|
||||
(current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch: buildPatchFromData(settings, current, USER_SETTINGS_COLUMNS, ['user_id']),
|
||||
}),
|
||||
UserSettingsTable,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return new UserSettings({...settings, version: result.finalVersion ?? 1});
|
||||
}
|
||||
}
|
||||
114
fluxer_api/src/user/repositories/VisionarySlotRepository.ts
Normal file
114
fluxer_api/src/user/repositories/VisionarySlotRepository.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {VisionarySlotRow} from '~/database/CassandraTypes';
|
||||
import {CannotShrinkReservedSlotsError} from '~/Errors';
|
||||
import {VisionarySlot} from '~/Models';
|
||||
import {VisionarySlots} from '~/Tables';
|
||||
|
||||
const FETCH_ALL_VISIONARY_SLOTS_QUERY = VisionarySlots.selectCql();
|
||||
|
||||
const FETCH_VISIONARY_SLOT_QUERY = VisionarySlots.selectCql({
|
||||
where: VisionarySlots.where.eq('slot_index'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class VisionarySlotRepository {
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
const slots = await fetchMany<VisionarySlotRow>(FETCH_ALL_VISIONARY_SLOTS_QUERY, {});
|
||||
return slots.map((slot) => new VisionarySlot(slot));
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
const existingSlots = await this.listVisionarySlots();
|
||||
const maxSlotIndex = existingSlots.length > 0 ? Math.max(...existingSlots.map((s) => s.slotIndex)) : -1;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (let i = 1; i <= byCount; i++) {
|
||||
const newSlotIndex = maxSlotIndex + i;
|
||||
batch.addPrepared(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: newSlotIndex,
|
||||
user_id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
const existingSlots = await this.listVisionarySlots();
|
||||
if (existingSlots.length <= toCount) return;
|
||||
|
||||
const sortedSlots = existingSlots.sort((a, b) => b.slotIndex - a.slotIndex);
|
||||
const slotsToRemove = sortedSlots.slice(0, existingSlots.length - toCount);
|
||||
|
||||
const reservedSlots = slotsToRemove.filter((slot) => slot.userId !== null);
|
||||
if (reservedSlots.length > 0) {
|
||||
throw new CannotShrinkReservedSlotsError(reservedSlots.map((s) => s.slotIndex));
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const slot of slotsToRemove) {
|
||||
batch.addPrepared(VisionarySlots.deleteByPk({slot_index: slot.slotIndex}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
|
||||
slot_index: slotIndex,
|
||||
});
|
||||
|
||||
if (!existingSlot) {
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
|
||||
slot_index: slotIndex,
|
||||
});
|
||||
|
||||
if (!existingSlot || existingSlot.user_id !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Db} from '~/database/Cassandra';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import {User} from '~/Models';
|
||||
import {UserDataRepository} from './crud/UserDataRepository';
|
||||
import {UserIndexRepository} from './crud/UserIndexRepository';
|
||||
import {UserSearchRepository} from './crud/UserSearchRepository';
|
||||
|
||||
export class UserAccountRepository {
|
||||
private dataRepo: UserDataRepository;
|
||||
private indexRepo: UserIndexRepository;
|
||||
private searchRepo: UserSearchRepository;
|
||||
|
||||
constructor() {
|
||||
this.dataRepo = new UserDataRepository();
|
||||
this.indexRepo = new UserIndexRepository();
|
||||
this.searchRepo = new UserSearchRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.upsert(data);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.dataRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.dataRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.dataRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.dataRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
const result = await this.dataRepo.upsertUserRow(data, oldData);
|
||||
|
||||
if (result.finalVersion === null) {
|
||||
throw new Error(`Failed to update user ${data.user_id} after max retries due to concurrent updates`);
|
||||
}
|
||||
|
||||
await this.indexRepo.syncIndices(data, oldData);
|
||||
const user = new User(data);
|
||||
await this.searchRepo.indexUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
|
||||
if (!oldData) {
|
||||
const existingUser = await this.findUnique(userId);
|
||||
if (!existingUser) return null;
|
||||
oldData = existingUser.toRow();
|
||||
}
|
||||
|
||||
const newData: UserRow = {...oldData, ...patchData, user_id: userId};
|
||||
|
||||
const userPatch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
|
||||
for (const [key, value] of Object.entries(patchData)) {
|
||||
if (key === 'user_id') continue;
|
||||
if (value === undefined) continue;
|
||||
|
||||
const userRowKey = key as keyof UserRow;
|
||||
|
||||
if (value === null) {
|
||||
const oldVal = oldData?.[userRowKey];
|
||||
if (oldVal !== null && oldVal !== undefined) {
|
||||
userPatch[key] = Db.clear();
|
||||
}
|
||||
} else {
|
||||
userPatch[key] = Db.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.dataRepo.patchUser(userId, userPatch);
|
||||
|
||||
if (result.finalVersion === null) {
|
||||
throw new Error(`Failed to patch user ${userId} after max retries due to concurrent updates`);
|
||||
}
|
||||
|
||||
await this.indexRepo.syncIndices(newData, oldData);
|
||||
|
||||
const user = new User(newData);
|
||||
await this.searchRepo.indexUser(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
const user = await this.findUnique(userId);
|
||||
if (!user) return;
|
||||
|
||||
await this.indexRepo.deleteIndices(
|
||||
userId,
|
||||
user.username,
|
||||
user.discriminator,
|
||||
user.email,
|
||||
user.phone,
|
||||
user.stripeSubscriptionId,
|
||||
);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
await this.dataRepo.updateLastActiveAt(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
|
||||
import type {User} from '~/Models';
|
||||
import {UsersPendingDeletion} from '~/Tables';
|
||||
|
||||
const FETCH_USERS_PENDING_DELETION_BY_DATE_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id', 'pending_deletion_at'],
|
||||
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.lte('pending_deletion_at', 'now')],
|
||||
});
|
||||
|
||||
const FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id', 'deletion_reason_code'],
|
||||
where: UsersPendingDeletion.where.eq('deletion_date'),
|
||||
});
|
||||
|
||||
const FETCH_USER_PENDING_DELETION_CHECK_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id'],
|
||||
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.eq('user_id')],
|
||||
});
|
||||
|
||||
export class UserDeletionRepository {
|
||||
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
|
||||
|
||||
await upsertOne(
|
||||
UsersPendingDeletion.upsertAll({
|
||||
deletion_date: deletionDate,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
user_id: userId,
|
||||
deletion_reason_code: deletionReasonCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
const userIds = new Set<bigint>();
|
||||
|
||||
const startDate = new Date(now);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
|
||||
const currentDate = new Date(startDate);
|
||||
while (currentDate <= now) {
|
||||
const deletionDate = currentDate.toISOString().split('T')[0];
|
||||
|
||||
const rows = await fetchMany<{user_id: bigint; pending_deletion_at: Date}>(
|
||||
FETCH_USERS_PENDING_DELETION_BY_DATE_CQL,
|
||||
{
|
||||
deletion_date: deletionDate,
|
||||
now,
|
||||
},
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
userIds.add(row.user_id);
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const users: Array<User> = [];
|
||||
for (const userId of userIds) {
|
||||
const user = await this.findUniqueUser(createUserID(userId));
|
||||
if (user?.pendingDeletionAt && user.pendingDeletionAt <= now) {
|
||||
users.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
const rows = await fetchMany<{user_id: bigint; deletion_reason_code: number}>(
|
||||
FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL,
|
||||
{deletion_date: deletionDate},
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
const rows = await fetchMany<{user_id: bigint}>(FETCH_USER_PENDING_DELETION_CHECK_CQL, {
|
||||
deletion_date: deletionDate,
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
|
||||
|
||||
await deleteOneOrMany(
|
||||
UsersPendingDeletion.deleteByPk({
|
||||
deletion_date: deletionDate,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany} from '~/database/Cassandra';
|
||||
import type {GuildMemberByUserIdRow} from '~/database/CassandraTypes';
|
||||
import {GuildMembers, GuildMembersByUserId} from '~/Tables';
|
||||
|
||||
const FETCH_GUILD_MEMBERS_BY_USER_CQL = GuildMembersByUserId.selectCql({
|
||||
where: GuildMembersByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class UserGuildRepository {
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return guilds.map((g) => g.guild_id);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const guild of guilds) {
|
||||
batch.addPrepared(
|
||||
GuildMembers.deleteByPk({
|
||||
guild_id: guild.guild_id,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildMembersByUserId.deleteByPk({
|
||||
user_id: userId,
|
||||
guild_id: guild.guild_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
117
fluxer_api/src/user/repositories/account/UserLookupRepository.ts
Normal file
117
fluxer_api/src/user/repositories/account/UserLookupRepository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {
|
||||
UserByEmailRow,
|
||||
UserByPhoneRow,
|
||||
UserByStripeCustomerIdRow,
|
||||
UserByStripeSubscriptionIdRow,
|
||||
UserByUsernameRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {User} from '~/Models';
|
||||
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/Tables';
|
||||
|
||||
const FETCH_DISCRIMINATORS_BY_USERNAME_QUERY = UserByUsername.select({
|
||||
columns: ['discriminator', 'user_id'],
|
||||
where: UserByUsername.where.eq('username'),
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_EMAIL_QUERY = UserByEmail.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByEmail.where.eq('email_lower'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_PHONE_QUERY = UserByPhone.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByPhone.where.eq('phone'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY = UserByStripeCustomerId.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByStripeCustomerId.where.eq('stripe_customer_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY = UserByStripeSubscriptionId.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByStripeSubscriptionId.where.eq('stripe_subscription_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY = UserByUsername.select({
|
||||
columns: ['user_id'],
|
||||
where: [UserByUsername.where.eq('username'), UserByUsername.where.eq('discriminator')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class UserLookupRepository {
|
||||
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const emailLower = email.toLowerCase();
|
||||
const result = await fetchOne<Pick<UserByEmailRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_EMAIL_QUERY.bind({email_lower: emailLower}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByPhoneRow, 'user_id'>>(FETCH_USER_ID_BY_PHONE_QUERY.bind({phone}));
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByStripeCustomerIdRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY.bind({stripe_customer_id: stripeCustomerId}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByStripeSubscriptionIdRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY.bind({stripe_subscription_id: stripeSubscriptionId}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
const result = await fetchOne<Pick<UserByUsernameRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY.bind({username: usernameLower, discriminator}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
const result = await fetchMany<Pick<UserByUsernameRow, 'discriminator'>>(
|
||||
FETCH_DISCRIMINATORS_BY_USERNAME_QUERY.bind({username: usernameLower}),
|
||||
);
|
||||
return new Set(result.map((r) => r.discriminator));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {buildPatchFromData, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import {EMPTY_USER_ROW, USER_COLUMNS, type UserRow} from '~/database/CassandraTypes';
|
||||
import {User} from '~/Models';
|
||||
import {Users} from '~/Tables';
|
||||
|
||||
const FETCH_USERS_BY_IDS_CQL = Users.selectCql({
|
||||
where: Users.where.in('user_id', 'user_ids'),
|
||||
});
|
||||
|
||||
const FETCH_USER_BY_ID_CQL = Users.selectCql({
|
||||
where: Users.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchAllUsersFirstPageCql = (limit: number) => Users.selectCql({limit});
|
||||
|
||||
const createFetchAllUsersPaginatedCql = (limit: number) =>
|
||||
Users.selectCql({
|
||||
where: Users.where.tokenGt('user_id', 'last_user_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
type UserPatch = Partial<{
|
||||
[K in Exclude<keyof UserRow, 'user_id'> & string]: import('~/database/Cassandra').DbOp<UserRow[K]>;
|
||||
}>;
|
||||
|
||||
export class UserDataRepository {
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
if (userId === 0n) {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(0n),
|
||||
username: 'Fluxer',
|
||||
discriminator: 0,
|
||||
bot: true,
|
||||
system: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (userId === 1n) {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(1n),
|
||||
username: 'Deleted User',
|
||||
discriminator: 0,
|
||||
bot: false,
|
||||
system: true,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
|
||||
return user ? new User(user) : null;
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return (await this.findUnique(userId))!;
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
let users: Array<UserRow>;
|
||||
|
||||
if (lastUserId) {
|
||||
const cql = createFetchAllUsersPaginatedCql(limit);
|
||||
users = await fetchMany<UserRow>(cql, {last_user_id: lastUserId});
|
||||
} else {
|
||||
const cql = createFetchAllUsersFirstPageCql(limit);
|
||||
users = await fetchMany<UserRow>(cql, {});
|
||||
}
|
||||
|
||||
return users.map((user) => new User(user));
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
if (userIds.length === 0) return [];
|
||||
const users = await fetchMany<UserRow>(FETCH_USERS_BY_IDS_CQL, {user_ids: userIds});
|
||||
return users.map((user) => new User(user));
|
||||
}
|
||||
|
||||
async upsertUserRow(data: UserRow, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
|
||||
const userId = data.user_id;
|
||||
let isFirstAttempt = true;
|
||||
|
||||
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
|
||||
async () => {
|
||||
if (isFirstAttempt && oldData !== undefined) {
|
||||
isFirstAttempt = false;
|
||||
return oldData;
|
||||
}
|
||||
isFirstAttempt = false;
|
||||
const user = await this.findUnique(userId);
|
||||
return user?.toRow() ?? null;
|
||||
},
|
||||
(current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch: buildPatchFromData(data, current, USER_COLUMNS, ['user_id']),
|
||||
}),
|
||||
Users,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return {finalVersion: result.finalVersion};
|
||||
}
|
||||
|
||||
async patchUser(userId: UserID, patch: UserPatch): Promise<{finalVersion: number | null}> {
|
||||
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
|
||||
async () => {
|
||||
const user = await this.findUnique(userId);
|
||||
return user?.toRow() ?? null;
|
||||
},
|
||||
(_current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch,
|
||||
}),
|
||||
Users,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return {finalVersion: result.finalVersion};
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
const {userId, lastActiveAt, lastActiveIp} = params;
|
||||
|
||||
const patch: UserPatch = {
|
||||
last_active_at: Db.set(lastActiveAt),
|
||||
...(lastActiveIp !== undefined ? {last_active_ip: Db.set(lastActiveIp)} : {}),
|
||||
};
|
||||
|
||||
await this.patchUser(userId, patch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder} from '~/database/Cassandra';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/Tables';
|
||||
|
||||
export class UserIndexRepository {
|
||||
async syncIndices(data: UserRow, oldData?: UserRow | null): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (!!data.username && data.discriminator != null && data.discriminator !== undefined) {
|
||||
batch.addPrepared(
|
||||
UserByUsername.upsertAll({
|
||||
username: data.username.toLowerCase(),
|
||||
discriminator: data.discriminator,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.username && oldData.discriminator != null && oldData.discriminator !== undefined) {
|
||||
if (
|
||||
oldData.username.toLowerCase() !== data.username?.toLowerCase() ||
|
||||
oldData.discriminator !== data.discriminator
|
||||
) {
|
||||
batch.addPrepared(
|
||||
UserByUsername.deleteByPk({
|
||||
username: oldData.username.toLowerCase(),
|
||||
discriminator: oldData.discriminator,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.upsertAll({
|
||||
email_lower: data.email.toLowerCase(),
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.email && oldData.email.toLowerCase() !== data.email?.toLowerCase()) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.deleteByPk({
|
||||
email_lower: oldData.email.toLowerCase(),
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.upsertAll({
|
||||
phone: data.phone,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.phone && oldData.phone !== data.phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.deleteByPk({
|
||||
phone: oldData.phone,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.stripe_subscription_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.upsertAll({
|
||||
stripe_subscription_id: data.stripe_subscription_id,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.stripe_subscription_id && oldData.stripe_subscription_id !== data.stripe_subscription_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.deleteByPk({
|
||||
stripe_subscription_id: oldData.stripe_subscription_id,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.stripe_customer_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeCustomerId.upsertAll({
|
||||
stripe_customer_id: data.stripe_customer_id,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.stripe_customer_id && oldData.stripe_customer_id !== data.stripe_customer_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeCustomerId.deleteByPk({
|
||||
stripe_customer_id: oldData.stripe_customer_id,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteIndices(
|
||||
userId: UserID,
|
||||
username: string,
|
||||
discriminator: number,
|
||||
email?: string | null,
|
||||
phone?: string | null,
|
||||
stripeSubscriptionId?: string | null,
|
||||
): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
UserByUsername.deleteByPk({
|
||||
username: username.toLowerCase(),
|
||||
discriminator: discriminator,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (email) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.deleteByPk({
|
||||
email_lower: email.toLowerCase(),
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.deleteByPk({
|
||||
phone: phone,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (stripeSubscriptionId) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.deleteByPk({
|
||||
stripe_subscription_id: stripeSubscriptionId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '~/Logger';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {User} from '~/Models';
|
||||
|
||||
export class UserSearchRepository {
|
||||
async indexUser(user: User): Promise<void> {
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService) {
|
||||
await userSearchService.indexUser(user).catch((error) => {
|
||||
Logger.error({userId: user.id, error}, 'Failed to index user in search');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
135
fluxer_api/src/user/repositories/auth/AuthSessionRepository.ts
Normal file
135
fluxer_api/src/user/repositories/auth/AuthSessionRepository.ts
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 type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {AuthSessionRow} from '~/database/CassandraTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import {AuthSession} from '~/Models';
|
||||
import {AuthSessions, AuthSessionsByUserId} from '~/Tables';
|
||||
|
||||
const FETCH_AUTH_SESSIONS_CQL = AuthSessions.selectCql({
|
||||
where: AuthSessions.where.in('session_id_hash', 'session_id_hashes'),
|
||||
});
|
||||
|
||||
const FETCH_AUTH_SESSION_BY_TOKEN_CQL = AuthSessions.selectCql({
|
||||
where: AuthSessions.where.eq('session_id_hash'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL = AuthSessionsByUserId.selectCql({
|
||||
columns: ['session_id_hash'],
|
||||
where: AuthSessionsByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class AuthSessionRepository {
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
const sessionResult = await executeConditional(AuthSessions.insertIfNotExists(sessionData));
|
||||
|
||||
if (!sessionResult.applied) {
|
||||
const existingSession = await this.getAuthSessionByToken(sessionData.session_id_hash);
|
||||
if (!existingSession) {
|
||||
Logger.error(
|
||||
{sessionIdHash: sessionData.session_id_hash},
|
||||
'Failed to create or retrieve existing auth session',
|
||||
);
|
||||
throw new Error('Failed to create or retrieve existing auth session');
|
||||
}
|
||||
Logger.debug(
|
||||
{sessionIdHash: sessionData.session_id_hash},
|
||||
'Auth session already exists, returning existing session',
|
||||
);
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertOne(
|
||||
AuthSessionsByUserId.insert({
|
||||
user_id: sessionData.user_id,
|
||||
session_id_hash: sessionData.session_id_hash,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error({sessionIdHash: sessionData.session_id_hash, error}, 'Failed to create AuthSessionsByUserId entry');
|
||||
}
|
||||
|
||||
return new AuthSession(sessionData);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
const session = await fetchOne<AuthSessionRow>(FETCH_AUTH_SESSION_BY_TOKEN_CQL, {session_id_hash: sessionIdHash});
|
||||
return session ? new AuthSession(session) : null;
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
const sessionHashes = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (sessionHashes.length === 0) return [];
|
||||
const sessions = await fetchMany<AuthSessionRow>(FETCH_AUTH_SESSIONS_CQL, {
|
||||
session_id_hashes: sessionHashes.map((s) => s.session_id_hash),
|
||||
});
|
||||
return sessions.map((session) => new AuthSession(session));
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
await upsertOne(
|
||||
AuthSessions.patchByPk(
|
||||
{session_id_hash: sessionIdHash},
|
||||
{
|
||||
approx_last_used_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
for (const sessionIdHash of sessionIdHashes) {
|
||||
batch.addPrepared(AuthSessions.deleteByPk({session_id_hash: sessionIdHash}));
|
||||
batch.addPrepared(AuthSessionsByUserId.deleteByPk({user_id: userId, session_id_hash: sessionIdHash}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
const sessions = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const session of sessions) {
|
||||
batch.addPrepared(
|
||||
AuthSessions.deleteByPk({
|
||||
session_id_hash: session.session_id_hash,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AuthSessionsByUserId.deleteByPk({
|
||||
user_id: userId,
|
||||
session_id_hash: session.session_id_hash,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {deleteOneOrMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {EmailChangeTicketRow, EmailChangeTokenRow} from '~/database/CassandraTypes';
|
||||
import {EmailChangeTickets, EmailChangeTokens} from '~/Tables';
|
||||
|
||||
const FETCH_TICKET_CQL = EmailChangeTickets.selectCql({
|
||||
where: EmailChangeTickets.where.eq('ticket'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_TOKEN_CQL = EmailChangeTokens.selectCql({
|
||||
where: EmailChangeTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class EmailChangeRepository {
|
||||
async createTicket(row: EmailChangeTicketRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTickets.insert(row));
|
||||
}
|
||||
|
||||
async updateTicket(row: EmailChangeTicketRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTickets.upsertAll(row));
|
||||
}
|
||||
|
||||
async findTicket(ticket: string): Promise<EmailChangeTicketRow | null> {
|
||||
return await fetchOne<EmailChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
|
||||
}
|
||||
|
||||
async deleteTicket(ticket: string): Promise<void> {
|
||||
await deleteOneOrMany(EmailChangeTickets.deleteByPk({ticket}));
|
||||
}
|
||||
|
||||
async createToken(row: EmailChangeTokenRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTokens.insert(row));
|
||||
}
|
||||
|
||||
async findToken(token: string): Promise<EmailChangeTokenRow | null> {
|
||||
return await fetchOne<EmailChangeTokenRow>(FETCH_TOKEN_CQL, {token_: token});
|
||||
}
|
||||
|
||||
async deleteToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(EmailChangeTokens.deleteByPk({token_: token}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createIpAuthorizationToken, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {AuthorizedIpRow, IpAuthorizationTokenRow} from '~/database/CassandraTypes';
|
||||
import {AuthorizedIps, IpAuthorizationTokens, Users} from '~/Tables';
|
||||
import type {IUserAccountRepository} from '../IUserAccountRepository';
|
||||
|
||||
export {IpAuthorizationTokens};
|
||||
|
||||
const AUTHORIZE_IP_BY_TOKEN_CQL = IpAuthorizationTokens.selectCql({
|
||||
where: IpAuthorizationTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const CHECK_IP_AUTHORIZED_CQL = AuthorizedIps.selectCql({
|
||||
where: [AuthorizedIps.where.eq('user_id'), AuthorizedIps.where.eq('ip')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const GET_AUTHORIZED_IPS_CQL = AuthorizedIps.selectCql({
|
||||
where: AuthorizedIps.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class IpAuthorizationRepository {
|
||||
constructor(private userAccountRepository: IUserAccountRepository) {}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
const result = await fetchOne<AuthorizedIpRow>(CHECK_IP_AUTHORIZED_CQL, {
|
||||
user_id: userId,
|
||||
ip,
|
||||
});
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
await upsertOne(AuthorizedIps.insert({user_id: userId, ip}));
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
await upsertOne(
|
||||
IpAuthorizationTokens.insert({
|
||||
token_: createIpAuthorizationToken(token),
|
||||
user_id: userId,
|
||||
email: user!.email!,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
const result = await fetchOne<IpAuthorizationTokenRow>(AUTHORIZE_IP_BY_TOKEN_CQL, {token_: token});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await deleteOneOrMany(
|
||||
IpAuthorizationTokens.deleteByPk({
|
||||
token_: createIpAuthorizationToken(token),
|
||||
user_id: result.user_id,
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await this.userAccountRepository.findUnique(result.user_id);
|
||||
if (!user || user.flags & UserFlags.DELETED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {userId: result.user_id, email: result.email};
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await upsertOne(
|
||||
Users.patchByPk(
|
||||
{user_id: userId},
|
||||
{
|
||||
last_active_at: Db.set(now),
|
||||
last_active_ip: Db.set(clientIp),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
|
||||
return ips.map((row) => ({ip: row.ip}));
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
|
||||
|
||||
for (const row of ips) {
|
||||
await deleteOneOrMany(
|
||||
AuthorizedIps.deleteByPk({
|
||||
user_id: userId,
|
||||
ip: row.ip,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMfaBackupCode, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
|
||||
import type {MfaBackupCodeRow} from '~/database/CassandraTypes';
|
||||
import {MfaBackupCode} from '~/Models';
|
||||
import {MfaBackupCodes} from '~/Tables';
|
||||
|
||||
const FETCH_MFA_BACKUP_CODES_CQL = MfaBackupCodes.selectCql({
|
||||
where: MfaBackupCodes.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class MfaBackupCodeRepository {
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
const codes = await fetchMany<MfaBackupCodeRow>(FETCH_MFA_BACKUP_CODES_CQL, {user_id: userId});
|
||||
return codes.map((code) => new MfaBackupCode(code));
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
const batch = new BatchBuilder();
|
||||
const backupCodes: Array<MfaBackupCode> = [];
|
||||
for (const code of codes) {
|
||||
const codeRow: MfaBackupCodeRow = {user_id: userId, code: createMfaBackupCode(code), consumed: false};
|
||||
batch.addPrepared(MfaBackupCodes.insert(codeRow));
|
||||
backupCodes.push(new MfaBackupCode(codeRow));
|
||||
}
|
||||
await batch.execute();
|
||||
return backupCodes;
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
const codes = await this.listMfaBackupCodes(userId);
|
||||
if (codes.length === 0) return;
|
||||
const batch = new BatchBuilder();
|
||||
for (const code of codes) {
|
||||
batch.addPrepared(MfaBackupCodes.deleteByPk({user_id: userId, code: createMfaBackupCode(code.code)}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
await upsertOne(
|
||||
MfaBackupCodes.patchByPk(
|
||||
{user_id: userId, code: createMfaBackupCode(code)},
|
||||
{
|
||||
consumed: Db.set(true),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(MfaBackupCodes.deleteCql({where: MfaBackupCodes.where.eq('user_id')}), {user_id: userId});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, executeConditional, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import {PendingVerifications, PendingVerificationsByTime} from '~/Tables';
|
||||
|
||||
const FETCH_PENDING_VERIFICATION_CQL = PendingVerifications.selectCql({
|
||||
where: PendingVerifications.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class PendingVerificationRepository {
|
||||
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
|
||||
const verificationResult = await executeConditional(
|
||||
PendingVerifications.insertIfNotExists({
|
||||
user_id: userId,
|
||||
created_at: createdAt,
|
||||
version: 1,
|
||||
metadata,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!verificationResult.applied) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
PendingVerificationsByTime.insert({
|
||||
created_at: createdAt,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deletePendingVerification(userId: UserID): Promise<void> {
|
||||
const pending = await fetchOne<{user_id: bigint; created_at: Date}>(FETCH_PENDING_VERIFICATION_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (!pending) return;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(PendingVerifications.deleteByPk({user_id: userId}));
|
||||
batch.addPrepared(
|
||||
PendingVerificationsByTime.deleteByPk({
|
||||
created_at: pending.created_at,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
141
fluxer_api/src/user/repositories/auth/TokenRepository.ts
Normal file
141
fluxer_api/src/user/repositories/auth/TokenRepository.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createEmailRevertToken,
|
||||
createEmailVerificationToken,
|
||||
createPasswordResetToken,
|
||||
type PhoneVerificationToken,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import {EmailRevertToken, EmailVerificationToken, PasswordResetToken} from '~/Models';
|
||||
import {EmailRevertTokens, EmailVerificationTokens, PasswordResetTokens, PhoneTokens} from '~/Tables';
|
||||
|
||||
const FETCH_EMAIL_VERIFICATION_TOKEN_CQL = EmailVerificationTokens.selectCql({
|
||||
where: EmailVerificationTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PASSWORD_RESET_TOKEN_CQL = PasswordResetTokens.selectCql({
|
||||
where: PasswordResetTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_EMAIL_REVERT_TOKEN_CQL = EmailRevertTokens.selectCql({
|
||||
where: EmailRevertTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PHONE_TOKEN_CQL = PhoneTokens.selectCql({
|
||||
where: PhoneTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class TokenRepository {
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
const tokenRow = await fetchOne<EmailVerificationTokenRow>(FETCH_EMAIL_VERIFICATION_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new EmailVerificationToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
await upsertOne(EmailVerificationTokens.insert(tokenData));
|
||||
return new EmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
EmailVerificationTokens.deleteCql({
|
||||
where: EmailVerificationTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createEmailVerificationToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
const tokenRow = await fetchOne<PasswordResetTokenRow>(FETCH_PASSWORD_RESET_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new PasswordResetToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
await upsertOne(PasswordResetTokens.insert(tokenData));
|
||||
return new PasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PasswordResetTokens.deleteCql({
|
||||
where: PasswordResetTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createPasswordResetToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
const tokenRow = await fetchOne<EmailRevertTokenRow>(FETCH_EMAIL_REVERT_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new EmailRevertToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
await upsertOne(EmailRevertTokens.insert(tokenData));
|
||||
return new EmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
EmailRevertTokens.deleteCql({
|
||||
where: EmailRevertTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createEmailRevertToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
const TTL = 900;
|
||||
await upsertOne(
|
||||
PhoneTokens.insertWithTtl(
|
||||
{
|
||||
token_: token,
|
||||
phone,
|
||||
user_id: userId,
|
||||
},
|
||||
TTL,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return await fetchOne<PhoneTokenRow>(FETCH_PHONE_TOKEN_CQL, {token_: token});
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PhoneTokens.deleteCql({
|
||||
where: PhoneTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: token},
|
||||
);
|
||||
}
|
||||
}
|
||||
171
fluxer_api/src/user/repositories/auth/WebAuthnRepository.ts
Normal file
171
fluxer_api/src/user/repositories/auth/WebAuthnRepository.ts
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 '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {WebAuthnCredentialRow} from '~/database/CassandraTypes';
|
||||
import {WebAuthnCredential} from '~/Models';
|
||||
import {WebAuthnCredentialLookup, WebAuthnCredentials} from '~/Tables';
|
||||
|
||||
const FETCH_USER_ID_BY_CREDENTIAL_ID_CQL = WebAuthnCredentialLookup.selectCql({
|
||||
where: WebAuthnCredentialLookup.where.eq('credential_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIALS_CQL = WebAuthnCredentials.selectCql({
|
||||
where: WebAuthnCredentials.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIAL_CQL = WebAuthnCredentials.selectCql({
|
||||
where: [WebAuthnCredentials.where.eq('user_id'), WebAuthnCredentials.where.eq('credential_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
|
||||
columns: ['credential_id'],
|
||||
where: WebAuthnCredentials.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class WebAuthnRepository {
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
|
||||
return credentials.map((cred) => new WebAuthnCredential(cred));
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
const cred = await fetchOne<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIAL_CQL, {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
});
|
||||
|
||||
if (!cred) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WebAuthnCredential(cred);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const credentialData = {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
public_key: publicKey,
|
||||
counter: counter,
|
||||
transports: transports,
|
||||
name: name,
|
||||
created_at: new Date(),
|
||||
last_used_at: null,
|
||||
version: 1 as const,
|
||||
};
|
||||
|
||||
await upsertOne(WebAuthnCredentials.insert(credentialData));
|
||||
|
||||
await upsertOne(
|
||||
WebAuthnCredentialLookup.insert({
|
||||
credential_id: credentialId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
counter: Db.set(counter),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
last_used_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
name: Db.set(name),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentialLookup.deleteByPk({
|
||||
credential_id: credentialId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
const row = await fetchOne<{credential_id: string; user_id: UserID}>(FETCH_USER_ID_BY_CREDENTIAL_ID_CQL, {
|
||||
credential_id: credentialId,
|
||||
});
|
||||
return row?.user_id ?? null;
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
const credentials = await fetchMany<{credential_id: string}>(FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const cred of credentials) {
|
||||
batch.addPrepared(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
user_id: userId,
|
||||
credential_id: cred.credential_id,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
WebAuthnCredentialLookup.deleteByPk({
|
||||
credential_id: cred.credential_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
49
fluxer_api/src/user/services/BaseUserUpdatePropagator.ts
Normal file
49
fluxer_api/src/user/services/BaseUserUpdatePropagator.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {User} from '~/Models';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import {invalidateUserCache} from '../UserCacheHelpers';
|
||||
|
||||
export interface BaseUserUpdatePropagatorDeps {
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class BaseUserUpdatePropagator {
|
||||
constructor(protected readonly baseDeps: BaseUserUpdatePropagatorDeps) {}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
await this.baseDeps.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
|
||||
async invalidateUserCache(userId: UserID): Promise<void> {
|
||||
await invalidateUserCache({
|
||||
userId,
|
||||
userCacheService: this.baseDeps.userCacheService,
|
||||
});
|
||||
}
|
||||
}
|
||||
96
fluxer_api/src/user/services/CustomStatusValidator.ts
Normal file
96
fluxer_api/src/user/services/CustomStatusValidator.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createEmojiID, type EmojiID, type UserID} from '~/BrandedTypes';
|
||||
import {InputValidationError} from '~/errors/InputValidationError';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {z} from '~/Schema';
|
||||
import type {IUserAccountRepository} from '~/user/repositories/IUserAccountRepository';
|
||||
import type {CustomStatusPayload} from '~/user/UserTypes';
|
||||
|
||||
export interface ValidatedCustomStatus {
|
||||
text: string | null;
|
||||
expiresAt: Date | null;
|
||||
emojiId: EmojiID | null;
|
||||
emojiName: string | null;
|
||||
emojiAnimated: boolean;
|
||||
}
|
||||
|
||||
export class CustomStatusValidator {
|
||||
constructor(
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
private readonly guildRepository: IGuildRepository,
|
||||
private readonly packService: PackService,
|
||||
) {}
|
||||
|
||||
async validate(userId: UserID, payload: z.infer<typeof CustomStatusPayload>): Promise<ValidatedCustomStatus> {
|
||||
const text = payload.text ?? null;
|
||||
const expiresAt = payload.expires_at ?? null;
|
||||
let emojiId: EmojiID | null = null;
|
||||
let emojiName: string | null = null;
|
||||
let emojiAnimated = false;
|
||||
|
||||
if (payload.emoji_id != null) {
|
||||
emojiId = createEmojiID(payload.emoji_id);
|
||||
|
||||
const emoji = await this.guildRepository.getEmojiById(emojiId);
|
||||
if (!emoji) {
|
||||
throw InputValidationError.create('custom_status.emoji_id', 'Custom emoji not found');
|
||||
}
|
||||
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user?.canUseGlobalExpressions()) {
|
||||
throw InputValidationError.create('custom_status.emoji_id', 'Premium required to use custom emoji');
|
||||
}
|
||||
|
||||
const guildMember = await this.guildRepository.getMember(emoji.guildId, userId);
|
||||
|
||||
let hasAccess = guildMember !== null;
|
||||
if (!hasAccess) {
|
||||
const resolver = await this.packService.createPackExpressionAccessResolver({
|
||||
userId,
|
||||
type: 'emoji',
|
||||
});
|
||||
const resolution = await resolver.resolve(emoji.guildId);
|
||||
hasAccess = resolution === 'accessible';
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
throw InputValidationError.create(
|
||||
'custom_status.emoji_id',
|
||||
'Cannot use this emoji without access to its guild or installed pack',
|
||||
);
|
||||
}
|
||||
|
||||
emojiName = emoji.name;
|
||||
emojiAnimated = emoji.isAnimated;
|
||||
} else if (payload.emoji_name != null) {
|
||||
emojiName = payload.emoji_name;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
expiresAt,
|
||||
emojiId,
|
||||
emojiName,
|
||||
emojiAnimated,
|
||||
};
|
||||
}
|
||||
}
|
||||
349
fluxer_api/src/user/services/EmailChangeService.ts
Normal file
349
fluxer_api/src/user/services/EmailChangeService.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {InputValidationError, RateLimitError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {User} from '~/Models';
|
||||
import type {EmailChangeRepository} from '../repositories/auth/EmailChangeRepository';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
|
||||
export interface StartEmailChangeResult {
|
||||
ticket: string;
|
||||
require_original: boolean;
|
||||
original_email?: string | null;
|
||||
original_proof?: string | null;
|
||||
original_code_expires_at?: string;
|
||||
resend_available_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VerifyOriginalResult {
|
||||
original_proof: string;
|
||||
}
|
||||
|
||||
export interface RequestNewEmailResult {
|
||||
ticket: string;
|
||||
new_email: string;
|
||||
new_code_expires_at: string;
|
||||
resend_available_at: string | null;
|
||||
}
|
||||
|
||||
export class EmailChangeService {
|
||||
private readonly ORIGINAL_CODE_TTL_MS = 10 * 60 * 1000;
|
||||
private readonly NEW_CODE_TTL_MS = 10 * 60 * 1000;
|
||||
private readonly TOKEN_TTL_MS = 30 * 60 * 1000;
|
||||
private readonly RESEND_COOLDOWN_MS = 30 * 1000;
|
||||
|
||||
constructor(
|
||||
private readonly repo: EmailChangeRepository,
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
private readonly rateLimitService: IRateLimitService,
|
||||
) {}
|
||||
|
||||
async start(user: User): Promise<StartEmailChangeResult> {
|
||||
const isUnclaimed = !user.passwordHash;
|
||||
const hasEmail = !!user.email;
|
||||
if (!hasEmail && !isUnclaimed) {
|
||||
throw InputValidationError.create('email', 'You must have an email to change it.');
|
||||
}
|
||||
|
||||
const ticket = this.generateTicket();
|
||||
const requireOriginal = !!user.emailVerified && hasEmail;
|
||||
const now = new Date();
|
||||
|
||||
let originalCode: string | null = null;
|
||||
let originalCodeExpiresAt: Date | null = null;
|
||||
let originalCodeSentAt: Date | null = null;
|
||||
|
||||
if (requireOriginal) {
|
||||
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, 15 * 60 * 1000);
|
||||
originalCode = this.generateCode();
|
||||
originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
|
||||
originalCodeSentAt = now;
|
||||
await this.emailService.sendEmailChangeOriginal(user.email!, user.username, originalCode, user.locale);
|
||||
}
|
||||
|
||||
const originalProof = requireOriginal ? null : this.generateProof();
|
||||
|
||||
await this.repo.createTicket({
|
||||
ticket,
|
||||
user_id: user.id,
|
||||
require_original: requireOriginal,
|
||||
original_email: user.email,
|
||||
original_verified: !requireOriginal,
|
||||
original_proof: originalProof,
|
||||
original_code: originalCode,
|
||||
original_code_sent_at: originalCodeSentAt,
|
||||
original_code_expires_at: originalCodeExpiresAt,
|
||||
new_email: null,
|
||||
new_code: null,
|
||||
new_code_sent_at: null,
|
||||
new_code_expires_at: null,
|
||||
status: requireOriginal ? 'pending_original' : 'pending_new',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ticket,
|
||||
require_original: requireOriginal,
|
||||
original_email: user.email,
|
||||
original_proof: originalProof,
|
||||
original_code_expires_at: originalCodeExpiresAt?.toISOString(),
|
||||
resend_available_at: requireOriginal ? new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
async resendOriginal(user: User, ticket: string): Promise<void> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.require_original || row.original_verified) {
|
||||
throw InputValidationError.create('ticket', 'Original email already verified.');
|
||||
}
|
||||
if (!row.original_email) {
|
||||
throw InputValidationError.create('ticket', 'No original email on record.');
|
||||
}
|
||||
|
||||
this.assertCooldown(row.original_code_sent_at);
|
||||
await this.ensureRateLimit(`email_change:orig:${user.id}`, 3, 15 * 60 * 1000);
|
||||
|
||||
const now = new Date();
|
||||
const originalCode = this.generateCode();
|
||||
const originalCodeExpiresAt = new Date(now.getTime() + this.ORIGINAL_CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendEmailChangeOriginal(row.original_email, user.username, originalCode, user.locale);
|
||||
|
||||
row.original_code = originalCode;
|
||||
row.original_code_sent_at = now;
|
||||
row.original_code_expires_at = originalCodeExpiresAt;
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
}
|
||||
|
||||
async verifyOriginal(user: User, ticket: string, code: string): Promise<VerifyOriginalResult> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.require_original) {
|
||||
throw InputValidationError.create('ticket', 'Original verification not required for this flow.');
|
||||
}
|
||||
if (row.original_verified && row.original_proof) {
|
||||
return {original_proof: row.original_proof};
|
||||
}
|
||||
if (!row.original_code || !row.original_code_expires_at) {
|
||||
throw InputValidationError.create('code', 'Verification code not issued.');
|
||||
}
|
||||
if (row.original_code_expires_at.getTime() < Date.now()) {
|
||||
throw InputValidationError.create('code', 'Verification code expired.');
|
||||
}
|
||||
if (row.original_code !== code.trim()) {
|
||||
throw InputValidationError.create('code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const originalProof = this.generateProof();
|
||||
row.original_verified = true;
|
||||
row.original_proof = originalProof;
|
||||
row.status = 'pending_new';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
|
||||
return {original_proof: originalProof};
|
||||
}
|
||||
|
||||
async requestNewEmail(
|
||||
user: User,
|
||||
ticket: string,
|
||||
newEmail: string,
|
||||
originalProof: string,
|
||||
): Promise<RequestNewEmailResult> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.original_verified || !row.original_proof) {
|
||||
throw InputValidationError.create('ticket', 'Original email must be verified first.');
|
||||
}
|
||||
if (row.original_proof !== originalProof) {
|
||||
throw InputValidationError.create('original_proof', 'Invalid proof token.');
|
||||
}
|
||||
const trimmedEmail = newEmail.trim();
|
||||
if (!trimmedEmail) {
|
||||
throw InputValidationError.create('new_email', 'Email is required.');
|
||||
}
|
||||
if (row.original_email && trimmedEmail.toLowerCase() === row.original_email.toLowerCase()) {
|
||||
throw InputValidationError.create('new_email', 'New email must be different.');
|
||||
}
|
||||
const existing = await this.userAccountRepository.findByEmail(trimmedEmail.toLowerCase());
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw InputValidationError.create('new_email', 'Email already in use.');
|
||||
}
|
||||
|
||||
this.assertCooldown(row.new_code_sent_at);
|
||||
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, 15 * 60 * 1000);
|
||||
|
||||
const now = new Date();
|
||||
const newCode = this.generateCode();
|
||||
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendEmailChangeNew(trimmedEmail, user.username, newCode, user.locale);
|
||||
|
||||
row.new_email = trimmedEmail;
|
||||
row.new_code = newCode;
|
||||
row.new_code_sent_at = now;
|
||||
row.new_code_expires_at = newCodeExpiresAt;
|
||||
row.status = 'pending_new';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
new_email: trimmedEmail,
|
||||
new_code_expires_at: newCodeExpiresAt.toISOString(),
|
||||
resend_available_at: new Date(now.getTime() + this.RESEND_COOLDOWN_MS).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async resendNew(user: User, ticket: string): Promise<void> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.new_email) {
|
||||
throw InputValidationError.create('ticket', 'No new email requested.');
|
||||
}
|
||||
this.assertCooldown(row.new_code_sent_at);
|
||||
await this.ensureRateLimit(`email_change:new:${user.id}`, 5, 15 * 60 * 1000);
|
||||
|
||||
const now = new Date();
|
||||
const newCode = this.generateCode();
|
||||
const newCodeExpiresAt = new Date(now.getTime() + this.NEW_CODE_TTL_MS);
|
||||
|
||||
await this.emailService.sendEmailChangeNew(row.new_email, user.username, newCode, user.locale);
|
||||
|
||||
row.new_code = newCode;
|
||||
row.new_code_sent_at = now;
|
||||
row.new_code_expires_at = newCodeExpiresAt;
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
}
|
||||
|
||||
async verifyNew(user: User, ticket: string, code: string, originalProof: string): Promise<string> {
|
||||
const row = await this.getTicketForUser(ticket, user.id);
|
||||
if (!row.original_verified || !row.original_proof) {
|
||||
throw InputValidationError.create('ticket', 'Original email must be verified first.');
|
||||
}
|
||||
if (row.original_proof !== originalProof) {
|
||||
throw InputValidationError.create('original_proof', 'Invalid proof token.');
|
||||
}
|
||||
if (!row.new_email || !row.new_code || !row.new_code_expires_at) {
|
||||
throw InputValidationError.create('code', 'Verification code not issued.');
|
||||
}
|
||||
if (row.new_code_expires_at.getTime() < Date.now()) {
|
||||
throw InputValidationError.create('code', 'Verification code expired.');
|
||||
}
|
||||
if (row.new_code !== code.trim()) {
|
||||
throw InputValidationError.create('code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const token = this.generateToken();
|
||||
const expiresAt = new Date(now.getTime() + this.TOKEN_TTL_MS);
|
||||
await this.repo.createToken({
|
||||
token_: token,
|
||||
user_id: user.id,
|
||||
new_email: row.new_email,
|
||||
expires_at: expiresAt,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
row.status = 'completed';
|
||||
row.updated_at = now;
|
||||
await this.repo.updateTicket(row);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async consumeToken(userId: bigint, token: string): Promise<string> {
|
||||
const row = await this.repo.findToken(token);
|
||||
if (!row || row.user_id !== userId) {
|
||||
throw InputValidationError.create('email_token', 'Invalid email token.');
|
||||
}
|
||||
if (row.expires_at.getTime() < Date.now()) {
|
||||
await this.repo.deleteToken(token).catch((error) => Logger.warn({error}, 'Failed to delete expired email token'));
|
||||
throw InputValidationError.create('email_token', 'Email token expired.');
|
||||
}
|
||||
await this.repo.deleteToken(token);
|
||||
return row.new_email;
|
||||
}
|
||||
|
||||
private async getTicketForUser(ticket: string, userId: bigint) {
|
||||
const row = await this.repo.findTicket(ticket);
|
||||
if (!row || row.user_id !== userId) {
|
||||
throw InputValidationError.create('ticket', 'Invalid or expired ticket.');
|
||||
}
|
||||
if (row.status === 'completed') {
|
||||
throw InputValidationError.create('ticket', 'Ticket already completed.');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private generateCode(): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let raw = '';
|
||||
while (raw.length < 8) {
|
||||
const byte = crypto.randomBytes(1)[0];
|
||||
const idx = byte % alphabet.length;
|
||||
raw += alphabet[idx];
|
||||
}
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}`;
|
||||
}
|
||||
|
||||
private generateTicket(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private generateToken(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private generateProof(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
private assertCooldown(sentAt: Date | null | undefined) {
|
||||
if (!sentAt) return;
|
||||
const nextAllowed = sentAt.getTime() + this.RESEND_COOLDOWN_MS;
|
||||
if (nextAllowed > Date.now()) {
|
||||
const retryAfter = Math.ceil((nextAllowed - Date.now()) / 1000);
|
||||
throw new RateLimitError({
|
||||
message: 'Please wait before resending.',
|
||||
retryAfter,
|
||||
limit: 1,
|
||||
resetTime: new Date(nextAllowed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRateLimit(identifier: string, maxAttempts: number, windowMs: number) {
|
||||
const result = await this.rateLimitService.checkLimit({identifier, maxAttempts, windowMs});
|
||||
if (!result.allowed) {
|
||||
throw new RateLimitError({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
retryAfter: result.retryAfter || 0,
|
||||
limit: maxAttempts,
|
||||
resetTime: new Date(Date.now() + (result.retryAfter || 0) * 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
106
fluxer_api/src/user/services/UserAccountLifecycleService.ts
Normal file
106
fluxer_api/src/user/services/UserAccountLifecycleService.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {DeletionReasons, UserFlags} from '~/Constants';
|
||||
import {UnknownUserError, UserOwnsGuildsError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
|
||||
import {hasPartialUserFieldsChanged} from '~/user/UserMappers';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
|
||||
|
||||
interface UserAccountLifecycleServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
redisDeletionQueue: RedisAccountDeletionQueueService;
|
||||
}
|
||||
|
||||
export class UserAccountLifecycleService {
|
||||
constructor(private readonly deps: UserAccountLifecycleServiceDeps) {}
|
||||
|
||||
async selfDisable(userId: UserID): Promise<void> {
|
||||
const user = await this.deps.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const ownedGuildIds = await this.deps.guildRepository.listOwnedGuildIds(userId);
|
||||
if (ownedGuildIds.length > 0) {
|
||||
throw new UserOwnsGuildsError();
|
||||
}
|
||||
|
||||
const updatedUser = await this.deps.userAccountRepository.patchUpsert(userId, {
|
||||
flags: user.flags | UserFlags.DISABLED,
|
||||
});
|
||||
|
||||
await this.deps.authService.terminateAllUserSessions(userId);
|
||||
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser!);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser!)) {
|
||||
await this.deps.updatePropagator.invalidateUserCache(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async selfDelete(userId: UserID): Promise<void> {
|
||||
const user = await this.deps.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const ownedGuildIds = await this.deps.guildRepository.listOwnedGuildIds(userId);
|
||||
if (ownedGuildIds.length > 0) {
|
||||
throw new UserOwnsGuildsError();
|
||||
}
|
||||
|
||||
const gracePeriodMs = Config.deletionGracePeriodHours * 60 * 60 * 1000;
|
||||
const pendingDeletionAt = new Date(Date.now() + gracePeriodMs);
|
||||
|
||||
const updatedUser = await this.deps.userAccountRepository.patchUpsert(userId, {
|
||||
flags: user.flags | UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
});
|
||||
|
||||
await this.deps.userAccountRepository.addPendingDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
|
||||
|
||||
await this.deps.redisDeletionQueue.scheduleDeletion(userId, pendingDeletionAt, DeletionReasons.USER_REQUESTED);
|
||||
|
||||
if (user.email) {
|
||||
await this.deps.emailService.sendSelfDeletionScheduledEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await this.deps.authService.terminateAllUserSessions(userId);
|
||||
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser!);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser!)) {
|
||||
await this.deps.updatePropagator.invalidateUserCache(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
254
fluxer_api/src/user/services/UserAccountLookupService.ts
Normal file
254
fluxer_api/src/user/services/UserAccountLookupService.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, RelationshipTypes, UserFlags, UserPremiumTypes} from '~/Constants';
|
||||
import {MissingAccessError, UnknownUserError} from '~/Errors';
|
||||
import type {GuildMemberResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {GuildMember, User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
|
||||
|
||||
interface UserAccountLookupServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
userChannelRepository: IUserChannelRepository;
|
||||
userRelationshipRepository: IUserRelationshipRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
guildService: GuildService;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
}
|
||||
|
||||
export class UserAccountLookupService {
|
||||
constructor(private readonly deps: UserAccountLookupServiceDeps) {}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return await this.deps.userAccountRepository.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return await this.deps.userAccountRepository.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async getUserProfile(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId?: GuildID;
|
||||
withMutualFriends?: boolean;
|
||||
withMutualGuilds?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<{
|
||||
user: User;
|
||||
guildMember?: GuildMemberResponse | null;
|
||||
guildMemberDomain?: GuildMember | null;
|
||||
premiumType?: number;
|
||||
premiumSince?: Date;
|
||||
premiumLifetimeSequence?: number;
|
||||
mutualFriends?: Array<User>;
|
||||
mutualGuilds?: Array<{id: string; nick: string | null}>;
|
||||
}> {
|
||||
const {userId, targetId, guildId, withMutualFriends, withMutualGuilds, requestCache} = params;
|
||||
const user = await this.deps.userAccountRepository.findUnique(targetId);
|
||||
if (!user) throw new UnknownUserError();
|
||||
|
||||
if (userId !== targetId) {
|
||||
await this.validateProfileAccess(userId, targetId, user);
|
||||
}
|
||||
|
||||
let guildMember: GuildMemberResponse | null = null;
|
||||
let guildMemberDomain: GuildMember | null = null;
|
||||
|
||||
if (guildId != null) {
|
||||
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
|
||||
if (guildMemberDomain) {
|
||||
guildMember = await this.deps.guildService.getMember({
|
||||
userId,
|
||||
targetId,
|
||||
guildId,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let premiumType = user.premiumType ?? undefined;
|
||||
let premiumSince = user.premiumSince ?? undefined;
|
||||
let premiumLifetimeSequence = user.premiumLifetimeSequence ?? undefined;
|
||||
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_HIDDEN) {
|
||||
premiumType = undefined;
|
||||
premiumSince = undefined;
|
||||
premiumLifetimeSequence = undefined;
|
||||
} else {
|
||||
if (user.premiumType === UserPremiumTypes.LIFETIME) {
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_MASKED) {
|
||||
premiumType = UserPremiumTypes.SUBSCRIPTION;
|
||||
}
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN) {
|
||||
premiumLifetimeSequence = undefined;
|
||||
}
|
||||
}
|
||||
if (user.flags & UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN) {
|
||||
premiumSince = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let mutualFriends: Array<User> | undefined;
|
||||
if (withMutualFriends && userId !== targetId) {
|
||||
mutualFriends = await this.getMutualFriends(userId, targetId);
|
||||
}
|
||||
|
||||
let mutualGuilds: Array<{id: string; nick: string | null}> | undefined;
|
||||
if (withMutualGuilds && userId !== targetId) {
|
||||
mutualGuilds = await this.getMutualGuilds(userId, targetId);
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
guildMember,
|
||||
guildMemberDomain,
|
||||
premiumType,
|
||||
premiumSince,
|
||||
premiumLifetimeSequence,
|
||||
mutualFriends,
|
||||
mutualGuilds,
|
||||
};
|
||||
}
|
||||
|
||||
private async validateProfileAccess(userId: UserID, targetId: UserID, targetUser: User): Promise<void> {
|
||||
if (targetUser.isBot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const friendship = await this.deps.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (friendship) {
|
||||
return;
|
||||
}
|
||||
|
||||
const incomingRequest = await this.deps.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.INCOMING_REQUEST,
|
||||
);
|
||||
if (incomingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [userGuildIds, targetGuildIds] = await Promise.all([
|
||||
this.deps.userAccountRepository.getUserGuildIds(userId),
|
||||
this.deps.userAccountRepository.getUserGuildIds(targetId),
|
||||
]);
|
||||
|
||||
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
|
||||
const hasMutualGuild = targetGuildIds.some((id) => userGuildIdSet.has(id.toString()));
|
||||
if (hasMutualGuild) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.hasSharedGroupDm(userId, targetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
private async hasSharedGroupDm(userId: UserID, targetId: UserID): Promise<boolean> {
|
||||
const privateChannels = await this.deps.userChannelRepository.listPrivateChannels(userId);
|
||||
return privateChannels.some(
|
||||
(channel) => channel.type === ChannelTypes.GROUP_DM && channel.recipientIds.has(targetId),
|
||||
);
|
||||
}
|
||||
|
||||
private async getMutualFriends(userId: UserID, targetId: UserID): Promise<Array<User>> {
|
||||
const [userRelationships, targetRelationships] = await Promise.all([
|
||||
this.deps.userRelationshipRepository.listRelationships(userId),
|
||||
this.deps.userRelationshipRepository.listRelationships(targetId),
|
||||
]);
|
||||
|
||||
const userFriendIds = new Set(
|
||||
userRelationships
|
||||
.filter((rel) => rel.type === RelationshipTypes.FRIEND)
|
||||
.map((rel) => rel.targetUserId.toString()),
|
||||
);
|
||||
|
||||
const mutualFriendIds = targetRelationships
|
||||
.filter((rel) => rel.type === RelationshipTypes.FRIEND && userFriendIds.has(rel.targetUserId.toString()))
|
||||
.map((rel) => rel.targetUserId);
|
||||
|
||||
if (mutualFriendIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const users = await this.deps.userAccountRepository.listUsers(mutualFriendIds);
|
||||
|
||||
return users.sort((a, b) => this.compareUsersByIdDesc(a, b));
|
||||
}
|
||||
|
||||
private compareUsersByIdDesc(a: User, b: User): number {
|
||||
if (b.id > a.id) return 1;
|
||||
if (b.id < a.id) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async getMutualGuilds(userId: UserID, targetId: UserID): Promise<Array<{id: string; nick: string | null}>> {
|
||||
const [userGuildIds, targetGuildIds] = await Promise.all([
|
||||
this.deps.userAccountRepository.getUserGuildIds(userId),
|
||||
this.deps.userAccountRepository.getUserGuildIds(targetId),
|
||||
]);
|
||||
|
||||
const userGuildIdSet = new Set(userGuildIds.map((id) => id.toString()));
|
||||
const mutualGuildIds = targetGuildIds.filter((id) => userGuildIdSet.has(id.toString()));
|
||||
|
||||
if (mutualGuildIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const memberPromises = mutualGuildIds.map((guildId) => this.deps.guildRepository.getMember(guildId, targetId));
|
||||
const members = await Promise.all(memberPromises);
|
||||
|
||||
return mutualGuildIds.map((guildId, index) => ({
|
||||
id: guildId.toString(),
|
||||
nick: members[index]?.nickname ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async generateUniqueDiscriminator(username: string): Promise<number> {
|
||||
const usedDiscriminators = await this.deps.userAccountRepository.findDiscriminatorsByUsername(username);
|
||||
for (let i = 1; i <= 9999; i++) {
|
||||
if (!usedDiscriminators.has(i)) return i;
|
||||
}
|
||||
throw new Error('No available discriminators for this username');
|
||||
}
|
||||
|
||||
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
|
||||
const {username, discriminator} = params;
|
||||
const isAvailable = await this.deps.discriminatorService.isDiscriminatorAvailableForUsername(
|
||||
username,
|
||||
discriminator,
|
||||
);
|
||||
return !isAvailable;
|
||||
}
|
||||
}
|
||||
59
fluxer_api/src/user/services/UserAccountNotesService.ts
Normal file
59
fluxer_api/src/user/services/UserAccountNotesService.ts
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 '~/BrandedTypes';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
|
||||
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
|
||||
|
||||
interface UserAccountNotesServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
userRelationshipRepository: IUserRelationshipRepository;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
}
|
||||
|
||||
export class UserAccountNotesService {
|
||||
constructor(private readonly deps: UserAccountNotesServiceDeps) {}
|
||||
|
||||
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
|
||||
const {userId, targetId} = params;
|
||||
const note = await this.deps.userRelationshipRepository.getUserNote(userId, targetId);
|
||||
return note ? {note: note.note} : null;
|
||||
}
|
||||
|
||||
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
|
||||
const notes = await this.deps.userRelationshipRepository.getUserNotes(userId);
|
||||
return Object.fromEntries(Array.from(notes.entries()).map(([k, v]) => [k.toString(), v]));
|
||||
}
|
||||
|
||||
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
|
||||
const {userId, targetId, note} = params;
|
||||
const targetUser = await this.deps.userAccountRepository.findUnique(targetId);
|
||||
if (!targetUser) throw new UnknownUserError();
|
||||
|
||||
if (note) {
|
||||
await this.deps.userRelationshipRepository.upsertUserNote(userId, targetId, note);
|
||||
} else {
|
||||
await this.deps.userRelationshipRepository.clearUserNote(userId, targetId);
|
||||
}
|
||||
|
||||
await this.deps.updatePropagator.dispatchUserNoteUpdate({userId, targetId, note: note ?? ''});
|
||||
}
|
||||
}
|
||||
431
fluxer_api/src/user/services/UserAccountProfileService.ts
Normal file
431
fluxer_api/src/user/services/UserAccountProfileService.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {UserFlags} from '~/Constants';
|
||||
import type {PartialRowUpdate, UserRow} from '~/database/CassandraTypes';
|
||||
import {InputValidationError, MissingAccessError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {User} from '~/Models';
|
||||
import type {UserUpdateRequest} from '~/user/UserModel';
|
||||
import {deriveDominantAvatarColor} from '~/utils/AvatarColorUtils';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
|
||||
|
||||
interface UserFieldUpdates extends PartialRowUpdate<UserRow> {
|
||||
invalidateAuthSessions?: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileUpdateResult {
|
||||
updates: UserFieldUpdates;
|
||||
preparedAvatarUpload: PreparedAssetUpload | null;
|
||||
preparedBannerUpload: PreparedAssetUpload | null;
|
||||
}
|
||||
|
||||
interface UserAccountProfileServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
entityAssetService: EntityAssetService;
|
||||
rateLimitService: IRateLimitService;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
}
|
||||
|
||||
export class UserAccountProfileService {
|
||||
constructor(private readonly deps: UserAccountProfileServiceDeps) {}
|
||||
|
||||
async processProfileUpdates(params: {user: User; data: UserUpdateRequest}): Promise<ProfileUpdateResult> {
|
||||
const {user, data} = params;
|
||||
const updates: UserFieldUpdates = {
|
||||
avatar_hash: user.avatarHash,
|
||||
banner_hash: user.bannerHash,
|
||||
flags: user.flags,
|
||||
};
|
||||
|
||||
let preparedAvatarUpload: PreparedAssetUpload | null = null;
|
||||
let preparedBannerUpload: PreparedAssetUpload | null = null;
|
||||
|
||||
if (data.bio !== undefined) {
|
||||
await this.processBioUpdate({user, bio: data.bio, updates});
|
||||
}
|
||||
|
||||
if (data.pronouns !== undefined) {
|
||||
await this.processPronounsUpdate({user, pronouns: data.pronouns, updates});
|
||||
}
|
||||
|
||||
if (data.accent_color !== undefined) {
|
||||
await this.processAccentColorUpdate({user, accentColor: data.accent_color, updates});
|
||||
}
|
||||
|
||||
if (data.avatar !== undefined) {
|
||||
preparedAvatarUpload = await this.processAvatarUpdate({user, avatar: data.avatar, updates});
|
||||
}
|
||||
|
||||
if (data.banner !== undefined) {
|
||||
try {
|
||||
preparedBannerUpload = await this.processBannerUpdate({user, banner: data.banner, updates});
|
||||
} catch (error) {
|
||||
if (preparedAvatarUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(preparedAvatarUpload);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.isBot) {
|
||||
this.processPremiumBadgeFlags({user, data, updates});
|
||||
this.processPremiumOnboardingDismissal({user, data, updates});
|
||||
this.processGiftInventoryRead({user, data, updates});
|
||||
this.processUsedMobileClient({user, data, updates});
|
||||
}
|
||||
|
||||
return {updates, preparedAvatarUpload, preparedBannerUpload};
|
||||
}
|
||||
|
||||
async commitAssetChanges(result: ProfileUpdateResult): Promise<void> {
|
||||
if (result.preparedAvatarUpload) {
|
||||
await this.deps.entityAssetService.commitAssetChange({
|
||||
prepared: result.preparedAvatarUpload,
|
||||
deferDeletion: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.preparedBannerUpload) {
|
||||
await this.deps.entityAssetService.commitAssetChange({
|
||||
prepared: result.preparedBannerUpload,
|
||||
deferDeletion: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async rollbackAssetChanges(result: ProfileUpdateResult): Promise<void> {
|
||||
if (result.preparedAvatarUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedAvatarUpload);
|
||||
}
|
||||
|
||||
if (result.preparedBannerUpload) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(result.preparedBannerUpload);
|
||||
}
|
||||
}
|
||||
|
||||
private async processBioUpdate(params: {user: User; bio: string | null; updates: UserFieldUpdates}): Promise<void> {
|
||||
const {user, bio, updates} = params;
|
||||
|
||||
if (bio !== user.bio) {
|
||||
const bioRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `bio_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!bioRateLimit.allowed) {
|
||||
const minutes = Math.ceil((bioRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.create(
|
||||
'bio',
|
||||
`You've changed your bio too many times recently. Please try again in ${minutes} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (bio && bio.length > 160 && !user.isPremium()) {
|
||||
throw InputValidationError.create('bio', 'Bio longer than 160 characters requires premium');
|
||||
}
|
||||
|
||||
let sanitizedBio = bio;
|
||||
if (bio) {
|
||||
sanitizedBio = await EmojiUtils.sanitizeCustomEmojis({
|
||||
content: bio,
|
||||
userId: user.id,
|
||||
webhookId: null,
|
||||
guildId: null,
|
||||
userRepository: this.deps.userAccountRepository,
|
||||
guildRepository: this.deps.guildRepository,
|
||||
});
|
||||
}
|
||||
|
||||
updates.bio = sanitizedBio;
|
||||
}
|
||||
}
|
||||
|
||||
private async processPronounsUpdate(params: {
|
||||
user: User;
|
||||
pronouns: string | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<void> {
|
||||
const {user, pronouns, updates} = params;
|
||||
|
||||
if (pronouns !== user.pronouns) {
|
||||
const pronounsRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `pronouns_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!pronounsRateLimit.allowed) {
|
||||
const minutes = Math.ceil((pronounsRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.create(
|
||||
'pronouns',
|
||||
`You've changed your pronouns too many times recently. Please try again in ${minutes} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
updates.pronouns = pronouns;
|
||||
}
|
||||
}
|
||||
|
||||
private async processAccentColorUpdate(params: {
|
||||
user: User;
|
||||
accentColor: number | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<void> {
|
||||
const {user, accentColor, updates} = params;
|
||||
|
||||
if (accentColor !== user.accentColor) {
|
||||
const accentColorRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `accent_color_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!accentColorRateLimit.allowed) {
|
||||
const minutes = Math.ceil((accentColorRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.create(
|
||||
'accent_color',
|
||||
`You've changed your accent color too many times recently. Please try again in ${minutes} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
updates.accent_color = accentColor;
|
||||
}
|
||||
}
|
||||
|
||||
private async processAvatarUpdate(params: {
|
||||
user: User;
|
||||
avatar: string | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<PreparedAssetUpload | null> {
|
||||
const {user, avatar, updates} = params;
|
||||
|
||||
if (avatar === null) {
|
||||
updates.avatar_hash = null;
|
||||
updates.avatar_color = null;
|
||||
if (user.avatarHash) {
|
||||
return await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
previousHash: user.avatarHash,
|
||||
base64Image: null,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `avatar_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!avatarRateLimit.allowed) {
|
||||
const minutes = Math.ceil((avatarRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.create(
|
||||
'avatar',
|
||||
`You've changed your avatar too many times recently. Please try again in ${minutes} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
previousHash: user.avatarHash,
|
||||
base64Image: avatar,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
|
||||
if (prepared.isAnimated && !user.isPremium()) {
|
||||
await this.deps.entityAssetService.rollbackAssetUpload(prepared);
|
||||
throw InputValidationError.create('avatar', 'Animated avatars require premium');
|
||||
}
|
||||
|
||||
if (prepared.imageBuffer) {
|
||||
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
|
||||
if (derivedColor !== user.avatarColor) {
|
||||
updates.avatar_color = derivedColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (prepared.newHash !== user.avatarHash) {
|
||||
updates.avatar_hash = prepared.newHash;
|
||||
return prepared;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async processBannerUpdate(params: {
|
||||
user: User;
|
||||
banner: string | null;
|
||||
updates: UserFieldUpdates;
|
||||
}): Promise<PreparedAssetUpload | null> {
|
||||
const {user, banner, updates} = params;
|
||||
|
||||
if (banner === null) {
|
||||
updates.banner_color = null;
|
||||
}
|
||||
|
||||
if (banner && !user.isPremium()) {
|
||||
throw InputValidationError.create('banner', 'Banners require premium');
|
||||
}
|
||||
|
||||
const bannerRateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `banner_change:${user.id}`,
|
||||
maxAttempts: 25,
|
||||
windowMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!bannerRateLimit.allowed) {
|
||||
const minutes = Math.ceil((bannerRateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.create(
|
||||
'banner',
|
||||
`You've changed your banner too many times recently. Please try again in ${minutes} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const prepared = await this.deps.entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
previousHash: user.bannerHash,
|
||||
base64Image: banner,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
|
||||
if (banner !== null && prepared.imageBuffer) {
|
||||
const derivedColor = await deriveDominantAvatarColor(prepared.imageBuffer);
|
||||
if (derivedColor !== user.bannerColor) {
|
||||
updates.banner_color = derivedColor;
|
||||
}
|
||||
}
|
||||
|
||||
if (prepared.newHash !== user.bannerHash) {
|
||||
updates.banner_hash = prepared.newHash;
|
||||
return prepared;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private processPremiumBadgeFlags(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
|
||||
const {user, data, updates} = params;
|
||||
let flagsUpdated = false;
|
||||
let newFlags = user.flags;
|
||||
|
||||
if (data.premium_badge_hidden !== undefined) {
|
||||
if (data.premium_badge_hidden) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_HIDDEN;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_HIDDEN;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_badge_masked !== undefined) {
|
||||
if (data.premium_badge_masked) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_MASKED;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_MASKED;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_badge_timestamp_hidden !== undefined) {
|
||||
if (data.premium_badge_timestamp_hidden) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_TIMESTAMP_HIDDEN;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_badge_sequence_hidden !== undefined) {
|
||||
if (data.premium_badge_sequence_hidden) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_BADGE_SEQUENCE_HIDDEN;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (data.premium_enabled_override !== undefined) {
|
||||
if (!(user.flags & UserFlags.STAFF)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
|
||||
if (data.premium_enabled_override) {
|
||||
newFlags = newFlags | UserFlags.PREMIUM_ENABLED_OVERRIDE;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.PREMIUM_ENABLED_OVERRIDE;
|
||||
}
|
||||
flagsUpdated = true;
|
||||
}
|
||||
|
||||
if (flagsUpdated) {
|
||||
updates.flags = newFlags;
|
||||
}
|
||||
}
|
||||
|
||||
private processPremiumOnboardingDismissal(params: {
|
||||
user: User;
|
||||
data: UserUpdateRequest;
|
||||
updates: UserFieldUpdates;
|
||||
}): void {
|
||||
const {data, updates} = params;
|
||||
|
||||
if (data.has_dismissed_premium_onboarding !== undefined) {
|
||||
if (data.has_dismissed_premium_onboarding) {
|
||||
updates.premium_onboarding_dismissed_at = new Date();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processGiftInventoryRead(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
|
||||
const {user, data, updates} = params;
|
||||
|
||||
if (data.has_unread_gift_inventory === false) {
|
||||
updates.gift_inventory_client_seq = user.giftInventoryServerSeq;
|
||||
}
|
||||
}
|
||||
|
||||
private processUsedMobileClient(params: {user: User; data: UserUpdateRequest; updates: UserFieldUpdates}): void {
|
||||
const {user, data, updates} = params;
|
||||
|
||||
if (data.used_mobile_client !== undefined) {
|
||||
let newFlags = updates.flags ?? user.flags;
|
||||
if (data.used_mobile_client) {
|
||||
newFlags = newFlags | UserFlags.USED_MOBILE_CLIENT;
|
||||
} else {
|
||||
newFlags = newFlags & ~UserFlags.USED_MOBILE_CLIENT;
|
||||
}
|
||||
updates.flags = newFlags;
|
||||
}
|
||||
}
|
||||
}
|
||||
281
fluxer_api/src/user/services/UserAccountSecurityService.ts
Normal file
281
fluxer_api/src/user/services/UserAccountSecurityService.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
|
||||
import {userHasMfa} from '~/auth/services/SudoVerificationService';
|
||||
import {UserPremiumTypes} from '~/Constants';
|
||||
import type {PartialRowUpdate, UserRow} from '~/database/CassandraTypes';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import {SudoModeRequiredError} from '~/errors/SudoModeRequiredError';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import type {UserUpdateRequest} from '~/user/UserModel';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
|
||||
interface UserFieldUpdates extends PartialRowUpdate<UserRow> {
|
||||
invalidateAuthSessions?: boolean;
|
||||
}
|
||||
|
||||
interface UserAccountSecurityServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
authService: AuthService;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
rateLimitService: IRateLimitService;
|
||||
}
|
||||
|
||||
export class UserAccountSecurityService {
|
||||
constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
|
||||
|
||||
async processSecurityUpdates(params: {
|
||||
user: User;
|
||||
data: UserUpdateRequest;
|
||||
sudoContext?: SudoVerificationResult;
|
||||
}): Promise<UserFieldUpdates> {
|
||||
const {user, data, sudoContext} = params;
|
||||
const updates: UserFieldUpdates = {
|
||||
password_hash: user.passwordHash,
|
||||
username: user.username,
|
||||
discriminator: user.discriminator,
|
||||
global_name: user.isBot ? null : user.globalName,
|
||||
email: user.email,
|
||||
invalidateAuthSessions: false,
|
||||
};
|
||||
|
||||
const isUnclaimedAccount = !user.passwordHash;
|
||||
const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext?.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
|
||||
const rawEmail = data.email?.trim();
|
||||
const normalizedEmail = rawEmail?.toLowerCase();
|
||||
|
||||
const hasPasswordRequiredChanges =
|
||||
(data.username !== undefined && data.username !== user.username) ||
|
||||
(data.discriminator !== undefined && data.discriminator !== user.discriminator) ||
|
||||
(data.email !== undefined && normalizedEmail !== user.email?.toLowerCase()) ||
|
||||
data.new_password !== undefined;
|
||||
|
||||
const requiresVerification = hasPasswordRequiredChanges && !isUnclaimedAccount;
|
||||
if (requiresVerification && !identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
|
||||
if (isUnclaimedAccount && data.new_password) {
|
||||
updates.password_hash = await this.hashNewPassword(data.new_password);
|
||||
updates.password_last_changed_at = new Date();
|
||||
updates.invalidateAuthSessions = false;
|
||||
} else if (data.new_password) {
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
updates.password_hash = await this.hashNewPassword(data.new_password);
|
||||
updates.password_last_changed_at = new Date();
|
||||
updates.invalidateAuthSessions = true;
|
||||
}
|
||||
|
||||
if (data.username) {
|
||||
const {newUsername, newDiscriminator} = await this.updateUsername({
|
||||
user,
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
});
|
||||
updates.username = newUsername;
|
||||
updates.discriminator = newDiscriminator;
|
||||
} else if (data.discriminator) {
|
||||
updates.discriminator = await this.updateDiscriminator({user, discriminator: data.discriminator});
|
||||
}
|
||||
|
||||
if (user.isBot) {
|
||||
updates.global_name = null;
|
||||
} else if (data.global_name !== undefined) {
|
||||
updates.global_name = data.global_name;
|
||||
}
|
||||
|
||||
if (rawEmail) {
|
||||
if (normalizedEmail !== user.email?.toLowerCase()) {
|
||||
const existing = await this.deps.userAccountRepository.findByEmail(normalizedEmail!);
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw InputValidationError.create('email', 'Email already in use');
|
||||
}
|
||||
}
|
||||
|
||||
updates.email = rawEmail;
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
async invalidateAndRecreateSessions({
|
||||
user,
|
||||
oldAuthSession,
|
||||
request,
|
||||
}: {
|
||||
user: User;
|
||||
oldAuthSession: AuthSession;
|
||||
request: Request;
|
||||
}): Promise<void> {
|
||||
await this.deps.authService.terminateAllUserSessions(user.id);
|
||||
|
||||
const [newToken, newAuthSession] = await this.deps.authService.createAuthSession({user, request});
|
||||
const oldAuthSessionIdHash = uint8ArrayToBase64(oldAuthSession.sessionIdHash, {urlSafe: true});
|
||||
|
||||
await this.deps.authService.dispatchAuthSessionChange({
|
||||
userId: user.id,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash: uint8ArrayToBase64(newAuthSession.sessionIdHash, {urlSafe: true}),
|
||||
newToken,
|
||||
});
|
||||
}
|
||||
|
||||
private async hashNewPassword(newPassword: string): Promise<string> {
|
||||
if (await this.deps.authService.isPasswordPwned(newPassword)) {
|
||||
throw InputValidationError.create('new_password', 'Password is too common');
|
||||
}
|
||||
return await this.deps.authService.hashPassword(newPassword);
|
||||
}
|
||||
|
||||
private async updateUsername({
|
||||
user,
|
||||
username,
|
||||
requestedDiscriminator,
|
||||
}: {
|
||||
user: User;
|
||||
username: string;
|
||||
requestedDiscriminator?: number;
|
||||
}): Promise<{newUsername: string; newDiscriminator: number}> {
|
||||
const normalizedRequestedDiscriminator =
|
||||
requestedDiscriminator == null ? undefined : Number(requestedDiscriminator);
|
||||
|
||||
if (
|
||||
user.username.toLowerCase() === username.toLowerCase() &&
|
||||
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
|
||||
) {
|
||||
return {
|
||||
newUsername: username,
|
||||
newDiscriminator: user.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
const rateLimit = await this.deps.rateLimitService.checkLimit({
|
||||
identifier: `username_change:${user.id}`,
|
||||
maxAttempts: 5,
|
||||
windowMs: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
const minutes = Math.ceil((rateLimit.retryAfter || 0) / 60);
|
||||
throw InputValidationError.create(
|
||||
'username',
|
||||
`You've changed your username too many times recently. Please try again in ${minutes} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const isPremium = user.isPremium();
|
||||
|
||||
if (
|
||||
!isPremium &&
|
||||
user.username === username &&
|
||||
(normalizedRequestedDiscriminator === undefined || normalizedRequestedDiscriminator === user.discriminator)
|
||||
) {
|
||||
return {
|
||||
newUsername: user.username,
|
||||
newDiscriminator: user.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPremium) {
|
||||
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username,
|
||||
requestedDiscriminator: undefined,
|
||||
isPremium: false,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw InputValidationError.create(
|
||||
'username',
|
||||
'Too many users with this username. Please try a different username.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
newUsername: username,
|
||||
newDiscriminator: discriminatorResult.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
if (user.premiumType !== UserPremiumTypes.LIFETIME) {
|
||||
if (requestedDiscriminator === 0) {
|
||||
throw InputValidationError.create(
|
||||
'discriminator',
|
||||
'You must be on the Visionary lifetime plan to use that discriminator.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const discriminatorToUse = normalizedRequestedDiscriminator ?? user.discriminator;
|
||||
|
||||
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username,
|
||||
requestedDiscriminator: discriminatorToUse,
|
||||
isPremium,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw InputValidationError.create(
|
||||
'username',
|
||||
discriminatorToUse !== undefined
|
||||
? 'This tag is already taken'
|
||||
: 'Too many users with this username. Please try a different username.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
newUsername: username,
|
||||
newDiscriminator: discriminatorResult.discriminator,
|
||||
};
|
||||
}
|
||||
|
||||
private async updateDiscriminator({user, discriminator}: {user: User; discriminator: number}): Promise<number> {
|
||||
if (!user.isPremium()) {
|
||||
throw InputValidationError.create('discriminator', 'Changing discriminator requires premium');
|
||||
}
|
||||
|
||||
if (user.premiumType !== UserPremiumTypes.LIFETIME && discriminator === 0) {
|
||||
throw InputValidationError.create(
|
||||
'discriminator',
|
||||
'You must be on the Visionary lifetime plan to use that discriminator.',
|
||||
);
|
||||
}
|
||||
|
||||
const discriminatorResult = await this.deps.discriminatorService.generateDiscriminator({
|
||||
username: user.username,
|
||||
requestedDiscriminator: discriminator,
|
||||
isPremium: true,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available) {
|
||||
throw InputValidationError.create('discriminator', 'This tag is already taken');
|
||||
}
|
||||
|
||||
return discriminator;
|
||||
}
|
||||
}
|
||||
294
fluxer_api/src/user/services/UserAccountService.ts
Normal file
294
fluxer_api/src/user/services/UserAccountService.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {AuthSession, User, UserGuildSettings, UserSettings} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import {hasPartialUserFieldsChanged} from '~/user/UserMappers';
|
||||
import type {UserGuildSettingsUpdateRequest, UserSettingsUpdateRequest, UserUpdateRequest} from '~/user/UserModel';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from '../repositories/IUserSettingsRepository';
|
||||
import {UserAccountLifecycleService} from './UserAccountLifecycleService';
|
||||
import {UserAccountLookupService} from './UserAccountLookupService';
|
||||
import {UserAccountNotesService} from './UserAccountNotesService';
|
||||
import {UserAccountProfileService} from './UserAccountProfileService';
|
||||
import {UserAccountSecurityService} from './UserAccountSecurityService';
|
||||
import {UserAccountSettingsService} from './UserAccountSettingsService';
|
||||
import {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
|
||||
import type {UserContactChangeLogService} from './UserContactChangeLogService';
|
||||
|
||||
interface UpdateUserParams {
|
||||
user: User;
|
||||
oldAuthSession: AuthSession;
|
||||
data: UserUpdateRequest;
|
||||
request: Request;
|
||||
sudoContext?: SudoVerificationResult;
|
||||
emailVerifiedViaToken?: boolean;
|
||||
}
|
||||
|
||||
export class UserAccountService {
|
||||
private readonly lookupService: UserAccountLookupService;
|
||||
private readonly profileService: UserAccountProfileService;
|
||||
private readonly securityService: UserAccountSecurityService;
|
||||
private readonly settingsService: UserAccountSettingsService;
|
||||
private readonly notesService: UserAccountNotesService;
|
||||
private readonly lifecycleService: UserAccountLifecycleService;
|
||||
private readonly updatePropagator: UserAccountUpdatePropagator;
|
||||
|
||||
constructor(
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
userSettingsRepository: IUserSettingsRepository,
|
||||
userRelationshipRepository: IUserRelationshipRepository,
|
||||
userChannelRepository: IUserChannelRepository,
|
||||
authService: AuthService,
|
||||
userCacheService: UserCacheService,
|
||||
guildService: GuildService,
|
||||
gatewayService: IGatewayService,
|
||||
entityAssetService: EntityAssetService,
|
||||
mediaService: IMediaService,
|
||||
packService: PackService,
|
||||
emailService: IEmailService,
|
||||
rateLimitService: IRateLimitService,
|
||||
guildRepository: IGuildRepository,
|
||||
discriminatorService: IDiscriminatorService,
|
||||
redisDeletionQueue: RedisAccountDeletionQueueService,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
) {
|
||||
this.updatePropagator = new UserAccountUpdatePropagator({
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
});
|
||||
|
||||
this.lookupService = new UserAccountLookupService({
|
||||
userAccountRepository,
|
||||
userRelationshipRepository,
|
||||
userChannelRepository,
|
||||
guildRepository,
|
||||
guildService,
|
||||
discriminatorService,
|
||||
});
|
||||
|
||||
this.profileService = new UserAccountProfileService({
|
||||
userAccountRepository,
|
||||
guildRepository,
|
||||
entityAssetService,
|
||||
rateLimitService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.securityService = new UserAccountSecurityService({
|
||||
userAccountRepository,
|
||||
authService,
|
||||
discriminatorService,
|
||||
rateLimitService,
|
||||
});
|
||||
|
||||
this.settingsService = new UserAccountSettingsService({
|
||||
userAccountRepository,
|
||||
userSettingsRepository,
|
||||
updatePropagator: this.updatePropagator,
|
||||
guildRepository,
|
||||
packService,
|
||||
});
|
||||
|
||||
this.notesService = new UserAccountNotesService({
|
||||
userAccountRepository,
|
||||
userRelationshipRepository,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.lifecycleService = new UserAccountLifecycleService({
|
||||
userAccountRepository,
|
||||
guildRepository,
|
||||
authService,
|
||||
emailService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
redisDeletionQueue,
|
||||
});
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.lookupService.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.lookupService.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async getUserProfile(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId?: GuildID;
|
||||
withMutualFriends?: boolean;
|
||||
withMutualGuilds?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}) {
|
||||
return this.lookupService.getUserProfile(params);
|
||||
}
|
||||
|
||||
async generateUniqueDiscriminator(username: string): Promise<number> {
|
||||
return this.lookupService.generateUniqueDiscriminator(username);
|
||||
}
|
||||
|
||||
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
|
||||
return this.lookupService.checkUsernameDiscriminatorAvailability(params);
|
||||
}
|
||||
|
||||
async update(params: UpdateUserParams): Promise<User> {
|
||||
const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params;
|
||||
|
||||
const profileResult = await this.profileService.processProfileUpdates({user, data});
|
||||
const securityUpdates = await this.securityService.processSecurityUpdates({user, data, sudoContext});
|
||||
|
||||
const updates = {
|
||||
...securityUpdates,
|
||||
...profileResult.updates,
|
||||
};
|
||||
|
||||
const updatedUserRow = {
|
||||
...user.toRow(),
|
||||
...updates,
|
||||
};
|
||||
|
||||
if (updates.avatar_hash === null) {
|
||||
updatedUserRow.avatar_hash = null;
|
||||
}
|
||||
if (updates.banner_hash === null) {
|
||||
updatedUserRow.banner_hash = null;
|
||||
}
|
||||
|
||||
const emailChanged = data.email !== undefined;
|
||||
if (emailChanged) {
|
||||
updatedUserRow.email_verified = !!emailVerifiedViaToken;
|
||||
}
|
||||
|
||||
let updatedUser: User;
|
||||
try {
|
||||
updatedUser = await this.userAccountRepository.upsert(updatedUserRow, user.toRow());
|
||||
} catch (error) {
|
||||
await this.profileService.rollbackAssetChanges(profileResult);
|
||||
Logger.error({error, userId: user.id}, 'User update failed, rolled back asset uploads');
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'user_requested',
|
||||
actorUserId: user.id,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.profileService.commitAssetChanges(profileResult);
|
||||
} catch (error) {
|
||||
Logger.error({error, userId: user.id}, 'Failed to commit asset changes after successful DB update');
|
||||
}
|
||||
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.updatePropagator.invalidateUserCache(updatedUser.id);
|
||||
}
|
||||
|
||||
if (updates.invalidateAuthSessions) {
|
||||
await this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request});
|
||||
}
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings> {
|
||||
return this.settingsService.findSettings(userId);
|
||||
}
|
||||
|
||||
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
|
||||
return this.settingsService.updateSettings(params);
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return this.settingsService.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async updateGuildSettings(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID | null;
|
||||
data: UserGuildSettingsUpdateRequest;
|
||||
}): Promise<UserGuildSettings> {
|
||||
return this.settingsService.updateGuildSettings(params);
|
||||
}
|
||||
|
||||
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
|
||||
return this.notesService.getUserNote(params);
|
||||
}
|
||||
|
||||
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
|
||||
return this.notesService.getUserNotes(userId);
|
||||
}
|
||||
|
||||
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
|
||||
return this.notesService.setUserNote(params);
|
||||
}
|
||||
|
||||
async selfDisable(userId: UserID): Promise<void> {
|
||||
return this.lifecycleService.selfDisable(userId);
|
||||
}
|
||||
|
||||
async selfDelete(userId: UserID): Promise<void> {
|
||||
return this.lifecycleService.selfDelete(userId);
|
||||
}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserUpdate(user);
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserGuildSettingsUpdate({
|
||||
userId,
|
||||
settings,
|
||||
}: {
|
||||
userId: UserID;
|
||||
settings: UserGuildSettings;
|
||||
}): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
return this.updatePropagator.dispatchUserNoteUpdate(params);
|
||||
}
|
||||
}
|
||||
273
fluxer_api/src/user/services/UserAccountSettingsService.ts
Normal file
273
fluxer_api/src/user/services/UserAccountSettingsService.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createChannelID, createGuildID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {FriendSourceFlags, GroupDmAddPermissionFlags, IncomingCallFlags, UserNotificationSettings} from '~/Constants';
|
||||
import type {ChannelOverride, UserGuildSettingsRow} from '~/database/types/UserTypes';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {UserGuildSettings, UserSettings} from '~/Models';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {UserGuildSettingsUpdateRequest, UserSettingsUpdateRequest} from '~/user/UserModel';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserSettingsRepository} from '../repositories/IUserSettingsRepository';
|
||||
import {CustomStatusValidator} from './CustomStatusValidator';
|
||||
import type {UserAccountUpdatePropagator} from './UserAccountUpdatePropagator';
|
||||
|
||||
interface UserAccountSettingsServiceDeps {
|
||||
userAccountRepository: IUserAccountRepository;
|
||||
userSettingsRepository: IUserSettingsRepository;
|
||||
updatePropagator: UserAccountUpdatePropagator;
|
||||
guildRepository: IGuildRepository;
|
||||
packService: PackService;
|
||||
}
|
||||
|
||||
export class UserAccountSettingsService {
|
||||
private readonly customStatusValidator: CustomStatusValidator;
|
||||
|
||||
constructor(private readonly deps: UserAccountSettingsServiceDeps) {
|
||||
this.customStatusValidator = new CustomStatusValidator(
|
||||
this.deps.userAccountRepository,
|
||||
this.deps.guildRepository,
|
||||
this.deps.packService,
|
||||
);
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings> {
|
||||
const userSettings = await this.deps.userSettingsRepository.findSettings(userId);
|
||||
if (!userSettings) throw new UnknownUserError();
|
||||
return userSettings;
|
||||
}
|
||||
|
||||
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
|
||||
const {userId, data} = params;
|
||||
const currentSettings = await this.deps.userSettingsRepository.findSettings(userId);
|
||||
if (!currentSettings) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedRowData = {...currentSettings.toRow(), user_id: userId};
|
||||
const localeChanged = data.locale !== undefined && data.locale !== currentSettings.locale;
|
||||
|
||||
if (data.status !== undefined) updatedRowData.status = data.status;
|
||||
if (data.status_resets_at !== undefined) updatedRowData.status_resets_at = data.status_resets_at;
|
||||
if (data.status_resets_to !== undefined) updatedRowData.status_resets_to = data.status_resets_to;
|
||||
if (data.theme !== undefined) updatedRowData.theme = data.theme;
|
||||
if (data.locale !== undefined) updatedRowData.locale = data.locale;
|
||||
if (data.custom_status !== undefined) {
|
||||
if (data.custom_status === null) {
|
||||
updatedRowData.custom_status = null;
|
||||
} else {
|
||||
const validated = await this.customStatusValidator.validate(userId, data.custom_status);
|
||||
updatedRowData.custom_status = {
|
||||
text: validated.text,
|
||||
expires_at: validated.expiresAt,
|
||||
emoji_id: validated.emojiId,
|
||||
emoji_name: validated.emojiName,
|
||||
emoji_animated: validated.emojiAnimated,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (data.flags !== undefined) updatedRowData.friend_source_flags = data.flags;
|
||||
if (data.guild_positions !== undefined) {
|
||||
updatedRowData.guild_positions = data.guild_positions ? data.guild_positions.map(createGuildID) : null;
|
||||
}
|
||||
if (data.restricted_guilds !== undefined) {
|
||||
updatedRowData.restricted_guilds = data.restricted_guilds
|
||||
? new Set(data.restricted_guilds.map(createGuildID))
|
||||
: null;
|
||||
}
|
||||
if (data.default_guilds_restricted !== undefined) {
|
||||
updatedRowData.default_guilds_restricted = data.default_guilds_restricted;
|
||||
}
|
||||
if (data.inline_attachment_media !== undefined) {
|
||||
updatedRowData.inline_attachment_media = data.inline_attachment_media;
|
||||
}
|
||||
if (data.inline_embed_media !== undefined) updatedRowData.inline_embed_media = data.inline_embed_media;
|
||||
if (data.gif_auto_play !== undefined) updatedRowData.gif_auto_play = data.gif_auto_play;
|
||||
if (data.render_embeds !== undefined) updatedRowData.render_embeds = data.render_embeds;
|
||||
if (data.render_reactions !== undefined) updatedRowData.render_reactions = data.render_reactions;
|
||||
if (data.animate_emoji !== undefined) updatedRowData.animate_emoji = data.animate_emoji;
|
||||
if (data.animate_stickers !== undefined) updatedRowData.animate_stickers = data.animate_stickers;
|
||||
if (data.render_spoilers !== undefined) updatedRowData.render_spoilers = data.render_spoilers;
|
||||
if (data.message_display_compact !== undefined) {
|
||||
updatedRowData.message_display_compact = data.message_display_compact;
|
||||
}
|
||||
if (data.friend_source_flags !== undefined) {
|
||||
updatedRowData.friend_source_flags = this.normalizeFriendSourceFlags(data.friend_source_flags);
|
||||
}
|
||||
if (data.incoming_call_flags !== undefined) {
|
||||
updatedRowData.incoming_call_flags = this.normalizeIncomingCallFlags(data.incoming_call_flags);
|
||||
}
|
||||
if (data.group_dm_add_permission_flags !== undefined) {
|
||||
updatedRowData.group_dm_add_permission_flags = this.normalizeGroupDmAddPermissionFlags(
|
||||
data.group_dm_add_permission_flags,
|
||||
);
|
||||
}
|
||||
if (data.guild_folders !== undefined) {
|
||||
updatedRowData.guild_folders = data.guild_folders.map((folder) => ({
|
||||
folder_id: folder.id,
|
||||
name: folder.name,
|
||||
color: folder.color ?? 0x000000,
|
||||
guild_ids: folder.guild_ids.map(createGuildID),
|
||||
}));
|
||||
}
|
||||
if (data.afk_timeout !== undefined) updatedRowData.afk_timeout = data.afk_timeout;
|
||||
if (data.time_format !== undefined) updatedRowData.time_format = data.time_format;
|
||||
if (data.developer_mode !== undefined) updatedRowData.developer_mode = data.developer_mode;
|
||||
|
||||
const updatedSettings = await this.deps.userSettingsRepository.upsertSettings(updatedRowData);
|
||||
await this.deps.updatePropagator.dispatchUserSettingsUpdate({userId, settings: updatedSettings});
|
||||
|
||||
if (localeChanged) {
|
||||
const updatedUser = await this.deps.userAccountRepository.patchUpsert(userId, {locale: data.locale});
|
||||
if (updatedUser) {
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async updateGuildSettings(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID | null;
|
||||
data: UserGuildSettingsUpdateRequest;
|
||||
}): Promise<UserGuildSettings> {
|
||||
const {userId, guildId, data} = params;
|
||||
const currentSettings = await this.deps.userSettingsRepository.findGuildSettings(userId, guildId);
|
||||
const resolvedGuildId = guildId ?? createGuildID(0n);
|
||||
const baseRow: UserGuildSettingsRow = currentSettings
|
||||
? {
|
||||
...currentSettings.toRow(),
|
||||
user_id: userId,
|
||||
guild_id: resolvedGuildId,
|
||||
}
|
||||
: {
|
||||
user_id: userId,
|
||||
guild_id: resolvedGuildId,
|
||||
message_notifications: UserNotificationSettings.INHERIT,
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
mobile_push: false,
|
||||
suppress_everyone: false,
|
||||
suppress_roles: false,
|
||||
hide_muted_channels: false,
|
||||
channel_overrides: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const updatedRowData: UserGuildSettingsRow = {...baseRow};
|
||||
|
||||
if (data.message_notifications !== undefined) updatedRowData.message_notifications = data.message_notifications;
|
||||
if (data.muted !== undefined) updatedRowData.muted = data.muted;
|
||||
if (data.mute_config !== undefined) {
|
||||
updatedRowData.mute_config = data.mute_config
|
||||
? {
|
||||
end_time: data.mute_config.end_time ?? null,
|
||||
selected_time_window: data.mute_config.selected_time_window,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (data.mobile_push !== undefined) updatedRowData.mobile_push = data.mobile_push;
|
||||
if (data.suppress_everyone !== undefined) updatedRowData.suppress_everyone = data.suppress_everyone;
|
||||
if (data.suppress_roles !== undefined) updatedRowData.suppress_roles = data.suppress_roles;
|
||||
if (data.hide_muted_channels !== undefined) updatedRowData.hide_muted_channels = data.hide_muted_channels;
|
||||
if (data.channel_overrides !== undefined) {
|
||||
if (data.channel_overrides) {
|
||||
const channelOverrides = new Map<ChannelID, ChannelOverride>();
|
||||
for (const [channelIdStr, override] of Object.entries(data.channel_overrides)) {
|
||||
const channelId = createChannelID(BigInt(channelIdStr));
|
||||
channelOverrides.set(channelId, {
|
||||
collapsed: override.collapsed,
|
||||
message_notifications: override.message_notifications,
|
||||
muted: override.muted,
|
||||
mute_config: override.mute_config
|
||||
? {
|
||||
end_time: override.mute_config.end_time ?? null,
|
||||
selected_time_window: override.mute_config.selected_time_window,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
updatedRowData.channel_overrides = channelOverrides.size > 0 ? channelOverrides : null;
|
||||
} else {
|
||||
updatedRowData.channel_overrides = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSettings = await this.deps.userSettingsRepository.upsertGuildSettings(updatedRowData);
|
||||
await this.deps.updatePropagator.dispatchUserGuildSettingsUpdate({userId, settings: updatedSettings});
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
private normalizeFriendSourceFlags(flags: number): number {
|
||||
let normalizedFlags = flags;
|
||||
|
||||
if ((normalizedFlags & FriendSourceFlags.NO_RELATION) === FriendSourceFlags.NO_RELATION) {
|
||||
const hasMutualFriends =
|
||||
(normalizedFlags & FriendSourceFlags.MUTUAL_FRIENDS) === FriendSourceFlags.MUTUAL_FRIENDS;
|
||||
const hasMutualGuilds = (normalizedFlags & FriendSourceFlags.MUTUAL_GUILDS) === FriendSourceFlags.MUTUAL_GUILDS;
|
||||
|
||||
if (!hasMutualFriends || !hasMutualGuilds) {
|
||||
normalizedFlags &= ~FriendSourceFlags.NO_RELATION;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedFlags;
|
||||
}
|
||||
|
||||
private normalizeIncomingCallFlags(flags: number): number {
|
||||
let normalizedFlags = flags;
|
||||
|
||||
const modifierFlags = flags & IncomingCallFlags.SILENT_EVERYONE;
|
||||
|
||||
if ((normalizedFlags & IncomingCallFlags.FRIENDS_ONLY) === IncomingCallFlags.FRIENDS_ONLY) {
|
||||
normalizedFlags = IncomingCallFlags.FRIENDS_ONLY | modifierFlags;
|
||||
}
|
||||
|
||||
if ((normalizedFlags & IncomingCallFlags.NOBODY) === IncomingCallFlags.NOBODY) {
|
||||
normalizedFlags = IncomingCallFlags.NOBODY | modifierFlags;
|
||||
}
|
||||
|
||||
return normalizedFlags;
|
||||
}
|
||||
|
||||
private normalizeGroupDmAddPermissionFlags(flags: number): number {
|
||||
let normalizedFlags = flags;
|
||||
|
||||
if ((normalizedFlags & GroupDmAddPermissionFlags.FRIENDS_ONLY) === GroupDmAddPermissionFlags.FRIENDS_ONLY) {
|
||||
normalizedFlags = GroupDmAddPermissionFlags.FRIENDS_ONLY;
|
||||
}
|
||||
|
||||
if ((normalizedFlags & GroupDmAddPermissionFlags.NOBODY) === GroupDmAddPermissionFlags.NOBODY) {
|
||||
normalizedFlags = GroupDmAddPermissionFlags.NOBODY;
|
||||
}
|
||||
|
||||
if ((normalizedFlags & GroupDmAddPermissionFlags.EVERYONE) === GroupDmAddPermissionFlags.EVERYONE) {
|
||||
normalizedFlags = GroupDmAddPermissionFlags.EVERYONE;
|
||||
}
|
||||
|
||||
return normalizedFlags;
|
||||
}
|
||||
}
|
||||
72
fluxer_api/src/user/services/UserAccountUpdatePropagator.ts
Normal file
72
fluxer_api/src/user/services/UserAccountUpdatePropagator.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {UserGuildSettings, UserSettings} from '~/Models';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '~/user/UserModel';
|
||||
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
|
||||
|
||||
interface UserAccountUpdatePropagatorDeps {
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
mediaService: IMediaService;
|
||||
}
|
||||
|
||||
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
constructor(private readonly deps: UserAccountUpdatePropagatorDeps) {
|
||||
super({
|
||||
userCacheService: deps.userCacheService,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_SETTINGS_UPDATE',
|
||||
data: mapUserSettingsToResponse({settings}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchUserGuildSettingsUpdate({
|
||||
userId,
|
||||
settings,
|
||||
}: {
|
||||
userId: UserID;
|
||||
settings: UserGuildSettings;
|
||||
}): Promise<void> {
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_GUILD_SETTINGS_UPDATE',
|
||||
data: mapUserGuildSettingsToResponse(settings),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
const {userId, targetId, note} = params;
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_NOTE_UPDATE',
|
||||
data: {id: targetId.toString(), note},
|
||||
});
|
||||
}
|
||||
}
|
||||
180
fluxer_api/src/user/services/UserAuthService.ts
Normal file
180
fluxer_api/src/user/services/UserAuthService.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
|
||||
import {userHasMfa} from '~/auth/services/SudoVerificationService';
|
||||
import {createEmailVerificationToken} from '~/BrandedTypes';
|
||||
import {UserAuthenticatorTypes} from '~/Constants';
|
||||
import {InputValidationError, MfaNotDisabledError, MfaNotEnabledError} from '~/Errors';
|
||||
import {SudoModeRequiredError} from '~/errors/SudoModeRequiredError';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {MfaBackupCode, User} from '~/Models';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from '../repositories/IUserAuthRepository';
|
||||
|
||||
export class UserAuthService {
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userAuthRepository: IUserAuthRepository,
|
||||
private authService: AuthService,
|
||||
private emailService: IEmailService,
|
||||
private gatewayService: IGatewayService,
|
||||
private botMfaMirrorService?: BotMfaMirrorService,
|
||||
) {}
|
||||
|
||||
async enableMfaTotp(params: {user: User; secret: string; code: string}): Promise<Array<MfaBackupCode>> {
|
||||
const {user, secret, code} = params;
|
||||
if (user.totpSecret) throw new MfaNotDisabledError();
|
||||
|
||||
const userId = user.id;
|
||||
if (!(await this.authService.verifyMfaCode({userId: user.id, mfaSecret: secret, code}))) {
|
||||
throw InputValidationError.create('code', 'Invalid code');
|
||||
}
|
||||
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.TOTP);
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(userId, {
|
||||
totp_secret: secret,
|
||||
authenticator_types: authenticatorTypes,
|
||||
});
|
||||
const newBackupCodes = this.authService.generateBackupCodes();
|
||||
const mfaBackupCodes = await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
|
||||
await this.dispatchUserUpdate(updatedUser!);
|
||||
if (updatedUser) {
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
return mfaBackupCodes;
|
||||
}
|
||||
|
||||
async disableMfaTotp(params: {user: User; code: string; sudoContext: SudoVerificationResult}): Promise<void> {
|
||||
const {user, code, sudoContext} = params;
|
||||
if (!user.totpSecret) throw new MfaNotEnabledError();
|
||||
|
||||
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this.authService.verifyMfaCode({
|
||||
userId: user.id,
|
||||
mfaSecret: user.totpSecret,
|
||||
code,
|
||||
allowBackup: true,
|
||||
}))
|
||||
) {
|
||||
throw InputValidationError.create('code', 'Invalid code');
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.TOTP);
|
||||
const hasSms = authenticatorTypes.has(UserAuthenticatorTypes.SMS);
|
||||
if (hasSms) {
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.SMS);
|
||||
}
|
||||
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(userId, {
|
||||
totp_secret: null,
|
||||
authenticator_types: authenticatorTypes,
|
||||
});
|
||||
await this.userAuthRepository.clearMfaBackupCodes(userId);
|
||||
await this.dispatchUserUpdate(updatedUser!);
|
||||
if (updatedUser) {
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
async getMfaBackupCodes(params: {
|
||||
user: User;
|
||||
regenerate: boolean;
|
||||
sudoContext: SudoVerificationResult;
|
||||
}): Promise<Array<MfaBackupCode>> {
|
||||
const {user, regenerate, sudoContext} = params;
|
||||
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
|
||||
if (regenerate) {
|
||||
return this.regenerateMfaBackupCodes(user);
|
||||
}
|
||||
|
||||
return await this.userAuthRepository.listMfaBackupCodes(user.id);
|
||||
}
|
||||
|
||||
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
|
||||
const userId = user.id;
|
||||
const newBackupCodes = this.authService.generateBackupCodes();
|
||||
await this.userAuthRepository.clearMfaBackupCodes(userId);
|
||||
return await this.userAuthRepository.createMfaBackupCodes(userId, newBackupCodes);
|
||||
}
|
||||
|
||||
async verifyEmail(token: string): Promise<boolean> {
|
||||
const emailToken = await this.userAuthRepository.getEmailVerificationToken(token);
|
||||
if (!emailToken) {
|
||||
return false;
|
||||
}
|
||||
const user = await this.userAccountRepository.findUnique(emailToken.userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(emailToken.userId, {
|
||||
email: emailToken.email,
|
||||
email_verified: true,
|
||||
});
|
||||
await this.userAuthRepository.deleteEmailVerificationToken(token);
|
||||
if (updatedUser) {
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<boolean> {
|
||||
if (user.emailVerified) {
|
||||
return true;
|
||||
}
|
||||
const verificationToken = createEmailVerificationToken(RandomUtils.randomString(64));
|
||||
await this.userAuthRepository.createEmailVerificationToken({
|
||||
token_: verificationToken,
|
||||
user_id: user.id,
|
||||
email: user.email!,
|
||||
});
|
||||
await this.emailService.sendEmailVerification(user.email!, user.username, verificationToken, user.locale);
|
||||
return true;
|
||||
}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(user),
|
||||
});
|
||||
}
|
||||
}
|
||||
487
fluxer_api/src/user/services/UserChannelService.ts
Normal file
487
fluxer_api/src/user/services/UserChannelService.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createChannelID, createMessageID, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes, MAX_GROUP_DMS_PER_USER, MessageTypes, RelationshipTypes} from '~/Constants';
|
||||
import {mapChannelToResponse} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {ChannelService} from '~/channel/services/ChannelService';
|
||||
import {dispatchMessageCreate} from '~/channel/services/group_dm/GroupDmHelpers';
|
||||
import {
|
||||
CannotSendMessagesToUserError,
|
||||
InputValidationError,
|
||||
MaxGroupDmRecipientsError,
|
||||
MaxGroupDmsError,
|
||||
MissingAccessError,
|
||||
NotFriendsWithUserError,
|
||||
UnclaimedAccountRestrictedError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel, Message, User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {CreatePrivateChannelRequest} from '~/user/UserModel';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import type {UserPermissionUtils} from '~/utils/UserPermissionUtils';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
|
||||
|
||||
export class UserChannelService {
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userChannelRepository: IUserChannelRepository,
|
||||
private userRelationshipRepository: IUserRelationshipRepository,
|
||||
private channelService: ChannelService,
|
||||
private channelRepository: IChannelRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private userPermissionUtils: UserPermissionUtils,
|
||||
) {}
|
||||
|
||||
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
return await this.userChannelRepository.listPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async createOrOpenDMChannel({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: CreatePrivateChannelRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
if (data.recipients !== undefined) {
|
||||
return await this.createGroupDMChannel({
|
||||
userId,
|
||||
recipients: data.recipients,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
const recipientId = createUserID(data.recipient_id!);
|
||||
if (userId === recipientId) {
|
||||
throw InputValidationError.create('recipient_id', 'Cannot DM yourself');
|
||||
}
|
||||
const targetUser = await this.userAccountRepository.findUnique(recipientId);
|
||||
if (!targetUser) throw new UnknownUserError();
|
||||
|
||||
await this.validateDmPermission(userId, recipientId, targetUser);
|
||||
|
||||
const existingChannel = await this.userChannelRepository.findExistingDmState(userId, recipientId);
|
||||
if (existingChannel) {
|
||||
return await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
|
||||
}
|
||||
const channel = await this.createNewDMChannel({userId, recipientId, userCacheService, requestCache});
|
||||
return channel;
|
||||
}
|
||||
|
||||
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw InputValidationError.create('channel_id', 'Channel must be a DM or group DM');
|
||||
}
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
const newPinnedDMs = await this.userChannelRepository.addPinnedDm(userId, channelId);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: userId,
|
||||
event: 'USER_PINNED_DMS_UPDATE',
|
||||
data: newPinnedDMs.map(String),
|
||||
});
|
||||
}
|
||||
|
||||
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
throw InputValidationError.create('channel_id', 'Channel must be a DM or group DM');
|
||||
}
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
const newPinnedDMs = await this.userChannelRepository.removePinnedDm(userId, channelId);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: userId,
|
||||
event: 'USER_PINNED_DMS_UPDATE',
|
||||
data: newPinnedDMs.map(String),
|
||||
});
|
||||
}
|
||||
|
||||
async preloadDMMessages(params: {
|
||||
userId: UserID;
|
||||
channelIds: Array<ChannelID>;
|
||||
}): Promise<Record<string, Message | null>> {
|
||||
const {userId, channelIds} = params;
|
||||
if (channelIds.length > 100) {
|
||||
throw InputValidationError.create('channels', 'Cannot preload more than 100 channels at once');
|
||||
}
|
||||
|
||||
const results: Record<string, Message | null> = {};
|
||||
const fetchPromises = channelIds.map(async (channelId) => {
|
||||
try {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
|
||||
return;
|
||||
}
|
||||
if (!channel.recipientIds.has(userId)) {
|
||||
return;
|
||||
}
|
||||
const messages = await this.channelService.getMessages({
|
||||
userId,
|
||||
channelId,
|
||||
limit: 1,
|
||||
before: undefined,
|
||||
after: undefined,
|
||||
around: undefined,
|
||||
});
|
||||
results[channelId.toString()] = messages[0] ?? null;
|
||||
} catch {
|
||||
results[channelId.toString()] = null;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(fetchPromises);
|
||||
return results;
|
||||
}
|
||||
|
||||
async getExistingDmForUsers(userId: UserID, recipientId: UserID): Promise<Channel | null> {
|
||||
return await this.userChannelRepository.findExistingDmState(userId, recipientId);
|
||||
}
|
||||
|
||||
async reopenDmForBothUsers({
|
||||
userId,
|
||||
recipientId,
|
||||
existingChannel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipientId: UserID;
|
||||
existingChannel: Channel;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.reopenExistingDMChannel({userId, existingChannel, userCacheService, requestCache});
|
||||
await this.reopenExistingDMChannel({
|
||||
userId: recipientId,
|
||||
existingChannel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async createNewDmForBothUsers({
|
||||
userId,
|
||||
recipientId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipientId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const newChannel = await this.createNewDMChannel({
|
||||
userId,
|
||||
recipientId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.userChannelRepository.openDmForUser(recipientId, newChannel.id);
|
||||
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
private async reopenExistingDMChannel({
|
||||
userId,
|
||||
existingChannel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
existingChannel: Channel;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
await this.userChannelRepository.openDmForUser(userId, existingChannel.id);
|
||||
await this.dispatchChannelCreate({userId, channel: existingChannel, userCacheService, requestCache});
|
||||
return existingChannel;
|
||||
}
|
||||
|
||||
private async createNewDMChannel({
|
||||
userId,
|
||||
recipientId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipientId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const channelId = createChannelID(this.snowflakeService.generate());
|
||||
const newChannel = await this.userChannelRepository.createDmChannelAndState(userId, recipientId, channelId);
|
||||
await this.userChannelRepository.openDmForUser(userId, channelId);
|
||||
await this.dispatchChannelCreate({userId, channel: newChannel, userCacheService, requestCache});
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
private async createGroupDMChannel({
|
||||
userId,
|
||||
recipients,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
recipients: Array<bigint>;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
if (recipients.length > 9) {
|
||||
throw new MaxGroupDmRecipientsError();
|
||||
}
|
||||
|
||||
const recipientIds = recipients.map(createUserID);
|
||||
const uniqueRecipientIds = new Set(recipientIds);
|
||||
if (uniqueRecipientIds.size !== recipientIds.length) {
|
||||
throw InputValidationError.create('recipients', 'Duplicate recipients are not allowed');
|
||||
}
|
||||
|
||||
if (uniqueRecipientIds.has(userId)) {
|
||||
throw InputValidationError.create('recipients', 'Cannot add yourself to a group DM');
|
||||
}
|
||||
|
||||
const usersToCheck = new Set<UserID>([userId, ...recipientIds]);
|
||||
await this.ensureUsersWithinGroupDmLimit(usersToCheck);
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
const targetUser = await this.userAccountRepository.findUnique(recipientId);
|
||||
if (!targetUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const friendship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
recipientId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (!friendship) {
|
||||
throw new NotFriendsWithUserError();
|
||||
}
|
||||
|
||||
await this.userPermissionUtils.validateGroupDmAddPermissions({userId, targetId: recipientId});
|
||||
}
|
||||
|
||||
const channelId = createChannelID(this.snowflakeService.generate());
|
||||
const allRecipients = new Set([userId, ...recipientIds]);
|
||||
|
||||
const channelData = {
|
||||
channel_id: channelId,
|
||||
guild_id: null,
|
||||
type: ChannelTypes.GROUP_DM,
|
||||
name: null,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: 0,
|
||||
owner_id: userId,
|
||||
recipient_ids: allRecipients,
|
||||
nsfw: false,
|
||||
rate_limit_per_user: 0,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const newChannel = await this.channelRepository.upsert(channelData);
|
||||
|
||||
for (const recipientId of allRecipients) {
|
||||
await this.userChannelRepository.openDmForUser(recipientId, channelId);
|
||||
}
|
||||
|
||||
const systemMessages: Array<Message> = [];
|
||||
for (const recipientId of recipientIds) {
|
||||
const messageId = createMessageID(this.snowflakeService.generate());
|
||||
const message = await this.channelRepository.upsertMessage({
|
||||
channel_id: channelId,
|
||||
bucket: BucketUtils.makeBucket(messageId),
|
||||
message_id: messageId,
|
||||
author_id: userId,
|
||||
type: MessageTypes.RECIPIENT_ADD,
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: null,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
mention_everyone: false,
|
||||
mention_users: new Set([recipientId]),
|
||||
mention_roles: null,
|
||||
mention_channels: null,
|
||||
attachments: null,
|
||||
embeds: null,
|
||||
sticker_items: null,
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
systemMessages.push(message);
|
||||
}
|
||||
|
||||
for (const recipientId of allRecipients) {
|
||||
await this.dispatchChannelCreate({userId: recipientId, channel: newChannel, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
for (const message of systemMessages) {
|
||||
await this.dispatchSystemMessage({
|
||||
channel: newChannel,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
private async dispatchSystemMessage({
|
||||
channel,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
channel: Channel;
|
||||
message: Message;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await dispatchMessageCreate({
|
||||
channel,
|
||||
message,
|
||||
requestCache,
|
||||
userCacheService,
|
||||
gatewayService: this.gatewayService,
|
||||
mediaService: this.mediaService,
|
||||
getReferencedMessage: (channelId, messageId) => this.channelRepository.getMessage(channelId, messageId),
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchChannelCreate({
|
||||
userId,
|
||||
channel,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channel: Channel;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_CREATE',
|
||||
data: channelResponse,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureUsersWithinGroupDmLimit(userIds: Iterable<UserID>): Promise<void> {
|
||||
for (const userId of userIds) {
|
||||
await this.ensureUserWithinGroupDmLimit(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserWithinGroupDmLimit(userId: UserID): Promise<void> {
|
||||
const summaries = await this.userChannelRepository.listPrivateChannelSummaries(userId);
|
||||
const openGroupDms = summaries.filter((summary) => summary.open && summary.isGroupDm).length;
|
||||
if (openGroupDms >= MAX_GROUP_DMS_PER_USER) {
|
||||
throw new MaxGroupDmsError();
|
||||
}
|
||||
}
|
||||
|
||||
private async validateDmPermission(userId: UserID, recipientId: UserID, recipientUser?: User | null): Promise<void> {
|
||||
const senderUser = await this.userAccountRepository.findUnique(userId);
|
||||
if (senderUser && !senderUser.passwordHash && !senderUser.isBot) {
|
||||
throw new UnclaimedAccountRestrictedError('send direct messages');
|
||||
}
|
||||
|
||||
const resolvedRecipient = recipientUser ?? (await this.userAccountRepository.findUnique(recipientId));
|
||||
if (resolvedRecipient && !resolvedRecipient.passwordHash && !resolvedRecipient.isBot) {
|
||||
throw new UnclaimedAccountRestrictedError('receive direct messages');
|
||||
}
|
||||
|
||||
const userBlockedRecipient = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
recipientId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (userBlockedRecipient) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const recipientBlockedUser = await this.userRelationshipRepository.getRelationship(
|
||||
recipientId,
|
||||
userId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (recipientBlockedUser) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
|
||||
const friendship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
recipientId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (friendship) return;
|
||||
|
||||
const hasMutualGuilds = await this.userPermissionUtils.checkMutualGuildsAsync({
|
||||
userId,
|
||||
targetId: recipientId,
|
||||
});
|
||||
if (hasMutualGuilds) return;
|
||||
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
}
|
||||
118
fluxer_api/src/user/services/UserContactChangeLogService.ts
Normal file
118
fluxer_api/src/user/services/UserContactChangeLogService.ts
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 '~/BrandedTypes';
|
||||
import type {UserContactChangeLogRow} from '~/database/CassandraTypes';
|
||||
import type {User} from '~/Models';
|
||||
import type {UserContactChangeLogRepository} from '../repositories/UserContactChangeLogRepository';
|
||||
|
||||
export type ContactChangeReason = 'user_requested' | 'admin_action';
|
||||
|
||||
interface RecordDiffParams {
|
||||
oldUser: User | null;
|
||||
newUser: User;
|
||||
reason: ContactChangeReason;
|
||||
actorUserId: UserID | null;
|
||||
eventAt?: Date;
|
||||
}
|
||||
|
||||
interface ListLogsParams {
|
||||
userId: UserID;
|
||||
limit?: number;
|
||||
beforeEventId?: string;
|
||||
}
|
||||
|
||||
export class UserContactChangeLogService {
|
||||
private readonly DEFAULT_LIMIT = 50;
|
||||
|
||||
constructor(private readonly repo: UserContactChangeLogRepository) {}
|
||||
|
||||
async recordDiff(params: RecordDiffParams): Promise<void> {
|
||||
const {oldUser, newUser, reason, actorUserId, eventAt} = params;
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
|
||||
const oldEmail = oldUser?.email?.toLowerCase() ?? null;
|
||||
const newEmail = newUser.email?.toLowerCase() ?? null;
|
||||
if (oldEmail !== newEmail) {
|
||||
tasks.push(
|
||||
this.repo.insertLog({
|
||||
userId: newUser.id,
|
||||
field: 'email',
|
||||
oldValue: oldEmail,
|
||||
newValue: newEmail,
|
||||
reason,
|
||||
actorUserId,
|
||||
eventAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const oldPhone = oldUser?.phone ?? null;
|
||||
const newPhone = newUser.phone ?? null;
|
||||
if (oldPhone !== newPhone) {
|
||||
tasks.push(
|
||||
this.repo.insertLog({
|
||||
userId: newUser.id,
|
||||
field: 'phone',
|
||||
oldValue: oldPhone,
|
||||
newValue: newPhone,
|
||||
reason,
|
||||
actorUserId,
|
||||
eventAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const oldTag = oldUser ? this.buildFluxerTag(oldUser) : null;
|
||||
const newTag = this.buildFluxerTag(newUser);
|
||||
if (oldTag !== newTag) {
|
||||
tasks.push(
|
||||
this.repo.insertLog({
|
||||
userId: newUser.id,
|
||||
field: 'fluxer_tag',
|
||||
oldValue: oldTag,
|
||||
newValue: newTag,
|
||||
reason,
|
||||
actorUserId,
|
||||
eventAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
async listLogs(params: ListLogsParams): Promise<Array<UserContactChangeLogRow>> {
|
||||
const {userId, beforeEventId} = params;
|
||||
const limit = params.limit ?? this.DEFAULT_LIMIT;
|
||||
return this.repo.listLogs({userId, limit, beforeEventId});
|
||||
}
|
||||
|
||||
private buildFluxerTag(user: User | null): string | null {
|
||||
if (!user) return null;
|
||||
const discriminator = user.discriminator?.toString() ?? '';
|
||||
if (!user.username || discriminator === '') {
|
||||
return null;
|
||||
}
|
||||
const paddedDiscriminator = discriminator.padStart(4, '0');
|
||||
return `${user.username}#${paddedDiscriminator}`;
|
||||
}
|
||||
}
|
||||
579
fluxer_api/src/user/services/UserContentService.ts
Normal file
579
fluxer_api/src/user/services/UserContentService.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {type ChannelID, createBetaCode, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {MAX_BOOKMARKS_NON_PREMIUM, MAX_BOOKMARKS_PREMIUM} from '~/Constants';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {ChannelService} from '~/channel/services/ChannelService';
|
||||
import type {PushSubscriptionRow} from '~/database/CassandraTypes';
|
||||
import {
|
||||
BetaCodeAllowanceExceededError,
|
||||
BetaCodeMaxUnclaimedError,
|
||||
HarvestExpiredError,
|
||||
HarvestFailedError,
|
||||
HarvestNotReadyError,
|
||||
HarvestOnCooldownError,
|
||||
MaxBookmarksError,
|
||||
MissingPermissionsError,
|
||||
UnknownChannelError,
|
||||
UnknownHarvestError,
|
||||
UnknownMessageError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {BetaCode, Message, PushSubscription} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserContentRepository} from '../repositories/IUserContentRepository';
|
||||
import {UserHarvest, type UserHarvestResponse} from '../UserHarvestModel';
|
||||
import {UserHarvestRepository} from '../UserHarvestRepository';
|
||||
import type {SavedMessageStatus} from '../UserTypes';
|
||||
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
|
||||
|
||||
export class UserContentService {
|
||||
private readonly updatePropagator: BaseUserUpdatePropagator;
|
||||
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userContentRepository: IUserContentRepository,
|
||||
userCacheService: UserCacheService,
|
||||
private channelService: ChannelService,
|
||||
private channelRepository: IChannelRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private mediaService: IMediaService,
|
||||
private workerService: IWorkerService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService,
|
||||
) {
|
||||
this.updatePropagator = new BaseUserUpdatePropagator({
|
||||
userCacheService,
|
||||
gatewayService: this.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async getBetaCodes(userId: UserID): Promise<Array<BetaCode>> {
|
||||
const betaCodes = await this.userContentRepository.listBetaCodes(userId);
|
||||
const now = new Date();
|
||||
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return betaCodes.filter((code) => {
|
||||
if (!code.redeemedAt) {
|
||||
return true;
|
||||
}
|
||||
return code.redeemedAt >= oneWeekAgo;
|
||||
});
|
||||
}
|
||||
|
||||
async getBetaCodeAllowanceInfo(userId: UserID): Promise<{allowance: number; nextResetAt: Date | null}> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let allowance = user.betaCodeAllowance;
|
||||
let lastResetAt = user.betaCodeLastResetAt;
|
||||
|
||||
if (!lastResetAt || lastResetAt < oneWeekAgo) {
|
||||
allowance = 3;
|
||||
lastResetAt = now;
|
||||
}
|
||||
|
||||
const nextResetAt = lastResetAt ? new Date(lastResetAt.getTime() + 7 * 24 * 60 * 60 * 1000) : null;
|
||||
|
||||
return {allowance, nextResetAt};
|
||||
}
|
||||
|
||||
async createBetaCode(userId: UserID): Promise<BetaCode> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const existingBetaCodes = await this.userContentRepository.listBetaCodes(userId);
|
||||
const unclaimedCount = existingBetaCodes.filter((code) => !code.redeemerId).length;
|
||||
|
||||
if (unclaimedCount >= 6) {
|
||||
throw new BetaCodeMaxUnclaimedError();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let allowance = user.betaCodeAllowance;
|
||||
let lastResetAt = user.betaCodeLastResetAt;
|
||||
|
||||
if (!lastResetAt || lastResetAt < oneWeekAgo) {
|
||||
allowance = 3;
|
||||
lastResetAt = now;
|
||||
}
|
||||
|
||||
if (allowance <= 0) {
|
||||
throw new BetaCodeAllowanceExceededError();
|
||||
}
|
||||
|
||||
await this.userAccountRepository.patchUpsert(userId, {
|
||||
beta_code_allowance: allowance - 1,
|
||||
beta_code_last_reset_at: lastResetAt,
|
||||
});
|
||||
|
||||
return await this.userContentRepository.upsertBetaCode({
|
||||
code: createBetaCode(RandomUtils.randomString(32)),
|
||||
created_at: now,
|
||||
creator_id: userId,
|
||||
redeemed_at: null,
|
||||
redeemer_id: null,
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBetaCode(params: {userId: UserID; code: string}): Promise<void> {
|
||||
const {userId, code} = params;
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let allowance = user.betaCodeAllowance;
|
||||
let lastResetAt = user.betaCodeLastResetAt;
|
||||
|
||||
if (!lastResetAt || lastResetAt < oneWeekAgo) {
|
||||
allowance = 3;
|
||||
lastResetAt = now;
|
||||
}
|
||||
|
||||
await this.userContentRepository.deleteBetaCode(code, userId);
|
||||
|
||||
await this.userAccountRepository.patchUpsert(userId, {
|
||||
beta_code_allowance: Math.min(allowance + 1, 3),
|
||||
beta_code_last_reset_at: lastResetAt,
|
||||
});
|
||||
}
|
||||
|
||||
async getRecentMentions(params: {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
everyone: boolean;
|
||||
roles: boolean;
|
||||
guilds: boolean;
|
||||
before?: MessageID;
|
||||
}): Promise<Array<Message>> {
|
||||
const {userId, limit, everyone, roles, guilds, before} = params;
|
||||
const mentions = await this.userContentRepository.listRecentMentions(
|
||||
userId,
|
||||
everyone,
|
||||
roles,
|
||||
guilds,
|
||||
limit,
|
||||
before,
|
||||
);
|
||||
const messagePromises = mentions.map(async (mention) => {
|
||||
try {
|
||||
return await this.channelService.getMessage({
|
||||
userId,
|
||||
channelId: mention.channelId,
|
||||
messageId: mention.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownMessageError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const messageResults = await Promise.all(messagePromises);
|
||||
const messages = messageResults.filter((message): message is Message => message != null);
|
||||
return messages.sort((a, b) => (b.id > a.id ? 1 : -1));
|
||||
}
|
||||
|
||||
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
const recentMention = await this.userContentRepository.getRecentMention(userId, messageId);
|
||||
if (!recentMention) return;
|
||||
await this.userContentRepository.deleteRecentMention(recentMention);
|
||||
await this.dispatchRecentMentionDelete({userId, messageId});
|
||||
}
|
||||
|
||||
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<
|
||||
Array<{
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
status: SavedMessageStatus;
|
||||
message: Message | null;
|
||||
}>
|
||||
> {
|
||||
const savedMessages = await this.userContentRepository.listSavedMessages(userId, limit);
|
||||
const results: Array<{
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
status: SavedMessageStatus;
|
||||
message: Message | null;
|
||||
}> = [];
|
||||
|
||||
for (const savedMessage of savedMessages) {
|
||||
let message: Message | null = null;
|
||||
let status: SavedMessageStatus = 'available';
|
||||
|
||||
try {
|
||||
message = await this.channelService.getMessage({
|
||||
userId,
|
||||
channelId: savedMessage.channelId,
|
||||
messageId: savedMessage.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownMessageError) {
|
||||
await this.userContentRepository.deleteSavedMessage(userId, savedMessage.messageId);
|
||||
continue;
|
||||
}
|
||||
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
|
||||
status = 'missing_permissions';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
channelId: savedMessage.channelId,
|
||||
messageId: savedMessage.messageId,
|
||||
status,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return results.sort((a, b) => (b.messageId > a.messageId ? 1 : a.messageId > b.messageId ? -1 : 0));
|
||||
}
|
||||
|
||||
async saveMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const savedMessages = await this.userContentRepository.listSavedMessages(userId, 1000);
|
||||
const maxBookmarks = user.isPremium() ? MAX_BOOKMARKS_PREMIUM : MAX_BOOKMARKS_NON_PREMIUM;
|
||||
|
||||
if (savedMessages.length >= maxBookmarks) {
|
||||
throw new MaxBookmarksError(user.isPremium());
|
||||
}
|
||||
|
||||
await this.channelService.getChannelAuthenticated({userId, channelId});
|
||||
const message = await this.channelService.getMessage({userId, channelId, messageId});
|
||||
if (!message) {
|
||||
throw new UnknownMessageError();
|
||||
}
|
||||
await this.userContentRepository.createSavedMessage(userId, channelId, messageId);
|
||||
await this.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
await this.userContentRepository.deleteSavedMessage(userId, messageId);
|
||||
await this.dispatchSavedMessageDelete({userId, messageId});
|
||||
}
|
||||
|
||||
async registerPushSubscription(params: {
|
||||
userId: UserID;
|
||||
endpoint: string;
|
||||
keys: {p256dh: string; auth: string};
|
||||
userAgent?: string;
|
||||
}): Promise<PushSubscription> {
|
||||
const {userId, endpoint, keys, userAgent} = params;
|
||||
|
||||
const subscriptionId = crypto.createHash('sha256').update(endpoint).digest('hex').substring(0, 32);
|
||||
|
||||
const data: PushSubscriptionRow = {
|
||||
user_id: userId,
|
||||
subscription_id: subscriptionId,
|
||||
endpoint,
|
||||
p256dh_key: keys.p256dh,
|
||||
auth_key: keys.auth,
|
||||
user_agent: userAgent ?? null,
|
||||
};
|
||||
|
||||
return await this.userContentRepository.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return await this.userContentRepository.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
await this.userContentRepository.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async requestDataHarvest(userId: UserID): Promise<{harvestId: string}> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (!user) throw new UnknownUserError();
|
||||
|
||||
if (!Config.dev.testModeEnabled) {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const latestHarvest = await harvestRepository.findLatestByUserId(userId);
|
||||
|
||||
if (latestHarvest?.requestedAt) {
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
if (latestHarvest.requestedAt > sevenDaysAgo) {
|
||||
const retryAfter = new Date(latestHarvest.requestedAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
throw new HarvestOnCooldownError({retryAfter});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const harvestId = this.snowflakeService.generate();
|
||||
const harvest = new UserHarvest({
|
||||
user_id: userId,
|
||||
harvest_id: harvestId,
|
||||
requested_at: new Date(),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
failed_at: null,
|
||||
storage_key: null,
|
||||
file_size: null,
|
||||
progress_percent: 0,
|
||||
progress_step: 'Queued',
|
||||
error_message: null,
|
||||
download_url_expires_at: null,
|
||||
});
|
||||
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
await harvestRepository.create(harvest);
|
||||
|
||||
await this.workerService.addJob('harvestUserData', {
|
||||
userId: userId.toString(),
|
||||
harvestId: harvestId.toString(),
|
||||
});
|
||||
|
||||
return {harvestId: harvestId.toString()};
|
||||
}
|
||||
|
||||
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
||||
if (!harvest) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
return harvest.toResponse();
|
||||
}
|
||||
|
||||
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findLatestByUserId(userId);
|
||||
return harvest ? harvest.toResponse() : null;
|
||||
}
|
||||
|
||||
async getHarvestDownloadUrl(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
storageService: IStorageService,
|
||||
): Promise<{downloadUrl: string; expiresAt: string}> {
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
||||
|
||||
if (!harvest) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
|
||||
if (!harvest.completedAt || !harvest.storageKey) {
|
||||
throw new HarvestNotReadyError();
|
||||
}
|
||||
|
||||
if (harvest.failedAt) {
|
||||
throw new HarvestFailedError();
|
||||
}
|
||||
|
||||
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
|
||||
throw new HarvestExpiredError();
|
||||
}
|
||||
|
||||
const ZIP_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
|
||||
const downloadUrl = await storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: harvest.storageKey,
|
||||
expiresIn: ZIP_EXPIRY_SECONDS,
|
||||
});
|
||||
|
||||
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_SECONDS * 1000);
|
||||
|
||||
return {
|
||||
downloadUrl,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
|
||||
const {userId, delayMs = 24 * 60 * 60 * 1000} = params;
|
||||
const scheduledAt = new Date(Date.now() + delayMs);
|
||||
|
||||
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
||||
|
||||
const counts = await this.countBulkDeletionTargets(userId, scheduledAt.getTime());
|
||||
Logger.debug(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
channelCount: counts.channelCount,
|
||||
messageCount: counts.messageCount,
|
||||
scheduledAt: scheduledAt.toISOString(),
|
||||
},
|
||||
'Scheduling bulk message deletion',
|
||||
);
|
||||
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(userId, {
|
||||
pending_bulk_message_deletion_at: scheduledAt,
|
||||
pending_bulk_message_deletion_channel_count: counts.channelCount,
|
||||
pending_bulk_message_deletion_message_count: counts.messageCount,
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await this.bulkMessageDeletionQueue.scheduleDeletion(userId, scheduledAt);
|
||||
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
|
||||
Logger.debug({userId: userId.toString()}, 'Canceling pending bulk message deletion');
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(userId, {
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
||||
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
}
|
||||
|
||||
private async countBulkDeletionTargets(
|
||||
userId: UserID,
|
||||
cutoffMs: number,
|
||||
): Promise<{
|
||||
channelCount: number;
|
||||
messageCount: number;
|
||||
}> {
|
||||
const CHUNK_SIZE = 200;
|
||||
let lastChannelId: ChannelID | undefined;
|
||||
let lastMessageId: MessageID | undefined;
|
||||
const channels = new Set<string>();
|
||||
let messageCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messageRefs = await this.channelRepository.listMessagesByAuthor(
|
||||
userId,
|
||||
CHUNK_SIZE,
|
||||
lastChannelId,
|
||||
lastMessageId,
|
||||
);
|
||||
if (messageRefs.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messageRefs) {
|
||||
if (SnowflakeUtils.extractTimestamp(messageId) > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
channels.add(channelId.toString());
|
||||
messageCount++;
|
||||
}
|
||||
|
||||
lastChannelId = messageRefs[messageRefs.length - 1].channelId;
|
||||
lastMessageId = messageRefs[messageRefs.length - 1].messageId;
|
||||
|
||||
if (messageRefs.length < CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelCount: channels.size,
|
||||
messageCount,
|
||||
};
|
||||
}
|
||||
|
||||
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RECENT_MENTION_DELETE',
|
||||
data: {message_id: messageId.toString()},
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageCreate({
|
||||
userId,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
message: Message;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'SAVED_MESSAGE_CREATE',
|
||||
data: await mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: userId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService: this.mediaService,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'SAVED_MESSAGE_DELETE',
|
||||
data: {message_id: messageId.toString()},
|
||||
});
|
||||
}
|
||||
}
|
||||
122
fluxer_api/src/user/services/UserDeletionEligibilityService.ts
Normal file
122
fluxer_api/src/user/services/UserDeletionEligibilityService.ts
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 type {Redis} from 'ioredis';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import type {User} from '~/models/User';
|
||||
|
||||
export class UserDeletionEligibilityService {
|
||||
private readonly INACTIVITY_WARNING_TTL_DAYS = 30;
|
||||
private readonly INACTIVITY_WARNING_PREFIX = 'inactivity_warning_sent';
|
||||
|
||||
constructor(private redis: Redis) {}
|
||||
|
||||
async isEligibleForInactivityDeletion(user: User): Promise<boolean> {
|
||||
if (user.isBot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.isSystem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check: User must not have APP_STORE_REVIEWER flag set
|
||||
if ((user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.pendingDeletionAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.lastActiveAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inactivityThresholdMs = this.getInactivityThresholdMs();
|
||||
const timeSinceLastActiveMs = Date.now() - user.lastActiveAt.getTime();
|
||||
|
||||
if (timeSinceLastActiveMs < inactivityThresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async isEligibleForWarningEmail(user: User): Promise<boolean> {
|
||||
const isEligibleForDeletion = await this.isEligibleForInactivityDeletion(user);
|
||||
if (!isEligibleForDeletion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const alreadySentWarning = await this.hasWarningSent(user.id);
|
||||
if (alreadySentWarning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async markWarningSent(userId: UserID): Promise<void> {
|
||||
const key = this.getWarningRedisKey(userId);
|
||||
const ttlSeconds = (this.INACTIVITY_WARNING_TTL_DAYS + 5) * 24 * 60 * 60;
|
||||
const timestamp = Date.now().toString();
|
||||
|
||||
await this.redis.setex(key, ttlSeconds, timestamp);
|
||||
}
|
||||
|
||||
async hasWarningSent(userId: UserID): Promise<boolean> {
|
||||
const key = this.getWarningRedisKey(userId);
|
||||
const exists = await this.redis.exists(key);
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
async getWarningSentTimestamp(userId: UserID): Promise<number | null> {
|
||||
const key = this.getWarningRedisKey(userId);
|
||||
const value = await this.redis.get(key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = parseInt(value, 10);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
async hasWarningGracePeriodExpired(userId: UserID): Promise<boolean> {
|
||||
const timestamp = await this.getWarningSentTimestamp(userId);
|
||||
if (timestamp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeSinceWarningMs = Date.now() - timestamp;
|
||||
const gracePeriodMs = this.INACTIVITY_WARNING_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
return timeSinceWarningMs >= gracePeriodMs;
|
||||
}
|
||||
|
||||
private getInactivityThresholdMs(): number {
|
||||
const thresholdDays = Config.inactivityDeletionThresholdDays ?? 365 * 2;
|
||||
return thresholdDays * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
private getWarningRedisKey(userId: UserID): string {
|
||||
return `${this.INACTIVITY_WARNING_PREFIX}:${userId}`;
|
||||
}
|
||||
}
|
||||
504
fluxer_api/src/user/services/UserDeletionService.ts
Normal file
504
fluxer_api/src/user/services/UserDeletionService.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
import {type ChannelID, createMessageID, createUserID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {ChannelTypes, MessageTypes, UserFlags} from '~/Constants';
|
||||
import {mapChannelToResponse} from '~/channel/ChannelModel';
|
||||
import type {ChannelRepository} from '~/channel/ChannelRepository';
|
||||
import type {FavoriteMemeRepository} from '~/favorite_meme/FavoriteMemeRepository';
|
||||
import type {GuildRepository} from '~/guild/repositories/GuildRepository';
|
||||
import type {CloudflarePurgeQueue, NoopCloudflarePurgeQueue} from '~/infrastructure/CloudflarePurgeQueue';
|
||||
import type {DiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {GatewayService} from '~/infrastructure/GatewayService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {StorageService} from '~/infrastructure/StorageService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {ApplicationRepository} from '~/oauth/repositories/ApplicationRepository';
|
||||
import type {OAuth2TokenRepository} from '~/oauth/repositories/OAuth2TokenRepository';
|
||||
import type {UserRepository} from '~/user/UserRepository';
|
||||
import * as BucketUtils from '~/utils/BucketUtils';
|
||||
import {randomString} from '~/utils/RandomUtils';
|
||||
import type {WorkerService} from '~/worker/WorkerService';
|
||||
|
||||
function createRequestCache(): RequestCache {
|
||||
return {
|
||||
userPartials: new Map(),
|
||||
clear: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 100;
|
||||
const DELETED_USERNAME = '__deleted__';
|
||||
|
||||
export interface UserDeletionDependencies {
|
||||
userRepository: UserRepository;
|
||||
guildRepository: GuildRepository;
|
||||
channelRepository: ChannelRepository;
|
||||
favoriteMemeRepository: FavoriteMemeRepository;
|
||||
oauth2TokenRepository: OAuth2TokenRepository;
|
||||
storageService: StorageService;
|
||||
cloudflarePurgeQueue: CloudflarePurgeQueue | NoopCloudflarePurgeQueue;
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: GatewayService;
|
||||
snowflakeService: SnowflakeService;
|
||||
discriminatorService: DiscriminatorService;
|
||||
stripe: Stripe | null;
|
||||
applicationRepository: ApplicationRepository;
|
||||
workerService: WorkerService;
|
||||
}
|
||||
|
||||
export async function processUserDeletion(
|
||||
userId: UserID,
|
||||
deletionReasonCode: number,
|
||||
deps: UserDeletionDependencies,
|
||||
): Promise<void> {
|
||||
const {
|
||||
userRepository,
|
||||
guildRepository,
|
||||
channelRepository,
|
||||
favoriteMemeRepository,
|
||||
oauth2TokenRepository,
|
||||
storageService,
|
||||
cloudflarePurgeQueue,
|
||||
userCacheService,
|
||||
gatewayService,
|
||||
snowflakeService,
|
||||
discriminatorService,
|
||||
stripe,
|
||||
applicationRepository,
|
||||
workerService,
|
||||
} = deps;
|
||||
|
||||
Logger.debug({userId, deletionReasonCode}, 'Starting user account deletion');
|
||||
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
Logger.warn({userId}, 'User not found, skipping deletion');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.stripeSubscriptionId && stripe) {
|
||||
try {
|
||||
Logger.debug({userId, subscriptionId: user.stripeSubscriptionId}, 'Canceling active Stripe subscription');
|
||||
await stripe.subscriptions.cancel(user.stripeSubscriptionId, {
|
||||
invoice_now: false,
|
||||
prorate: false,
|
||||
});
|
||||
Logger.debug({userId, subscriptionId: user.stripeSubscriptionId}, 'Stripe subscription cancelled successfully');
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, userId, subscriptionId: user.stripeSubscriptionId},
|
||||
'Failed to cancel Stripe subscription, continuing with deletion',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const deletedUserId = createUserID(snowflakeService.generate());
|
||||
Logger.debug({userId, deletedUserId}, 'Creating dedicated deleted user record');
|
||||
|
||||
let foundUsername: string;
|
||||
let foundDiscriminator: number;
|
||||
while (true) {
|
||||
foundUsername = `DeletedUser${randomString(8)}`;
|
||||
const discriminatorResult = await discriminatorService.generateDiscriminator({
|
||||
username: foundUsername,
|
||||
isPremium: false,
|
||||
});
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
continue;
|
||||
}
|
||||
foundDiscriminator = discriminatorResult.discriminator;
|
||||
break;
|
||||
}
|
||||
|
||||
await userRepository.create({
|
||||
user_id: deletedUserId,
|
||||
username: foundUsername,
|
||||
discriminator: foundDiscriminator,
|
||||
global_name: 'Deleted User',
|
||||
bot: false,
|
||||
system: true,
|
||||
email: null,
|
||||
email_verified: null,
|
||||
email_bounced: null,
|
||||
phone: null,
|
||||
password_hash: null,
|
||||
password_last_changed_at: null,
|
||||
totp_secret: null,
|
||||
authenticator_types: null,
|
||||
avatar_hash: null,
|
||||
avatar_color: null,
|
||||
banner_hash: null,
|
||||
banner_color: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
date_of_birth: null,
|
||||
locale: null,
|
||||
flags: UserFlags.DELETED,
|
||||
premium_type: null,
|
||||
premium_since: null,
|
||||
premium_until: null,
|
||||
premium_will_cancel: null,
|
||||
premium_billing_cycle: null,
|
||||
premium_lifetime_sequence: null,
|
||||
stripe_subscription_id: null,
|
||||
stripe_customer_id: null,
|
||||
has_ever_purchased: null,
|
||||
suspicious_activity_flags: null,
|
||||
terms_agreed_at: null,
|
||||
privacy_agreed_at: null,
|
||||
last_active_at: null,
|
||||
last_active_ip: null,
|
||||
temp_banned_until: null,
|
||||
pending_deletion_at: null,
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
acls: null,
|
||||
first_refund_at: null,
|
||||
beta_code_allowance: null,
|
||||
beta_code_last_reset_at: null,
|
||||
gift_inventory_server_seq: null,
|
||||
gift_inventory_client_seq: null,
|
||||
premium_onboarding_dismissed_at: null,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await userRepository.deleteUserSecondaryIndices(deletedUserId);
|
||||
|
||||
Logger.debug({userId}, 'Leaving all guilds');
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
try {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
Logger.debug({userId, guildId}, 'Member not found in guild, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (member.avatarHash) {
|
||||
try {
|
||||
const key = `guilds/${guildId}/users/${userId}/avatars/${member.avatarHash}`;
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, key);
|
||||
await cloudflarePurgeQueue.addUrls([`${Config.endpoints.media}/${key}`]);
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, guildId, avatarHash: member.avatarHash}, 'Failed to delete guild member avatar');
|
||||
}
|
||||
}
|
||||
|
||||
if (member.bannerHash) {
|
||||
try {
|
||||
const key = `guilds/${guildId}/users/${userId}/banners/${member.bannerHash}`;
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, key);
|
||||
await cloudflarePurgeQueue.addUrls([`${Config.endpoints.media}/${key}`]);
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, guildId, bannerHash: member.bannerHash}, 'Failed to delete guild member banner');
|
||||
}
|
||||
}
|
||||
|
||||
await guildRepository.deleteMember(guildId, userId);
|
||||
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (guild) {
|
||||
const guildRow = guild.toRow();
|
||||
await guildRepository.upsert({
|
||||
...guildRow,
|
||||
member_count: Math.max(0, guild.memberCount - 1),
|
||||
});
|
||||
}
|
||||
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_REMOVE',
|
||||
data: {user: {id: userId.toString()}},
|
||||
});
|
||||
|
||||
await gatewayService.leaveGuild({userId, guildId});
|
||||
|
||||
Logger.debug({userId, guildId}, 'Left guild successfully');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, guildId}, 'Failed to leave guild');
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({userId}, 'Leaving all group DMs');
|
||||
|
||||
const allPrivateChannels = await userRepository.listPrivateChannels(userId);
|
||||
const groupDmChannels = allPrivateChannels.filter((channel) => channel.type === ChannelTypes.GROUP_DM);
|
||||
|
||||
for (const channel of groupDmChannels) {
|
||||
try {
|
||||
const updatedRecipientIds = new Set(channel.recipientIds);
|
||||
updatedRecipientIds.delete(userId);
|
||||
|
||||
let newOwnerId = channel.ownerId;
|
||||
if (userId === channel.ownerId && updatedRecipientIds.size > 0) {
|
||||
newOwnerId = Array.from(updatedRecipientIds)[0];
|
||||
}
|
||||
|
||||
if (updatedRecipientIds.size === 0) {
|
||||
await channelRepository.delete(channel.id);
|
||||
await userRepository.closeDmForUser(userId, channel.id);
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService,
|
||||
requestCache: createRequestCache(),
|
||||
});
|
||||
|
||||
await gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: channelResponse,
|
||||
});
|
||||
|
||||
Logger.debug({userId, channelId: channel.id}, 'Deleted empty group DM');
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedNicknames = new Map(channel.nicknames);
|
||||
updatedNicknames.delete(userId.toString());
|
||||
|
||||
await channelRepository.upsert({
|
||||
...channel.toRow(),
|
||||
owner_id: newOwnerId,
|
||||
recipient_ids: updatedRecipientIds,
|
||||
nicks: updatedNicknames.size > 0 ? updatedNicknames : null,
|
||||
});
|
||||
|
||||
await userRepository.closeDmForUser(userId, channel.id);
|
||||
|
||||
const messageId = createMessageID(snowflakeService.generate());
|
||||
|
||||
await channelRepository.upsertMessage({
|
||||
channel_id: channel.id,
|
||||
bucket: BucketUtils.makeBucket(messageId),
|
||||
message_id: messageId,
|
||||
author_id: userId,
|
||||
type: MessageTypes.RECIPIENT_REMOVE,
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: null,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
mention_everyone: false,
|
||||
mention_users: new Set([userId]),
|
||||
mention_roles: null,
|
||||
mention_channels: null,
|
||||
attachments: null,
|
||||
embeds: null,
|
||||
sticker_items: null,
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
const recipientUserResponse = await userCacheService.getUserPartialResponse(userId, createRequestCache());
|
||||
|
||||
for (const recId of updatedRecipientIds) {
|
||||
await gatewayService.dispatchPresence({
|
||||
userId: recId,
|
||||
event: 'CHANNEL_RECIPIENT_REMOVE',
|
||||
data: {
|
||||
channel_id: channel.id.toString(),
|
||||
user: recipientUserResponse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService,
|
||||
requestCache: createRequestCache(),
|
||||
});
|
||||
|
||||
await gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_DELETE',
|
||||
data: channelResponse,
|
||||
});
|
||||
|
||||
Logger.debug({userId, channelId: channel.id}, 'Left group DM successfully');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, channelId: channel.id}, 'Failed to leave group DM');
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({userId}, 'Anonymizing user messages');
|
||||
|
||||
let lastChannelId: ChannelID | undefined;
|
||||
let lastMessageId: MessageID | undefined;
|
||||
let processedCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messagesToAnonymize = await channelRepository.listMessagesByAuthor(
|
||||
userId,
|
||||
CHUNK_SIZE,
|
||||
lastChannelId,
|
||||
lastMessageId,
|
||||
);
|
||||
|
||||
if (messagesToAnonymize.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messagesToAnonymize) {
|
||||
await channelRepository.anonymizeMessage(channelId, messageId, deletedUserId);
|
||||
}
|
||||
|
||||
processedCount += messagesToAnonymize.length;
|
||||
lastChannelId = messagesToAnonymize[messagesToAnonymize.length - 1].channelId;
|
||||
lastMessageId = messagesToAnonymize[messagesToAnonymize.length - 1].messageId;
|
||||
|
||||
Logger.debug({userId, processedCount, chunkSize: messagesToAnonymize.length}, 'Anonymized message chunk');
|
||||
|
||||
if (messagesToAnonymize.length < CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.debug({userId, totalProcessed: processedCount}, 'Completed message anonymization');
|
||||
|
||||
Logger.debug({userId}, 'Deleting S3 objects');
|
||||
|
||||
if (user.avatarHash) {
|
||||
try {
|
||||
await storageService.deleteAvatar({prefix: 'avatars', key: `${userId}/${user.avatarHash}`});
|
||||
await cloudflarePurgeQueue.addUrls([`${Config.endpoints.media}/avatars/${userId}/${user.avatarHash}`]);
|
||||
Logger.debug({userId, avatarHash: user.avatarHash}, 'Deleted avatar');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId}, 'Failed to delete avatar');
|
||||
}
|
||||
}
|
||||
|
||||
if (user.bannerHash) {
|
||||
try {
|
||||
await storageService.deleteAvatar({prefix: 'banners', key: `${userId}/${user.bannerHash}`});
|
||||
await cloudflarePurgeQueue.addUrls([`${Config.endpoints.media}/banners/${userId}/${user.bannerHash}`]);
|
||||
Logger.debug({userId, bannerHash: user.bannerHash}, 'Deleted banner');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId}, 'Failed to delete banner');
|
||||
}
|
||||
}
|
||||
|
||||
const favoriteMemes = await favoriteMemeRepository.findByUserId(userId);
|
||||
for (const meme of favoriteMemes) {
|
||||
try {
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, meme.storageKey);
|
||||
Logger.debug({userId, memeId: meme.id}, 'Deleted favorite meme');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, memeId: meme.id}, 'Failed to delete favorite meme');
|
||||
}
|
||||
}
|
||||
|
||||
await favoriteMemeRepository.deleteAllByUserId(userId);
|
||||
|
||||
Logger.debug({userId}, 'Deleting OAuth tokens');
|
||||
|
||||
await Promise.all([
|
||||
oauth2TokenRepository.deleteAllAccessTokensForUser(userId),
|
||||
oauth2TokenRepository.deleteAllRefreshTokensForUser(userId),
|
||||
]);
|
||||
|
||||
Logger.debug({userId}, 'Deleting owned developer applications and bots');
|
||||
try {
|
||||
const applications = await applicationRepository.listApplicationsByOwner(userId);
|
||||
for (const application of applications) {
|
||||
await workerService.addJob('applicationProcessDeletion', {
|
||||
applicationId: application.applicationId.toString(),
|
||||
});
|
||||
}
|
||||
Logger.debug({userId, applicationCount: applications.length}, 'Scheduled application deletions');
|
||||
} catch (error) {
|
||||
Logger.error({error, userId}, 'Failed to schedule application deletions');
|
||||
}
|
||||
|
||||
Logger.debug({userId}, 'Deleting user data');
|
||||
|
||||
await Promise.all([
|
||||
userRepository.deleteUserSettings(userId),
|
||||
userRepository.deleteAllUserGuildSettings(userId),
|
||||
userRepository.deleteAllRelationships(userId),
|
||||
userRepository.deleteAllNotes(userId),
|
||||
userRepository.deleteAllReadStates(userId),
|
||||
userRepository.deleteAllSavedMessages(userId),
|
||||
userRepository.deleteAllAuthSessions(userId),
|
||||
userRepository.deleteAllMfaBackupCodes(userId),
|
||||
userRepository.deleteAllWebAuthnCredentials(userId),
|
||||
userRepository.deleteAllPushSubscriptions(userId),
|
||||
userRepository.deleteAllBetaCodes(userId),
|
||||
userRepository.deleteAllRecentMentions(userId),
|
||||
userRepository.deleteAllAuthorizedIps(userId),
|
||||
userRepository.deletePendingVerification(userId),
|
||||
userRepository.deletePinnedDmsByUserId(userId),
|
||||
]);
|
||||
|
||||
await userRepository.deleteUserSecondaryIndices(userId);
|
||||
|
||||
Logger.debug({userId}, 'Anonymizing user record');
|
||||
|
||||
await userRepository.patchUpsert(userId, {
|
||||
username: DELETED_USERNAME,
|
||||
discriminator: 0,
|
||||
email: null,
|
||||
email_verified: false,
|
||||
phone: null,
|
||||
password_hash: null,
|
||||
totp_secret: null,
|
||||
avatar_hash: null,
|
||||
banner_hash: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
date_of_birth: null,
|
||||
flags: UserFlags.DELETED,
|
||||
premium_type: null,
|
||||
premium_since: null,
|
||||
premium_until: null,
|
||||
stripe_customer_id: null,
|
||||
stripe_subscription_id: null,
|
||||
pending_deletion_at: null,
|
||||
authenticator_types: new Set(),
|
||||
});
|
||||
|
||||
Logger.debug({userId, deletionReasonCode}, 'User account anonymization completed successfully');
|
||||
getMetricsService().counter({
|
||||
name: 'user.deletion',
|
||||
dimensions: {
|
||||
reason_code: deletionReasonCode.toString(),
|
||||
source: 'worker',
|
||||
},
|
||||
});
|
||||
}
|
||||
471
fluxer_api/src/user/services/UserRelationshipService.ts
Normal file
471
fluxer_api/src/user/services/UserRelationshipService.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {MAX_RELATIONSHIPS, RelationshipTypes, UserFlags} from '~/Constants';
|
||||
import {
|
||||
AlreadyFriendsError,
|
||||
BotsCannotHaveFriendsError,
|
||||
CannotSendFriendRequestToBlockedUserError,
|
||||
CannotSendFriendRequestToSelfError,
|
||||
FriendRequestBlockedError,
|
||||
InvalidDiscriminatorError,
|
||||
MaxRelationshipsError,
|
||||
NoUsersWithFluxertagError,
|
||||
UnclaimedAccountRestrictedError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Relationship} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {type FriendRequestByTagRequest, mapRelationshipToResponse} from '~/user/UserModel';
|
||||
import type {UserPermissionUtils} from '~/utils/UserPermissionUtils';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
|
||||
import {getCachedUserPartialResponse} from '../UserCacheHelpers';
|
||||
|
||||
export class UserRelationshipService {
|
||||
constructor(
|
||||
private userAccountRepository: IUserAccountRepository,
|
||||
private userRelationshipRepository: IUserRelationshipRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private userPermissionUtils: UserPermissionUtils,
|
||||
) {}
|
||||
|
||||
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
|
||||
return await this.userRelationshipRepository.listRelationships(userId);
|
||||
}
|
||||
|
||||
async sendFriendRequestByTag({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: FriendRequestByTagRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const {username, discriminator} = data;
|
||||
const discrimValue = Number.parseInt(discriminator, 10);
|
||||
if (Number.isNaN(discrimValue) || discrimValue < 0 || discrimValue > 9999) {
|
||||
throw new InvalidDiscriminatorError();
|
||||
}
|
||||
const targetUser = await this.userAccountRepository.findByUsernameDiscriminator(username, discrimValue);
|
||||
if (!targetUser) {
|
||||
throw new NoUsersWithFluxertagError();
|
||||
}
|
||||
const existingRelationship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetUser.id,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (existingRelationship) {
|
||||
throw new AlreadyFriendsError();
|
||||
}
|
||||
return this.sendFriendRequest({userId, targetId: targetUser.id, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async sendFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
await this.validateFriendRequest({userId, targetId});
|
||||
const pendingIncoming = await this.userRelationshipRepository.getRelationship(
|
||||
targetId,
|
||||
userId,
|
||||
RelationshipTypes.OUTGOING_REQUEST,
|
||||
);
|
||||
if (pendingIncoming) {
|
||||
return this.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
const existingFriendship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.OUTGOING_REQUEST,
|
||||
);
|
||||
if (existingFriendship || existingOutgoingRequest) {
|
||||
const relationships = await this.userRelationshipRepository.listRelationships(userId);
|
||||
const relationship = relationships.find((r) => r.targetUserId === targetId);
|
||||
if (relationship) {
|
||||
return relationship;
|
||||
}
|
||||
}
|
||||
await this.validateRelationshipCounts({userId, targetId});
|
||||
return await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async acceptFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
if (user && !user.passwordHash) {
|
||||
throw new UnclaimedAccountRestrictedError('accept friend requests');
|
||||
}
|
||||
|
||||
const incomingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.INCOMING_REQUEST,
|
||||
);
|
||||
if (!incomingRequest) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
await this.validateRelationshipCounts({userId, targetId});
|
||||
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST);
|
||||
|
||||
const now = new Date();
|
||||
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.FRIEND,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: targetId,
|
||||
target_user_id: userId,
|
||||
type: RelationshipTypes.FRIEND,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
await this.dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship: userRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.dispatchRelationshipUpdate({
|
||||
userId: targetId,
|
||||
relationship: targetRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return userRelationship;
|
||||
}
|
||||
|
||||
async blockUser({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const targetUser = await this.userAccountRepository.findUnique(targetId);
|
||||
if (!targetUser) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const existingBlocked = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (existingBlocked) {
|
||||
return existingBlocked;
|
||||
}
|
||||
|
||||
const existingFriend = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
const existingIncomingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.INCOMING_REQUEST,
|
||||
);
|
||||
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.OUTGOING_REQUEST,
|
||||
);
|
||||
|
||||
if (existingFriend) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
} else if (existingOutgoingRequest) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
} else if (existingIncomingRequest) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const blockRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.BLOCKED,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship: blockRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return blockRelationship;
|
||||
}
|
||||
|
||||
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
const existingRelationship =
|
||||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND)) ||
|
||||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST)) ||
|
||||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST)) ||
|
||||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED));
|
||||
if (!existingRelationship) throw new UnknownUserError();
|
||||
const relationshipType = existingRelationship.type;
|
||||
if (relationshipType === RelationshipTypes.INCOMING_REQUEST || relationshipType === RelationshipTypes.BLOCKED) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
|
||||
await this.dispatchRelationshipRemove({
|
||||
userId,
|
||||
targetId: targetId.toString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
|
||||
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
return;
|
||||
}
|
||||
if (relationshipType === RelationshipTypes.FRIEND) {
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
|
||||
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
|
||||
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
||||
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
|
||||
return;
|
||||
}
|
||||
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
|
||||
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
|
||||
}
|
||||
|
||||
async updateFriendNickname({
|
||||
userId,
|
||||
targetId,
|
||||
nickname,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
nickname: string | null;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const relationship = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.FRIEND,
|
||||
);
|
||||
if (!relationship) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.FRIEND,
|
||||
nickname,
|
||||
since: relationship.since ?? new Date(),
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship: updatedRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
|
||||
return updatedRelationship;
|
||||
}
|
||||
|
||||
private async validateFriendRequest({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
if (userId === targetId) {
|
||||
throw new CannotSendFriendRequestToSelfError();
|
||||
}
|
||||
|
||||
const requesterUser = await this.userAccountRepository.findUnique(userId);
|
||||
if (requesterUser && !requesterUser.passwordHash) {
|
||||
throw new UnclaimedAccountRestrictedError('send friend requests');
|
||||
}
|
||||
|
||||
const targetUser = await this.userAccountRepository.findUnique(targetId);
|
||||
if (!targetUser) throw new UnknownUserError();
|
||||
if (targetUser.isBot) {
|
||||
throw new BotsCannotHaveFriendsError();
|
||||
}
|
||||
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
const requesterBlockedTarget = await this.userRelationshipRepository.getRelationship(
|
||||
userId,
|
||||
targetId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (requesterBlockedTarget) {
|
||||
throw new CannotSendFriendRequestToBlockedUserError();
|
||||
}
|
||||
const targetBlockedRequester = await this.userRelationshipRepository.getRelationship(
|
||||
targetId,
|
||||
userId,
|
||||
RelationshipTypes.BLOCKED,
|
||||
);
|
||||
if (targetBlockedRequester) {
|
||||
throw new FriendRequestBlockedError();
|
||||
}
|
||||
await this.userPermissionUtils.validateFriendSourcePermissions({userId, targetId});
|
||||
}
|
||||
|
||||
private async validateRelationshipCounts({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
const relationships = await this.userRelationshipRepository.listRelationships(userId);
|
||||
if (relationships.length >= MAX_RELATIONSHIPS) {
|
||||
throw new MaxRelationshipsError();
|
||||
}
|
||||
const targetRelationships = await this.userRelationshipRepository.listRelationships(targetId);
|
||||
if (targetRelationships.length >= MAX_RELATIONSHIPS) {
|
||||
throw new MaxRelationshipsError();
|
||||
}
|
||||
}
|
||||
|
||||
private async createFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
const now = new Date();
|
||||
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: userId,
|
||||
target_user_id: targetId,
|
||||
type: RelationshipTypes.OUTGOING_REQUEST,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
|
||||
source_user_id: targetId,
|
||||
target_user_id: userId,
|
||||
type: RelationshipTypes.INCOMING_REQUEST,
|
||||
nickname: null,
|
||||
since: now,
|
||||
version: 1,
|
||||
});
|
||||
await this.dispatchRelationshipCreate({userId, relationship: userRelationship, userCacheService, requestCache});
|
||||
await this.dispatchRelationshipCreate({
|
||||
userId: targetId,
|
||||
relationship: targetRelationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
return userRelationship;
|
||||
}
|
||||
|
||||
async dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const userPartialResolver = (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RELATIONSHIP_ADD',
|
||||
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const userPartialResolver = (userId: UserID) =>
|
||||
getCachedUserPartialResponse({userId, userCacheService, requestCache});
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RELATIONSHIP_UPDATE',
|
||||
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'RELATIONSHIP_REMOVE',
|
||||
data: {id: targetId},
|
||||
});
|
||||
}
|
||||
}
|
||||
610
fluxer_api/src/user/services/UserService.ts
Normal file
610
fluxer_api/src/user/services/UserService.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {SudoVerificationResult} from '~/auth/services/SudoVerificationService';
|
||||
import type {ChannelID, GuildID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {ChannelService} from '~/channel/services/ChannelService';
|
||||
import type {GuildMemberResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
|
||||
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {
|
||||
AuthSession,
|
||||
BetaCode,
|
||||
Channel,
|
||||
GuildMember,
|
||||
Message,
|
||||
MfaBackupCode,
|
||||
PushSubscription,
|
||||
Relationship,
|
||||
User,
|
||||
UserGuildSettings,
|
||||
UserSettings,
|
||||
} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {PackService} from '~/pack/PackService';
|
||||
import type {
|
||||
CreatePrivateChannelRequest,
|
||||
FriendRequestByTagRequest,
|
||||
UserGuildSettingsUpdateRequest,
|
||||
UserSettingsUpdateRequest,
|
||||
UserUpdateRequest,
|
||||
} from '~/user/UserModel';
|
||||
import type {SavedMessageStatus} from '~/user/UserTypes';
|
||||
import type {UserPermissionUtils} from '~/utils/UserPermissionUtils';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from '../repositories/IUserAuthRepository';
|
||||
import type {IUserChannelRepository} from '../repositories/IUserChannelRepository';
|
||||
import type {IUserContentRepository} from '../repositories/IUserContentRepository';
|
||||
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from '../repositories/IUserSettingsRepository';
|
||||
import type {UserHarvestResponse} from '../UserHarvestModel';
|
||||
import {UserAccountService} from './UserAccountService';
|
||||
import {UserAuthService} from './UserAuthService';
|
||||
import {UserChannelService} from './UserChannelService';
|
||||
import type {UserContactChangeLogService} from './UserContactChangeLogService';
|
||||
import {UserContentService} from './UserContentService';
|
||||
import {UserRelationshipService} from './UserRelationshipService';
|
||||
|
||||
interface UpdateUserParams {
|
||||
user: User;
|
||||
oldAuthSession: AuthSession;
|
||||
data: UserUpdateRequest;
|
||||
request: Request;
|
||||
sudoContext?: SudoVerificationResult;
|
||||
emailVerifiedViaToken?: boolean;
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
private accountService: UserAccountService;
|
||||
private authService: UserAuthService;
|
||||
private relationshipService: UserRelationshipService;
|
||||
private channelService: UserChannelService;
|
||||
private contentService: UserContentService;
|
||||
|
||||
constructor(
|
||||
userAccountRepository: IUserAccountRepository,
|
||||
userSettingsRepository: IUserSettingsRepository,
|
||||
userAuthRepository: IUserAuthRepository,
|
||||
userRelationshipRepository: IUserRelationshipRepository,
|
||||
userChannelRepository: IUserChannelRepository,
|
||||
userContentRepository: IUserContentRepository,
|
||||
authService: AuthService,
|
||||
userCacheService: UserCacheService,
|
||||
channelService: ChannelService,
|
||||
channelRepository: IChannelRepository,
|
||||
guildService: GuildService,
|
||||
gatewayService: IGatewayService,
|
||||
entityAssetService: EntityAssetService,
|
||||
mediaService: IMediaService,
|
||||
packService: PackService,
|
||||
emailService: IEmailService,
|
||||
snowflakeService: SnowflakeService,
|
||||
discriminatorService: IDiscriminatorService,
|
||||
rateLimitService: IRateLimitService,
|
||||
guildRepository: IGuildRepository,
|
||||
workerService: IWorkerService,
|
||||
userPermissionUtils: UserPermissionUtils,
|
||||
redisDeletionQueue: RedisAccountDeletionQueueService,
|
||||
bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService,
|
||||
botMfaMirrorService: BotMfaMirrorService,
|
||||
contactChangeLogService: UserContactChangeLogService,
|
||||
) {
|
||||
this.accountService = new UserAccountService(
|
||||
userAccountRepository,
|
||||
userSettingsRepository,
|
||||
userRelationshipRepository,
|
||||
userChannelRepository,
|
||||
authService,
|
||||
userCacheService,
|
||||
guildService,
|
||||
gatewayService,
|
||||
entityAssetService,
|
||||
mediaService,
|
||||
packService,
|
||||
emailService,
|
||||
rateLimitService,
|
||||
guildRepository,
|
||||
discriminatorService,
|
||||
redisDeletionQueue,
|
||||
contactChangeLogService,
|
||||
);
|
||||
|
||||
this.authService = new UserAuthService(
|
||||
userAccountRepository,
|
||||
userAuthRepository,
|
||||
authService,
|
||||
emailService,
|
||||
gatewayService,
|
||||
botMfaMirrorService,
|
||||
);
|
||||
|
||||
this.relationshipService = new UserRelationshipService(
|
||||
userAccountRepository,
|
||||
userRelationshipRepository,
|
||||
gatewayService,
|
||||
userPermissionUtils,
|
||||
);
|
||||
|
||||
this.channelService = new UserChannelService(
|
||||
userAccountRepository,
|
||||
userChannelRepository,
|
||||
userRelationshipRepository,
|
||||
channelService,
|
||||
channelRepository,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
snowflakeService,
|
||||
userPermissionUtils,
|
||||
);
|
||||
|
||||
this.contentService = new UserContentService(
|
||||
userAccountRepository,
|
||||
userContentRepository,
|
||||
userCacheService,
|
||||
channelService,
|
||||
channelRepository,
|
||||
gatewayService,
|
||||
mediaService,
|
||||
workerService,
|
||||
snowflakeService,
|
||||
bulkMessageDeletionQueue,
|
||||
);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return await this.accountService.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return await this.accountService.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async getUserProfile(params: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId?: GuildID;
|
||||
withMutualFriends?: boolean;
|
||||
withMutualGuilds?: boolean;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<{
|
||||
user: User;
|
||||
guildMember?: GuildMemberResponse | null;
|
||||
guildMemberDomain?: GuildMember | null;
|
||||
premiumType?: number;
|
||||
premiumSince?: Date;
|
||||
premiumLifetimeSequence?: number;
|
||||
mutualFriends?: Array<User>;
|
||||
mutualGuilds?: Array<{id: string; nick: string | null}>;
|
||||
}> {
|
||||
return await this.accountService.getUserProfile(params);
|
||||
}
|
||||
|
||||
async update(params: UpdateUserParams): Promise<User> {
|
||||
return await this.accountService.update(params);
|
||||
}
|
||||
|
||||
async generateUniqueDiscriminator(username: string): Promise<number> {
|
||||
return await this.accountService.generateUniqueDiscriminator(username);
|
||||
}
|
||||
|
||||
async checkUsernameDiscriminatorAvailability(params: {username: string; discriminator: number}): Promise<boolean> {
|
||||
return await this.accountService.checkUsernameDiscriminatorAvailability(params);
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings> {
|
||||
return await this.accountService.findSettings(userId);
|
||||
}
|
||||
|
||||
async updateSettings(params: {userId: UserID; data: UserSettingsUpdateRequest}): Promise<UserSettings> {
|
||||
return await this.accountService.updateSettings(params);
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return await this.accountService.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async updateGuildSettings(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID | null;
|
||||
data: UserGuildSettingsUpdateRequest;
|
||||
}): Promise<UserGuildSettings> {
|
||||
return await this.accountService.updateGuildSettings(params);
|
||||
}
|
||||
|
||||
async getUserNote(params: {userId: UserID; targetId: UserID}): Promise<{note: string} | null> {
|
||||
return await this.accountService.getUserNote(params);
|
||||
}
|
||||
|
||||
async getUserNotes(userId: UserID): Promise<Record<string, string>> {
|
||||
return await this.accountService.getUserNotes(userId);
|
||||
}
|
||||
|
||||
async setUserNote(params: {userId: UserID; targetId: UserID; note: string | null}): Promise<void> {
|
||||
return await this.accountService.setUserNote(params);
|
||||
}
|
||||
|
||||
async selfDisable(userId: UserID): Promise<void> {
|
||||
return await this.accountService.selfDisable(userId);
|
||||
}
|
||||
|
||||
async selfDelete(userId: UserID): Promise<void> {
|
||||
return await this.accountService.selfDelete(userId);
|
||||
}
|
||||
|
||||
async dispatchUserUpdate(user: User): Promise<void> {
|
||||
return await this.accountService.dispatchUserUpdate(user);
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
return await this.accountService.dispatchUserSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserGuildSettingsUpdate({
|
||||
userId,
|
||||
settings,
|
||||
}: {
|
||||
userId: UserID;
|
||||
settings: UserGuildSettings;
|
||||
}): Promise<void> {
|
||||
return await this.accountService.dispatchUserGuildSettingsUpdate({userId, settings});
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
return await this.accountService.dispatchUserNoteUpdate(params);
|
||||
}
|
||||
|
||||
async enableMfaTotp(params: {user: User; secret: string; code: string}): Promise<Array<MfaBackupCode>> {
|
||||
return await this.authService.enableMfaTotp(params);
|
||||
}
|
||||
|
||||
async disableMfaTotp(params: {
|
||||
user: User;
|
||||
code: string;
|
||||
sudoContext: SudoVerificationResult;
|
||||
password?: string;
|
||||
}): Promise<void> {
|
||||
return await this.authService.disableMfaTotp(params);
|
||||
}
|
||||
|
||||
async getMfaBackupCodes(params: {
|
||||
user: User;
|
||||
regenerate: boolean;
|
||||
sudoContext: SudoVerificationResult;
|
||||
password?: string;
|
||||
}): Promise<Array<MfaBackupCode>> {
|
||||
return await this.authService.getMfaBackupCodes(params);
|
||||
}
|
||||
|
||||
async regenerateMfaBackupCodes(user: User): Promise<Array<MfaBackupCode>> {
|
||||
return await this.authService.regenerateMfaBackupCodes(user);
|
||||
}
|
||||
|
||||
async verifyEmail(token: string): Promise<boolean> {
|
||||
return await this.authService.verifyEmail(token);
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<boolean> {
|
||||
return await this.authService.resendVerificationEmail(user);
|
||||
}
|
||||
|
||||
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
|
||||
return await this.relationshipService.getRelationships(userId);
|
||||
}
|
||||
|
||||
async sendFriendRequestByTag({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: FriendRequestByTagRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.sendFriendRequestByTag({userId, data, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async sendFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.sendFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async acceptFriendRequest({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async blockUser({
|
||||
userId,
|
||||
targetId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.blockUser({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async updateFriendNickname({
|
||||
userId,
|
||||
targetId,
|
||||
nickname,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
nickname: string | null;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
return await this.relationshipService.updateFriendNickname({
|
||||
userId,
|
||||
targetId,
|
||||
nickname,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
|
||||
return await this.relationshipService.removeRelationship({userId, targetId});
|
||||
}
|
||||
|
||||
async dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.relationshipService.dispatchRelationshipCreate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
relationship: Relationship;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.relationshipService.dispatchRelationshipUpdate({
|
||||
userId,
|
||||
relationship,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
|
||||
return await this.relationshipService.dispatchRelationshipRemove({userId, targetId});
|
||||
}
|
||||
|
||||
async getPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
return await this.channelService.getPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async createOrOpenDMChannel({
|
||||
userId,
|
||||
data,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: CreatePrivateChannelRequest;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
return await this.channelService.createOrOpenDMChannel({userId, data, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async pinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
return await this.channelService.pinDmChannel({userId, channelId});
|
||||
}
|
||||
|
||||
async unpinDmChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
return await this.channelService.unpinDmChannel({userId, channelId});
|
||||
}
|
||||
|
||||
async preloadDMMessages(params: {
|
||||
userId: UserID;
|
||||
channelIds: Array<ChannelID>;
|
||||
}): Promise<Record<string, Message | null>> {
|
||||
return await this.channelService.preloadDMMessages(params);
|
||||
}
|
||||
|
||||
async getBetaCodes(userId: UserID): Promise<Array<BetaCode>> {
|
||||
return await this.contentService.getBetaCodes(userId);
|
||||
}
|
||||
|
||||
async getBetaCodeAllowanceInfo(userId: UserID): Promise<{allowance: number; nextResetAt: Date | null}> {
|
||||
return await this.contentService.getBetaCodeAllowanceInfo(userId);
|
||||
}
|
||||
|
||||
async createBetaCode(userId: UserID): Promise<BetaCode> {
|
||||
return await this.contentService.createBetaCode(userId);
|
||||
}
|
||||
|
||||
async deleteBetaCode(params: {userId: UserID; code: string}): Promise<void> {
|
||||
return await this.contentService.deleteBetaCode(params);
|
||||
}
|
||||
|
||||
async getRecentMentions(params: {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
everyone: boolean;
|
||||
roles: boolean;
|
||||
guilds: boolean;
|
||||
before?: MessageID;
|
||||
}): Promise<Array<Message>> {
|
||||
return await this.contentService.getRecentMentions(params);
|
||||
}
|
||||
|
||||
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.deleteRecentMention({userId, messageId});
|
||||
}
|
||||
|
||||
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<
|
||||
Array<{
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
status: SavedMessageStatus;
|
||||
message: Message | null;
|
||||
}>
|
||||
> {
|
||||
return await this.contentService.getSavedMessages({userId, limit});
|
||||
}
|
||||
|
||||
async saveMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.contentService.saveMessage({userId, channelId, messageId, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.unsaveMessage({userId, messageId});
|
||||
}
|
||||
|
||||
async registerPushSubscription(params: {
|
||||
userId: UserID;
|
||||
endpoint: string;
|
||||
keys: {p256dh: string; auth: string};
|
||||
userAgent?: string;
|
||||
}): Promise<PushSubscription> {
|
||||
return await this.contentService.registerPushSubscription(params);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return await this.contentService.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return await this.contentService.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async requestDataHarvest(userId: UserID): Promise<{harvestId: string}> {
|
||||
return await this.contentService.requestDataHarvest(userId);
|
||||
}
|
||||
|
||||
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
|
||||
return await this.contentService.getHarvestStatus(userId, harvestId);
|
||||
}
|
||||
|
||||
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
|
||||
return await this.contentService.getLatestHarvest(userId);
|
||||
}
|
||||
|
||||
async getHarvestDownloadUrl(
|
||||
userId: UserID,
|
||||
harvestId: bigint,
|
||||
storageService: IStorageService,
|
||||
): Promise<{downloadUrl: string; expiresAt: string}> {
|
||||
return await this.contentService.getHarvestDownloadUrl(userId, harvestId, storageService);
|
||||
}
|
||||
|
||||
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
|
||||
return await this.contentService.requestBulkMessageDeletion(params);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
|
||||
return await this.contentService.cancelBulkMessageDeletion(userId);
|
||||
}
|
||||
|
||||
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.dispatchRecentMentionDelete({userId, messageId});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageCreate({
|
||||
userId,
|
||||
message,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
userId: UserID;
|
||||
message: Message;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
return await this.contentService.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
|
||||
}
|
||||
|
||||
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
||||
return await this.contentService.dispatchSavedMessageDelete({userId, messageId});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user