initial commit
This commit is contained in:
218
fluxer_api/src/admin/services/AdminArchiveService.ts
Normal file
218
fluxer_api/src/admin/services/AdminArchiveService.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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 {Config} from '~/Config';
|
||||
import {
|
||||
HarvestExpiredError,
|
||||
HarvestFailedError,
|
||||
HarvestNotReadyError,
|
||||
UnknownGuildError,
|
||||
UnknownHarvestError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {WorkerService} from '~/worker/WorkerService';
|
||||
import {AdminArchive, type AdminArchiveResponse, type ArchiveSubjectType} from '../models/AdminArchiveModel';
|
||||
import type {AdminArchiveRepository} from '../repositories/AdminArchiveRepository';
|
||||
|
||||
const ARCHIVE_RETENTION_DAYS = 365;
|
||||
const DOWNLOAD_LINK_DAYS = 7;
|
||||
const DOWNLOAD_LINK_SECONDS = DOWNLOAD_LINK_DAYS * 24 * 60 * 60;
|
||||
|
||||
interface ListArchivesParams {
|
||||
subjectType?: ArchiveSubjectType | 'all';
|
||||
subjectId?: bigint;
|
||||
requestedBy?: bigint;
|
||||
limit?: number;
|
||||
includeExpired?: boolean;
|
||||
}
|
||||
|
||||
export class AdminArchiveService {
|
||||
constructor(
|
||||
private readonly adminArchiveRepository: AdminArchiveRepository,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly guildRepository: IGuildRepository,
|
||||
private readonly storageService: IStorageService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly workerService: WorkerService,
|
||||
) {}
|
||||
|
||||
private computeExpiry(): Date {
|
||||
return new Date(Date.now() + ARCHIVE_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async triggerUserArchive(targetUserId: UserID, requestedBy: UserID): Promise<AdminArchiveResponse> {
|
||||
const user = await this.userRepository.findUnique(targetUserId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const archiveId = this.snowflakeService.generate();
|
||||
const archive = new AdminArchive({
|
||||
subject_type: 'user',
|
||||
subject_id: targetUserId,
|
||||
archive_id: archiveId,
|
||||
requested_by: requestedBy,
|
||||
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,
|
||||
expires_at: this.computeExpiry(),
|
||||
});
|
||||
|
||||
await this.adminArchiveRepository.create(archive);
|
||||
|
||||
await this.workerService.addJob('harvestUserData', {
|
||||
userId: targetUserId.toString(),
|
||||
harvestId: archive.archiveId.toString(),
|
||||
adminRequestedBy: requestedBy.toString(),
|
||||
});
|
||||
|
||||
return archive.toResponse();
|
||||
}
|
||||
|
||||
async triggerGuildArchive(targetGuildId: GuildID, requestedBy: UserID): Promise<AdminArchiveResponse> {
|
||||
const guild = await this.guildRepository.findUnique(targetGuildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const archiveId = this.snowflakeService.generate();
|
||||
const archive = new AdminArchive({
|
||||
subject_type: 'guild',
|
||||
subject_id: targetGuildId,
|
||||
archive_id: archiveId,
|
||||
requested_by: requestedBy,
|
||||
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,
|
||||
expires_at: this.computeExpiry(),
|
||||
});
|
||||
|
||||
await this.adminArchiveRepository.create(archive);
|
||||
|
||||
await this.workerService.addJob('harvestGuildData', {
|
||||
guildId: targetGuildId.toString(),
|
||||
archiveId: archive.archiveId.toString(),
|
||||
requestedBy: requestedBy.toString(),
|
||||
});
|
||||
|
||||
return archive.toResponse();
|
||||
}
|
||||
|
||||
async getArchive(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<AdminArchiveResponse | null> {
|
||||
const archive = await this.adminArchiveRepository.findBySubjectAndArchiveId(subjectType, subjectId, archiveId);
|
||||
return archive ? archive.toResponse() : null;
|
||||
}
|
||||
|
||||
async listArchives(params: ListArchivesParams): Promise<Array<AdminArchiveResponse>> {
|
||||
const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params;
|
||||
|
||||
if (subjectId !== undefined && subjectType === 'all') {
|
||||
throw new Error('subject_type must be specified when subject_id is provided');
|
||||
}
|
||||
|
||||
if (subjectId !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listBySubject(
|
||||
subjectType as ArchiveSubjectType,
|
||||
subjectId,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
if (requestedBy !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listByRequester(requestedBy, limit, includeExpired);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
if (subjectType === 'all') {
|
||||
const [users, guilds] = await Promise.all([
|
||||
this.adminArchiveRepository.listByType('user', limit, includeExpired),
|
||||
this.adminArchiveRepository.listByType('guild', limit, includeExpired),
|
||||
]);
|
||||
|
||||
return [...users, ...guilds]
|
||||
.sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime())
|
||||
.slice(0, limit)
|
||||
.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
const archives = await this.adminArchiveRepository.listByType(
|
||||
subjectType as ArchiveSubjectType,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
async getDownloadUrl(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<{downloadUrl: string; expiresAt: string}> {
|
||||
const archive = await this.adminArchiveRepository.findBySubjectAndArchiveId(subjectType, subjectId, archiveId);
|
||||
if (!archive) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
|
||||
if (!archive.completedAt || !archive.storageKey) {
|
||||
throw new HarvestNotReadyError();
|
||||
}
|
||||
|
||||
if (archive.failedAt) {
|
||||
throw new HarvestFailedError();
|
||||
}
|
||||
|
||||
if (archive.expiresAt && archive.expiresAt < new Date()) {
|
||||
throw new HarvestExpiredError();
|
||||
}
|
||||
|
||||
const downloadUrl = await this.storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: archive.storageKey,
|
||||
expiresIn: DOWNLOAD_LINK_SECONDS,
|
||||
});
|
||||
|
||||
const expiresAt = new Date(Date.now() + DOWNLOAD_LINK_SECONDS * 1000);
|
||||
return {downloadUrl, expiresAt: expiresAt.toISOString()};
|
||||
}
|
||||
}
|
||||
197
fluxer_api/src/admin/services/AdminAssetPurgeService.ts
Normal file
197
fluxer_api/src/admin/services/AdminAssetPurgeService.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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, createStickerID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {mapGuildEmojiToResponse, mapGuildStickerToResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import {ExpressionAssetPurger} from '~/guild/services/content/ExpressionAssetPurger';
|
||||
import type {IAssetDeletionQueue} from '~/infrastructure/IAssetDeletionQueue';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {PurgeGuildAssetError, PurgeGuildAssetResult, PurgeGuildAssetsResponse} from '../models/AdminTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminAssetPurgeServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
gatewayService: IGatewayService;
|
||||
assetDeletionQueue: IAssetDeletionQueue;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminAssetPurgeService {
|
||||
private readonly assetPurger: ExpressionAssetPurger;
|
||||
|
||||
constructor(private readonly deps: AdminAssetPurgeServiceDeps) {
|
||||
this.assetPurger = new ExpressionAssetPurger(deps.assetDeletionQueue);
|
||||
}
|
||||
|
||||
async purgeGuildAssets(args: {
|
||||
ids: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<PurgeGuildAssetsResponse> {
|
||||
const {ids, adminUserId, auditLogReason} = args;
|
||||
const processed: Array<PurgeGuildAssetResult> = [];
|
||||
const errors: Array<PurgeGuildAssetError> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const rawId of ids) {
|
||||
const trimmedId = rawId.trim();
|
||||
if (trimmedId === '' || seen.has(trimmedId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmedId);
|
||||
|
||||
let numericId: bigint;
|
||||
try {
|
||||
numericId = BigInt(trimmedId);
|
||||
} catch {
|
||||
errors.push({id: trimmedId, error: 'Invalid numeric ID'});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.processAssetId(numericId, trimmedId, adminUserId, auditLogReason);
|
||||
processed.push(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message !== '' ? error.message : 'Failed to purge asset';
|
||||
errors.push({id: trimmedId, error: message});
|
||||
}
|
||||
}
|
||||
|
||||
return {processed, errors};
|
||||
}
|
||||
|
||||
private async processAssetId(
|
||||
numericId: bigint,
|
||||
idString: string,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<PurgeGuildAssetResult> {
|
||||
const {guildRepository} = this.deps;
|
||||
|
||||
const emojiId = createEmojiID(numericId);
|
||||
const emoji = await guildRepository.getEmojiById(emojiId);
|
||||
if (emoji) {
|
||||
await guildRepository.deleteEmoji(emoji.guildId, emojiId);
|
||||
await this.dispatchGuildEmojisUpdate(emoji.guildId);
|
||||
await this.assetPurger.purgeEmoji(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_emoji',
|
||||
targetId: numericId,
|
||||
action: 'purge_guild_emoji_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['asset_type', 'emoji'],
|
||||
['guild_id', emoji.guildId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'emoji',
|
||||
found_in_db: true,
|
||||
guild_id: emoji.guildId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const stickerId = createStickerID(numericId);
|
||||
const sticker = await guildRepository.getStickerById(stickerId);
|
||||
if (sticker) {
|
||||
await guildRepository.deleteSticker(sticker.guildId, stickerId);
|
||||
await this.dispatchGuildStickersUpdate(sticker.guildId);
|
||||
await this.assetPurger.purgeSticker(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_sticker',
|
||||
targetId: numericId,
|
||||
action: 'purge_guild_sticker_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['asset_type', 'sticker'],
|
||||
['guild_id', sticker.guildId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'sticker',
|
||||
found_in_db: true,
|
||||
guild_id: sticker.guildId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
await this.assetPurger.purgeEmoji(idString);
|
||||
await this.assetPurger.purgeSticker(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'asset',
|
||||
targetId: numericId,
|
||||
action: 'purge_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([['asset_type', 'unknown']]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'unknown',
|
||||
found_in_db: false,
|
||||
guild_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchGuildEmojisUpdate(guildId: GuildID): Promise<void> {
|
||||
const {guildRepository, gatewayService} = this.deps;
|
||||
const emojis = await guildRepository.listEmojis(guildId);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_EMOJIS_UPDATE',
|
||||
data: {emojis: emojis.map(mapGuildEmojiToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildStickersUpdate(guildId: GuildID): Promise<void> {
|
||||
const {guildRepository, gatewayService} = this.deps;
|
||||
const stickers = await guildRepository.listStickers(guildId);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_STICKERS_UPDATE',
|
||||
data: {stickers: stickers.map(mapGuildStickerToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
private async createAuditLog(params: {
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata: Map<string, string>;
|
||||
}): Promise<void> {
|
||||
const {auditService} = this.deps;
|
||||
await auditService.createAuditLog({
|
||||
adminUserId: params.adminUserId,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId,
|
||||
action: params.action,
|
||||
auditLogReason: params.auditLogReason,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
190
fluxer_api/src/admin/services/AdminAuditService.ts
Normal file
190
fluxer_api/src/admin/services/AdminAuditService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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 {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {AdminAuditLog, IAdminRepository} from '../IAdminRepository';
|
||||
|
||||
interface CreateAdminAuditLogParams {
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata?: Map<string, string>;
|
||||
}
|
||||
|
||||
export class AdminAuditService {
|
||||
constructor(
|
||||
private readonly adminRepository: IAdminRepository,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
) {}
|
||||
|
||||
async createAuditLog({
|
||||
adminUserId,
|
||||
targetType,
|
||||
targetId,
|
||||
action,
|
||||
auditLogReason,
|
||||
metadata = new Map(),
|
||||
}: CreateAdminAuditLogParams): Promise<void> {
|
||||
const log = await this.adminRepository.createAuditLog({
|
||||
log_id: this.snowflakeService.generate(),
|
||||
admin_user_id: adminUserId,
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
action,
|
||||
audit_log_reason: auditLogReason ?? null,
|
||||
metadata,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const {getAuditLogSearchService} = await import('~/Meilisearch');
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
if (auditLogSearchService) {
|
||||
auditLogSearchService.indexAuditLog(log).catch((error) => {
|
||||
Logger.error({error, logId: log.logId}, 'Failed to index audit log to Meilisearch');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async listAuditLogs(data: {
|
||||
adminUserId?: bigint;
|
||||
targetType?: string;
|
||||
targetId?: bigint;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{logs: Array<AdminAuditLogResponse>; total: number}> {
|
||||
const auditLogSearchService = await this.requireAuditLogSearchService();
|
||||
|
||||
const limit = data.limit || 50;
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
if (data.adminUserId) {
|
||||
filters.adminUserId = data.adminUserId.toString();
|
||||
}
|
||||
if (data.targetType) {
|
||||
filters.targetType = data.targetType;
|
||||
}
|
||||
if (data.targetId) {
|
||||
filters.targetId = data.targetId.toString();
|
||||
}
|
||||
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs('', filters, {
|
||||
limit,
|
||||
offset: data.offset || 0,
|
||||
});
|
||||
|
||||
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
||||
|
||||
return {
|
||||
logs: orderedLogs.map((log) => this.toResponse(log)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async searchAuditLogs(data: {
|
||||
query?: string;
|
||||
adminUserId?: bigint;
|
||||
targetType?: string;
|
||||
targetId?: bigint;
|
||||
action?: string;
|
||||
sortBy?: 'createdAt' | 'relevance';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{logs: Array<AdminAuditLogResponse>; total: number}> {
|
||||
const auditLogSearchService = await this.requireAuditLogSearchService();
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
if (data.adminUserId) {
|
||||
filters.adminUserId = data.adminUserId.toString();
|
||||
}
|
||||
if (data.targetType) {
|
||||
filters.targetType = data.targetType;
|
||||
}
|
||||
if (data.targetId) {
|
||||
filters.targetId = data.targetId.toString();
|
||||
}
|
||||
if (data.action) {
|
||||
filters.action = data.action;
|
||||
}
|
||||
if (data.sortBy) {
|
||||
filters.sortBy = data.sortBy;
|
||||
}
|
||||
if (data.sortOrder) {
|
||||
filters.sortOrder = data.sortOrder;
|
||||
}
|
||||
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs(data.query || '', filters, {
|
||||
limit: data.limit || 50,
|
||||
offset: data.offset || 0,
|
||||
});
|
||||
|
||||
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
||||
|
||||
return {
|
||||
logs: orderedLogs.map((log) => this.toResponse(log)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
private async requireAuditLogSearchService() {
|
||||
const {getAuditLogSearchService} = await import('~/Meilisearch');
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
|
||||
if (!auditLogSearchService) {
|
||||
throw new Error('Audit log search service not available');
|
||||
}
|
||||
|
||||
return auditLogSearchService;
|
||||
}
|
||||
|
||||
private async loadLogsInSearchOrder(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await this.adminRepository.listAuditLogsByIds(logIds);
|
||||
const logMap = new Map(logs.map((log) => [log.logId.toString(), log]));
|
||||
return logIds.map((logId) => logMap.get(logId.toString())).filter((log): log is AdminAuditLog => log !== undefined);
|
||||
}
|
||||
|
||||
private toResponse(log: AdminAuditLog): AdminAuditLogResponse {
|
||||
return {
|
||||
log_id: log.logId.toString(),
|
||||
admin_user_id: log.adminUserId.toString(),
|
||||
target_type: log.targetType,
|
||||
target_id: log.targetId.toString(),
|
||||
action: log.action,
|
||||
audit_log_reason: log.auditLogReason,
|
||||
metadata: Object.fromEntries(log.metadata),
|
||||
created_at: log.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminAuditLogResponse {
|
||||
log_id: string;
|
||||
admin_user_id: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
action: string;
|
||||
audit_log_reason: string | null;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
135
fluxer_api/src/admin/services/AdminBanManagementService.ts
Normal file
135
fluxer_api/src/admin/services/AdminBanManagementService.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 {IAdminRepository} from '~/admin/IAdminRepository';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {ipBanCache} from '~/middleware/IpBanMiddleware';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminBanManagementServiceDeps {
|
||||
adminRepository: IAdminRepository;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminBanManagementService {
|
||||
constructor(private readonly deps: AdminBanManagementServiceDeps) {}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanIp(data.ip);
|
||||
ipBanCache.unban(data.ip);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}): Promise<{banned: boolean}> {
|
||||
const banned = ipBanCache.isBanned(data.ip);
|
||||
return {banned};
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banEmail(data.email);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'email',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', data.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanEmail(data.email);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'email',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', data.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkEmailBan(data: {email: string}): Promise<{banned: boolean}> {
|
||||
const {adminRepository} = this.deps;
|
||||
const banned = await adminRepository.isEmailBanned(data.email);
|
||||
return {banned};
|
||||
}
|
||||
|
||||
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banPhone(data.phone);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'phone',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', data.phone]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanPhone(data.phone);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'phone',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', data.phone]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}): Promise<{banned: boolean}> {
|
||||
const {adminRepository} = this.deps;
|
||||
const banned = await adminRepository.isPhoneBanned(data.phone);
|
||||
return {banned};
|
||||
}
|
||||
}
|
||||
92
fluxer_api/src/admin/services/AdminCodeGenerationService.ts
Normal file
92
fluxer_api/src/admin/services/AdminCodeGenerationService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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} from '~/BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '~/Constants';
|
||||
import type {GiftCodeRow} from '~/database/CassandraTypes';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
|
||||
const CODE_LENGTH = 32;
|
||||
|
||||
export class AdminCodeGenerationService {
|
||||
constructor(private readonly userRepository: IUserRepository) {}
|
||||
|
||||
async generateBetaCodes(count: number): Promise<Array<string>> {
|
||||
const codes: Array<string> = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const code = await this.generateUniqueBetaCode();
|
||||
const betaCodeRow = {
|
||||
code: createBetaCode(code),
|
||||
creator_id: SYSTEM_USER_ID,
|
||||
created_at: new Date(),
|
||||
redeemer_id: null,
|
||||
redeemed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
await this.userRepository.upsertBetaCode(betaCodeRow);
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
async generateGiftCodes(count: number, durationMonths: number): Promise<Array<string>> {
|
||||
const codes: Array<string> = [];
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const code = await this.generateUniqueGiftCode();
|
||||
const giftCodeRow: GiftCodeRow = {
|
||||
code,
|
||||
duration_months: durationMonths,
|
||||
created_at: new Date(),
|
||||
created_by_user_id: SYSTEM_USER_ID,
|
||||
redeemed_at: null,
|
||||
redeemed_by_user_id: null,
|
||||
stripe_payment_intent_id: null,
|
||||
visionary_sequence_number: null,
|
||||
checkout_session_id: null,
|
||||
version: 1,
|
||||
};
|
||||
await this.userRepository.createGiftCode(giftCodeRow);
|
||||
codes.push(code);
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
private async generateUniqueBetaCode(): Promise<string> {
|
||||
while (true) {
|
||||
const candidate = RandomUtils.randomString(CODE_LENGTH);
|
||||
const exists = await this.userRepository.getBetaCode(candidate);
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateUniqueGiftCode(): Promise<string> {
|
||||
while (true) {
|
||||
const candidate = RandomUtils.randomString(CODE_LENGTH);
|
||||
const exists = await this.userRepository.findGiftCode(candidate);
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
233
fluxer_api/src/admin/services/AdminGuildService.ts
Normal file
233
fluxer_api/src/admin/services/AdminGuildService.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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 {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {InviteRepository} from '~/invite/InviteRepository';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
ClearGuildFieldsRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
ListGuildEmojisResponse,
|
||||
ListGuildMembersRequest,
|
||||
ListGuildStickersResponse,
|
||||
ListUserGuildsRequest,
|
||||
LookupGuildRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import {AdminGuildBulkService} from './guild/AdminGuildBulkService';
|
||||
import {AdminGuildLookupService} from './guild/AdminGuildLookupService';
|
||||
import {AdminGuildManagementService} from './guild/AdminGuildManagementService';
|
||||
import {AdminGuildMembershipService} from './guild/AdminGuildMembershipService';
|
||||
import {AdminGuildUpdatePropagator} from './guild/AdminGuildUpdatePropagator';
|
||||
import {AdminGuildUpdateService} from './guild/AdminGuildUpdateService';
|
||||
import {AdminGuildVanityService} from './guild/AdminGuildVanityService';
|
||||
|
||||
interface AdminGuildServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
userRepository: IUserRepository;
|
||||
channelRepository: IChannelRepository;
|
||||
inviteRepository: InviteRepository;
|
||||
guildService: GuildService;
|
||||
gatewayService: IGatewayService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildService {
|
||||
private readonly lookupService: AdminGuildLookupService;
|
||||
private readonly updateService: AdminGuildUpdateService;
|
||||
private readonly vanityService: AdminGuildVanityService;
|
||||
private readonly membershipService: AdminGuildMembershipService;
|
||||
private readonly bulkService: AdminGuildBulkService;
|
||||
private readonly managementService: AdminGuildManagementService;
|
||||
private readonly updatePropagator: AdminGuildUpdatePropagator;
|
||||
|
||||
constructor(deps: AdminGuildServiceDeps) {
|
||||
this.updatePropagator = new AdminGuildUpdatePropagator({
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.lookupService = new AdminGuildLookupService({
|
||||
guildRepository: deps.guildRepository,
|
||||
userRepository: deps.userRepository,
|
||||
channelRepository: deps.channelRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.updateService = new AdminGuildUpdateService({
|
||||
guildRepository: deps.guildRepository,
|
||||
entityAssetService: deps.entityAssetService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.vanityService = new AdminGuildVanityService({
|
||||
guildRepository: deps.guildRepository,
|
||||
inviteRepository: deps.inviteRepository,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.membershipService = new AdminGuildMembershipService({
|
||||
userRepository: deps.userRepository,
|
||||
guildService: deps.guildService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.bulkService = new AdminGuildBulkService({
|
||||
guildUpdateService: this.updateService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.managementService = new AdminGuildManagementService({
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
guildService: deps.guildService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
return this.lookupService.lookupGuild(data);
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
return this.lookupService.listUserGuilds(data);
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
return this.lookupService.listGuildMembers(data);
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
return this.lookupService.listGuildEmojis(guildId);
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
return this.lookupService.listGuildStickers(guildId);
|
||||
}
|
||||
|
||||
async updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.updateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.clearGuildFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.updateGuildName(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.updateGuildSettings(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.updateService.transferGuildOwnership(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.vanityService.updateGuildVanity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.membershipService.forceAddUserToGuild({data, requestCache, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.membershipService.bulkAddGuildMembers(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.bulkService.bulkUpdateGuildFeatures(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.reloadGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.shutdownGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.deleteGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
return this.managementService.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
return this.managementService.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
return this.managementService.getNodeStats();
|
||||
}
|
||||
}
|
||||
141
fluxer_api/src/admin/services/AdminMessageDeletionService.ts
Normal file
141
fluxer_api/src/admin/services/AdminMessageDeletionService.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 type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {createUserID} from '~/BrandedTypes';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {DeleteAllUserMessagesRequest, DeleteAllUserMessagesResponse} from '../models/MessageTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminMessageShredService} from './AdminMessageShredService';
|
||||
|
||||
interface AdminMessageDeletionServiceDeps {
|
||||
channelRepository: IChannelRepository;
|
||||
messageShredService: AdminMessageShredService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
const FETCH_CHUNK_SIZE = 200;
|
||||
|
||||
export class AdminMessageDeletionService {
|
||||
constructor(private readonly deps: AdminMessageDeletionServiceDeps) {}
|
||||
|
||||
async deleteAllUserMessages(
|
||||
data: DeleteAllUserMessagesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<DeleteAllUserMessagesResponse> {
|
||||
const authorId = createUserID(data.user_id);
|
||||
|
||||
const {entries, channelCount, messageCount} = await this.collectMessageRefs(authorId, !data.dry_run);
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['user_id', data.user_id.toString()],
|
||||
['channel_count', channelCount.toString()],
|
||||
['message_count', messageCount.toString()],
|
||||
['dry_run', data.dry_run ? 'true' : 'false'],
|
||||
]);
|
||||
|
||||
const action = data.dry_run ? 'delete_all_user_messages_dry_run' : 'delete_all_user_messages';
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_deletion',
|
||||
targetId: data.user_id,
|
||||
action,
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
Logger.debug(
|
||||
{user_id: data.user_id, channel_count: channelCount, message_count: messageCount, dry_run: data.dry_run},
|
||||
'Computed delete-all-messages stats',
|
||||
);
|
||||
|
||||
const response: DeleteAllUserMessagesResponse = {
|
||||
success: true,
|
||||
dry_run: data.dry_run,
|
||||
channel_count: channelCount,
|
||||
message_count: messageCount,
|
||||
};
|
||||
|
||||
if (data.dry_run || messageCount === 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const shredResult = await this.deps.messageShredService.queueMessageShred(
|
||||
{
|
||||
user_id: data.user_id,
|
||||
entries,
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
response.job_id = shredResult.job_id;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async collectMessageRefs(authorId: UserID, includeEntries: boolean) {
|
||||
let lastChannelId: ChannelID | undefined;
|
||||
let lastMessageId: MessageID | undefined;
|
||||
const entries: Array<{channel_id: ChannelID; message_id: MessageID}> = [];
|
||||
const channels = new Set<string>();
|
||||
let messageCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messageRefs = await this.deps.channelRepository.listMessagesByAuthor(
|
||||
authorId,
|
||||
FETCH_CHUNK_SIZE,
|
||||
lastChannelId,
|
||||
lastMessageId,
|
||||
);
|
||||
|
||||
if (messageRefs.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messageRefs) {
|
||||
channels.add(channelId.toString());
|
||||
messageCount += 1;
|
||||
|
||||
if (includeEntries) {
|
||||
entries.push({
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lastChannelId = messageRefs[messageRefs.length - 1].channelId;
|
||||
lastMessageId = messageRefs[messageRefs.length - 1].messageId;
|
||||
|
||||
if (messageRefs.length < FETCH_CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
channelCount: channels.size,
|
||||
messageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
204
fluxer_api/src/admin/services/AdminMessageService.ts
Normal file
204
fluxer_api/src/admin/services/AdminMessageService.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AttachmentID,
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {type MessageResponse, mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {DeleteMessageRequest, LookupMessageByAttachmentRequest, LookupMessageRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminMessageServiceDeps {
|
||||
channelRepository: IChannelRepository;
|
||||
userCacheService: UserCacheService;
|
||||
mediaService: IMediaService;
|
||||
gatewayService: IGatewayService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminMessageService {
|
||||
constructor(private readonly deps: AdminMessageServiceDeps) {}
|
||||
|
||||
async lookupAttachment({
|
||||
channelId,
|
||||
attachmentId,
|
||||
filename,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
attachmentId: AttachmentID;
|
||||
filename: string;
|
||||
}): Promise<{message_id: MessageID | null}> {
|
||||
const {channelRepository} = this.deps;
|
||||
const messageId = await channelRepository.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
|
||||
return {
|
||||
message_id: messageId,
|
||||
};
|
||||
}
|
||||
|
||||
async lookupMessage(data: LookupMessageRequest) {
|
||||
const {channelRepository, userCacheService, mediaService} = this.deps;
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const messageId = createMessageID(data.message_id);
|
||||
const contextPerSide = Math.floor(data.context_limit / 2);
|
||||
|
||||
const [targetMessage, messagesBefore, messagesAfter] = await Promise.all([
|
||||
channelRepository.getMessage(channelId, messageId),
|
||||
channelRepository.listMessages(channelId, messageId, contextPerSide),
|
||||
channelRepository.listMessages(channelId, undefined, contextPerSide, messageId),
|
||||
]);
|
||||
|
||||
const allMessages = [...messagesBefore.reverse(), ...(targetMessage ? [targetMessage] : []), ...messagesAfter];
|
||||
|
||||
const requestCache: RequestCache = {
|
||||
userPartials: new Map(),
|
||||
clear: () => {},
|
||||
};
|
||||
|
||||
const messageResponses = await Promise.all(
|
||||
allMessages.map((message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: undefined,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
messages: messageResponses.map((message) => this.mapMessageResponseToAdminMessage(message)),
|
||||
message_id: messageId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const attachmentId = data.attachment_id as AttachmentID;
|
||||
|
||||
const messageId = await this.deps.channelRepository.lookupAttachmentByChannelAndFilename(
|
||||
channelId,
|
||||
attachmentId,
|
||||
data.filename,
|
||||
);
|
||||
|
||||
if (!messageId) {
|
||||
return {
|
||||
messages: [],
|
||||
message_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.lookupMessage({
|
||||
channel_id: data.channel_id,
|
||||
message_id: BigInt(messageId),
|
||||
context_limit: data.context_limit,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: result.messages,
|
||||
message_id: messageId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {channelRepository, gatewayService, auditService} = this.deps;
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const messageId = createMessageID(data.message_id);
|
||||
|
||||
const channel = await channelRepository.findUnique(channelId);
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
|
||||
if (message) {
|
||||
await channelRepository.deleteMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
message.authorId || createUserID(0n),
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
|
||||
if (channel) {
|
||||
if (channel.guildId) {
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId: channel.guildId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message',
|
||||
targetId: BigInt(messageId),
|
||||
action: 'delete_message',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['channel_id', channelId.toString()],
|
||||
['message_id', messageId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private mapMessageResponseToAdminMessage(message: MessageResponse) {
|
||||
return {
|
||||
id: message.id,
|
||||
channel_id: message.channel_id ?? '',
|
||||
author_id: message.author.id,
|
||||
author_username: message.author.username,
|
||||
content: message.content ?? '',
|
||||
timestamp: message.timestamp,
|
||||
attachments:
|
||||
message.attachments?.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
url: attachment.url,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
125
fluxer_api/src/admin/services/AdminMessageShredService.ts
Normal file
125
fluxer_api/src/admin/services/AdminMessageShredService.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {InputValidationError} from '~/errors/InputValidationError';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {MessageShredRequest} from '../models/MessageTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
export type MessageShredStatusCacheEntry = {
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
requested: number;
|
||||
total: number;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
failed_at?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type MessageShredStatusResult =
|
||||
| MessageShredStatusCacheEntry
|
||||
| {
|
||||
status: 'not_found';
|
||||
};
|
||||
|
||||
interface AdminMessageShredServiceDeps {
|
||||
workerService: IWorkerService;
|
||||
cacheService: ICacheService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
interface QueueMessageShredJobPayload {
|
||||
job_id: string;
|
||||
admin_user_id: string;
|
||||
target_user_id: string;
|
||||
entries: Array<{channel_id: string; message_id: string}>;
|
||||
}
|
||||
|
||||
export class AdminMessageShredService {
|
||||
constructor(private readonly deps: AdminMessageShredServiceDeps) {}
|
||||
|
||||
async queueMessageShred(
|
||||
data: MessageShredRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<{success: true; job_id: string; requested: number}> {
|
||||
if (data.entries.length === 0) {
|
||||
throw InputValidationError.create('entries', 'At least one entry is required');
|
||||
}
|
||||
|
||||
const jobId = this.deps.snowflakeService.generate().toString();
|
||||
const payload: QueueMessageShredJobPayload = {
|
||||
job_id: jobId,
|
||||
admin_user_id: adminUserId.toString(),
|
||||
target_user_id: data.user_id.toString(),
|
||||
entries: data.entries.map((entry) => ({
|
||||
channel_id: entry.channel_id.toString(),
|
||||
message_id: entry.message_id.toString(),
|
||||
})),
|
||||
};
|
||||
|
||||
await this.deps.workerService.addJob('messageShred', payload, {
|
||||
jobKey: `message_shred_${data.user_id.toString()}_${jobId}`,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
Logger.debug({target_user_id: data.user_id, job_id: jobId}, 'Queued message shred job');
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['user_id', data.user_id.toString()],
|
||||
['job_id', jobId],
|
||||
['requested_entries', data.entries.length.toString()],
|
||||
]);
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_shred',
|
||||
targetId: data.user_id,
|
||||
action: 'queue_message_shred',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job_id: jobId,
|
||||
requested: data.entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getMessageShredStatus(jobId: string): Promise<MessageShredStatusResult> {
|
||||
const statusKey = `message_shred_status:${jobId}`;
|
||||
const status = await this.deps.cacheService.get<MessageShredStatusCacheEntry>(statusKey);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
301
fluxer_api/src/admin/services/AdminReportService.ts
Normal file
301
fluxer_api/src/admin/services/AdminReportService.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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, ReportID, UserID} from '~/BrandedTypes';
|
||||
import {createReportID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {makeAttachmentCdnKey} from '~/channel/services/message/MessageHelpers';
|
||||
import type {MessageAttachment} from '~/database/types/MessageTypes';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getReportSearchService} from '~/Meilisearch';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IARMessageContext, IARSubmission} from '~/report/IReportRepository';
|
||||
import type {ReportService} from '~/report/ReportService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {SearchReportsRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminReportServiceDeps {
|
||||
reportService: ReportService;
|
||||
userRepository: IUserRepository;
|
||||
emailService: IEmailService;
|
||||
storageService: IStorageService;
|
||||
auditService: AdminAuditService;
|
||||
userCacheService: UserCacheService;
|
||||
}
|
||||
|
||||
export class AdminReportService {
|
||||
constructor(private readonly deps: AdminReportServiceDeps) {}
|
||||
|
||||
async listReports(status: number, limit?: number, offset?: number) {
|
||||
const {reportService} = this.deps;
|
||||
const requestedLimit = limit || 50;
|
||||
const currentOffset = offset || 0;
|
||||
|
||||
const reports = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
|
||||
const requestCache = createRequestCache();
|
||||
const reportResponses = await Promise.all(
|
||||
reports.map((report: IARSubmission) => this.mapReportToResponse(report, false, requestCache)),
|
||||
);
|
||||
|
||||
return {
|
||||
reports: reportResponses,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID) {
|
||||
const {reportService} = this.deps;
|
||||
const report = await reportService.getReport(reportId);
|
||||
const requestCache = createRequestCache();
|
||||
return this.mapReportToResponse(report, true, requestCache);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
reportId: ReportID,
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {reportService, userRepository, emailService, auditService} = this.deps;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'report',
|
||||
targetId: BigInt(reportId),
|
||||
action: 'resolve_report',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['report_id', reportId.toString()],
|
||||
['report_type', resolvedReport.reportType.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
if (resolvedReport.reporterId && publicComment) {
|
||||
const reporter = await userRepository.findUnique(resolvedReport.reporterId);
|
||||
if (reporter?.email) {
|
||||
await emailService.sendReportResolvedEmail(
|
||||
reporter.email,
|
||||
reporter.username,
|
||||
reportId.toString(),
|
||||
publicComment,
|
||||
reporter.locale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
report_id: resolvedReport.reportId.toString(),
|
||||
status: resolvedReport.status,
|
||||
resolved_at: resolvedReport.resolvedAt?.toISOString() ?? null,
|
||||
public_comment: resolvedReport.publicComment,
|
||||
};
|
||||
}
|
||||
|
||||
async searchReports(data: SearchReportsRequest) {
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
const filters: Record<string, string | number> = {};
|
||||
if (data.reporter_id !== undefined) {
|
||||
filters.reporterId = data.reporter_id.toString();
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
filters.status = data.status;
|
||||
}
|
||||
if (data.report_type !== undefined) {
|
||||
filters.reportType = data.report_type;
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
filters.category = data.category;
|
||||
}
|
||||
if (data.reported_user_id !== undefined) {
|
||||
filters.reportedUserId = data.reported_user_id.toString();
|
||||
}
|
||||
if (data.reported_guild_id !== undefined) {
|
||||
filters.reportedGuildId = data.reported_guild_id.toString();
|
||||
}
|
||||
if (data.reported_channel_id !== undefined) {
|
||||
filters.reportedChannelId = data.reported_channel_id.toString();
|
||||
}
|
||||
if (data.guild_context_id !== undefined) {
|
||||
filters.guildContextId = data.guild_context_id.toString();
|
||||
}
|
||||
if (data.resolved_by_admin_id !== undefined) {
|
||||
filters.resolvedByAdminId = data.resolved_by_admin_id.toString();
|
||||
}
|
||||
if (data.sort_by) {
|
||||
filters.sortBy = data.sort_by;
|
||||
}
|
||||
if (data.sort_order) {
|
||||
filters.sortOrder = data.sort_order;
|
||||
}
|
||||
|
||||
const {hits, total} = await reportSearchService.searchReports(data.query || '', filters, {
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
|
||||
const requestCache = createRequestCache();
|
||||
const reports = await Promise.all(
|
||||
hits.map(async (hit) => {
|
||||
const report = await this.deps.reportService.getReport(createReportID(BigInt(hit.id)));
|
||||
return this.mapReportToResponse(report, false, requestCache);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
reports,
|
||||
total,
|
||||
offset: data.offset,
|
||||
limit: data.limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapReportToResponse(report: IARSubmission, includeContext: boolean, requestCache: RequestCache) {
|
||||
const reporterInfo = await this.buildUserTag(report.reporterId, requestCache);
|
||||
const reportedUserInfo = await this.buildUserTag(report.reportedUserId, requestCache);
|
||||
|
||||
const baseResponse = {
|
||||
report_id: report.reportId.toString(),
|
||||
reporter_id: report.reporterId?.toString() ?? null,
|
||||
reporter_tag: reporterInfo?.tag ?? null,
|
||||
reporter_username: reporterInfo?.username ?? null,
|
||||
reporter_discriminator: reporterInfo?.discriminator ?? null,
|
||||
reporter_email: report.reporterEmail,
|
||||
reporter_full_legal_name: report.reporterFullLegalName,
|
||||
reporter_country_of_residence: report.reporterCountryOfResidence,
|
||||
reported_at: report.reportedAt.toISOString(),
|
||||
status: report.status,
|
||||
report_type: report.reportType,
|
||||
category: report.category,
|
||||
additional_info: report.additionalInfo,
|
||||
reported_user_id: report.reportedUserId?.toString() ?? null,
|
||||
reported_user_tag: reportedUserInfo?.tag ?? null,
|
||||
reported_user_username: reportedUserInfo?.username ?? null,
|
||||
reported_user_discriminator: reportedUserInfo?.discriminator ?? null,
|
||||
reported_user_avatar_hash: report.reportedUserAvatarHash,
|
||||
reported_guild_id: report.reportedGuildId?.toString() ?? null,
|
||||
reported_guild_name: report.reportedGuildName,
|
||||
reported_message_id: report.reportedMessageId?.toString() ?? null,
|
||||
reported_channel_id: report.reportedChannelId?.toString() ?? null,
|
||||
reported_channel_name: report.reportedChannelName,
|
||||
reported_guild_invite_code: report.reportedGuildInviteCode,
|
||||
resolved_at: report.resolvedAt?.toISOString() ?? null,
|
||||
resolved_by_admin_id: report.resolvedByAdminId?.toString() ?? null,
|
||||
public_comment: report.publicComment,
|
||||
};
|
||||
|
||||
if (!includeContext) {
|
||||
return baseResponse;
|
||||
}
|
||||
|
||||
const messageContext =
|
||||
report.messageContext && report.messageContext.length > 0
|
||||
? await Promise.all(
|
||||
report.messageContext.map((message) =>
|
||||
this.mapReportMessageContextToResponse(message, report.reportedChannelId ?? null),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...baseResponse,
|
||||
message_context: messageContext,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapReportMessageContextToResponse(message: IARMessageContext, fallbackChannelId: ChannelID | null) {
|
||||
const channelId = message.channelId ?? fallbackChannelId;
|
||||
const attachments =
|
||||
message.attachments && message.attachments.length > 0
|
||||
? (
|
||||
await Promise.all(
|
||||
message.attachments.map((attachment) => this.mapReportAttachmentToResponse(attachment, channelId)),
|
||||
)
|
||||
).filter((attachment): attachment is {filename: string; url: string} => attachment !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: message.messageId.toString(),
|
||||
channel_id: channelId ? channelId.toString() : '',
|
||||
content: message.content ?? '',
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
attachments,
|
||||
author_id: message.authorId.toString(),
|
||||
author_username: message.authorUsername,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapReportAttachmentToResponse(
|
||||
attachment: MessageAttachment,
|
||||
channelId: ChannelID | null,
|
||||
): Promise<{filename: string; url: string} | null> {
|
||||
if (!attachment || attachment.attachment_id == null || !attachment.filename || !channelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {storageService} = this.deps;
|
||||
const attachmentId = attachment.attachment_id;
|
||||
const filename = String(attachment.filename);
|
||||
const key = makeAttachmentCdnKey(channelId, attachmentId, filename);
|
||||
|
||||
try {
|
||||
const url = await storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.reports,
|
||||
key,
|
||||
expiresIn: 300,
|
||||
});
|
||||
return {filename, url};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, attachmentId, filename, channelId},
|
||||
'Failed to generate presigned URL for report attachment',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async buildUserTag(userId: UserID | null, requestCache: RequestCache): Promise<UserTagInfo | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
|
||||
return {tag: `${user.username}#${discriminator}`, username: user.username, discriminator};
|
||||
} catch (error) {
|
||||
Logger.warn({userId: userId.toString(), error}, 'Failed to resolve user tag for report');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UserTagInfo {
|
||||
tag: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
}
|
||||
207
fluxer_api/src/admin/services/AdminSearchService.ts
Normal file
207
fluxer_api/src/admin/services/AdminSearchService.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapGuildToAdminResponse, mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import {createGuildID, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '~/Meilisearch';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface RefreshSearchIndexJobPayload {
|
||||
index_type: 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'favorite_memes';
|
||||
admin_user_id: string;
|
||||
audit_log_reason: string | null;
|
||||
job_id: string;
|
||||
guild_id?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface AdminSearchServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
userRepository: IUserRepository;
|
||||
workerService: IWorkerService;
|
||||
cacheService: ICacheService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminSearchService {
|
||||
constructor(private readonly deps: AdminSearchServiceDeps) {}
|
||||
|
||||
async searchGuilds(data: {query?: string; limit: number; offset: number}) {
|
||||
const {guildRepository} = this.deps;
|
||||
Logger.debug(
|
||||
{query: data.query, limit: data.limit, offset: data.offset},
|
||||
'[AdminSearchService] searchGuilds called',
|
||||
);
|
||||
|
||||
const guildSearchService = getGuildSearchService();
|
||||
if (!guildSearchService) {
|
||||
Logger.error('[AdminSearchService] searchGuilds - Search service not enabled');
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
Logger.debug('[AdminSearchService] searchGuilds - Calling Meilisearch');
|
||||
const {hits, total} = await guildSearchService.searchGuilds(
|
||||
data.query || '',
|
||||
{},
|
||||
{
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
},
|
||||
);
|
||||
|
||||
const guildIds = hits.map((hit) => createGuildID(BigInt(hit.id)));
|
||||
Logger.debug(
|
||||
{guild_ids: guildIds.map((id) => id.toString())},
|
||||
'[AdminSearchService] searchGuilds - Fetching from DB',
|
||||
);
|
||||
|
||||
const guilds = await guildRepository.listGuilds(guildIds);
|
||||
Logger.debug({guilds_count: guilds.length}, '[AdminSearchService] searchGuilds - Got guilds from DB');
|
||||
|
||||
const response = guilds.map((guild) => mapGuildToAdminResponse(guild));
|
||||
Logger.debug({response_count: response.length}, '[AdminSearchService] searchGuilds - Mapped to response');
|
||||
|
||||
return {
|
||||
guilds: response,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async searchUsers(data: {query?: string; limit: number; offset: number}) {
|
||||
const {userRepository} = this.deps;
|
||||
const userSearchService = getUserSearchService();
|
||||
if (!userSearchService) {
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
const {hits, total} = await userSearchService.searchUsers(
|
||||
data.query || '',
|
||||
{},
|
||||
{
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
},
|
||||
);
|
||||
|
||||
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
|
||||
const users = await userRepository.listUsers(userIds);
|
||||
|
||||
const {cacheService} = this.deps;
|
||||
return {
|
||||
users: await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService))),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshSearchIndex(
|
||||
data: {
|
||||
index_type: 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'favorite_memes';
|
||||
guild_id?: bigint;
|
||||
user_id?: bigint;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {workerService, snowflakeService, auditService} = this.deps;
|
||||
const jobId = snowflakeService.generate().toString();
|
||||
|
||||
const payload: RefreshSearchIndexJobPayload = {
|
||||
index_type: data.index_type,
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
job_id: jobId,
|
||||
};
|
||||
|
||||
if (data.index_type === 'channel_messages') {
|
||||
if (!data.guild_id) {
|
||||
throw new Error('guild_id is required for the channel_messages index type');
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
|
||||
if (data.index_type === 'favorite_memes') {
|
||||
if (!data.user_id) {
|
||||
throw new Error('user_id is required for favorite_memes index type');
|
||||
}
|
||||
payload.user_id = data.user_id.toString();
|
||||
}
|
||||
|
||||
await workerService.addJob('refreshSearchIndex', payload, {
|
||||
jobKey: `refreshSearchIndex_${data.index_type}_${jobId}`,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
Logger.debug({index_type: data.index_type, job_id: jobId}, 'Queued search index refresh job');
|
||||
|
||||
const metadata = new Map([
|
||||
['index_type', data.index_type],
|
||||
['job_id', jobId],
|
||||
]);
|
||||
if (data.guild_id) {
|
||||
metadata.set('guild_id', data.guild_id.toString());
|
||||
}
|
||||
if (data.user_id) {
|
||||
metadata.set('user_id', data.user_id.toString());
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'search_index',
|
||||
targetId: BigInt(0),
|
||||
action: 'queue_refresh_index',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job_id: jobId,
|
||||
};
|
||||
}
|
||||
|
||||
async getIndexRefreshStatus(jobId: string) {
|
||||
const {cacheService} = this.deps;
|
||||
const statusKey = `index_refresh_status:${jobId}`;
|
||||
const status = await cacheService.get<{
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
index_type: string;
|
||||
total?: number;
|
||||
indexed?: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
failed_at?: string;
|
||||
error?: string;
|
||||
}>(statusKey);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'not_found' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
117
fluxer_api/src/admin/services/AdminUserBanService.ts
Normal file
117
fluxer_api/src/admin/services/AdminUserBanService.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 {AuthService} from '~/auth/AuthService';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {TempBanUserRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserBanServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminUserBanService {
|
||||
constructor(private readonly deps: AdminUserBanServiceDeps) {}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const tempBannedUntil = new Date();
|
||||
tempBannedUntil.setHours(tempBannedUntil.getHours() + data.duration_hours);
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
temp_banned_until: tempBannedUntil,
|
||||
flags: user.flags | UserFlags.DISABLED,
|
||||
});
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountTempBannedEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.reason ?? null,
|
||||
data.duration_hours,
|
||||
tempBannedUntil,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'temp_ban',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['duration_hours', data.duration_hours.toString()],
|
||||
['reason', data.reason ?? 'null'],
|
||||
['banned_until', tempBannedUntil.toISOString()],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
temp_banned_until: null,
|
||||
flags: user.flags & ~UserFlags.DISABLED,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendUnbanNotification(
|
||||
user.email,
|
||||
user.username,
|
||||
auditLogReason || 'administrative action',
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'unban',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
}
|
||||
187
fluxer_api/src/admin/services/AdminUserDeletionService.ts
Normal file
187
fluxer_api/src/admin/services/AdminUserDeletionService.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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 {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {DeletionReasons, UserFlags} from '~/Constants';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {BulkScheduleUserDeletionRequest, ScheduleAccountDeletionRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserDeletionServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
}
|
||||
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
const minStandardDeletionDays = 60;
|
||||
|
||||
export class AdminUserDeletionService {
|
||||
constructor(private readonly deps: AdminUserDeletionServiceDeps) {}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data: ScheduleAccountDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const minDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const daysUntilDeletion = Math.max(data.days_until_deletion, minDays);
|
||||
const pendingDeletionAt = new Date();
|
||||
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + daysUntilDeletion);
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags | UserFlags.DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
deletion_reason_code: data.reason_code,
|
||||
deletion_public_reason: data.public_reason ?? null,
|
||||
deletion_audit_log_reason: auditLogReason,
|
||||
});
|
||||
|
||||
await userRepository.addPendingDeletion(userId, pendingDeletionAt, data.reason_code);
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountScheduledForDeletionEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.public_reason ?? null,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: data.user_id,
|
||||
action: 'schedule_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([['days', daysUntilDeletion.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (user.pendingDeletionAt) {
|
||||
await userRepository.removePendingDeletion(userId, user.pendingDeletionAt);
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags & ~UserFlags.DELETED & ~UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendUnbanNotification(
|
||||
user.email,
|
||||
user.username,
|
||||
auditLogReason || 'deletion canceled',
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'cancel_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
await this.scheduleAccountDeletion(
|
||||
{
|
||||
user_id: userIdBigInt,
|
||||
reason_code: data.reason_code,
|
||||
public_reason: data.public_reason,
|
||||
days_until_deletion: data.days_until_deletion,
|
||||
},
|
||||
adminUserId,
|
||||
null,
|
||||
);
|
||||
successful.push(userIdBigInt.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bulkMinDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const bulkDaysUntilDeletion = Math.max(data.days_until_deletion, bulkMinDays);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_schedule_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
['reason_code', data.reason_code.toString()],
|
||||
['days', bulkDaysUntilDeletion.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
64
fluxer_api/src/admin/services/AdminUserLookupService.ts
Normal file
64
fluxer_api/src/admin/services/AdminUserLookupService.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import {createUserID} from '~/BrandedTypes';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
|
||||
interface LookupUserRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface AdminUserLookupServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserLookupService {
|
||||
constructor(private readonly deps: AdminUserLookupServiceDeps) {}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
const {userRepository, cacheService} = this.deps;
|
||||
let user = null;
|
||||
const query = data.query.trim();
|
||||
|
||||
const fluxerTagMatch = query.match(/^(.+)#(\d{1,4})$/);
|
||||
if (fluxerTagMatch) {
|
||||
const username = fluxerTagMatch[1];
|
||||
const discriminator = parseInt(fluxerTagMatch[2], 10);
|
||||
user = await userRepository.findByUsernameDiscriminator(username, discriminator);
|
||||
} else if (/^\d+$/.test(query)) {
|
||||
try {
|
||||
const userId = createUserID(BigInt(query));
|
||||
user = await userRepository.findUnique(userId);
|
||||
} catch {}
|
||||
} else if (/^\+\d{1,15}$/.test(query)) {
|
||||
user = await userRepository.findByPhone(query);
|
||||
} else if (query.includes('@')) {
|
||||
user = await userRepository.findByEmail(query);
|
||||
} else {
|
||||
user = await userRepository.findByStripeSubscriptionId(query);
|
||||
}
|
||||
|
||||
return {
|
||||
user: user ? await mapUserToAdminResponse(user, cacheService) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
293
fluxer_api/src/admin/services/AdminUserProfileService.ts
Normal file
293
fluxer_api/src/admin/services/AdminUserProfileService.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {types} from 'cassandra-driver';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {InputValidationError, TagAlreadyTakenError, UnknownUserError} from '~/Errors';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import type {
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserProfileServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
}
|
||||
|
||||
export class AdminUserProfileService {
|
||||
constructor(private readonly deps: AdminUserProfileServiceDeps) {}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, entityAssetService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updates: Record<string, null | string> = {};
|
||||
const preparedAssets: Array<PreparedAssetUpload> = [];
|
||||
|
||||
for (const field of data.fields) {
|
||||
if (field === 'avatar') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
previousHash: user.avatarHash,
|
||||
base64Image: null,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.avatar_hash = prepared.newHash;
|
||||
} else if (field === 'banner') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
previousHash: user.bannerHash,
|
||||
base64Image: null,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.banner_hash = prepared.newHash;
|
||||
} else if (field === 'bio') {
|
||||
updates.bio = null;
|
||||
} else if (field === 'pronouns') {
|
||||
updates.pronouns = null;
|
||||
} else if (field === 'global_name') {
|
||||
updates.global_name = null;
|
||||
}
|
||||
}
|
||||
|
||||
let updatedUser: User | null = null;
|
||||
try {
|
||||
updatedUser = await userRepository.patchUpsert(userId, updates);
|
||||
} catch (error) {
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.rollbackAssetUpload(p)));
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.commitAssetChange({prepared: p})));
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'clear_fields',
|
||||
auditLogReason,
|
||||
metadata: new Map([['fields', data.fields.join(',')]]),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updates: Record<string, boolean> = {bot: data.bot};
|
||||
if (!data.bot) {
|
||||
updates.system = false;
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, updates);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_bot_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['bot', data.bot.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (data.system && !user.isBot) {
|
||||
throw InputValidationError.create('system', 'User must be a bot to be marked as a system user');
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_system_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['system', data.system.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {email_verified: true});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'verify_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', user.email ?? 'null']]),
|
||||
});
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, discriminatorService, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const discriminatorResult = await discriminatorService.generateDiscriminator({
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
isPremium: true,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw new TagAlreadyTakenError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
username: data.username,
|
||||
discriminator: discriminatorResult.discriminator,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser!,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_username',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_username', user.username],
|
||||
['new_username', data.username],
|
||||
['discriminator', discriminatorResult.discriminator.toString()],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
email: data.email,
|
||||
email_verified: false,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser!,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_email', user.email ?? 'null'],
|
||||
['new_email', data.email],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
date_of_birth: types.LocalDate.fromString(data.date_of_birth),
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_dob',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_dob', user.dateOfBirth ?? 'null'],
|
||||
['new_dob', data.date_of_birth],
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
183
fluxer_api/src/admin/services/AdminUserRegistrationService.ts
Normal file
183
fluxer_api/src/admin/services/AdminUserRegistrationService.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import type {IAdminRepository} from '~/admin/IAdminRepository';
|
||||
import {createInviteCode, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {InputValidationError, UnknownUserError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserRegistrationServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
adminRepository: IAdminRepository;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
inviteService: InviteService;
|
||||
pendingJoinInviteStore: PendingJoinInviteStore;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserRegistrationService {
|
||||
constructor(private readonly deps: AdminUserRegistrationServiceDeps) {}
|
||||
|
||||
async listPendingVerifications(limit: number = 100) {
|
||||
const {adminRepository, userRepository, cacheService} = this.deps;
|
||||
const pendingVerifications = await adminRepository.listPendingVerifications(limit);
|
||||
const userIds = pendingVerifications.map((pv) => pv.userId);
|
||||
const users = await userRepository.listUsers(userIds);
|
||||
const userMap = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
const mappedVerifications = await Promise.all(
|
||||
pendingVerifications.map(async (pv) => {
|
||||
const metadataEntries = Array.from((pv.metadata ?? new Map()).entries()).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
|
||||
return {
|
||||
user_id: pv.userId.toString(),
|
||||
created_at: pv.createdAt.toISOString(),
|
||||
user: await mapUserToAdminResponse(userMap.get(pv.userId.toString())!, cacheService),
|
||||
metadata: metadataEntries,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
pending_verifications: mappedVerifications,
|
||||
};
|
||||
}
|
||||
|
||||
async approveRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {
|
||||
userRepository,
|
||||
adminRepository,
|
||||
emailService,
|
||||
auditService,
|
||||
updatePropagator,
|
||||
inviteService,
|
||||
pendingJoinInviteStore,
|
||||
} = this.deps;
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) === 0n) {
|
||||
throw InputValidationError.create('user_id', 'User is not pending verification');
|
||||
}
|
||||
|
||||
await adminRepository.removePendingVerification(userId);
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags & ~UserFlags.PENDING_MANUAL_VERIFICATION,
|
||||
});
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendRegistrationApprovedEmail(user.email, user.username, user.locale);
|
||||
}
|
||||
|
||||
const pendingInviteCode = await pendingJoinInviteStore.getPendingInvite(userId);
|
||||
if (pendingInviteCode) {
|
||||
try {
|
||||
await inviteService.acceptInvite({
|
||||
userId,
|
||||
inviteCode: createInviteCode(pendingInviteCode),
|
||||
requestCache: createRequestCache(),
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{userId, inviteCode: pendingInviteCode, error},
|
||||
'Failed to auto-join invite after approving registration',
|
||||
);
|
||||
} finally {
|
||||
await pendingJoinInviteStore.deletePendingInvite(userId);
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'approve_registration',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async rejectRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, adminRepository, auditService} = this.deps;
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) === 0n) {
|
||||
throw InputValidationError.create('user_id', 'User is not pending verification');
|
||||
}
|
||||
|
||||
await adminRepository.removePendingVerification(userId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'reject_registration',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async bulkApproveRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
for (const userId of userIds) {
|
||||
await this.approveRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processed: userIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkRejectRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
for (const userId of userIds) {
|
||||
await this.rejectRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processed: userIds.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
377
fluxer_api/src/admin/services/AdminUserSecurityService.ts
Normal file
377
fluxer_api/src/admin/services/AdminUserSecurityService.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {InputValidationError, UnknownUserError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import type {
|
||||
BulkUpdateUserFlagsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserSecurityServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
botMfaMirrorService?: BotMfaMirrorService;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserSecurityService {
|
||||
constructor(private readonly deps: AdminUserSecurityServiceDeps) {}
|
||||
|
||||
async updateUserFlags({
|
||||
userId,
|
||||
data,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
let newFlags = user.flags;
|
||||
for (const flag of data.addFlags) {
|
||||
newFlags |= flag;
|
||||
}
|
||||
for (const flag of data.removeFlags) {
|
||||
newFlags &= ~flag;
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: newFlags,
|
||||
});
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['add_flags', data.addFlags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', data.removeFlags.map((f) => f.toString()).join(',')],
|
||||
['new_flags', newFlags.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser!, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
totp_secret: null,
|
||||
authenticator_types: null,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.deps.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
|
||||
await userRepository.clearMfaBackupCodes(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'disable_mfa',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, snowflakeService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw InputValidationError.create('email', 'User does not have an email address');
|
||||
}
|
||||
|
||||
const token = createPasswordResetToken(snowflakeService.generate().toString());
|
||||
await userRepository.createPasswordResetToken({
|
||||
token_: token,
|
||||
user_id: userId,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await emailService.sendPasswordResetEmail(user.email, user.username, token, user.locale);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'send_password_reset',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', user.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, authService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'terminate_sessions',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
acls: new Set(data.acls),
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_acls',
|
||||
auditLogReason,
|
||||
metadata: new Map([['acls', data.acls.join(',')]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
phone: null,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser!,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'unlink_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', user.phone ?? 'null']]),
|
||||
});
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
suspicious_activity_flags: data.flags,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_suspicious_activity_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map([['flags', data.flags.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags | UserFlags.DISABLED_SUSPICIOUS_ACTIVITY,
|
||||
suspicious_activity_flags: data.flags,
|
||||
password_hash: null,
|
||||
});
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountDisabledForSuspiciousActivityEmail(user.email, user.username, null, user.locale);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'disable_suspicious_activity',
|
||||
auditLogReason,
|
||||
metadata: new Map([['flags', data.flags.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await this.updateUserFlags({
|
||||
userId,
|
||||
data: {addFlags: data.add_flags, removeFlags: data.remove_flags},
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_user_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
['add_flags', data.add_flags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', data.remove_flags.map((f) => f.toString()).join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService} = this.deps;
|
||||
const userIdTyped = createUserID(userId);
|
||||
const user = await userRepository.findUnique(userIdTyped);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const sessions = await userRepository.listAuthSessions(userIdTyped);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: 'list_user_sessions',
|
||||
auditLogReason,
|
||||
metadata: new Map([['session_count', sessions.length.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessions.map((session) => ({
|
||||
session_id_hash: session.sessionIdHash.toString('base64url'),
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt.toISOString(),
|
||||
client_ip: session.clientIp,
|
||||
client_os: session.clientOs,
|
||||
client_platform: session.clientPlatform,
|
||||
client_location: session.clientLocation ?? 'Unknown Location',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
407
fluxer_api/src/admin/services/AdminUserService.ts
Normal file
407
fluxer_api/src/admin/services/AdminUserService.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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 {IAdminRepository} from '~/admin/IAdminRepository';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import type {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
CancelBulkMessageDeletionRequest,
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
ListUserChangeLogRequest,
|
||||
ScheduleAccountDeletionRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
TempBanUserRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import {AdminBanManagementService} from './AdminBanManagementService';
|
||||
import {AdminUserBanService} from './AdminUserBanService';
|
||||
import {AdminUserDeletionService} from './AdminUserDeletionService';
|
||||
import {AdminUserLookupService} from './AdminUserLookupService';
|
||||
import {AdminUserProfileService} from './AdminUserProfileService';
|
||||
import {AdminUserRegistrationService} from './AdminUserRegistrationService';
|
||||
import {AdminUserSecurityService} from './AdminUserSecurityService';
|
||||
import {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface LookupUserRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface AdminUserServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
snowflakeService: SnowflakeService;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
gatewayService: IGatewayService;
|
||||
userCacheService: UserCacheService;
|
||||
adminRepository: IAdminRepository;
|
||||
botMfaMirrorService: BotMfaMirrorService;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService;
|
||||
inviteService: InviteService;
|
||||
pendingJoinInviteStore: PendingJoinInviteStore;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserService {
|
||||
private readonly lookupService: AdminUserLookupService;
|
||||
private readonly profileService: AdminUserProfileService;
|
||||
private readonly securityService: AdminUserSecurityService;
|
||||
private readonly banService: AdminUserBanService;
|
||||
private readonly deletionService: AdminUserDeletionService;
|
||||
private readonly banManagementService: AdminBanManagementService;
|
||||
private readonly registrationService: AdminUserRegistrationService;
|
||||
private readonly updatePropagator: AdminUserUpdatePropagator;
|
||||
private readonly contactChangeLogService: UserContactChangeLogService;
|
||||
private readonly auditService: AdminAuditService;
|
||||
private readonly userRepository: IUserRepository;
|
||||
private readonly bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService;
|
||||
|
||||
constructor(deps: AdminUserServiceDeps) {
|
||||
this.updatePropagator = new AdminUserUpdatePropagator({
|
||||
userCacheService: deps.userCacheService,
|
||||
userRepository: deps.userRepository,
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.userRepository = deps.userRepository;
|
||||
this.auditService = deps.auditService;
|
||||
this.bulkMessageDeletionQueue = deps.bulkMessageDeletionQueue;
|
||||
|
||||
this.lookupService = new AdminUserLookupService({
|
||||
userRepository: deps.userRepository,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.profileService = new AdminUserProfileService({
|
||||
userRepository: deps.userRepository,
|
||||
discriminatorService: deps.discriminatorService,
|
||||
entityAssetService: deps.entityAssetService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
contactChangeLogService: deps.contactChangeLogService,
|
||||
});
|
||||
|
||||
this.securityService = new AdminUserSecurityService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
snowflakeService: deps.snowflakeService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
botMfaMirrorService: deps.botMfaMirrorService,
|
||||
contactChangeLogService: deps.contactChangeLogService,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.banService = new AdminUserBanService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.deletionService = new AdminUserDeletionService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.banManagementService = new AdminBanManagementService({
|
||||
adminRepository: deps.adminRepository,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.registrationService = new AdminUserRegistrationService({
|
||||
userRepository: deps.userRepository,
|
||||
adminRepository: deps.adminRepository,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
inviteService: deps.inviteService,
|
||||
pendingJoinInviteStore: deps.pendingJoinInviteStore,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.contactChangeLogService = deps.contactChangeLogService;
|
||||
}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
return this.lookupService.lookupUser(data);
|
||||
}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.clearUserFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.setUserBotStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.setUserSystemStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.verifyUserEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeUsername(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeDob(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateUserFlags({
|
||||
userId,
|
||||
data,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.securityService.updateUserFlags({userId, data, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.disableMfa(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.sendPasswordReset(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.terminateSessions(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.setUserAcls(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.unlinkPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.securityService.updateSuspiciousActivityFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.securityService.disableForSuspiciousActivity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.bulkUpdateUserFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.listUserSessions(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserChangeLog(data: ListUserChangeLogRequest) {
|
||||
const entries = await this.contactChangeLogService.listLogs({
|
||||
userId: createUserID(data.user_id),
|
||||
limit: data.limit,
|
||||
beforeEventId: data.page_token,
|
||||
});
|
||||
|
||||
const nextPageToken =
|
||||
entries.length === data.limit && entries.length > 0 ? entries.at(-1)!.event_id.toString() : null;
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
event_id: entry.event_id.toString(),
|
||||
field: entry.field,
|
||||
old_value: entry.old_value ?? null,
|
||||
new_value: entry.new_value ?? null,
|
||||
reason: entry.reason,
|
||||
actor_user_id: entry.actor_user_id ? entry.actor_user_id.toString() : null,
|
||||
event_at: entry.event_at.toISOString(),
|
||||
})),
|
||||
next_page_token: nextPageToken,
|
||||
};
|
||||
}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banService.tempBanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banService.unbanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data: ScheduleAccountDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.deletionService.scheduleAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.deletionService.cancelAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(
|
||||
data: CancelBulkMessageDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await this.userRepository.patchUpsert(userId, {
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
});
|
||||
|
||||
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
||||
|
||||
await this.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'cancel_bulk_message_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.deletionService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}) {
|
||||
return this.banManagementService.checkIpBan(data);
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkEmailBan(data: {email: string}) {
|
||||
return this.banManagementService.checkEmailBan(data);
|
||||
}
|
||||
|
||||
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}) {
|
||||
return this.banManagementService.checkPhoneBan(data);
|
||||
}
|
||||
|
||||
async listPendingVerifications(limit: number = 100) {
|
||||
return this.registrationService.listPendingVerifications(limit);
|
||||
}
|
||||
|
||||
async approveRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.approveRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async rejectRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.rejectRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkApproveRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.bulkApproveRegistrations(userIds, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkRejectRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.bulkRejectRegistrations(userIds, adminUserId, auditLogReason);
|
||||
}
|
||||
}
|
||||
94
fluxer_api/src/admin/services/AdminUserUpdatePropagator.ts
Normal file
94
fluxer_api/src/admin/services/AdminUserUpdatePropagator.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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 {mapGuildMemberToResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {BaseUserUpdatePropagator} from '~/user/services/BaseUserUpdatePropagator';
|
||||
import {hasPartialUserFieldsChanged} from '~/user/UserMappers';
|
||||
|
||||
interface AdminUserUpdatePropagatorDeps {
|
||||
userCacheService: UserCacheService;
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
constructor(private readonly deps: AdminUserUpdatePropagatorDeps) {
|
||||
super({
|
||||
userCacheService: deps.userCacheService,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async propagateUserUpdate({
|
||||
userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
}: {
|
||||
userId: UserID;
|
||||
oldUser: User;
|
||||
updatedUser: User;
|
||||
}): Promise<void> {
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
|
||||
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
|
||||
await this.invalidateUserCache(userId);
|
||||
await this.propagateToGuilds(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async propagateToGuilds(userId: UserID): Promise<void> {
|
||||
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
|
||||
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestCache: RequestCache = {
|
||||
userPartials: new Map(),
|
||||
clear() {
|
||||
this.userPartials.clear();
|
||||
},
|
||||
};
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
401
fluxer_api/src/admin/services/AdminVoiceService.ts
Normal file
401
fluxer_api/src/admin/services/AdminVoiceService.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildIDSet, createUserIDSet, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownVoiceRegionError, UnknownVoiceServerError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import {VOICE_CONFIGURATION_CHANNEL} from '~/voice/VoiceConstants';
|
||||
import type {VoiceRegionRecord, VoiceRegionWithServers, VoiceServerRecord} from '~/voice/VoiceModel';
|
||||
import type {VoiceRepository} from '~/voice/VoiceRepository';
|
||||
import type {
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceServerRequest,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceServerRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceServerRequest,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceServersRequest,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceServerRequest,
|
||||
VoiceRegionAdminResponse,
|
||||
VoiceServerAdminResponse,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminVoiceServiceDeps {
|
||||
voiceRepository: VoiceRepository;
|
||||
cacheService: ICacheService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminVoiceService {
|
||||
constructor(private readonly deps: AdminVoiceServiceDeps) {}
|
||||
|
||||
async listVoiceRegions(data: ListVoiceRegionsRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const regions = data.include_servers
|
||||
? await voiceRepository.listRegionsWithServers()
|
||||
: await voiceRepository.listRegions();
|
||||
|
||||
regions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (data.include_servers) {
|
||||
const regionsWithServers = regions as Array<VoiceRegionWithServers>;
|
||||
return {
|
||||
regions: regionsWithServers.map((region) => ({
|
||||
...this.mapVoiceRegionToAdminResponse(region),
|
||||
servers: region.servers
|
||||
.sort((a, b) => a.serverId.localeCompare(b.serverId))
|
||||
.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
regions: regions.map((region) => this.mapVoiceRegionToAdminResponse(region)),
|
||||
};
|
||||
}
|
||||
|
||||
async getVoiceRegion(data: GetVoiceRegionRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const region = data.include_servers
|
||||
? await voiceRepository.getRegionWithServers(data.id)
|
||||
: await voiceRepository.getRegion(data.id);
|
||||
|
||||
if (!region) {
|
||||
return {region: null};
|
||||
}
|
||||
|
||||
if (data.include_servers && 'servers' in region) {
|
||||
const regionWithServers = region as VoiceRegionWithServers;
|
||||
return {
|
||||
region: {
|
||||
...this.mapVoiceRegionToAdminResponse(regionWithServers),
|
||||
servers: regionWithServers.servers.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(region),
|
||||
};
|
||||
}
|
||||
|
||||
async createVoiceRegion(data: CreateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const region = await voiceRepository.createRegion({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
emoji: data.emoji,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
isDefault: data.is_default ?? false,
|
||||
restrictions: {
|
||||
vipOnly: data.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(data.required_guild_features ?? []),
|
||||
allowedGuildIds: createGuildIDSet(new Set((data.allowed_guild_ids ?? []).map(BigInt))),
|
||||
allowedUserIds: createUserIDSet(new Set((data.allowed_user_ids ?? []).map(BigInt))),
|
||||
},
|
||||
});
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_created', regionId: region.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'create_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', region.id],
|
||||
['name', region.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(region),
|
||||
};
|
||||
}
|
||||
|
||||
async updateVoiceRegion(data: UpdateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getRegion(data.id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceRegionError();
|
||||
}
|
||||
|
||||
const updates: VoiceRegionRecord = {...existing};
|
||||
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.emoji !== undefined) updates.emoji = data.emoji;
|
||||
if (data.latitude !== undefined) updates.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updates.longitude = data.longitude;
|
||||
if (data.is_default !== undefined) updates.isDefault = data.is_default;
|
||||
|
||||
if (
|
||||
data.vip_only !== undefined ||
|
||||
data.required_guild_features !== undefined ||
|
||||
data.allowed_guild_ids !== undefined ||
|
||||
data.allowed_user_ids !== undefined
|
||||
) {
|
||||
updates.restrictions = {...existing.restrictions};
|
||||
if (data.vip_only !== undefined) updates.restrictions.vipOnly = data.vip_only;
|
||||
if (data.required_guild_features !== undefined)
|
||||
updates.restrictions.requiredGuildFeatures = new Set(data.required_guild_features);
|
||||
if (data.allowed_guild_ids !== undefined) {
|
||||
updates.restrictions.allowedGuildIds = createGuildIDSet(new Set(data.allowed_guild_ids.map(BigInt)));
|
||||
}
|
||||
if (data.allowed_user_ids !== undefined) {
|
||||
updates.restrictions.allowedUserIds = createUserIDSet(new Set(data.allowed_user_ids.map(BigInt)));
|
||||
}
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
await voiceRepository.upsertRegion(updates);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_updated', regionId: data.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'update_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([['region_id', data.id]]),
|
||||
});
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(updates),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteVoiceRegion(data: DeleteVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getRegion(data.id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceRegionError();
|
||||
}
|
||||
|
||||
await voiceRepository.deleteRegion(data.id);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_deleted', regionId: data.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'delete_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.id],
|
||||
['name', existing.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async listVoiceServers(data: ListVoiceServersRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const servers = await voiceRepository.listServers(data.region_id);
|
||||
|
||||
return {
|
||||
servers: servers.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
};
|
||||
}
|
||||
|
||||
async getVoiceServer(data: GetVoiceServerRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const server = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
|
||||
return {
|
||||
server: server ? this.mapVoiceServerToAdminResponse(server) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async createVoiceServer(data: CreateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const server = await voiceRepository.createServer({
|
||||
regionId: data.region_id,
|
||||
serverId: data.server_id,
|
||||
endpoint: data.endpoint,
|
||||
isActive: data.is_active ?? true,
|
||||
apiKey: data.api_key ?? null,
|
||||
apiSecret: data.api_secret ?? null,
|
||||
restrictions: {
|
||||
vipOnly: data.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(data.required_guild_features ?? []),
|
||||
allowedGuildIds: createGuildIDSet(new Set((data.allowed_guild_ids ?? []).map(BigInt))),
|
||||
allowedUserIds: createUserIDSet(new Set((data.allowed_user_ids ?? []).map(BigInt))),
|
||||
},
|
||||
});
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_created', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'create_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', server.regionId],
|
||||
['server_id', server.serverId],
|
||||
['endpoint', server.endpoint],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
server: this.mapVoiceServerToAdminResponse(server),
|
||||
};
|
||||
}
|
||||
|
||||
async updateVoiceServer(data: UpdateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceServerError();
|
||||
}
|
||||
|
||||
const updates: VoiceServerRecord = {...existing};
|
||||
if (data.endpoint !== undefined) updates.endpoint = data.endpoint;
|
||||
if (data.api_key !== undefined && data.api_key !== '') updates.apiKey = data.api_key;
|
||||
if (data.api_secret !== undefined && data.api_secret !== '') updates.apiSecret = data.api_secret;
|
||||
if (data.is_active !== undefined) updates.isActive = data.is_active;
|
||||
|
||||
if (
|
||||
data.vip_only !== undefined ||
|
||||
data.required_guild_features !== undefined ||
|
||||
data.allowed_guild_ids !== undefined ||
|
||||
data.allowed_user_ids !== undefined
|
||||
) {
|
||||
updates.restrictions = {...existing.restrictions};
|
||||
if (data.vip_only !== undefined) updates.restrictions.vipOnly = data.vip_only;
|
||||
if (data.required_guild_features !== undefined)
|
||||
updates.restrictions.requiredGuildFeatures = new Set(data.required_guild_features);
|
||||
if (data.allowed_guild_ids !== undefined) {
|
||||
updates.restrictions.allowedGuildIds = createGuildIDSet(new Set(data.allowed_guild_ids.map(BigInt)));
|
||||
}
|
||||
if (data.allowed_user_ids !== undefined) {
|
||||
updates.restrictions.allowedUserIds = createUserIDSet(new Set(data.allowed_user_ids.map(BigInt)));
|
||||
}
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
await voiceRepository.upsertServer(updates);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_updated', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'update_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.region_id],
|
||||
['server_id', data.server_id],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
server: this.mapVoiceServerToAdminResponse(updates),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteVoiceServer(data: DeleteVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceServerError();
|
||||
}
|
||||
|
||||
await voiceRepository.deleteServer(data.region_id, data.server_id);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_deleted', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'delete_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.region_id],
|
||||
['server_id', data.server_id],
|
||||
['endpoint', existing.endpoint],
|
||||
]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
private mapVoiceRegionToAdminResponse(region: VoiceRegionRecord): VoiceRegionAdminResponse {
|
||||
return {
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
emoji: region.emoji,
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude,
|
||||
is_default: region.isDefault,
|
||||
vip_only: region.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(region.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: Array.from(region.restrictions.allowedGuildIds).map((id) => id.toString()),
|
||||
allowed_user_ids: Array.from(region.restrictions.allowedUserIds).map((id) => id.toString()),
|
||||
created_at: region.createdAt?.toISOString() ?? null,
|
||||
updated_at: region.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapVoiceServerToAdminResponse(server: VoiceServerRecord): VoiceServerAdminResponse {
|
||||
return {
|
||||
region_id: server.regionId,
|
||||
server_id: server.serverId,
|
||||
endpoint: server.endpoint,
|
||||
is_active: server.isActive,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: Array.from(server.restrictions.allowedGuildIds).map((id) => id.toString()),
|
||||
allowed_user_ids: Array.from(server.restrictions.allowedUserIds).map((id) => id.toString()),
|
||||
created_at: server.createdAt?.toISOString() ?? null,
|
||||
updated_at: server.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
79
fluxer_api/src/admin/services/guild/AdminGuildBulkService.ts
Normal file
79
fluxer_api/src/admin/services/guild/AdminGuildBulkService.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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 type {BulkUpdateGuildFeaturesRequest} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
import type {AdminGuildUpdateService} from './AdminGuildUpdateService';
|
||||
|
||||
interface AdminGuildBulkServiceDeps {
|
||||
guildUpdateService: AdminGuildUpdateService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildBulkService {
|
||||
constructor(private readonly deps: AdminGuildBulkServiceDeps) {}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {guildUpdateService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const guildIdBigInt of data.guild_ids) {
|
||||
try {
|
||||
const guildId = createGuildID(guildIdBigInt);
|
||||
await guildUpdateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures: data.add_features,
|
||||
removeFeatures: data.remove_features,
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
});
|
||||
successful.push(guildId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: guildIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_guild_features',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_count', data.guild_ids.length.toString()],
|
||||
['add_features', data.add_features.join(',')],
|
||||
['remove_features', data.remove_features.join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
183
fluxer_api/src/admin/services/guild/AdminGuildLookupService.ts
Normal file
183
fluxer_api/src/admin/services/guild/AdminGuildLookupService.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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} from '~/BrandedTypes';
|
||||
import {createGuildID, createUserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import {StickerFormatTypes} from '~/constants/Guild';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {
|
||||
ListGuildEmojisResponse,
|
||||
ListGuildMembersRequest,
|
||||
ListGuildStickersResponse,
|
||||
ListUserGuildsRequest,
|
||||
LookupGuildRequest,
|
||||
} from '../../AdminModel';
|
||||
import {mapGuildsToAdminResponse} from '../../AdminModel';
|
||||
|
||||
interface AdminGuildLookupServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
userRepository: IUserRepository;
|
||||
channelRepository: IChannelRepository;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminGuildLookupService {
|
||||
constructor(private readonly deps: AdminGuildLookupServiceDeps) {}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
const {guildRepository, channelRepository} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
|
||||
if (!guild) {
|
||||
return {guild: null};
|
||||
}
|
||||
|
||||
const channels = await channelRepository.listGuildChannels(guildId);
|
||||
const roles = await guildRepository.listRoles(guildId);
|
||||
|
||||
return {
|
||||
guild: {
|
||||
id: guild.id.toString(),
|
||||
owner_id: guild.ownerId.toString(),
|
||||
name: guild.name,
|
||||
vanity_url_code: guild.vanityUrlCode,
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
splash: guild.splashHash,
|
||||
features: Array.from(guild.features),
|
||||
verification_level: guild.verificationLevel,
|
||||
mfa_level: guild.mfaLevel,
|
||||
nsfw_level: guild.nsfwLevel,
|
||||
explicit_content_filter: guild.explicitContentFilter,
|
||||
default_message_notifications: guild.defaultMessageNotifications,
|
||||
afk_channel_id: guild.afkChannelId?.toString() ?? null,
|
||||
afk_timeout: guild.afkTimeout,
|
||||
system_channel_id: guild.systemChannelId?.toString() ?? null,
|
||||
system_channel_flags: guild.systemChannelFlags,
|
||||
rules_channel_id: guild.rulesChannelId?.toString() ?? null,
|
||||
disabled_operations: guild.disabledOperations,
|
||||
member_count: guild.memberCount,
|
||||
channels: channels.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
position: c.position,
|
||||
parent_id: c.parentId?.toString() ?? null,
|
||||
})),
|
||||
roles: roles.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
permissions: r.permissions.toString(),
|
||||
hoist: r.isHoisted,
|
||||
mentionable: r.isMentionable,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
const {userRepository, guildRepository} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
const guilds = await guildRepository.listGuilds(guildIds);
|
||||
|
||||
return mapGuildsToAdminResponse(guilds);
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
const {gatewayService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const limit = data.limit ?? 50;
|
||||
const offset = data.offset ?? 0;
|
||||
|
||||
const result = await gatewayService.listGuildMembers({
|
||||
guildId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
members: result.members,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
const {guildRepository} = this.deps;
|
||||
const emojis = await guildRepository.listEmojis(guildId);
|
||||
|
||||
return {
|
||||
guild_id: guildId.toString(),
|
||||
emojis: emojis.map((emoji) => {
|
||||
const emojiId = emoji.id.toString();
|
||||
return {
|
||||
id: emojiId,
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
creator_id: emoji.creatorId.toString(),
|
||||
media_url: this.buildEmojiMediaUrl(emojiId, emoji.isAnimated),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
const {guildRepository} = this.deps;
|
||||
const stickers = await guildRepository.listStickers(guildId);
|
||||
|
||||
return {
|
||||
guild_id: guildId.toString(),
|
||||
stickers: stickers.map((sticker) => {
|
||||
const stickerId = sticker.id.toString();
|
||||
return {
|
||||
id: stickerId,
|
||||
name: sticker.name,
|
||||
format_type: sticker.formatType,
|
||||
creator_id: sticker.creatorId.toString(),
|
||||
media_url: this.buildStickerMediaUrl(stickerId, sticker.formatType),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildEmojiMediaUrl(id: string, animated: boolean): string {
|
||||
const format = animated ? 'gif' : 'webp';
|
||||
return `${Config.endpoints.media}/emojis/${id}.${format}?size=160`;
|
||||
}
|
||||
|
||||
private buildStickerMediaUrl(id: string, formatType: number): string {
|
||||
const ext = formatType === StickerFormatTypes.GIF ? 'gif' : 'webp';
|
||||
return `${Config.endpoints.media}/stickers/${id}.${ext}?size=160`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createGuildID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownGuildError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
|
||||
interface AdminGuildManagementServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
gatewayService: IGatewayService;
|
||||
guildService: GuildService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildManagementService {
|
||||
constructor(private readonly deps: AdminGuildManagementServiceDeps) {}
|
||||
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
await gatewayService.reloadGuild(guildId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'reload_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
await gatewayService.shutdownGuild(guildId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'shutdown_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
|
||||
await guildService.deleteGuildAsAdmin(guildId, auditLogReason);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'delete_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.getNodeStats();
|
||||
}
|
||||
}
|
||||
@@ -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 {createGuildID, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {BulkAddGuildMembersRequest, ForceAddUserToGuildRequest} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
|
||||
interface AdminGuildMembershipServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildService: GuildService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildMembershipService {
|
||||
constructor(private readonly deps: AdminGuildMembershipServiceDeps) {}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {userRepository, guildService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
skipBanCheck: true,
|
||||
requestCache,
|
||||
initiatorId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'force_add_to_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', String(guildId)]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
skipBanCheck: true,
|
||||
requestCache: {} as RequestCache,
|
||||
initiatorId: adminUserId,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'bulk_add_guild_members',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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} from '~/BrandedTypes';
|
||||
import {mapGuildToGuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {Guild} from '~/Models';
|
||||
|
||||
interface AdminGuildUpdatePropagatorDeps {
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminGuildUpdatePropagator {
|
||||
constructor(private readonly deps: AdminGuildUpdatePropagatorDeps) {}
|
||||
|
||||
async dispatchGuildUpdate(guildId: GuildID, updatedGuild: Guild): Promise<void> {
|
||||
const {gatewayService} = this.deps;
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_UPDATE',
|
||||
data: mapGuildToGuildResponse(updatedGuild),
|
||||
});
|
||||
}
|
||||
}
|
||||
314
fluxer_api/src/admin/services/guild/AdminGuildUpdateService.ts
Normal file
314
fluxer_api/src/admin/services/guild/AdminGuildUpdateService.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You 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, createUserID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownGuildError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
||||
import type {Guild} from '~/Models';
|
||||
import type {
|
||||
ClearGuildFieldsRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
} from '../../AdminModel';
|
||||
import {mapGuildToAdminResponse} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
import type {AdminGuildUpdatePropagator} from './AdminGuildUpdatePropagator';
|
||||
|
||||
interface AdminGuildUpdateServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminGuildUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminGuildUpdateService {
|
||||
constructor(private readonly deps: AdminGuildUpdateServiceDeps) {}
|
||||
|
||||
async updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const newFeatures = new Set(guild.features);
|
||||
for (const feature of addFeatures) {
|
||||
newFeatures.add(feature);
|
||||
}
|
||||
for (const feature of removeFeatures) {
|
||||
newFeatures.delete(feature);
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
features: newFeatures,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_features',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['add_features', addFeatures.join(',')],
|
||||
['remove_features', removeFeatures.join(',')],
|
||||
['new_features', Array.from(newFeatures).join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, entityAssetService, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updates: Partial<typeof guildRow> = {};
|
||||
const preparedAssets: Array<PreparedAssetUpload> = [];
|
||||
|
||||
for (const field of data.fields) {
|
||||
if (field === 'icon') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'icon',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.iconHash,
|
||||
base64Image: null,
|
||||
errorPath: 'icon',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.icon_hash = prepared.newHash;
|
||||
} else if (field === 'banner') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.bannerHash,
|
||||
base64Image: null,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.banner_hash = prepared.newHash;
|
||||
} else if (field === 'splash') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.splashHash,
|
||||
base64Image: null,
|
||||
errorPath: 'splash',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.splash_hash = prepared.newHash;
|
||||
} else if (field === 'embed_splash') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'embed_splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.embedSplashHash,
|
||||
base64Image: null,
|
||||
errorPath: 'embed_splash',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.embed_splash_hash = prepared.newHash;
|
||||
}
|
||||
}
|
||||
|
||||
let updatedGuild: Guild;
|
||||
try {
|
||||
updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
...updates,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.rollbackAssetUpload(p)));
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.commitAssetChange({prepared: p})));
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'clear_fields',
|
||||
auditLogReason,
|
||||
metadata: new Map([['fields', data.fields.join(',')]]),
|
||||
});
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const oldName = guild.name;
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
name: data.name,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_name',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_name', oldName],
|
||||
['new_name', data.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updates: Partial<typeof guildRow> = {};
|
||||
const metadata = new Map<string, string>();
|
||||
|
||||
if (data.verification_level !== undefined) {
|
||||
updates.verification_level = data.verification_level;
|
||||
metadata.set('verification_level', data.verification_level.toString());
|
||||
}
|
||||
if (data.mfa_level !== undefined) {
|
||||
updates.mfa_level = data.mfa_level;
|
||||
metadata.set('mfa_level', data.mfa_level.toString());
|
||||
}
|
||||
if (data.nsfw_level !== undefined) {
|
||||
updates.nsfw_level = data.nsfw_level;
|
||||
metadata.set('nsfw_level', data.nsfw_level.toString());
|
||||
}
|
||||
if (data.explicit_content_filter !== undefined) {
|
||||
updates.explicit_content_filter = data.explicit_content_filter;
|
||||
metadata.set('explicit_content_filter', data.explicit_content_filter.toString());
|
||||
}
|
||||
if (data.default_message_notifications !== undefined) {
|
||||
updates.default_message_notifications = data.default_message_notifications;
|
||||
metadata.set('default_message_notifications', data.default_message_notifications.toString());
|
||||
}
|
||||
if (data.disabled_operations !== undefined) {
|
||||
updates.disabled_operations = data.disabled_operations;
|
||||
metadata.set('disabled_operations', data.disabled_operations.toString());
|
||||
}
|
||||
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
...updates,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_settings',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const newOwnerId = createUserID(data.new_owner_id);
|
||||
|
||||
const oldOwnerId = guild.ownerId;
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
owner_id: newOwnerId,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'transfer_ownership',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_owner_id', oldOwnerId.toString()],
|
||||
['new_owner_id', newOwnerId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
}
|
||||
106
fluxer_api/src/admin/services/guild/AdminGuildVanityService.ts
Normal file
106
fluxer_api/src/admin/services/guild/AdminGuildVanityService.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 {
|
||||
createGuildID,
|
||||
createInviteCode,
|
||||
createVanityURLCode,
|
||||
type UserID,
|
||||
vanityCodeToInviteCode,
|
||||
} from '~/BrandedTypes';
|
||||
import {InviteTypes} from '~/Constants';
|
||||
import {InputValidationError, UnknownGuildError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {InviteRepository} from '~/invite/InviteRepository';
|
||||
import type {UpdateGuildVanityRequest} from '../../AdminModel';
|
||||
import {mapGuildToAdminResponse} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
import type {AdminGuildUpdatePropagator} from './AdminGuildUpdatePropagator';
|
||||
|
||||
interface AdminGuildVanityServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
inviteRepository: InviteRepository;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminGuildUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminGuildVanityService {
|
||||
constructor(private readonly deps: AdminGuildVanityServiceDeps) {}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, inviteRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const oldVanity = guild.vanityUrlCode;
|
||||
const guildRow = guild.toRow();
|
||||
|
||||
if (data.vanity_url_code) {
|
||||
const inviteCode = createInviteCode(data.vanity_url_code);
|
||||
const existingInvite = await inviteRepository.findUnique(inviteCode);
|
||||
if (existingInvite) {
|
||||
throw InputValidationError.create('vanity_url_code', 'This vanity URL is already taken');
|
||||
}
|
||||
|
||||
if (oldVanity) {
|
||||
await inviteRepository.delete(vanityCodeToInviteCode(oldVanity));
|
||||
}
|
||||
|
||||
await inviteRepository.create({
|
||||
code: inviteCode,
|
||||
type: InviteTypes.GUILD,
|
||||
guild_id: guildId,
|
||||
channel_id: null,
|
||||
inviter_id: null,
|
||||
uses: 0,
|
||||
max_uses: 0,
|
||||
max_age: 0,
|
||||
temporary: false,
|
||||
});
|
||||
} else if (oldVanity) {
|
||||
await inviteRepository.delete(vanityCodeToInviteCode(oldVanity));
|
||||
}
|
||||
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
vanity_url_code: data.vanity_url_code ? createVanityURLCode(data.vanity_url_code) : null,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_vanity',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_vanity', oldVanity ?? ''],
|
||||
['new_vanity', data.vanity_url_code ?? ''],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user