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