initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export * from './models';

View File

@@ -0,0 +1,182 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {createUserID} from '~/BrandedTypes';
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {AdminAuditLogRow, PendingVerificationRow} from '~/database/types/AdminArchiveTypes';
import {AdminAuditLogs, BannedEmails, BannedIps, BannedPhones, PendingVerifications} from '~/Tables';
import type {AdminAuditLog, IAdminRepository} from './IAdminRepository';
const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({
where: AdminAuditLogs.where.eq('log_id'),
});
const FETCH_AUDIT_LOGS_BY_IDS_QUERY = AdminAuditLogs.select({
where: AdminAuditLogs.where.in('log_id', 'log_ids'),
});
const IS_IP_BANNED_QUERY = BannedIps.select({
where: BannedIps.where.eq('ip'),
});
const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select();
const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
where: BannedEmails.where.eq('email_lower'),
});
const IS_PHONE_BANNED_QUERY = BannedPhones.select({
where: BannedPhones.where.eq('phone'),
});
const createListAllAuditLogsPaginatedQuery = (limit: number) =>
AdminAuditLogs.select({
where: AdminAuditLogs.where.tokenGt('log_id', 'last_log_id'),
limit,
});
const createListAllAuditLogsFirstPageQuery = (limit: number) =>
AdminAuditLogs.select({
limit,
});
const createListPendingVerificationsQuery = (limit: number) =>
PendingVerifications.select({
limit,
});
export class AdminRepository implements IAdminRepository {
async createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog> {
await upsertOne(AdminAuditLogs.insert(log));
return this.mapRowToAuditLog(log);
}
async getAuditLog(logId: bigint): Promise<AdminAuditLog | null> {
const row = await fetchOne<AdminAuditLogRow>(FETCH_AUDIT_LOG_BY_ID_QUERY.bind({log_id: logId}));
return row ? this.mapRowToAuditLog(row) : null;
}
async listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> {
if (logIds.length === 0) {
return [];
}
const rows = await fetchMany<AdminAuditLogRow>(FETCH_AUDIT_LOGS_BY_IDS_QUERY.bind({log_ids: logIds}));
return rows.map((row) => this.mapRowToAuditLog(row));
}
async listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>> {
let rows: Array<AdminAuditLogRow>;
if (lastLogId) {
const query = createListAllAuditLogsPaginatedQuery(limit);
rows = await fetchMany<AdminAuditLogRow>(query.bind({last_log_id: lastLogId}));
} else {
const query = createListAllAuditLogsFirstPageQuery(limit);
rows = await fetchMany<AdminAuditLogRow>(query.bind({}));
}
return rows.map((row) => this.mapRowToAuditLog(row));
}
async isIpBanned(ip: string): Promise<boolean> {
const result = await fetchOne<{ip: string}>(IS_IP_BANNED_QUERY.bind({ip}));
return !!result;
}
async banIp(ip: string): Promise<void> {
await upsertOne(BannedIps.insert({ip}));
}
async unbanIp(ip: string): Promise<void> {
await deleteOneOrMany(BannedIps.deleteByPk({ip}));
}
async listBannedIps(): Promise<Array<string>> {
const rows = await fetchMany<{ip: string}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
return rows.map((row) => row.ip);
}
async loadAllBannedIps(): Promise<Set<string>> {
const rows = await fetchMany<{ip: string}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
return new Set(rows.map((row) => row.ip));
}
async isEmailBanned(email: string): Promise<boolean> {
const emailLower = email.toLowerCase();
const result = await fetchOne<{email_lower: string}>(IS_EMAIL_BANNED_QUERY.bind({email_lower: emailLower}));
return !!result;
}
async banEmail(email: string): Promise<void> {
const emailLower = email.toLowerCase();
await upsertOne(BannedEmails.insert({email_lower: emailLower}));
}
async unbanEmail(email: string): Promise<void> {
const emailLower = email.toLowerCase();
await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower}));
}
async isPhoneBanned(phone: string): Promise<boolean> {
const result = await fetchOne<{phone: string}>(IS_PHONE_BANNED_QUERY.bind({phone}));
return !!result;
}
async banPhone(phone: string): Promise<void> {
await upsertOne(BannedPhones.insert({phone}));
}
async unbanPhone(phone: string): Promise<void> {
await deleteOneOrMany(BannedPhones.deleteByPk({phone}));
}
async listPendingVerifications(
limit = 100,
): Promise<Array<{userId: UserID; createdAt: Date; metadata: Map<string, string>}>> {
const query = createListPendingVerificationsQuery(limit);
const rows = await fetchMany<PendingVerificationRow>(query.bind({}));
return rows
.sort((a, b) => (a.user_id < b.user_id ? -1 : 1))
.map((row) => ({
userId: createUserID(row.user_id),
createdAt: row.created_at,
metadata: row.metadata ?? new Map(),
}));
}
async removePendingVerification(userId: UserID): Promise<void> {
await deleteOneOrMany(PendingVerifications.deleteByPk({user_id: userId}));
}
private mapRowToAuditLog(row: AdminAuditLogRow): AdminAuditLog {
return {
logId: row.log_id,
adminUserId: createUserID(row.admin_user_id),
targetType: row.target_type,
targetId: row.target_id,
action: row.action,
auditLogReason: row.audit_log_reason,
metadata: row.metadata || new Map(),
createdAt: row.created_at,
};
}
}

View File

@@ -0,0 +1,686 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '~/auth/AuthService';
import type {AttachmentID, ChannelID, GuildID, ReportID, UserID} from '~/BrandedTypes';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {GuildService} from '~/guild/services/GuildService';
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
import type {IAssetDeletionQueue} from '~/infrastructure/IAssetDeletionQueue';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IStorageService} from '~/infrastructure/IStorageService';
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 {InviteRepository} from '~/invite/InviteRepository';
import type {InviteService} from '~/invite/InviteService';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
import type {ReportService} from '~/report/ReportService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
import type {VoiceRepository} from '~/voice/VoiceRepository';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {
BulkAddGuildMembersRequest,
BulkScheduleUserDeletionRequest,
BulkUpdateGuildFeaturesRequest,
BulkUpdateUserFlagsRequest,
CancelBulkMessageDeletionRequest,
ChangeDobRequest,
ChangeEmailRequest,
ChangeUsernameRequest,
ClearGuildFieldsRequest,
ClearUserFieldsRequest,
CreateVoiceRegionRequest,
CreateVoiceServerRequest,
DeleteAllUserMessagesRequest,
DeleteMessageRequest,
DeleteVoiceRegionRequest,
DeleteVoiceServerRequest,
DisableForSuspiciousActivityRequest,
DisableMfaRequest,
ForceAddUserToGuildRequest,
GetVoiceRegionRequest,
GetVoiceServerRequest,
ListAuditLogsRequest,
ListGuildEmojisResponse,
ListGuildMembersRequest,
ListGuildStickersResponse,
ListUserChangeLogRequest,
ListUserGuildsRequest,
ListVoiceRegionsRequest,
ListVoiceServersRequest,
LookupGuildRequest,
LookupMessageByAttachmentRequest,
LookupMessageRequest,
LookupUserRequest,
MessageShredRequest,
PurgeGuildAssetsRequest,
PurgeGuildAssetsResponse,
SearchReportsRequest,
SendPasswordResetRequest,
SetUserAclsRequest,
SetUserBotStatusRequest,
SetUserSystemStatusRequest,
TempBanUserRequest,
TerminateSessionsRequest,
TransferGuildOwnershipRequest,
UnlinkPhoneRequest,
UpdateGuildNameRequest,
UpdateGuildSettingsRequest,
UpdateGuildVanityRequest,
UpdateSuspiciousActivityFlagsRequest,
UpdateVoiceRegionRequest,
UpdateVoiceServerRequest,
VerifyUserEmailRequest,
} from './AdminModel';
import type {IAdminRepository} from './IAdminRepository';
import {AdminAssetPurgeService} from './services/AdminAssetPurgeService';
import {AdminAuditService} from './services/AdminAuditService';
import {AdminCodeGenerationService} from './services/AdminCodeGenerationService';
import {AdminGuildService} from './services/AdminGuildService';
import {AdminMessageDeletionService} from './services/AdminMessageDeletionService';
import {AdminMessageService} from './services/AdminMessageService';
import {AdminMessageShredService} from './services/AdminMessageShredService';
import {AdminReportService} from './services/AdminReportService';
import {AdminSearchService} from './services/AdminSearchService';
import {AdminUserService} from './services/AdminUserService';
import {AdminVoiceService} from './services/AdminVoiceService';
interface ForceAddUserToGuildParams {
data: ForceAddUserToGuildRequest;
requestCache: RequestCache;
}
interface LookupAttachmentParams {
channelId: ChannelID;
attachmentId: AttachmentID;
filename: string;
}
export class AdminService {
private readonly auditService: AdminAuditService;
private readonly userService: AdminUserService;
private readonly guildServiceAggregate: AdminGuildService;
private readonly messageService: AdminMessageService;
private readonly messageShredService: AdminMessageShredService;
private readonly messageDeletionService: AdminMessageDeletionService;
private readonly reportServiceAggregate: AdminReportService;
private readonly voiceService: AdminVoiceService;
private readonly searchService: AdminSearchService;
private readonly codeGenerationService: AdminCodeGenerationService;
private readonly assetPurgeService: AdminAssetPurgeService;
constructor(
private readonly userRepository: IUserRepository,
private readonly guildRepository: IGuildRepository,
private readonly channelRepository: IChannelRepository,
private readonly adminRepository: IAdminRepository,
private readonly inviteRepository: InviteRepository,
private readonly inviteService: InviteService,
private readonly pendingJoinInviteStore: PendingJoinInviteStore,
private readonly discriminatorService: IDiscriminatorService,
private readonly snowflakeService: SnowflakeService,
private readonly guildService: GuildService,
private readonly authService: AuthService,
private readonly gatewayService: IGatewayService,
private readonly userCacheService: UserCacheService,
private readonly entityAssetService: EntityAssetService,
private readonly assetDeletionQueue: IAssetDeletionQueue,
private readonly emailService: IEmailService,
private readonly mediaService: IMediaService,
private readonly storageService: IStorageService,
private readonly reportService: ReportService,
private readonly workerService: IWorkerService,
private readonly cacheService: ICacheService,
private readonly voiceRepository: VoiceRepository,
private readonly botMfaMirrorService: BotMfaMirrorService,
private readonly contactChangeLogService: UserContactChangeLogService,
private readonly bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService,
) {
this.auditService = new AdminAuditService(this.adminRepository, this.snowflakeService);
this.userService = new AdminUserService({
userRepository: this.userRepository,
guildRepository: this.guildRepository,
discriminatorService: this.discriminatorService,
snowflakeService: this.snowflakeService,
authService: this.authService,
emailService: this.emailService,
entityAssetService: this.entityAssetService,
auditService: this.auditService,
gatewayService: this.gatewayService,
userCacheService: this.userCacheService,
adminRepository: this.adminRepository,
botMfaMirrorService: this.botMfaMirrorService,
inviteService: this.inviteService,
pendingJoinInviteStore: this.pendingJoinInviteStore,
contactChangeLogService: this.contactChangeLogService,
bulkMessageDeletionQueue: this.bulkMessageDeletionQueue,
cacheService: this.cacheService,
});
this.guildServiceAggregate = new AdminGuildService({
guildRepository: this.guildRepository,
userRepository: this.userRepository,
channelRepository: this.channelRepository,
inviteRepository: this.inviteRepository,
guildService: this.guildService,
gatewayService: this.gatewayService,
entityAssetService: this.entityAssetService,
auditService: this.auditService,
});
this.assetPurgeService = new AdminAssetPurgeService({
guildRepository: this.guildRepository,
gatewayService: this.gatewayService,
assetDeletionQueue: this.assetDeletionQueue,
auditService: this.auditService,
});
this.messageService = new AdminMessageService({
channelRepository: this.channelRepository,
userCacheService: this.userCacheService,
mediaService: this.mediaService,
gatewayService: this.gatewayService,
auditService: this.auditService,
});
this.messageShredService = new AdminMessageShredService({
workerService: this.workerService,
cacheService: this.cacheService,
snowflakeService: this.snowflakeService,
auditService: this.auditService,
});
this.messageDeletionService = new AdminMessageDeletionService({
channelRepository: this.channelRepository,
messageShredService: this.messageShredService,
auditService: this.auditService,
});
this.reportServiceAggregate = new AdminReportService({
reportService: this.reportService,
userRepository: this.userRepository,
emailService: this.emailService,
storageService: this.storageService,
auditService: this.auditService,
userCacheService: this.userCacheService,
});
this.voiceService = new AdminVoiceService({
voiceRepository: this.voiceRepository,
cacheService: this.cacheService,
auditService: this.auditService,
});
this.searchService = new AdminSearchService({
guildRepository: this.guildRepository,
userRepository: this.userRepository,
workerService: this.workerService,
cacheService: this.cacheService,
snowflakeService: this.snowflakeService,
auditService: this.auditService,
});
this.codeGenerationService = new AdminCodeGenerationService(this.userRepository);
}
async lookupUser(data: LookupUserRequest) {
return this.userService.lookupUser(data);
}
async updateUserFlags(args: {
userId: UserID;
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
adminUserId: UserID;
auditLogReason: string | null;
}) {
return this.userService.updateUserFlags(args);
}
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.disableMfa(data, adminUserId, auditLogReason);
}
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.clearUserFields(data, adminUserId, auditLogReason);
}
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.setUserBotStatus(data, adminUserId, auditLogReason);
}
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.setUserSystemStatus(data, adminUserId, auditLogReason);
}
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.verifyUserEmail(data, adminUserId, auditLogReason);
}
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.sendPasswordReset(data, adminUserId, auditLogReason);
}
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.changeUsername(data, adminUserId, auditLogReason);
}
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.changeEmail(data, adminUserId, auditLogReason);
}
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.terminateSessions(data, adminUserId, auditLogReason);
}
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.tempBanUser(data, adminUserId, auditLogReason);
}
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.unbanUser(data, adminUserId, auditLogReason);
}
async scheduleAccountDeletion(
data:
| BulkScheduleUserDeletionRequest
| {user_id: bigint; reason_code: number; public_reason?: string | null; days_until_deletion: number},
adminUserId: UserID,
auditLogReason: string | null,
) {
if ('user_ids' in data) {
return this.userService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
}
return this.userService.scheduleAccountDeletion(
{
user_id: data.user_id,
reason_code: data.reason_code,
public_reason: data.public_reason ?? undefined,
days_until_deletion: data.days_until_deletion,
},
adminUserId,
auditLogReason,
);
}
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.cancelAccountDeletion(data, adminUserId, auditLogReason);
}
async cancelBulkMessageDeletion(
data: CancelBulkMessageDeletionRequest,
adminUserId: UserID,
auditLogReason: string | null,
) {
return this.userService.cancelBulkMessageDeletion(data, adminUserId, auditLogReason);
}
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.banIp(data, adminUserId, auditLogReason);
}
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.unbanIp(data, adminUserId, auditLogReason);
}
async checkIpBan(data: {ip: string}) {
return this.userService.checkIpBan(data);
}
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.banEmail(data, adminUserId, auditLogReason);
}
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.unbanEmail(data, adminUserId, auditLogReason);
}
async checkEmailBan(data: {email: string}) {
return this.userService.checkEmailBan(data);
}
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.banPhone(data, adminUserId, auditLogReason);
}
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.unbanPhone(data, adminUserId, auditLogReason);
}
async checkPhoneBan(data: {phone: string}) {
return this.userService.checkPhoneBan(data);
}
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.setUserAcls(data, adminUserId, auditLogReason);
}
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.unlinkPhone(data, adminUserId, auditLogReason);
}
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.changeDob(data, adminUserId, auditLogReason);
}
async updateSuspiciousActivityFlags(
data: UpdateSuspiciousActivityFlagsRequest,
adminUserId: UserID,
auditLogReason: string | null,
) {
return this.userService.updateSuspiciousActivityFlags(data, adminUserId, auditLogReason);
}
async disableForSuspiciousActivity(
data: DisableForSuspiciousActivityRequest,
adminUserId: UserID,
auditLogReason: string | null,
) {
return this.userService.disableForSuspiciousActivity(data, adminUserId, auditLogReason);
}
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.bulkUpdateUserFlags(data, adminUserId, auditLogReason);
}
async bulkScheduleUserDeletion(
data: BulkScheduleUserDeletionRequest,
adminUserId: UserID,
auditLogReason: string | null,
) {
return this.userService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
}
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.listUserSessions(userId, adminUserId, auditLogReason);
}
async listUserChangeLog(data: ListUserChangeLogRequest) {
return this.userService.listUserChangeLog(data);
}
async listPendingVerifications(limit: number = 100) {
return this.userService.listPendingVerifications(limit);
}
async approveRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.approveRegistration(userId, adminUserId, auditLogReason);
}
async rejectRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.rejectRegistration(userId, adminUserId, auditLogReason);
}
async bulkApproveRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.bulkApproveRegistrations(userIds, adminUserId, auditLogReason);
}
async bulkRejectRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
return this.userService.bulkRejectRegistrations(userIds, adminUserId, auditLogReason);
}
async updateGuildFeatures(args: {
guildId: GuildID;
addFeatures: Array<string>;
removeFeatures: Array<string>;
adminUserId: UserID;
auditLogReason: string | null;
}) {
return this.guildServiceAggregate.updateGuildFeatures(args);
}
async forceAddUserToGuild({
data,
requestCache,
adminUserId,
auditLogReason,
}: ForceAddUserToGuildParams & {adminUserId: UserID; auditLogReason: string | null}) {
return this.guildServiceAggregate.forceAddUserToGuild({data, requestCache, adminUserId, auditLogReason});
}
async lookupGuild(data: LookupGuildRequest) {
return this.guildServiceAggregate.lookupGuild(data);
}
async listUserGuilds(data: ListUserGuildsRequest) {
return this.guildServiceAggregate.listUserGuilds(data);
}
async listGuildMembers(data: ListGuildMembersRequest) {
return this.guildServiceAggregate.listGuildMembers(data);
}
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
return this.guildServiceAggregate.listGuildEmojis(guildId);
}
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
return this.guildServiceAggregate.listGuildStickers(guildId);
}
async purgeGuildAssets(
data: PurgeGuildAssetsRequest,
adminUserId: UserID,
auditLogReason: string | null,
): Promise<PurgeGuildAssetsResponse> {
return this.assetPurgeService.purgeGuildAssets({
ids: data.ids,
adminUserId,
auditLogReason,
});
}
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.clearGuildFields(data, adminUserId, auditLogReason);
}
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.updateGuildName(data, adminUserId, auditLogReason);
}
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.updateGuildSettings(data, adminUserId, auditLogReason);
}
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.updateGuildVanity(data, adminUserId, auditLogReason);
}
async transferGuildOwnership(
data: TransferGuildOwnershipRequest,
adminUserId: UserID,
auditLogReason: string | null,
) {
return this.guildServiceAggregate.transferGuildOwnership(data, adminUserId, auditLogReason);
}
async bulkUpdateGuildFeatures(
data: BulkUpdateGuildFeaturesRequest,
adminUserId: UserID,
auditLogReason: string | null,
) {
return this.guildServiceAggregate.bulkUpdateGuildFeatures(data, adminUserId, auditLogReason);
}
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.bulkAddGuildMembers(data, adminUserId, auditLogReason);
}
async reloadGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.reloadGuild(guildId, adminUserId, auditLogReason);
}
async shutdownGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.shutdownGuild(guildId, adminUserId, auditLogReason);
}
async deleteGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
return this.guildServiceAggregate.deleteGuild(guildId, adminUserId, auditLogReason);
}
async getGuildMemoryStats(limit: number) {
return this.guildServiceAggregate.getGuildMemoryStats(limit);
}
async reloadAllGuilds(guildIds: Array<GuildID>) {
return this.guildServiceAggregate.reloadAllGuilds(guildIds);
}
async getNodeStats() {
return this.guildServiceAggregate.getNodeStats();
}
async lookupAttachment(params: LookupAttachmentParams) {
return this.messageService.lookupAttachment(params);
}
async lookupMessage(data: LookupMessageRequest) {
return this.messageService.lookupMessage(data);
}
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
return this.messageService.lookupMessageByAttachment(data);
}
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.messageService.deleteMessage(data, adminUserId, auditLogReason);
}
async deleteAllUserMessages(data: DeleteAllUserMessagesRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.messageDeletionService.deleteAllUserMessages(data, adminUserId, auditLogReason);
}
async queueMessageShred(data: MessageShredRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.messageShredService.queueMessageShred(data, adminUserId, auditLogReason);
}
async getMessageShredStatus(jobId: string) {
return this.messageShredService.getMessageShredStatus(jobId);
}
async listAuditLogs(data: ListAuditLogsRequest) {
return this.auditService.listAuditLogs(data);
}
async searchAuditLogs(data: {
query?: string;
adminUserId?: bigint;
targetType?: string;
targetId?: bigint;
action?: string;
sortBy?: 'createdAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
limit?: number;
offset?: number;
}) {
return this.auditService.searchAuditLogs(data);
}
async listReports(status: number, limit?: number, offset?: number) {
return this.reportServiceAggregate.listReports(status, limit, offset);
}
async getReport(reportId: ReportID) {
return this.reportServiceAggregate.getReport(reportId);
}
async resolveReport(
reportId: ReportID,
adminUserId: UserID,
publicComment: string | null,
auditLogReason: string | null,
) {
return this.reportServiceAggregate.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
}
async searchReports(data: SearchReportsRequest) {
return this.reportServiceAggregate.searchReports(data);
}
async searchGuilds(data: {query?: string; limit: number; offset: number}) {
return this.searchService.searchGuilds(data);
}
async searchUsers(data: {query?: string; limit: number; offset: number}) {
return this.searchService.searchUsers(data);
}
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,
) {
return this.searchService.refreshSearchIndex(data, adminUserId, auditLogReason);
}
async getIndexRefreshStatus(jobId: string) {
return this.searchService.getIndexRefreshStatus(jobId);
}
async listVoiceRegions(data: ListVoiceRegionsRequest, _adminUserId: UserID, _auditLogReason: string | null) {
return this.voiceService.listVoiceRegions(data);
}
async getVoiceRegion(data: GetVoiceRegionRequest, _adminUserId: UserID, _auditLogReason: string | null) {
return this.voiceService.getVoiceRegion(data);
}
async createVoiceRegion(data: CreateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.voiceService.createVoiceRegion(data, adminUserId, auditLogReason);
}
async updateVoiceRegion(data: UpdateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.voiceService.updateVoiceRegion(data, adminUserId, auditLogReason);
}
async deleteVoiceRegion(data: DeleteVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.voiceService.deleteVoiceRegion(data, adminUserId, auditLogReason);
}
async listVoiceServers(data: ListVoiceServersRequest, _adminUserId: UserID, _auditLogReason: string | null) {
return this.voiceService.listVoiceServers(data);
}
async getVoiceServer(data: GetVoiceServerRequest, _adminUserId: UserID, _auditLogReason: string | null) {
return this.voiceService.getVoiceServer(data);
}
async createVoiceServer(data: CreateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.voiceService.createVoiceServer(data, adminUserId, auditLogReason);
}
async updateVoiceServer(data: UpdateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.voiceService.updateVoiceServer(data, adminUserId, auditLogReason);
}
async deleteVoiceServer(data: DeleteVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
return this.voiceService.deleteVoiceServer(data, adminUserId, auditLogReason);
}
async generateBetaCodes(count: number) {
return this.codeGenerationService.generateBetaCodes(count);
}
async generateGiftCodes(count: number, durationMonths: number) {
return this.codeGenerationService.generateGiftCodes(count, durationMonths);
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You 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 {AdminAuditLogRow} from '~/database/types/AdminArchiveTypes';
export type {AdminAuditLogRow};
export interface AdminAuditLog {
logId: bigint;
adminUserId: UserID;
targetType: string;
targetId: bigint;
action: string;
auditLogReason: string | null;
metadata: Map<string, string>;
createdAt: Date;
}
export abstract class IAdminRepository {
abstract createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog>;
abstract getAuditLog(logId: bigint): Promise<AdminAuditLog | null>;
abstract listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>>;
abstract listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>>;
abstract isIpBanned(ip: string): Promise<boolean>;
abstract banIp(ip: string): Promise<void>;
abstract unbanIp(ip: string): Promise<void>;
abstract listBannedIps(limit?: number): Promise<Array<string>>;
abstract isEmailBanned(email: string): Promise<boolean>;
abstract banEmail(email: string): Promise<void>;
abstract unbanEmail(email: string): Promise<void>;
abstract isPhoneBanned(phone: string): Promise<boolean>;
abstract banPhone(phone: string): Promise<void>;
abstract unbanPhone(phone: string): Promise<void>;
abstract loadAllBannedIps(): Promise<Set<string>>;
abstract listPendingVerifications(
limit?: number,
): Promise<Array<{userId: UserID; createdAt: Date; metadata: Map<string, string>}>>;
abstract removePendingVerification(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createGuildID, createUserID} from '~/BrandedTypes';
import {AdminACLs} from '~/Constants';
import {MissingACLError} from '~/Errors';
import {requireAdminACL, requireAnyAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import type {ListArchivesRequest} from '../models';
import {
ListArchivesRequest as ListArchivesSchema,
TriggerGuildArchiveRequest,
TriggerUserArchiveRequest,
} from '../models';
const canViewArchive = (adminAcls: Set<string>, subjectType: 'user' | 'guild'): boolean => {
if (adminAcls.has(AdminACLs.WILDCARD) || adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL)) return true;
if (subjectType === 'user') return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_USER);
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
};
export const ArchiveAdminController = (app: HonoApp) => {
app.post(
'/admin/archives/user',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER),
Validator('json', TriggerUserArchiveRequest),
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminUserId = ctx.get('adminUserId');
const body = ctx.req.valid('json');
const result = await adminArchiveService.triggerUserArchive(createUserID(BigInt(body.user_id)), adminUserId);
return ctx.json(result, 200);
},
);
app.post(
'/admin/archives/guild',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_GUILD),
Validator('json', TriggerGuildArchiveRequest),
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminUserId = ctx.get('adminUserId');
const body = ctx.req.valid('json');
const result = await adminArchiveService.triggerGuildArchive(createGuildID(BigInt(body.guild_id)), adminUserId);
return ctx.json(result, 200);
},
);
app.post(
'/admin/archives/list',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
Validator('json', ListArchivesSchema),
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const body = ctx.req.valid('json') as ListArchivesRequest;
if (
body.subject_type === 'all' &&
!adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL) &&
!adminAcls.has(AdminACLs.WILDCARD)
) {
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
}
if (
body.subject_type !== 'all' &&
!canViewArchive(adminAcls, body.subject_type) &&
!adminAcls.has(AdminACLs.WILDCARD)
) {
throw new MissingACLError(
body.subject_type === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
);
}
const result = await adminArchiveService.listArchives({
subjectType: body.subject_type as 'user' | 'guild' | 'all',
subjectId: body.subject_id ? BigInt(body.subject_id) : undefined,
requestedBy: body.requested_by ? BigInt(body.requested_by) : undefined,
limit: body.limit,
includeExpired: body.include_expired,
});
return ctx.json({archives: result}, 200);
},
);
app.get(
'/admin/archives/:subjectType/:subjectId/:archiveId',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const subjectType = ctx.req.param('subjectType') as 'user' | 'guild';
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
throw new MissingACLError(
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
);
}
const subjectId = BigInt(ctx.req.param('subjectId'));
const archiveId = BigInt(ctx.req.param('archiveId'));
const archive = await adminArchiveService.getArchive(subjectType, subjectId, archiveId);
return ctx.json({archive}, 200);
},
);
app.get(
'/admin/archives/:subjectType/:subjectId/:archiveId/download',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const subjectType = ctx.req.param('subjectType') as 'user' | 'guild';
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
throw new MissingACLError(
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
);
}
const subjectId = BigInt(ctx.req.param('subjectId'));
const archiveId = BigInt(ctx.req.param('archiveId'));
const result = await adminArchiveService.getDownloadUrl(subjectType, subjectId, archiveId);
return ctx.json(result, 200);
},
);
};

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {AdminRateLimitConfigs} from '~/rate_limit_configs/AdminRateLimitConfig';
import {Validator} from '~/Validator';
import {PurgeGuildAssetsRequest} from '../AdminModel';
export const AssetAdminController = (app: HonoApp) => {
app.post(
'/admin/assets/purge',
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.ASSET_PURGE),
Validator('json', PurgeGuildAssetsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.purgeGuildAssets(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
};

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import {ListAuditLogsRequest, SearchAuditLogsRequest} from '../AdminModel';
export const AuditLogAdminController = (app: HonoApp) => {
app.post(
'/admin/audit-logs',
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
Validator('json', ListAuditLogsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.listAuditLogs(ctx.req.valid('json')));
},
);
app.post(
'/admin/audit-logs/search',
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
Validator('json', SearchAuditLogsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.searchAuditLogs(ctx.req.valid('json')));
},
);
};

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import {BanEmailRequest, BanIpRequest, BanPhoneRequest} from '../AdminModel';
export const BanAdminController = (app: HonoApp) => {
app.post(
'/admin/bans/ip/add',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_IP_ADD),
Validator('json', BanIpRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.banIp(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bans/ip/remove',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_IP_REMOVE),
Validator('json', BanIpRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.unbanIp(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bans/ip/check',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_IP_CHECK),
Validator('json', BanIpRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.checkIpBan(ctx.req.valid('json')));
},
);
app.post(
'/admin/bans/email/add',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_EMAIL_ADD),
Validator('json', BanEmailRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.banEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bans/email/remove',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_EMAIL_REMOVE),
Validator('json', BanEmailRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.unbanEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bans/email/check',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_EMAIL_CHECK),
Validator('json', BanEmailRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.checkEmailBan(ctx.req.valid('json')));
},
);
app.post(
'/admin/bans/phone/add',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_PHONE_ADD),
Validator('json', BanPhoneRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.banPhone(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bans/phone/remove',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_PHONE_REMOVE),
Validator('json', BanPhoneRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.unbanPhone(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bans/phone/check',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
requireAdminACL(AdminACLs.BAN_PHONE_CHECK),
Validator('json', BanPhoneRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.checkPhoneBan(ctx.req.valid('json')));
},
);
};

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import {
BulkAddGuildMembersRequest,
BulkScheduleUserDeletionRequest,
BulkUpdateGuildFeaturesRequest,
BulkUpdateUserFlagsRequest,
} from '../AdminModel';
export const BulkAdminController = (app: HonoApp) => {
app.post(
'/admin/bulk/update-user-flags',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_UPDATE_USER_FLAGS),
Validator('json', BulkUpdateUserFlagsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.bulkUpdateUserFlags(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bulk/update-guild-features',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_UPDATE_GUILD_FEATURES),
Validator('json', BulkUpdateGuildFeaturesRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.bulkUpdateGuildFeatures(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bulk/add-guild-members',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_ADD_GUILD_MEMBERS),
Validator('json', BulkAddGuildMembersRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.bulkAddGuildMembers(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/bulk/schedule-user-deletion',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_DELETE_USERS),
Validator('json', BulkScheduleUserDeletionRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.bulkScheduleUserDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
};

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {Config} from '~/Config';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {ProductType} from '~/stripe/ProductRegistry';
import {Validator} from '~/Validator';
import {GenerateBetaCodesRequest, GenerateGiftCodesRequest, type GiftProductType} from '../models/CodeRequestTypes';
const trimTrailingSlash = (value: string): string => (value.endsWith('/') ? value.slice(0, -1) : value);
const giftDurations: Record<GiftProductType, number> = {
[ProductType.GIFT_1_MONTH]: 1,
[ProductType.GIFT_1_YEAR]: 12,
[ProductType.GIFT_VISIONARY]: 0,
};
export const CodesAdminController = (app: HonoApp) => {
app.post(
'/admin/codes/beta',
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
requireAdminACL(AdminACLs.BETA_CODES_GENERATE),
Validator('json', GenerateBetaCodesRequest),
async (ctx) => {
const {count} = ctx.req.valid('json');
const codes = await ctx.get('adminService').generateBetaCodes(count);
return ctx.json({codes});
},
);
app.post(
'/admin/codes/gift',
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
requireAdminACL(AdminACLs.GIFT_CODES_GENERATE),
Validator('json', GenerateGiftCodesRequest),
async (ctx) => {
const {count, product_type} = ctx.req.valid('json');
const durationMonths = giftDurations[product_type];
const codes = await ctx.get('adminService').generateGiftCodes(count, durationMonths);
const baseUrl = trimTrailingSlash(Config.endpoints.gift);
return ctx.json({
codes: codes.map((code) => `${baseUrl}/${code}`),
});
},
);
};

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {ALL_FEATURE_FLAGS, FeatureFlags} from '~/constants/FeatureFlags';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {z} from '~/Schema';
import {Validator} from '~/Validator';
export const FeatureFlagAdminController = (app: HonoApp) => {
app.post(
'/admin/feature-flags/get',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.FEATURE_FLAG_VIEW),
async (ctx) => {
const featureFlagService = ctx.get('featureFlagService');
const config = featureFlagService.getConfigForSession();
return ctx.json({
feature_flags: config,
});
},
);
app.post(
'/admin/feature-flags/update',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.FEATURE_FLAG_MANAGE),
Validator(
'json',
z.object({
flag: z.enum([FeatureFlags.MESSAGE_SCHEDULING, FeatureFlags.EXPRESSION_PACKS]),
guild_ids: z.string(),
}),
),
async (ctx) => {
const {flag, guild_ids} = ctx.req.valid('json');
const featureFlagService = ctx.get('featureFlagService');
const guildIdArray = guild_ids
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0);
const guildIdSet = new Set(guildIdArray);
await featureFlagService.setFeatureGuildIds(flag, guildIdSet);
const updatedConfig = featureFlagService.getConfigForSession();
return ctx.json({
feature_flags: updatedConfig,
});
},
);
app.post(
'/admin/feature-flags/list',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.FEATURE_FLAG_VIEW),
async (ctx) => {
return ctx.json({
flags: ALL_FEATURE_FLAGS.map((flag) => ({
key: flag,
label: getFeatureFlagLabel(flag),
description: getFeatureFlagDescription(flag),
})),
});
},
);
};
function getFeatureFlagLabel(flag: string): string {
switch (flag) {
case FeatureFlags.MESSAGE_SCHEDULING:
return 'Message Scheduling';
case FeatureFlags.EXPRESSION_PACKS:
return 'Expression Packs';
default:
return flag;
}
}
function getFeatureFlagDescription(flag: string): string {
switch (flag) {
case FeatureFlags.MESSAGE_SCHEDULING:
return 'Allows users to schedule messages to be sent later';
case FeatureFlags.EXPRESSION_PACKS:
return 'Allows users to create and share emoji/sticker packs';
default:
return '';
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createGuildID} from '~/BrandedTypes';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
import {GetProcessMemoryStatsRequest} from '../AdminModel';
export const GatewayAdminController = (app: HonoApp) => {
app.post(
'/admin/gateway/memory-stats',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
Validator('json', GetProcessMemoryStatsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
return ctx.json(await adminService.getGuildMemoryStats(body.limit));
},
);
app.post(
'/admin/gateway/reload-all',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD),
requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL),
Validator('json', z.object({guild_ids: z.array(Int64Type)})),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
const guildIds = body.guild_ids.map((id) => createGuildID(id));
return ctx.json(await adminService.reloadAllGuilds(guildIds));
},
);
app.get(
'/admin/gateway/stats',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.getNodeStats());
},
);
};

View File

@@ -0,0 +1,242 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import z from 'zod';
import type {HonoApp} from '~/App';
import {createGuildID} from '~/BrandedTypes';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {AdminRateLimitConfigs} from '~/rate_limit_configs/AdminRateLimitConfig';
import {Int64Type} from '~/Schema';
import {Validator} from '~/Validator';
import {
ClearGuildFieldsRequest,
DeleteGuildRequest,
ForceAddUserToGuildRequest,
ListGuildMembersRequest,
LookupGuildRequest,
ReloadGuildRequest,
ShutdownGuildRequest,
TransferGuildOwnershipRequest,
UpdateGuildFeaturesRequest,
UpdateGuildNameRequest,
UpdateGuildSettingsRequest,
UpdateGuildVanityRequest,
} from '../AdminModel';
export const GuildAdminController = (app: HonoApp) => {
app.post(
'/admin/guilds/lookup',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GUILD_LOOKUP),
Validator('json', LookupGuildRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.lookupGuild(ctx.req.valid('json')));
},
);
app.post(
'/admin/guilds/list-members',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS),
Validator('json', ListGuildMembersRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.listGuildMembers(ctx.req.valid('json')));
},
);
app.get(
'/admin/guilds/:guild_id/emojis',
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ASSET_PURGE),
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const adminService = ctx.get('adminService');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
return ctx.json(await adminService.listGuildEmojis(guildId));
},
);
app.get(
'/admin/guilds/:guild_id/stickers',
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ASSET_PURGE),
Validator('param', z.object({guild_id: Int64Type})),
async (ctx) => {
const adminService = ctx.get('adminService');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
return ctx.json(await adminService.listGuildStickers(guildId));
},
);
app.post(
'/admin/guilds/clear-fields',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
Validator('json', ClearGuildFieldsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.clearGuildFields(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/guilds/update-features',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_UPDATE_FEATURES),
Validator('json', UpdateGuildFeaturesRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const guildId = createGuildID(body.guild_id);
return ctx.json(
await adminService.updateGuildFeatures({
guildId,
addFeatures: body.add_features,
removeFeatures: body.remove_features,
adminUserId,
auditLogReason,
}),
);
},
);
app.post(
'/admin/guilds/update-name',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_UPDATE_NAME),
Validator('json', UpdateGuildNameRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.updateGuildName(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/guilds/update-settings',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
Validator('json', UpdateGuildSettingsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.updateGuildSettings(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/guilds/transfer-ownership',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_TRANSFER_OWNERSHIP),
Validator('json', TransferGuildOwnershipRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.transferGuildOwnership(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/guilds/update-vanity',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_UPDATE_VANITY),
Validator('json', UpdateGuildVanityRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.updateGuildVanity(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/guilds/force-add-user',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER),
Validator('json', ForceAddUserToGuildRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const requestCache = ctx.get('requestCache');
return ctx.json(
await adminService.forceAddUserToGuild({
data: ctx.req.valid('json'),
requestCache,
adminUserId,
auditLogReason,
}),
);
},
);
app.post(
'/admin/guilds/reload',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_RELOAD),
Validator('json', ReloadGuildRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
return ctx.json(await adminService.reloadGuild(body.guild_id, adminUserId, auditLogReason));
},
);
app.post(
'/admin/guilds/shutdown',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_SHUTDOWN),
Validator('json', ShutdownGuildRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
return ctx.json(await adminService.shutdownGuild(body.guild_id, adminUserId, auditLogReason));
},
);
app.post(
'/admin/guilds/delete',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.GUILD_DELETE),
Validator('json', DeleteGuildRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
return ctx.json(await adminService.deleteGuild(body.guild_id, adminUserId, auditLogReason));
},
);
};

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {InstanceConfigRepository} from '~/instance/InstanceConfigRepository';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {z} from '~/Schema';
import {Validator} from '~/Validator';
const instanceConfigRepository = new InstanceConfigRepository();
export const InstanceConfigAdminController = (app: HonoApp) => {
app.post(
'/admin/instance-config/get',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW),
async (ctx) => {
const config = await instanceConfigRepository.getInstanceConfig();
const isActiveNow = instanceConfigRepository.isManualReviewActiveNow(config);
return ctx.json({
manual_review_enabled: config.manualReviewEnabled,
manual_review_schedule_enabled: config.manualReviewScheduleEnabled,
manual_review_schedule_start_hour_utc: config.manualReviewScheduleStartHourUtc,
manual_review_schedule_end_hour_utc: config.manualReviewScheduleEndHourUtc,
manual_review_active_now: isActiveNow,
registration_alerts_webhook_url: config.registrationAlertsWebhookUrl,
system_alerts_webhook_url: config.systemAlertsWebhookUrl,
});
},
);
app.post(
'/admin/instance-config/update',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
Validator(
'json',
z.object({
manual_review_enabled: z.boolean().optional(),
manual_review_schedule_enabled: z.boolean().optional(),
manual_review_schedule_start_hour_utc: z.number().min(0).max(23).optional(),
manual_review_schedule_end_hour_utc: z.number().min(0).max(23).optional(),
registration_alerts_webhook_url: z.string().url().nullable().optional(),
system_alerts_webhook_url: z.string().url().nullable().optional(),
}),
),
async (ctx) => {
const data = ctx.req.valid('json');
if (data.manual_review_enabled !== undefined) {
await instanceConfigRepository.setManualReviewEnabled(data.manual_review_enabled);
}
if (
data.manual_review_schedule_enabled !== undefined ||
data.manual_review_schedule_start_hour_utc !== undefined ||
data.manual_review_schedule_end_hour_utc !== undefined
) {
const currentConfig = await instanceConfigRepository.getInstanceConfig();
const scheduleEnabled = data.manual_review_schedule_enabled ?? currentConfig.manualReviewScheduleEnabled;
const startHour = data.manual_review_schedule_start_hour_utc ?? currentConfig.manualReviewScheduleStartHourUtc;
const endHour = data.manual_review_schedule_end_hour_utc ?? currentConfig.manualReviewScheduleEndHourUtc;
await instanceConfigRepository.setManualReviewSchedule(scheduleEnabled, startHour, endHour);
}
if (data.registration_alerts_webhook_url !== undefined) {
await instanceConfigRepository.setRegistrationAlertsWebhookUrl(data.registration_alerts_webhook_url);
}
if (data.system_alerts_webhook_url !== undefined) {
await instanceConfigRepository.setSystemAlertsWebhookUrl(data.system_alerts_webhook_url);
}
const updatedConfig = await instanceConfigRepository.getInstanceConfig();
const isActiveNow = instanceConfigRepository.isManualReviewActiveNow(updatedConfig);
return ctx.json({
manual_review_enabled: updatedConfig.manualReviewEnabled,
manual_review_schedule_enabled: updatedConfig.manualReviewScheduleEnabled,
manual_review_schedule_start_hour_utc: updatedConfig.manualReviewScheduleStartHourUtc,
manual_review_schedule_end_hour_utc: updatedConfig.manualReviewScheduleEndHourUtc,
manual_review_active_now: isActiveNow,
registration_alerts_webhook_url: updatedConfig.registrationAlertsWebhookUrl,
system_alerts_webhook_url: updatedConfig.systemAlertsWebhookUrl,
});
},
);
};

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import {
DeleteAllUserMessagesRequest,
DeleteMessageRequest,
LookupMessageByAttachmentRequest,
LookupMessageRequest,
MessageShredRequest,
MessageShredStatusRequest,
} from '../AdminModel';
export const MessageAdminController = (app: HonoApp) => {
app.post(
'/admin/messages/lookup',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
Validator('json', LookupMessageRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.lookupMessage(ctx.req.valid('json')));
},
);
app.post(
'/admin/messages/lookup-by-attachment',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
Validator('json', LookupMessageByAttachmentRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.lookupMessageByAttachment(ctx.req.valid('json')));
},
);
app.post(
'/admin/messages/delete',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.MESSAGE_DELETE),
Validator('json', DeleteMessageRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.deleteMessage(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/messages/shred',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.MESSAGE_SHRED),
Validator('json', MessageShredRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.queueMessageShred(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/messages/delete-all',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL),
Validator('json', DeleteAllUserMessagesRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.deleteAllUserMessages(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/messages/shred-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.MESSAGE_SHRED),
Validator('json', MessageShredStatusRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
return ctx.json(await adminService.getMessageShredStatus(body.job_id));
},
);
};

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createReportID} from '~/BrandedTypes';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createStringType, Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
import {SearchReportsRequest} from '../AdminModel';
export const ReportAdminController = (app: HonoApp) => {
app.post(
'/admin/reports/list',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REPORT_VIEW),
Validator(
'json',
z.object({
status: z.number().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
}),
),
async (ctx) => {
const adminService = ctx.get('adminService');
const {status, limit, offset} = ctx.req.valid('json');
return ctx.json(await adminService.listReports(status ?? 0, limit, offset));
},
);
app.get(
'/admin/reports/:report_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REPORT_VIEW),
Validator('param', z.object({report_id: Int64Type})),
async (ctx) => {
const adminService = ctx.get('adminService');
const {report_id} = ctx.req.valid('param');
const report = await adminService.getReport(createReportID(report_id));
return ctx.json(report);
},
);
app.post(
'/admin/reports/resolve',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REPORT_RESOLVE),
Validator(
'json',
z.object({
report_id: Int64Type,
public_comment: createStringType(0, 512).optional(),
}),
),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {report_id, public_comment} = ctx.req.valid('json');
return ctx.json(
await adminService.resolveReport(
createReportID(report_id),
adminUserId,
public_comment || null,
auditLogReason,
),
);
},
);
app.post(
'/admin/reports/search',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REPORT_VIEW),
Validator('json', SearchReportsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
return ctx.json(await adminService.searchReports(body));
},
);
};

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import {
GetIndexRefreshStatusRequest,
RefreshSearchIndexRequest,
SearchGuildsRequest,
SearchUsersRequest,
} from '../AdminModel';
export const SearchAdminController = (app: HonoApp) => {
app.post(
'/admin/guilds/search',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GUILD_LOOKUP),
Validator('json', SearchGuildsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
return ctx.json(await adminService.searchGuilds(body));
},
);
app.post(
'/admin/users/search',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.USER_LOOKUP),
Validator('json', SearchUsersRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
return ctx.json(await adminService.searchUsers(body));
},
);
app.post(
'/admin/search/refresh-index',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GUILD_LOOKUP),
Validator('json', RefreshSearchIndexRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
return ctx.json(await adminService.refreshSearchIndex(body, adminUserId, auditLogReason));
},
);
app.post(
'/admin/search/refresh-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GUILD_LOOKUP),
Validator('json', GetIndexRefreshStatusRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
return ctx.json(await adminService.getIndexRefreshStatus(body.job_id));
},
);
};

View File

@@ -0,0 +1,374 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createUserID} from '~/BrandedTypes';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import {
CancelBulkMessageDeletionRequest,
ChangeDobRequest,
ChangeEmailRequest,
ChangeUsernameRequest,
ClearUserFieldsRequest,
DisableForSuspiciousActivityRequest,
DisableMfaRequest,
ListUserChangeLogRequest,
ListUserGuildsRequest,
ListUserSessionsRequest,
LookupUserRequest,
ScheduleAccountDeletionRequest,
SendPasswordResetRequest,
SetUserAclsRequest,
SetUserBotStatusRequest,
SetUserSystemStatusRequest,
TempBanUserRequest,
TerminateSessionsRequest,
UnlinkPhoneRequest,
UpdateSuspiciousActivityFlagsRequest,
UpdateUserFlagsRequest,
VerifyUserEmailRequest,
} from '../AdminModel';
export const UserAdminController = (app: HonoApp) => {
app.post(
'/admin/users/lookup',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.USER_LOOKUP),
Validator('json', LookupUserRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.lookupUser(ctx.req.valid('json')));
},
);
app.post(
'/admin/users/list-guilds',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.USER_LIST_GUILDS),
Validator('json', ListUserGuildsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.listUserGuilds(ctx.req.valid('json')));
},
);
app.post(
'/admin/users/disable-mfa',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_MFA),
Validator('json', DisableMfaRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
await adminService.disableMfa(ctx.req.valid('json'), adminUserId, auditLogReason);
return ctx.body(null, 204);
},
);
app.post(
'/admin/users/clear-fields',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_PROFILE),
Validator('json', ClearUserFieldsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.clearUserFields(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/set-bot-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('json', SetUserBotStatusRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.setUserBotStatus(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/set-system-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('json', SetUserSystemStatusRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.setUserSystemStatus(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/verify-email',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
Validator('json', VerifyUserEmailRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.verifyUserEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/send-password-reset',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
Validator('json', SendPasswordResetRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
await adminService.sendPasswordReset(ctx.req.valid('json'), adminUserId, auditLogReason);
return ctx.body(null, 204);
},
);
app.post(
'/admin/users/change-username',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_USERNAME),
Validator('json', ChangeUsernameRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.changeUsername(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/change-email',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
Validator('json', ChangeEmailRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.changeEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/terminate-sessions',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
Validator('json', TerminateSessionsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.terminateSessions(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/temp-ban',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_TEMP_BAN),
Validator('json', TempBanUserRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.tempBanUser(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/unban',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_TEMP_BAN),
Validator('json', DisableMfaRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.unbanUser(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/schedule-deletion',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DELETE),
Validator('json', ScheduleAccountDeletionRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.scheduleAccountDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/cancel-deletion',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DELETE),
Validator('json', DisableMfaRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.cancelAccountDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/cancel-bulk-message-deletion',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_CANCEL_BULK_MESSAGE_DELETION),
Validator('json', CancelBulkMessageDeletionRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.cancelBulkMessageDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/set-acls',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.ACL_SET_USER),
Validator('json', SetUserAclsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.setUserAcls(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/update-flags',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
Validator('json', UpdateUserFlagsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const userId = createUserID(body.user_id);
return ctx.json(
await adminService.updateUserFlags({
userId,
data: {addFlags: body.add_flags, removeFlags: body.remove_flags},
adminUserId,
auditLogReason,
}),
);
},
);
app.post(
'/admin/users/unlink-phone',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_PHONE),
Validator('json', UnlinkPhoneRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.unlinkPhone(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/change-dob',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_DOB),
Validator('json', ChangeDobRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.changeDob(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/update-suspicious-activity-flags',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_SUSPICIOUS_ACTIVITY),
Validator('json', UpdateSuspiciousActivityFlagsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(
await adminService.updateSuspiciousActivityFlags(ctx.req.valid('json'), adminUserId, auditLogReason),
);
},
);
app.post(
'/admin/users/disable-suspicious',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DISABLE_SUSPICIOUS),
Validator('json', DisableForSuspiciousActivityRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(
await adminService.disableForSuspiciousActivity(ctx.req.valid('json'), adminUserId, auditLogReason),
);
},
);
app.post(
'/admin/users/list-sessions',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_LIST_SESSIONS),
Validator('json', ListUserSessionsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
return ctx.json(await adminService.listUserSessions(body.user_id, adminUserId, auditLogReason));
},
);
app.post(
'/admin/users/change-log',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.USER_LOOKUP),
Validator('json', ListUserChangeLogRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.listUserChangeLog(ctx.req.valid('json')));
},
);
};

View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createUserID} from '~/BrandedTypes';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
export const VerificationAdminController = (app: HonoApp) => {
app.post(
'/admin/pending-verifications/list',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.USER_LOOKUP),
Validator('json', z.object({limit: z.number().default(100)})),
async (ctx) => {
const adminService = ctx.get('adminService');
const {limit} = ctx.req.valid('json');
return ctx.json(await adminService.listPendingVerifications(limit));
},
);
app.post(
'/admin/pending-verifications/approve',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
Validator('json', z.object({user_id: Int64Type})),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {user_id} = ctx.req.valid('json');
return ctx.json(await adminService.approveRegistration(createUserID(user_id), adminUserId, auditLogReason));
},
);
app.post(
'/admin/pending-verifications/reject',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
Validator('json', z.object({user_id: Int64Type})),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {user_id} = ctx.req.valid('json');
return ctx.json(await adminService.rejectRegistration(createUserID(user_id), adminUserId, auditLogReason));
},
);
app.post(
'/admin/pending-verifications/bulk-approve',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {user_ids} = ctx.req.valid('json');
const parsedUserIds = user_ids.map(createUserID);
return ctx.json(await adminService.bulkApproveRegistrations(parsedUserIds, adminUserId, auditLogReason));
},
);
app.post(
'/admin/pending-verifications/bulk-reject',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {user_ids} = ctx.req.valid('json');
const parsedUserIds = user_ids.map(createUserID);
return ctx.json(await adminService.bulkRejectRegistrations(parsedUserIds, adminUserId, auditLogReason));
},
);
};

View File

@@ -0,0 +1,169 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {AdminACLs} from '~/Constants';
import {requireAdminACL} from '~/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Validator} from '~/Validator';
import {
CreateVoiceRegionRequest,
CreateVoiceServerRequest,
DeleteVoiceRegionRequest,
DeleteVoiceServerRequest,
GetVoiceRegionRequest,
GetVoiceServerRequest,
ListVoiceRegionsRequest,
ListVoiceServersRequest,
UpdateVoiceRegionRequest,
UpdateVoiceServerRequest,
} from '../AdminModel';
export const VoiceAdminController = (app: HonoApp) => {
app.post(
'/admin/voice/regions/list',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
Validator('json', ListVoiceRegionsRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.listVoiceRegions(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/regions/get',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
Validator('json', GetVoiceRegionRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.getVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/regions/create',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.VOICE_REGION_CREATE),
Validator('json', CreateVoiceRegionRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.createVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/regions/update',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.VOICE_REGION_UPDATE),
Validator('json', UpdateVoiceRegionRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.updateVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/regions/delete',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.VOICE_REGION_DELETE),
Validator('json', DeleteVoiceRegionRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.deleteVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/servers/list',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
Validator('json', ListVoiceServersRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.listVoiceServers(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/servers/get',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
Validator('json', GetVoiceServerRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.getVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/servers/create',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.VOICE_SERVER_CREATE),
Validator('json', CreateVoiceServerRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.createVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/servers/update',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.VOICE_SERVER_UPDATE),
Validator('json', UpdateVoiceServerRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.updateVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
app.post(
'/admin/voice/servers/delete',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.VOICE_SERVER_DELETE),
Validator('json', DeleteVoiceServerRequest),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
return ctx.json(await adminService.deleteVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
},
);
};

View File

@@ -0,0 +1,55 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {ArchiveAdminController} from './ArchiveAdminController';
import {AssetAdminController} from './AssetAdminController';
import {AuditLogAdminController} from './AuditLogAdminController';
import {BanAdminController} from './BanAdminController';
import {BulkAdminController} from './BulkAdminController';
import {CodesAdminController} from './CodesAdminController';
import {FeatureFlagAdminController} from './FeatureFlagAdminController';
import {GatewayAdminController} from './GatewayAdminController';
import {GuildAdminController} from './GuildAdminController';
import {InstanceConfigAdminController} from './InstanceConfigAdminController';
import {MessageAdminController} from './MessageAdminController';
import {ReportAdminController} from './ReportAdminController';
import {SearchAdminController} from './SearchAdminController';
import {UserAdminController} from './UserAdminController';
import {VerificationAdminController} from './VerificationAdminController';
import {VoiceAdminController} from './VoiceAdminController';
export const registerAdminControllers = (app: HonoApp) => {
UserAdminController(app);
CodesAdminController(app);
GuildAdminController(app);
AssetAdminController(app);
BanAdminController(app);
InstanceConfigAdminController(app);
MessageAdminController(app);
BulkAdminController(app);
AuditLogAdminController(app);
ArchiveAdminController(app);
ReportAdminController(app);
VoiceAdminController(app);
GatewayAdminController(app);
SearchAdminController(app);
VerificationAdminController(app);
FeatureFlagAdminController(app);
};

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AdminArchiveRow} from '~/database/types/AdminArchiveTypes';
export type ArchiveSubjectType = 'user' | 'guild';
export class AdminArchive {
subjectType: ArchiveSubjectType;
subjectId: bigint;
archiveId: bigint;
requestedBy: bigint;
requestedAt: Date;
startedAt: Date | null;
completedAt: Date | null;
failedAt: Date | null;
storageKey: string | null;
fileSize: bigint | null;
progressPercent: number;
progressStep: string | null;
errorMessage: string | null;
downloadUrlExpiresAt: Date | null;
expiresAt: Date | null;
constructor(row: AdminArchiveRow) {
this.subjectType = row.subject_type;
this.subjectId = row.subject_id;
this.archiveId = row.archive_id;
this.requestedBy = row.requested_by;
this.requestedAt = row.requested_at;
this.startedAt = row.started_at ?? null;
this.completedAt = row.completed_at ?? null;
this.failedAt = row.failed_at ?? null;
this.storageKey = row.storage_key ?? null;
this.fileSize = row.file_size ?? null;
this.progressPercent = row.progress_percent;
this.progressStep = row.progress_step ?? null;
this.errorMessage = row.error_message ?? null;
this.downloadUrlExpiresAt = row.download_url_expires_at ?? null;
this.expiresAt = row.expires_at ?? null;
}
toRow(): AdminArchiveRow {
return {
subject_type: this.subjectType,
subject_id: this.subjectId,
archive_id: this.archiveId,
requested_by: this.requestedBy,
requested_at: this.requestedAt,
started_at: this.startedAt,
completed_at: this.completedAt,
failed_at: this.failedAt,
storage_key: this.storageKey,
file_size: this.fileSize,
progress_percent: this.progressPercent,
progress_step: this.progressStep,
error_message: this.errorMessage,
download_url_expires_at: this.downloadUrlExpiresAt,
expires_at: this.expiresAt,
};
}
toResponse(): {
archive_id: string;
subject_type: ArchiveSubjectType;
subject_id: string;
requested_by: string;
requested_at: string;
started_at: string | null;
completed_at: string | null;
failed_at: string | null;
file_size: string | null;
progress_percent: number;
progress_step: string | null;
error_message: string | null;
download_url_expires_at: string | null;
expires_at: string | null;
} {
return {
archive_id: this.archiveId.toString(),
subject_type: this.subjectType,
subject_id: this.subjectId.toString(),
requested_by: this.requestedBy.toString(),
requested_at: this.requestedAt.toISOString(),
started_at: this.startedAt?.toISOString() ?? null,
completed_at: this.completedAt?.toISOString() ?? null,
failed_at: this.failedAt?.toISOString() ?? null,
file_size: this.fileSize?.toString() ?? null,
progress_percent: this.progressPercent,
progress_step: this.progressStep,
error_message: this.errorMessage,
download_url_expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
expires_at: this.expiresAt?.toISOString() ?? null,
};
}
}
export type AdminArchiveResponse = ReturnType<AdminArchive['toResponse']>;

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createStringType, Int64Type, z} from '~/Schema';
export const ListAuditLogsRequest = z.object({
admin_user_id: Int64Type.optional(),
target_type: createStringType(1, 64).optional(),
target_id: Int64Type.optional(),
limit: z.number().default(50),
offset: z.number().default(0),
});
export type ListAuditLogsRequest = z.infer<typeof ListAuditLogsRequest>;
export const SearchAuditLogsRequest = z.object({
query: createStringType(1, 1024).optional(),
admin_user_id: Int64Type.optional(),
target_type: createStringType(1, 64).optional(),
target_id: Int64Type.optional(),
action: createStringType(1, 64).optional(),
sort_by: z.enum(['createdAt', 'relevance']).default('createdAt'),
sort_order: z.enum(['asc', 'desc']).default('desc'),
limit: z.number().default(50),
offset: z.number().default(0),
});
export type SearchAuditLogsRequest = z.infer<typeof SearchAuditLogsRequest>;
export const SearchReportsRequest = z.object({
query: createStringType(1, 1024).optional(),
limit: z.number().default(50),
offset: z.number().default(0),
reporter_id: Int64Type.optional(),
status: z.number().optional(),
report_type: z.number().optional(),
category: createStringType(1, 128).optional(),
reported_user_id: Int64Type.optional(),
reported_guild_id: Int64Type.optional(),
reported_channel_id: Int64Type.optional(),
guild_context_id: Int64Type.optional(),
resolved_by_admin_id: Int64Type.optional(),
sort_by: z.enum(['createdAt', 'reportedAt', 'resolvedAt']).default('reportedAt'),
sort_order: z.enum(['asc', 'desc']).default('desc'),
});
export type SearchReportsRequest = z.infer<typeof SearchReportsRequest>;
export const RefreshSearchIndexRequest = z.object({
index_type: z.enum(['guilds', 'users', 'reports', 'audit_logs', 'channel_messages', 'favorite_memes']),
guild_id: Int64Type.optional(),
user_id: Int64Type.optional(),
});
export type RefreshSearchIndexRequest = z.infer<typeof RefreshSearchIndexRequest>;
export const GetIndexRefreshStatusRequest = z.object({
job_id: createStringType(1, 128),
});
export type GetIndexRefreshStatusRequest = z.infer<typeof GetIndexRefreshStatusRequest>;
export const PurgeGuildAssetsRequest = z.object({
ids: z.array(createStringType(1, 64)),
});
export type PurgeGuildAssetsRequest = z.infer<typeof PurgeGuildAssetsRequest>;
export interface PurgeGuildAssetResult {
id: string;
asset_type: 'emoji' | 'sticker' | 'unknown';
found_in_db: boolean;
guild_id: string | null;
}
export interface PurgeGuildAssetError {
id: string;
error: string;
}
export interface PurgeGuildAssetsResponse {
processed: Array<PurgeGuildAssetResult>;
errors: Array<PurgeGuildAssetError>;
}
export interface GuildEmojiAsset {
id: string;
name: string;
animated: boolean;
creator_id: string;
media_url: string;
}
export interface ListGuildEmojisResponse {
guild_id: string;
emojis: Array<GuildEmojiAsset>;
}
export interface GuildStickerAsset {
id: string;
name: string;
format_type: number;
creator_id: string;
media_url: string;
}
export interface ListGuildStickersResponse {
guild_id: string;
stickers: Array<GuildStickerAsset>;
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Int64Type, z} from '~/Schema';
export const TriggerUserArchiveRequest = z.object({
user_id: Int64Type,
});
export type TriggerUserArchiveRequest = z.infer<typeof TriggerUserArchiveRequest>;
export const TriggerGuildArchiveRequest = z.object({
guild_id: Int64Type,
});
export type TriggerGuildArchiveRequest = z.infer<typeof TriggerGuildArchiveRequest>;
export const ListArchivesRequest = z.object({
subject_type: z.enum(['user', 'guild', 'all']).default('all'),
subject_id: Int64Type.optional(),
requested_by: Int64Type.optional(),
limit: z.number().min(1).max(200).default(50),
include_expired: z.boolean().default(false),
});
export type ListArchivesRequest = z.infer<typeof ListArchivesRequest>;

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createStringType, EmailType, PhoneNumberType, z} from '~/Schema';
import {isValidIpOrRange} from '~/utils/IpRangeUtils';
export const BanIpRequest = z.object({
ip: createStringType(1, 45).refine(
(value) => isValidIpOrRange(value),
'Must be a valid IPv4/IPv6 address or CIDR range',
),
});
export type BanIpRequest = z.infer<typeof BanIpRequest>;
export const BanEmailRequest = z.object({
email: EmailType,
});
export type BanEmailRequest = z.infer<typeof BanEmailRequest>;
export const BanPhoneRequest = z.object({
phone: PhoneNumberType,
});
export type BanPhoneRequest = z.infer<typeof BanPhoneRequest>;

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {z} from '~/Schema';
import {ProductType} from '~/stripe/ProductRegistry';
const GiftProductTypes = [ProductType.GIFT_1_MONTH, ProductType.GIFT_1_YEAR, ProductType.GIFT_VISIONARY] as const;
const MAX_CODES_PER_REQUEST = 100;
export const GiftProductTypeEnum = z.enum(GiftProductTypes);
export type GiftProductType = z.infer<typeof GiftProductTypeEnum>;
export const GenerateBetaCodesRequest = z.object({
count: z.number().int().min(1).max(MAX_CODES_PER_REQUEST),
});
export type GenerateBetaCodesRequest = z.infer<typeof GenerateBetaCodesRequest>;
export const GenerateGiftCodesRequest = z.object({
count: z.number().int().min(1).max(MAX_CODES_PER_REQUEST),
product_type: GiftProductTypeEnum,
});
export type GenerateGiftCodesRequest = z.infer<typeof GenerateGiftCodesRequest>;

View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createStringType, Int64Type, VanityURLCodeType, z} from '~/Schema';
export const UpdateGuildFeaturesRequest = z.object({
guild_id: Int64Type,
add_features: z.array(createStringType(1, 64)).default([]),
remove_features: z.array(createStringType(1, 64)).default([]),
});
export type UpdateGuildFeaturesRequest = z.infer<typeof UpdateGuildFeaturesRequest>;
export const ForceAddUserToGuildRequest = z.object({
user_id: Int64Type,
guild_id: Int64Type,
});
export interface ForceAddUserToGuildRequest {
user_id: bigint;
guild_id: bigint;
}
export const ClearGuildFieldsRequest = z.object({
guild_id: Int64Type,
fields: z.array(z.enum(['icon', 'banner', 'splash'])),
});
export type ClearGuildFieldsRequest = z.infer<typeof ClearGuildFieldsRequest>;
export const DeleteGuildRequest = z.object({
guild_id: Int64Type,
});
export type DeleteGuildRequest = z.infer<typeof DeleteGuildRequest>;
export const UpdateGuildVanityRequest = z.object({
guild_id: Int64Type,
vanity_url_code: VanityURLCodeType.nullable(),
});
export type UpdateGuildVanityRequest = z.infer<typeof UpdateGuildVanityRequest>;
export const UpdateGuildNameRequest = z.object({
guild_id: Int64Type,
name: createStringType(1, 100),
});
export type UpdateGuildNameRequest = z.infer<typeof UpdateGuildNameRequest>;
export const UpdateGuildSettingsRequest = z.object({
guild_id: Int64Type,
verification_level: z.number().optional(),
mfa_level: z.number().optional(),
nsfw_level: z.number().optional(),
explicit_content_filter: z.number().optional(),
default_message_notifications: z.number().optional(),
disabled_operations: z.number().optional(),
});
export type UpdateGuildSettingsRequest = z.infer<typeof UpdateGuildSettingsRequest>;
export const TransferGuildOwnershipRequest = z.object({
guild_id: Int64Type,
new_owner_id: Int64Type,
});
export type TransferGuildOwnershipRequest = z.infer<typeof TransferGuildOwnershipRequest>;
export const BulkUpdateGuildFeaturesRequest = z.object({
guild_ids: z.array(Int64Type),
add_features: z.array(createStringType(1, 64)).default([]),
remove_features: z.array(createStringType(1, 64)).default([]),
});
export type BulkUpdateGuildFeaturesRequest = z.infer<typeof BulkUpdateGuildFeaturesRequest>;
export const BulkAddGuildMembersRequest = z.object({
guild_id: Int64Type,
user_ids: z.array(Int64Type),
});
export type BulkAddGuildMembersRequest = z.infer<typeof BulkAddGuildMembersRequest>;

View File

@@ -0,0 +1,113 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Guild} from '~/Models';
import {createStringType, Int64Type, z} from '~/Schema';
export const mapGuildToAdminResponse = (guild: Guild): GuildAdminResponse => ({
id: guild.id.toString(),
name: guild.name,
features: Array.from(guild.features),
owner_id: guild.ownerId.toString(),
icon: guild.iconHash,
banner: guild.bannerHash,
member_count: guild.memberCount,
});
export const GuildAdminResponse = z.object({
id: z.string(),
name: z.string(),
features: z.array(z.string()),
owner_id: z.string(),
icon: z.string().nullable(),
banner: z.string().nullable(),
member_count: z.number(),
});
export type GuildAdminResponse = z.infer<typeof GuildAdminResponse>;
export const mapGuildsToAdminResponse = (guilds: Array<Guild>): GuildsAdminResponse => {
return {
guilds: [
...guilds.map((guild) => {
return {
id: guild.id.toString(),
name: guild.name,
features: Array.from(guild.features),
owner_id: guild.ownerId.toString(),
icon: guild.iconHash,
banner: guild.bannerHash,
member_count: guild.memberCount,
};
}),
],
};
};
const ListGuildsAdminResponse = z.object({
guilds: z.array(GuildAdminResponse),
});
type GuildsAdminResponse = z.infer<typeof ListGuildsAdminResponse>;
export const ListUserGuildsRequest = z.object({
user_id: Int64Type,
});
export type ListUserGuildsRequest = z.infer<typeof ListUserGuildsRequest>;
export const LookupGuildRequest = z.object({
guild_id: Int64Type,
});
export type LookupGuildRequest = z.infer<typeof LookupGuildRequest>;
export const ListGuildMembersRequest = z.object({
guild_id: Int64Type,
limit: z.number().default(50),
offset: z.number().default(0),
});
export type ListGuildMembersRequest = z.infer<typeof ListGuildMembersRequest>;
export const SearchGuildsRequest = z.object({
query: createStringType(1, 1024).optional(),
limit: z.number().default(50),
offset: z.number().default(0),
});
export type SearchGuildsRequest = z.infer<typeof SearchGuildsRequest>;
export const ReloadGuildRequest = z.object({
guild_id: Int64Type,
});
export type ReloadGuildRequest = z.infer<typeof ReloadGuildRequest>;
export const ShutdownGuildRequest = z.object({
guild_id: Int64Type,
});
export type ShutdownGuildRequest = z.infer<typeof ShutdownGuildRequest>;
export const GetProcessMemoryStatsRequest = z.object({
limit: z.number().int().min(1).max(100).default(25),
});
export type GetProcessMemoryStatsRequest = z.infer<typeof GetProcessMemoryStatsRequest>;

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {FilenameType, Int64Type, z} from '~/Schema';
export const LookupMessageRequest = z.object({
channel_id: Int64Type,
message_id: Int64Type,
context_limit: z.number().default(50),
});
export type LookupMessageRequest = z.infer<typeof LookupMessageRequest>;
export const LookupMessageByAttachmentRequest = z.object({
channel_id: Int64Type,
attachment_id: Int64Type,
filename: FilenameType,
context_limit: z.number().default(50),
});
export type LookupMessageByAttachmentRequest = z.infer<typeof LookupMessageByAttachmentRequest>;
export const DeleteMessageRequest = z.object({
channel_id: Int64Type,
message_id: Int64Type,
});
export type DeleteMessageRequest = z.infer<typeof DeleteMessageRequest>;
const MessageShredEntryType = z.object({
channel_id: Int64Type,
message_id: Int64Type,
});
export const MessageShredRequest = z.object({
user_id: Int64Type,
entries: z.array(MessageShredEntryType).min(1),
});
export type MessageShredRequest = z.infer<typeof MessageShredRequest>;
export const MessageShredResponse = z.object({
success: z.literal(true),
job_id: z.string(),
requested: z.number().int().min(0).optional(),
});
export type MessageShredResponse = z.infer<typeof MessageShredResponse>;
export const MessageShredStatusRequest = z.object({
job_id: z.string(),
});
export type MessageShredStatusRequest = z.infer<typeof MessageShredStatusRequest>;
export const DeleteAllUserMessagesRequest = z.object({
user_id: Int64Type,
dry_run: z.boolean().default(true),
});
export type DeleteAllUserMessagesRequest = z.infer<typeof DeleteAllUserMessagesRequest>;
export const DeleteAllUserMessagesResponse = z.object({
success: z.literal(true),
dry_run: z.boolean(),
channel_count: z.number().int(),
message_count: z.number().int(),
job_id: z.string().optional(),
});
export type DeleteAllUserMessagesResponse = z.infer<typeof DeleteAllUserMessagesResponse>;

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createStringType, EmailType, Int64Type, UsernameType, z} from '~/Schema';
export const UpdateUserFlagsRequest = z.object({
user_id: Int64Type,
add_flags: z.array(Int64Type).default([]),
remove_flags: z.array(Int64Type).default([]),
});
export type UpdateUserFlagsRequest = z.infer<typeof UpdateUserFlagsRequest>;
export const DisableMfaRequest = z.object({
user_id: Int64Type,
});
export type DisableMfaRequest = z.infer<typeof DisableMfaRequest>;
export const CancelBulkMessageDeletionRequest = z.object({
user_id: Int64Type,
});
export type CancelBulkMessageDeletionRequest = z.infer<typeof CancelBulkMessageDeletionRequest>;
export const ClearUserFieldsRequest = z.object({
user_id: Int64Type,
fields: z.array(z.enum(['avatar', 'banner', 'bio', 'pronouns', 'global_name'])),
});
export type ClearUserFieldsRequest = z.infer<typeof ClearUserFieldsRequest>;
export const SetUserBotStatusRequest = z.object({
user_id: Int64Type,
bot: z.boolean(),
});
export type SetUserBotStatusRequest = z.infer<typeof SetUserBotStatusRequest>;
export const SetUserSystemStatusRequest = z.object({
user_id: Int64Type,
system: z.boolean(),
});
export type SetUserSystemStatusRequest = z.infer<typeof SetUserSystemStatusRequest>;
export const VerifyUserEmailRequest = z.object({
user_id: Int64Type,
});
export type VerifyUserEmailRequest = z.infer<typeof VerifyUserEmailRequest>;
export const SendPasswordResetRequest = z.object({
user_id: Int64Type,
});
export type SendPasswordResetRequest = z.infer<typeof SendPasswordResetRequest>;
export const ChangeUsernameRequest = z.object({
user_id: Int64Type,
username: UsernameType,
discriminator: z.number().optional(),
});
export type ChangeUsernameRequest = z.infer<typeof ChangeUsernameRequest>;
export const ChangeEmailRequest = z.object({
user_id: Int64Type,
email: EmailType,
});
export type ChangeEmailRequest = z.infer<typeof ChangeEmailRequest>;
export const TerminateSessionsRequest = z.object({
user_id: Int64Type,
});
export type TerminateSessionsRequest = z.infer<typeof TerminateSessionsRequest>;
export const TempBanUserRequest = z.object({
user_id: Int64Type,
duration_hours: z.number(),
reason: createStringType(0, 512).optional(),
});
export type TempBanUserRequest = z.infer<typeof TempBanUserRequest>;
export const ScheduleAccountDeletionRequest = z.object({
user_id: Int64Type,
reason_code: z.number(),
public_reason: createStringType(0, 512).optional(),
days_until_deletion: z.number().default(60),
});
export type ScheduleAccountDeletionRequest = z.infer<typeof ScheduleAccountDeletionRequest>;
export const SetUserAclsRequest = z.object({
user_id: Int64Type,
acls: z.array(createStringType(1, 64)),
});
export type SetUserAclsRequest = z.infer<typeof SetUserAclsRequest>;
export const UnlinkPhoneRequest = z.object({
user_id: Int64Type,
});
export type UnlinkPhoneRequest = z.infer<typeof UnlinkPhoneRequest>;
export const ChangeDobRequest = z.object({
user_id: Int64Type,
date_of_birth: createStringType(10, 10).refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), 'Invalid date format'),
});
export type ChangeDobRequest = z.infer<typeof ChangeDobRequest>;
export const UpdateSuspiciousActivityFlagsRequest = z.object({
user_id: Int64Type,
flags: z.number(),
});
export type UpdateSuspiciousActivityFlagsRequest = z.infer<typeof UpdateSuspiciousActivityFlagsRequest>;
export const DisableForSuspiciousActivityRequest = z.object({
user_id: Int64Type,
flags: z.number(),
});
export type DisableForSuspiciousActivityRequest = z.infer<typeof DisableForSuspiciousActivityRequest>;
export const BulkUpdateUserFlagsRequest = z.object({
user_ids: z.array(Int64Type),
add_flags: z.array(Int64Type).default([]),
remove_flags: z.array(Int64Type).default([]),
});
export type BulkUpdateUserFlagsRequest = z.infer<typeof BulkUpdateUserFlagsRequest>;
export const BulkScheduleUserDeletionRequest = z.object({
user_ids: z.array(Int64Type),
reason_code: z.number(),
public_reason: createStringType(0, 512).optional(),
days_until_deletion: z.number().default(60),
});
export type BulkScheduleUserDeletionRequest = z.infer<typeof BulkScheduleUserDeletionRequest>;
export const ListUserChangeLogRequest = z.object({
user_id: Int64Type,
limit: z.number().min(1).max(200).default(50),
page_token: z.string().optional(),
});
export type ListUserChangeLogRequest = z.infer<typeof ListUserChangeLogRequest>;

View File

@@ -0,0 +1,180 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import dns from 'node:dns';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {User} from '~/Models';
import {createStringType, Int64Type, z} from '~/Schema';
import * as IpUtils from '~/utils/IpUtils';
const REVERSE_DNS_CACHE_TTL_SECONDS = 86400;
async function reverseDnsLookup(ip: string, cacheService?: ICacheService): Promise<string | null> {
const cacheKey = `reverse-dns:${ip}`;
if (cacheService) {
const cached = await cacheService.get<string | null>(cacheKey);
if (cached !== null) {
return cached === '' ? null : cached;
}
}
let result: string | null = null;
try {
const hostnames = await dns.promises.reverse(ip);
result = hostnames[0] ?? null;
} catch {
result = null;
}
if (cacheService) {
await cacheService.set(cacheKey, result ?? '', REVERSE_DNS_CACHE_TTL_SECONDS);
}
return result;
}
export const mapUserToAdminResponse = async (user: User, cacheService?: ICacheService): Promise<UserAdminResponse> => {
const lastActiveIpReverse = user.lastActiveIp ? await reverseDnsLookup(user.lastActiveIp, cacheService) : null;
let lastActiveLocation: string | null = null;
if (user.lastActiveIp) {
try {
const geoip = await IpUtils.getCountryCodeDetailed(user.lastActiveIp);
const formattedLocation = IpUtils.formatGeoipLocation(geoip);
lastActiveLocation = formattedLocation === IpUtils.UNKNOWN_LOCATION ? null : formattedLocation;
} catch {
lastActiveLocation = null;
}
}
return {
id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
global_name: user.globalName,
bot: user.isBot,
system: user.isSystem,
flags: user.flags.toString(),
avatar: user.avatarHash,
banner: user.bannerHash,
bio: user.bio,
pronouns: user.pronouns,
accent_color: user.accentColor,
email: user.email,
email_verified: user.emailVerified,
email_bounced: user.emailBounced,
phone: user.phone,
date_of_birth: user.dateOfBirth,
locale: user.locale,
premium_type: user.premiumType,
premium_since: user.premiumSince?.toISOString() ?? null,
premium_until: user.premiumUntil?.toISOString() ?? null,
suspicious_activity_flags: user.suspiciousActivityFlags,
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
deletion_reason_code: user.deletionReasonCode,
deletion_public_reason: user.deletionPublicReason,
acls: user.acls ? Array.from(user.acls) : [],
has_totp: user.totpSecret !== null,
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
last_active_at: user.lastActiveAt?.toISOString() ?? null,
last_active_ip: user.lastActiveIp,
last_active_ip_reverse: lastActiveIpReverse,
last_active_location: lastActiveLocation,
};
};
export const UserAdminResponse = z.object({
id: z.string(),
username: z.string(),
discriminator: z.number(),
global_name: z.string().nullable(),
bot: z.boolean(),
system: z.boolean(),
flags: z.string(),
avatar: z.string().nullable(),
banner: z.string().nullable(),
bio: z.string().nullable(),
pronouns: z.string().nullable(),
accent_color: z.number().nullable(),
email: z.string().nullable(),
email_verified: z.boolean(),
email_bounced: z.boolean(),
phone: z.string().nullable(),
date_of_birth: z.string().nullable(),
locale: z.string().nullable(),
premium_type: z.number().nullable(),
premium_since: z.string().nullable(),
premium_until: z.string().nullable(),
suspicious_activity_flags: z.number(),
temp_banned_until: z.string().nullable(),
pending_deletion_at: z.string().nullable(),
pending_bulk_message_deletion_at: z.string().nullable(),
deletion_reason_code: z.number().nullable(),
deletion_public_reason: z.string().nullable(),
acls: z.array(z.string()),
has_totp: z.boolean(),
authenticator_types: z.array(z.number()),
last_active_at: z.string().nullable(),
last_active_ip: z.string().nullable(),
last_active_ip_reverse: z.string().nullable(),
last_active_location: z.string().nullable(),
});
export type UserAdminResponse = z.infer<typeof UserAdminResponse>;
export const LookupUserRequest = z.object({
query: createStringType(1, 1024),
});
export type LookupUserRequest = z.infer<typeof LookupUserRequest>;
export const SearchUsersRequest = z.object({
query: createStringType(1, 1024).optional(),
limit: z.number().default(50),
offset: z.number().default(0),
});
export type SearchUsersRequest = z.infer<typeof SearchUsersRequest>;
export const ListUserSessionsRequest = z.object({
user_id: Int64Type,
});
export type ListUserSessionsRequest = z.infer<typeof ListUserSessionsRequest>;
export const UserContactChangeLogEntry = z.object({
event_id: z.string(),
field: z.string(),
old_value: z.string().nullable(),
new_value: z.string().nullable(),
reason: z.enum(['user_requested', 'admin_action']),
actor_user_id: z.string().nullable(),
event_at: z.string(),
});
export type UserContactChangeLogEntry = z.infer<typeof UserContactChangeLogEntry>;
export const ListUserChangeLogResponse = z.object({
entries: z.array(UserContactChangeLogEntry),
next_page_token: z.string().nullable().optional(),
});
export type ListUserChangeLogResponse = z.infer<typeof ListUserChangeLogResponse>;

View File

@@ -0,0 +1,151 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createStringType, Int64Type, z} from '~/Schema';
export const VoiceRegionAdminResponse = z.object({
id: z.string(),
name: z.string(),
emoji: z.string(),
latitude: z.number(),
longitude: z.number(),
is_default: z.boolean(),
vip_only: z.boolean(),
required_guild_features: z.array(z.string()),
allowed_guild_ids: z.array(z.string()),
allowed_user_ids: z.array(z.string()),
created_at: z.string().nullable(),
updated_at: z.string().nullable(),
});
export type VoiceRegionAdminResponse = z.infer<typeof VoiceRegionAdminResponse>;
export const VoiceServerAdminResponse = z.object({
region_id: z.string(),
server_id: z.string(),
endpoint: z.url(),
is_active: z.boolean(),
vip_only: z.boolean(),
required_guild_features: z.array(z.string()),
allowed_guild_ids: z.array(z.string()),
allowed_user_ids: z.array(z.string()),
created_at: z.string().nullable(),
updated_at: z.string().nullable(),
});
export type VoiceServerAdminResponse = z.infer<typeof VoiceServerAdminResponse>;
export const CreateVoiceRegionRequest = z.object({
id: createStringType(1, 64),
name: createStringType(1, 100),
emoji: createStringType(1, 64),
latitude: z.number(),
longitude: z.number(),
is_default: z.boolean().optional().default(false),
vip_only: z.boolean().optional().default(false),
required_guild_features: z.array(createStringType(1, 64)).optional().default([]),
allowed_guild_ids: z.array(Int64Type).optional().default([]),
allowed_user_ids: z.array(Int64Type).optional().default([]),
});
export type CreateVoiceRegionRequest = z.infer<typeof CreateVoiceRegionRequest>;
export const UpdateVoiceRegionRequest = z.object({
id: createStringType(1, 64),
name: createStringType(1, 100).optional(),
emoji: createStringType(1, 64).optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
is_default: z.boolean().optional(),
vip_only: z.boolean().optional(),
required_guild_features: z.array(createStringType(1, 64)).optional(),
allowed_guild_ids: z.array(Int64Type).optional(),
allowed_user_ids: z.array(Int64Type).optional(),
});
export type UpdateVoiceRegionRequest = z.infer<typeof UpdateVoiceRegionRequest>;
export const DeleteVoiceRegionRequest = z.object({
id: createStringType(1, 64),
});
export type DeleteVoiceRegionRequest = z.infer<typeof DeleteVoiceRegionRequest>;
export const CreateVoiceServerRequest = z.object({
region_id: createStringType(1, 64),
server_id: createStringType(1, 64),
endpoint: z.url(),
api_key: createStringType(1, 256),
api_secret: createStringType(1, 256),
is_active: z.boolean().optional().default(true),
vip_only: z.boolean().optional().default(false),
required_guild_features: z.array(createStringType(1, 64)).optional().default([]),
allowed_guild_ids: z.array(Int64Type).optional().default([]),
allowed_user_ids: z.array(Int64Type).optional().default([]),
});
export type CreateVoiceServerRequest = z.infer<typeof CreateVoiceServerRequest>;
export const UpdateVoiceServerRequest = z.object({
region_id: createStringType(1, 64),
server_id: createStringType(1, 64),
endpoint: z.url().optional(),
api_key: createStringType(1, 256).optional(),
api_secret: createStringType(1, 256).optional(),
is_active: z.boolean().optional(),
vip_only: z.boolean().optional(),
required_guild_features: z.array(createStringType(1, 64)).optional(),
allowed_guild_ids: z.array(Int64Type).optional(),
allowed_user_ids: z.array(Int64Type).optional(),
});
export type UpdateVoiceServerRequest = z.infer<typeof UpdateVoiceServerRequest>;
export const DeleteVoiceServerRequest = z.object({
region_id: createStringType(1, 64),
server_id: createStringType(1, 64),
});
export type DeleteVoiceServerRequest = z.infer<typeof DeleteVoiceServerRequest>;
export const ListVoiceRegionsRequest = z.object({
include_servers: z.boolean().optional().default(false),
});
export type ListVoiceRegionsRequest = z.infer<typeof ListVoiceRegionsRequest>;
export const GetVoiceRegionRequest = z.object({
id: createStringType(1, 64),
include_servers: z.boolean().optional().default(true),
});
export type GetVoiceRegionRequest = z.infer<typeof GetVoiceRegionRequest>;
export const ListVoiceServersRequest = z.object({
region_id: createStringType(1, 64),
});
export type ListVoiceServersRequest = z.infer<typeof ListVoiceServersRequest>;
export const GetVoiceServerRequest = z.object({
region_id: createStringType(1, 64),
server_id: createStringType(1, 64),
});
export type GetVoiceServerRequest = z.infer<typeof GetVoiceServerRequest>;

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export * from './AdminArchiveModel';
export * from './AdminTypes';
export * from './ArchiveTypes';
export * from './BanTypes';
export * from './CodeRequestTypes';
export * from './GuildRequestTypes';
export * from './GuildTypes';
export * from './MessageTypes';
export * from './UserRequestTypes';
export * from './UserTypes';
export * from './VoiceTypes';

View File

@@ -0,0 +1,392 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BatchBuilder, Db, fetchMany, fetchOne} from '~/database/Cassandra';
import type {AdminArchiveRow} from '~/database/CassandraTypes';
import {Logger} from '~/Logger';
import {AdminArchivesByRequester, AdminArchivesBySubject, AdminArchivesByType} from '~/Tables';
import {AdminArchive, type ArchiveSubjectType} from '../models/AdminArchiveModel';
const RETENTION_DAYS = 365;
const SECONDS_PER_DAY = 24 * 60 * 60;
const RETENTION_SECONDS = RETENTION_DAYS * SECONDS_PER_DAY;
const DEFAULT_RETENTION_MS = RETENTION_SECONDS * 1000;
function computeTtlSeconds(expiresAt: Date): number {
const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
return Math.max(diffSeconds, 1);
}
function filterExpired(rows: Array<AdminArchiveRow>, includeExpired: boolean): Array<AdminArchiveRow> {
if (includeExpired) return rows;
const now = Date.now();
return rows.filter((row) => !row.expires_at || row.expires_at.getTime() > now);
}
export class AdminArchiveRepository {
private ensureExpiry(archive: AdminArchive): AdminArchive {
if (!archive.expiresAt) {
archive.expiresAt = new Date(Date.now() + DEFAULT_RETENTION_MS);
}
return archive;
}
async create(archive: AdminArchive): Promise<void> {
const withExpiry = this.ensureExpiry(archive);
const row = withExpiry.toRow();
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
const batch = new BatchBuilder();
batch.addPrepared(
AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
);
batch.addPrepared(
AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
);
batch.addPrepared(
AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
);
await batch.execute();
Logger.debug(
{subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId},
'Created admin archive record',
);
}
async update(archive: AdminArchive): Promise<void> {
const withExpiry = this.ensureExpiry(archive);
const row = withExpiry.toRow();
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
const batch = new BatchBuilder();
batch.addPrepared(
AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
);
batch.addPrepared(
AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
);
batch.addPrepared(
AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
);
await batch.execute();
Logger.debug(
{subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId},
'Updated admin archive record',
);
}
async markAsStarted(archive: AdminArchive, progressStep = 'Starting archive'): Promise<void> {
const withExpiry = this.ensureExpiry(archive);
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
const batch = new BatchBuilder();
batch.addPrepared(
AdminArchivesBySubject.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
subject_id: withExpiry.subjectId,
archive_id: withExpiry.archiveId,
},
{
started_at: Db.set(new Date()),
progress_percent: Db.set(0),
progress_step: Db.set(progressStep),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByRequester.patchByPkWithTtlParam(
{
requested_by: withExpiry.requestedBy,
archive_id: withExpiry.archiveId,
},
{
started_at: Db.set(new Date()),
progress_percent: Db.set(0),
progress_step: Db.set(progressStep),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByType.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
archive_id: withExpiry.archiveId,
},
{
started_at: Db.set(new Date()),
progress_percent: Db.set(0),
progress_step: Db.set(progressStep),
},
'ttl_seconds',
ttlSeconds,
),
);
await batch.execute();
}
async updateProgress(archive: AdminArchive, progressPercent: number, progressStep: string): Promise<void> {
const withExpiry = this.ensureExpiry(archive);
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
const batch = new BatchBuilder();
batch.addPrepared(
AdminArchivesBySubject.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
subject_id: withExpiry.subjectId,
archive_id: withExpiry.archiveId,
},
{
progress_percent: Db.set(progressPercent),
progress_step: Db.set(progressStep),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByRequester.patchByPkWithTtlParam(
{
requested_by: withExpiry.requestedBy,
archive_id: withExpiry.archiveId,
},
{
progress_percent: Db.set(progressPercent),
progress_step: Db.set(progressStep),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByType.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
archive_id: withExpiry.archiveId,
},
{
progress_percent: Db.set(progressPercent),
progress_step: Db.set(progressStep),
},
'ttl_seconds',
ttlSeconds,
),
);
await batch.execute();
Logger.debug({archiveId: withExpiry.archiveId, progressPercent, progressStep}, 'Updated admin archive progress');
}
async markAsCompleted(
archive: AdminArchive,
storageKey: string,
fileSize: bigint,
downloadUrlExpiresAt: Date,
): Promise<void> {
const withExpiry = this.ensureExpiry(archive);
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
const batch = new BatchBuilder();
batch.addPrepared(
AdminArchivesBySubject.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
subject_id: withExpiry.subjectId,
archive_id: withExpiry.archiveId,
},
{
completed_at: Db.set(new Date()),
storage_key: Db.set(storageKey),
file_size: Db.set(fileSize),
download_url_expires_at: Db.set(downloadUrlExpiresAt),
progress_percent: Db.set(100),
progress_step: Db.set('Completed'),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByRequester.patchByPkWithTtlParam(
{
requested_by: withExpiry.requestedBy,
archive_id: withExpiry.archiveId,
},
{
completed_at: Db.set(new Date()),
storage_key: Db.set(storageKey),
file_size: Db.set(fileSize),
download_url_expires_at: Db.set(downloadUrlExpiresAt),
progress_percent: Db.set(100),
progress_step: Db.set('Completed'),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByType.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
archive_id: withExpiry.archiveId,
},
{
completed_at: Db.set(new Date()),
storage_key: Db.set(storageKey),
file_size: Db.set(fileSize),
download_url_expires_at: Db.set(downloadUrlExpiresAt),
progress_percent: Db.set(100),
progress_step: Db.set('Completed'),
},
'ttl_seconds',
ttlSeconds,
),
);
await batch.execute();
}
async markAsFailed(archive: AdminArchive, errorMessage: string): Promise<void> {
const withExpiry = this.ensureExpiry(archive);
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
const batch = new BatchBuilder();
batch.addPrepared(
AdminArchivesBySubject.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
subject_id: withExpiry.subjectId,
archive_id: withExpiry.archiveId,
},
{
failed_at: Db.set(new Date()),
error_message: Db.set(errorMessage),
progress_step: Db.set('Failed'),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByRequester.patchByPkWithTtlParam(
{
requested_by: withExpiry.requestedBy,
archive_id: withExpiry.archiveId,
},
{
failed_at: Db.set(new Date()),
error_message: Db.set(errorMessage),
progress_step: Db.set('Failed'),
},
'ttl_seconds',
ttlSeconds,
),
);
batch.addPrepared(
AdminArchivesByType.patchByPkWithTtlParam(
{
subject_type: withExpiry.subjectType,
archive_id: withExpiry.archiveId,
},
{
failed_at: Db.set(new Date()),
error_message: Db.set(errorMessage),
progress_step: Db.set('Failed'),
},
'ttl_seconds',
ttlSeconds,
),
);
await batch.execute();
}
async findBySubjectAndArchiveId(
subjectType: ArchiveSubjectType,
subjectId: bigint,
archiveId: bigint,
): Promise<AdminArchive | null> {
const query = AdminArchivesBySubject.select({
where: [
AdminArchivesBySubject.where.eq('subject_type'),
AdminArchivesBySubject.where.eq('subject_id'),
AdminArchivesBySubject.where.eq('archive_id'),
],
limit: 1,
});
const row = await fetchOne<AdminArchiveRow>(
query.bind({
subject_type: subjectType,
subject_id: subjectId,
archive_id: archiveId,
}),
);
return row ? new AdminArchive(row) : null;
}
async listBySubject(
subjectType: ArchiveSubjectType,
subjectId: bigint,
limit = 20,
includeExpired = false,
): Promise<Array<AdminArchive>> {
const query = AdminArchivesBySubject.select({
where: [AdminArchivesBySubject.where.eq('subject_type'), AdminArchivesBySubject.where.eq('subject_id')],
limit,
});
const rows = await fetchMany<AdminArchiveRow>(
query.bind({
subject_type: subjectType,
subject_id: subjectId,
}),
);
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
}
async listByType(subjectType: ArchiveSubjectType, limit = 50, includeExpired = false): Promise<Array<AdminArchive>> {
const query = AdminArchivesByType.select({
where: AdminArchivesByType.where.eq('subject_type'),
limit,
});
const rows = await fetchMany<AdminArchiveRow>(
query.bind({
subject_type: subjectType,
}),
);
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
}
async listByRequester(requestedBy: bigint, limit = 50, includeExpired = false): Promise<Array<AdminArchive>> {
const query = AdminArchivesByRequester.select({
where: AdminArchivesByRequester.where.eq('requested_by'),
limit,
});
const rows = await fetchMany<AdminArchiveRow>(
query.bind({
requested_by: requestedBy,
}),
);
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
}
}

View 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()};
}
}

View 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,
});
}
}

View 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;
}

View File

@@ -0,0 +1,135 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {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};
}
}

View 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;
}
}
}
}

View 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();
}
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import 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,
};
}
}

View File

@@ -0,0 +1,204 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type 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,
})) ?? [],
};
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import 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;
}
}

View 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;
}

View File

@@ -0,0 +1,207 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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;
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {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(),
});
}
}

View 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,
};
}
}

View 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,
};
}
}

View 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],
]),
});
}
}

View 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,
};
}
}

View 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',
})),
};
}
}

View 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);
}
}

View 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();
}
}

View 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,
};
}
}

View 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,
};
}
}

View 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`;
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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();
}
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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,
};
}
}

View File

@@ -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),
});
}
}

View 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),
};
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
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),
};
}
}