mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-14 06:28:32 +09:00
783 lines
29 KiB
TypeScript
783 lines
29 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {ChannelID, GuildID, RoleID, UserID} from '@app/api/BrandedTypes';
|
|
import {createChannelID, createGuildID, createRoleID, createUserID} from '@app/api/BrandedTypes';
|
|
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
|
import type {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService';
|
|
import type {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService';
|
|
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
|
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
|
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
|
import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers';
|
|
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
|
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
|
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
|
|
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
|
|
import type {IInviteRepository} from '@app/api/invite/IInviteRepository';
|
|
import {Logger} from '@app/api/Logger';
|
|
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
|
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
|
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
|
import type {Channel} from '@app/api/models/Channel';
|
|
import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite';
|
|
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
|
|
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
|
import {serializeChannelForAudit} from '@app/api/utils/AuditSerializationUtils';
|
|
import {applyProtectedOverwriteBits} from '@app/api/utils/featureUtils';
|
|
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
|
|
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
|
|
import type {VoiceRegionAvailability} from '@app/api/voice/VoiceModel';
|
|
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
|
|
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
|
import {
|
|
ALL_PERMISSIONS,
|
|
ChannelTypes,
|
|
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
|
Permissions,
|
|
} from '@fluxer/constants/src/ChannelConstants';
|
|
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
|
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
|
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
|
import {MaxCategoryChannelsError} from '@fluxer/errors/src/domains/channel/MaxCategoryChannelsError';
|
|
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
|
import {CannotExecuteOnDmError} from '@fluxer/errors/src/domains/core/CannotExecuteOnDmError';
|
|
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
|
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
|
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
|
import {ChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
|
|
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
|
|
|
export interface ChannelUpdateData {
|
|
name?: string;
|
|
topic?: string | null;
|
|
url?: string | null;
|
|
parent_id?: bigint | null;
|
|
bitrate?: number | null;
|
|
user_limit?: number | null;
|
|
voice_connection_limit?: number | null;
|
|
nsfw?: boolean;
|
|
nsfw_override?: boolean | null;
|
|
content_warning_level?: number;
|
|
content_warning_text?: string | null;
|
|
rate_limit_per_user?: number;
|
|
permission_overwrites?: Array<{
|
|
id: bigint;
|
|
type: number;
|
|
allow?: bigint;
|
|
deny?: bigint;
|
|
}> | null;
|
|
rtc_region?: string | null;
|
|
icon?: string | null;
|
|
owner_id?: bigint | null;
|
|
nicks?: Record<string, string | null> | null;
|
|
}
|
|
|
|
export class ChannelOperationsService {
|
|
constructor(
|
|
private channelRepository: IChannelRepositoryAggregate,
|
|
private userRepository: IUserRepository,
|
|
private gatewayService: IGatewayService,
|
|
private channelAuthService: ChannelAuthService,
|
|
private channelUtilsService: ChannelUtilsService,
|
|
private voiceRoomStore: IVoiceRoomStore,
|
|
private liveKitService: ILiveKitService,
|
|
private voiceAvailabilityService: VoiceAvailabilityService | null,
|
|
private readonly guildAuditLogService: GuildAuditLogService,
|
|
private inviteRepository: IInviteRepository,
|
|
private webhookRepository: IWebhookRepository,
|
|
private guildRepository: IGuildRepositoryAggregate,
|
|
private limitConfigService: LimitConfigService,
|
|
private rateLimitService: IRateLimitService,
|
|
) {}
|
|
|
|
async getChannel({
|
|
userId,
|
|
channelId,
|
|
skipNsfwValidation,
|
|
}: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
skipNsfwValidation?: boolean;
|
|
}): Promise<Channel> {
|
|
const {channel} = await this.channelAuthService.getChannelAuthenticated({
|
|
userId,
|
|
channelId,
|
|
skipNsfwValidation,
|
|
});
|
|
return channel;
|
|
}
|
|
|
|
async getPublicChannelData(channelId: ChannelID) {
|
|
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
|
if (!channel) throw new UnknownChannelError();
|
|
return channel;
|
|
}
|
|
|
|
async getChannelMemberCount(channelId: ChannelID): Promise<number> {
|
|
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
|
if (!channel) throw new UnknownChannelError();
|
|
return channel.recipientIds.size;
|
|
}
|
|
|
|
async getChannelSystem(channelId: ChannelID): Promise<Channel | null> {
|
|
return await this.channelRepository.channelData.findUnique(channelId);
|
|
}
|
|
|
|
async editChannel({
|
|
userId,
|
|
channelId,
|
|
data,
|
|
clientFeatures,
|
|
requestCache,
|
|
auditLogReason,
|
|
}: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
data: ChannelUpdateData;
|
|
clientFeatures: ReadonlySet<string>;
|
|
requestCache: RequestCache;
|
|
auditLogReason: string | null;
|
|
}): Promise<Channel> {
|
|
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
|
userId,
|
|
channelId,
|
|
skipNsfwValidation: true,
|
|
});
|
|
if (channel.type === ChannelTypes.GROUP_DM) {
|
|
throw new InvalidChannelTypeError();
|
|
}
|
|
if (!guild) throw new MissingPermissionsError();
|
|
await checkPermission(Permissions.MANAGE_CHANNELS);
|
|
const guildIdValue = createGuildID(BigInt(guild.id));
|
|
contentModerationService.scanText(data.name ?? null, {
|
|
userId,
|
|
guildId: guildIdValue,
|
|
channelId,
|
|
messageId: null,
|
|
surface: 'profile_field',
|
|
});
|
|
contentModerationService.scanText(data.topic ?? null, {
|
|
userId,
|
|
guildId: guildIdValue,
|
|
channelId,
|
|
messageId: null,
|
|
surface: 'profile_field',
|
|
});
|
|
let channelName = data.name ?? channel.name;
|
|
if (data.name !== undefined && channel.type === ChannelTypes.GUILD_TEXT) {
|
|
const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false;
|
|
if (!hasFlexibleNamesEnabled) {
|
|
channelName = ChannelNameType.parse(data.name);
|
|
}
|
|
}
|
|
if (data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
|
|
await checkPermission(Permissions.UPDATE_RTC_REGION);
|
|
if (data.rtc_region !== null) {
|
|
if (this.voiceAvailabilityService !== null) {
|
|
const guildId = createGuildID(BigInt(guild.id));
|
|
const availableRegions = this.voiceAvailabilityService.getAvailableRegions({
|
|
requestingUserId: userId,
|
|
guildId,
|
|
guildFeatures: new Set(guild.features ?? []),
|
|
});
|
|
const regionAllowed = availableRegions.some((region) => region.id === data.rtc_region && region.isAccessible);
|
|
if (!regionAllowed) {
|
|
throw InputValidationError.fromCode('rtc_region', ValidationErrorCodes.INVALID_OR_RESTRICTED_RTC_REGION, {
|
|
region: data.rtc_region ?? 'unknown',
|
|
});
|
|
}
|
|
} else {
|
|
const availableRegions = this.liveKitService.getRegionMetadata().map((region) => region.id);
|
|
if (availableRegions.length > 0 && !availableRegions.includes(data.rtc_region)) {
|
|
throw InputValidationError.fromCode('rtc_region', ValidationErrorCodes.INVALID_RTC_REGION, {
|
|
region: data.rtc_region ?? 'unknown',
|
|
availableRegions: availableRegions.join(', '),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const previousPermissionOverwrites = channel.permissionOverwrites;
|
|
let permissionOverwrites = channel.permissionOverwrites;
|
|
if (data.permission_overwrites !== undefined) {
|
|
const guildId = createGuildID(BigInt(guild.id));
|
|
await checkPermission(Permissions.MANAGE_ROLES);
|
|
const isOwner = guild.owner_id === userId.toString();
|
|
const channelPermissions = await this.gatewayService.getUserPermissions({
|
|
guildId,
|
|
userId,
|
|
channelId: channel.id,
|
|
});
|
|
permissionOverwrites = new Map();
|
|
for (const overwrite of data.permission_overwrites ?? []) {
|
|
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
|
const existing = previousPermissionOverwrites?.get(targetId);
|
|
const protectedBits = applyProtectedOverwriteBits(
|
|
{
|
|
allow: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS,
|
|
deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS,
|
|
},
|
|
{
|
|
allow: existing?.allow ?? 0n,
|
|
deny: existing?.deny ?? 0n,
|
|
},
|
|
clientFeatures,
|
|
);
|
|
permissionOverwrites.set(
|
|
targetId,
|
|
new ChannelPermissionOverwrite({
|
|
type: overwrite.type,
|
|
allow_: protectedBits.allow,
|
|
deny_: protectedBits.deny,
|
|
}),
|
|
);
|
|
}
|
|
if (!isOwner) {
|
|
const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]);
|
|
for (const targetId of targetIds) {
|
|
const grantedBits = overwriteGrantedBits(
|
|
previousPermissionOverwrites?.get(targetId),
|
|
permissionOverwrites.get(targetId),
|
|
);
|
|
if ((grantedBits & ~channelPermissions) !== 0n) {
|
|
throw new MissingPermissionsError();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const requestedParentId =
|
|
data.parent_id !== undefined ? (data.parent_id ? createChannelID(data.parent_id) : null) : channel.parentId;
|
|
if (data.parent_id !== undefined) {
|
|
await this.validateParentCategory({
|
|
guildId: guildIdValue,
|
|
channel,
|
|
parentId: requestedParentId,
|
|
validateCapacity: requestedParentId !== null && requestedParentId !== (channel.parentId ?? null),
|
|
});
|
|
}
|
|
let nextBitrate = channel.bitrate;
|
|
if (data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
|
|
nextBitrate = data.bitrate === null ? null : clampVoiceChannelBitrate(data.bitrate, guild.features ?? []);
|
|
}
|
|
const updatedChannelData = {
|
|
...channel.toRow(),
|
|
name: channelName,
|
|
topic: data.topic !== undefined ? data.topic : channel.topic,
|
|
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
|
|
parent_id: requestedParentId,
|
|
bitrate: nextBitrate,
|
|
user_limit:
|
|
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
|
? data.user_limit
|
|
: channel.userLimit,
|
|
voice_connection_limit:
|
|
data.voice_connection_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
|
? data.voice_connection_limit
|
|
: channel.voiceConnectionLimit,
|
|
rate_limit_per_user:
|
|
data.rate_limit_per_user !== undefined && GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)
|
|
? data.rate_limit_per_user
|
|
: channel.rateLimitPerUser,
|
|
nsfw: resolveNsfwOverrideWrite(channel, data),
|
|
content_warning_level: resolveContentWarningLevelWrite(channel, data),
|
|
content_warning_text: resolveContentWarningTextWrite(channel, data),
|
|
rtc_region:
|
|
data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
|
? data.rtc_region
|
|
: channel.rtcRegion,
|
|
permission_overwrites: new Map(
|
|
Array.from(permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
|
targetId,
|
|
overwrite.toPermissionOverwrite(),
|
|
]),
|
|
),
|
|
};
|
|
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
|
|
if (
|
|
data.rate_limit_per_user !== undefined &&
|
|
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
|
|
data.rate_limit_per_user !== channel.rateLimitPerUser
|
|
) {
|
|
try {
|
|
await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode:${channelId}:`);
|
|
} catch (error) {
|
|
Logger.error(
|
|
{error, channelId: channelId.toString()},
|
|
'Failed to clear slowmode rate-limit state on channel edit',
|
|
);
|
|
}
|
|
}
|
|
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
|
|
if (channel.type === ChannelTypes.GUILD_CATEGORY && data.permission_overwrites !== undefined && guild) {
|
|
await this.propagatePermissionsToSyncedChildren({
|
|
categoryChannel: updatedChannel,
|
|
previousPermissionOverwrites,
|
|
guildId: createGuildID(BigInt(guild.id)),
|
|
requestCache,
|
|
});
|
|
}
|
|
if (
|
|
data.rtc_region !== undefined &&
|
|
channel.type === ChannelTypes.GUILD_VOICE &&
|
|
data.rtc_region !== channel.rtcRegion &&
|
|
this.voiceRoomStore
|
|
) {
|
|
await this.handleRtcRegionSwitch({
|
|
guildId: createGuildID(BigInt(guild.id)),
|
|
channelId,
|
|
});
|
|
}
|
|
const beforeSnapshot = serializeChannelForAudit(channel);
|
|
const afterSnapshot = serializeChannelForAudit(updatedChannel);
|
|
const changes = this.guildAuditLogService.computeChanges(beforeSnapshot, afterSnapshot);
|
|
if (changes.length > 0) {
|
|
const builder = this.guildAuditLogService
|
|
.createBuilder(guildIdValue, userId)
|
|
.withAction(AuditLogActionType.CHANNEL_UPDATE, channel.id.toString())
|
|
.withReason(auditLogReason)
|
|
.withMetadata({
|
|
type: updatedChannel.type.toString(),
|
|
})
|
|
.withChanges(changes);
|
|
try {
|
|
await builder.commit();
|
|
} catch (error) {
|
|
Logger.error(
|
|
{
|
|
error,
|
|
guildId: guildIdValue.toString(),
|
|
userId: userId.toString(),
|
|
action: AuditLogActionType.CHANNEL_UPDATE,
|
|
targetId: channel.id.toString(),
|
|
},
|
|
'Failed to record guild audit log',
|
|
);
|
|
}
|
|
}
|
|
if (data.permission_overwrites !== undefined) {
|
|
await this.guildAuditLogService.recordPermissionOverwriteDiff({
|
|
guildId: guildIdValue,
|
|
userId,
|
|
channelId: updatedChannel.id,
|
|
previous: previousPermissionOverwrites,
|
|
next: updatedChannel.permissionOverwrites,
|
|
reason: auditLogReason,
|
|
});
|
|
}
|
|
return updatedChannel;
|
|
}
|
|
|
|
async deleteChannel({
|
|
userId,
|
|
channelId,
|
|
requestCache,
|
|
auditLogReason,
|
|
}: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
requestCache: RequestCache;
|
|
auditLogReason: string | null;
|
|
}): Promise<void> {
|
|
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
|
userId,
|
|
channelId,
|
|
skipNsfwValidation: true,
|
|
});
|
|
if (this.channelAuthService.isPersonalNotesChannel({userId, channelId})) {
|
|
throw new CannotExecuteOnDmError();
|
|
}
|
|
if (guild) {
|
|
await checkPermission(Permissions.MANAGE_CHANNELS);
|
|
const guildId = createGuildID(BigInt(guild.id));
|
|
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
|
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
|
|
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === channelId);
|
|
for (const childChannel of childChannels) {
|
|
const updatedChild = await this.channelRepository.channelData.upsert({
|
|
...childChannel.toRow(),
|
|
parent_id: null,
|
|
});
|
|
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
|
|
}
|
|
}
|
|
const [channelInvites, channelWebhooks] = await Promise.all([
|
|
this.inviteRepository.listChannelInvites(channelId),
|
|
this.webhookRepository.listByChannel(channelId),
|
|
]);
|
|
await Promise.all([
|
|
...channelInvites.map((invite) => this.inviteRepository.delete(invite.code)),
|
|
...channelWebhooks.map((webhook) => this.webhookRepository.delete(webhook.id)),
|
|
]);
|
|
await this.channelUtilsService.purgeChannelAttachments(channel);
|
|
await this.channelRepository.messages.deleteAllChannelMessages(channelId);
|
|
await deleteChannelMessageSearchDocuments(channelId, {context: {source: 'channel_delete'}});
|
|
await this.channelUtilsService.dispatchChannelDelete({channel, requestCache});
|
|
const guildIdValue = createGuildID(BigInt(guild.id));
|
|
const changes = this.guildAuditLogService.computeChanges(ChannelHelpers.serializeChannelForAudit(channel), null);
|
|
const builder = this.guildAuditLogService
|
|
.createBuilder(guildIdValue, userId)
|
|
.withAction(AuditLogActionType.CHANNEL_DELETE, channel.id.toString())
|
|
.withReason(auditLogReason)
|
|
.withMetadata({
|
|
type: channel.type.toString(),
|
|
})
|
|
.withChanges(changes);
|
|
try {
|
|
await builder.commit();
|
|
} catch (error) {
|
|
Logger.error(
|
|
{
|
|
error,
|
|
guildId: guildIdValue.toString(),
|
|
userId: userId.toString(),
|
|
action: AuditLogActionType.CHANNEL_DELETE,
|
|
targetId: channel.id.toString(),
|
|
},
|
|
'Failed to record guild audit log',
|
|
);
|
|
}
|
|
await this.channelRepository.channelData.delete(channelId, guildId);
|
|
const guildModel = await this.guildRepository.findUnique(guildId);
|
|
if (guildModel) {
|
|
const guildRow = guildModel.toRow();
|
|
const patch: Partial<typeof guildRow> = {};
|
|
if (guildRow.system_channel_id === channelId) patch.system_channel_id = null;
|
|
if (guildRow.rules_channel_id === channelId) patch.rules_channel_id = null;
|
|
if (guildRow.afk_channel_id === channelId) patch.afk_channel_id = null;
|
|
if (Object.keys(patch).length > 0) {
|
|
const updatedGuild = await this.guildRepository.upsertPartial(guildId, patch, guildRow);
|
|
await this.gatewayService.dispatchGuild({
|
|
guildId,
|
|
event: 'GUILD_UPDATE',
|
|
data: mapGuildToGuildResponse(updatedGuild),
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
await this.userRepository.closeDmForUser(userId, channelId);
|
|
await this.channelUtilsService.dispatchDmChannelDelete({channel, userId, requestCache});
|
|
}
|
|
}
|
|
|
|
async getAvailableRtcRegions({
|
|
userId,
|
|
channelId,
|
|
}: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
}): Promise<Array<VoiceRegionAvailability>> {
|
|
if (this.voiceAvailabilityService === null) {
|
|
return [];
|
|
}
|
|
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({
|
|
userId,
|
|
channelId,
|
|
skipNsfwValidation: true,
|
|
});
|
|
if (channel.type !== ChannelTypes.GUILD_VOICE) {
|
|
throw new InvalidChannelTypeError();
|
|
}
|
|
if (!guild) {
|
|
return [];
|
|
}
|
|
const guildId = createGuildID(BigInt(guild.id));
|
|
const regions = this.voiceAvailabilityService.getAvailableRegions({
|
|
requestingUserId: userId,
|
|
guildId,
|
|
guildFeatures: new Set(guild.features ?? []),
|
|
});
|
|
const accessibleRegions = regions.filter((region) => region.isAccessible);
|
|
return accessibleRegions.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
private async propagatePermissionsToSyncedChildren({
|
|
categoryChannel,
|
|
previousPermissionOverwrites,
|
|
guildId,
|
|
requestCache,
|
|
}: {
|
|
categoryChannel: Channel;
|
|
previousPermissionOverwrites: Map<RoleID | UserID, ChannelPermissionOverwrite>;
|
|
guildId: GuildID;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
|
|
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === categoryChannel.id);
|
|
const syncedChannels: Array<Channel> = [];
|
|
for (const child of childChannels) {
|
|
if (this.arePermissionsEqual(child.permissionOverwrites, previousPermissionOverwrites)) {
|
|
syncedChannels.push(child);
|
|
}
|
|
}
|
|
if (syncedChannels.length > 0) {
|
|
await Promise.all(
|
|
syncedChannels.map(async (child) => {
|
|
const updatedChild = await this.channelRepository.channelData.upsert({
|
|
...child.toRow(),
|
|
permission_overwrites: new Map(
|
|
Array.from(categoryChannel.permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
|
targetId,
|
|
overwrite.toPermissionOverwrite(),
|
|
]),
|
|
),
|
|
});
|
|
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private arePermissionsEqual(
|
|
perms1: Map<RoleID | UserID, ChannelPermissionOverwrite>,
|
|
perms2: Map<RoleID | UserID, ChannelPermissionOverwrite>,
|
|
): boolean {
|
|
if (perms1.size !== perms2.size) return false;
|
|
for (const [targetId, overwrite1] of perms1.entries()) {
|
|
const overwrite2 = perms2.get(targetId);
|
|
if (!overwrite2) return false;
|
|
if (
|
|
overwrite1.type !== overwrite2.type ||
|
|
overwrite1.allow !== overwrite2.allow ||
|
|
overwrite1.deny !== overwrite2.deny
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async handleRtcRegionSwitch({guildId, channelId}: {guildId: GuildID; channelId: ChannelID}): Promise<void> {
|
|
if (!this.voiceRoomStore) {
|
|
Logger.warn('[ChannelOperationsService] VoiceRoomStore not available, skipping region switch');
|
|
return;
|
|
}
|
|
await this.voiceRoomStore.deleteRoomServer(guildId, channelId);
|
|
await this.gatewayService.switchVoiceRegion({guildId, channelId});
|
|
}
|
|
|
|
private async ensureCategoryHasCapacity(params: {guildId: GuildID; categoryId: ChannelID}): Promise<void> {
|
|
const count = await this.gatewayService.getCategoryChannelCount(params);
|
|
let maxChannels = MAX_CHANNELS_PER_CATEGORY;
|
|
const guild = await this.guildRepository.findUnique(params.guildId);
|
|
const ctx = createLimitMatchContext({user: null, guildFeatures: guild?.features ?? null});
|
|
const resolved = resolveLimit(this.limitConfigService.getConfigSnapshot(), ctx, 'max_channels_per_category', {
|
|
evaluationContext: 'guild',
|
|
});
|
|
if (Number.isFinite(resolved) && resolved >= 0) {
|
|
maxChannels = Math.floor(resolved);
|
|
}
|
|
if (count >= maxChannels) {
|
|
throw new MaxCategoryChannelsError(maxChannels);
|
|
}
|
|
}
|
|
|
|
private async validateParentCategory(params: {
|
|
guildId: GuildID;
|
|
channel: Channel;
|
|
parentId: ChannelID | null;
|
|
validateCapacity: boolean;
|
|
}): Promise<void> {
|
|
if (params.parentId === null) {
|
|
return;
|
|
}
|
|
if (params.channel.type === ChannelTypes.GUILD_CATEGORY) {
|
|
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENTS);
|
|
}
|
|
const guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId);
|
|
const parentChannel = guildChannels.find((channel) => channel.id === params.parentId);
|
|
if (!parentChannel) {
|
|
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
|
|
}
|
|
if (parentChannel.type !== ChannelTypes.GUILD_CATEGORY) {
|
|
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.PARENT_MUST_BE_CATEGORY);
|
|
}
|
|
if (params.validateCapacity) {
|
|
await this.ensureCategoryHasCapacity({guildId: params.guildId, categoryId: params.parentId});
|
|
}
|
|
}
|
|
|
|
async setChannelPermissionOverwrite(params: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
overwriteId: bigint;
|
|
overwrite: {
|
|
type: number;
|
|
allow_: bigint;
|
|
deny_: bigint;
|
|
};
|
|
clientFeatures: ReadonlySet<string>;
|
|
requestCache: RequestCache;
|
|
auditLogReason: string | null;
|
|
}): Promise<void> {
|
|
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
|
if (!channel?.guildId) throw new UnknownChannelError();
|
|
const canManageRoles = await this.gatewayService.checkPermission({
|
|
guildId: channel.guildId,
|
|
userId: params.userId,
|
|
channelId: channel.id,
|
|
permission: Permissions.MANAGE_ROLES,
|
|
});
|
|
if (!canManageRoles) throw new MissingPermissionsError();
|
|
const userPermissions = await this.gatewayService.getUserPermissions({
|
|
guildId: channel.guildId,
|
|
userId: params.userId,
|
|
channelId: channel.id,
|
|
});
|
|
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
|
|
const existing = channel.permissionOverwrites?.get(targetId);
|
|
const protectedBits = applyProtectedOverwriteBits(
|
|
{
|
|
allow: params.overwrite.allow_ & ALL_PERMISSIONS,
|
|
deny: params.overwrite.deny_ & ALL_PERMISSIONS,
|
|
},
|
|
{
|
|
allow: existing?.allow ?? 0n,
|
|
deny: existing?.deny ?? 0n,
|
|
},
|
|
params.clientFeatures,
|
|
);
|
|
const sanitizedAllow = protectedBits.allow;
|
|
const sanitizedDeny = protectedBits.deny;
|
|
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
|
const grantedBits = overwriteGrantedBits(existing, {allow: sanitizedAllow, deny: sanitizedDeny});
|
|
if (!hasAdministrator && (grantedBits & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
|
const previousPermissionOverwrites = channel.permissionOverwrites;
|
|
const nextOverwrite = new ChannelPermissionOverwrite({
|
|
type: params.overwrite.type,
|
|
allow_: sanitizedAllow,
|
|
deny_: sanitizedDeny,
|
|
});
|
|
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
|
overwrites.set(targetId, nextOverwrite);
|
|
const updated = await this.channelRepository.channelData.upsert({
|
|
...channel.toRow(),
|
|
permission_overwrites: new Map(
|
|
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
|
|
),
|
|
});
|
|
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
|
|
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
|
await this.propagatePermissionsToSyncedChildren({
|
|
categoryChannel: updated,
|
|
previousPermissionOverwrites,
|
|
guildId: channel.guildId,
|
|
requestCache: params.requestCache,
|
|
});
|
|
}
|
|
await this.guildAuditLogService.recordPermissionOverwriteDiff({
|
|
guildId: channel.guildId,
|
|
userId: params.userId,
|
|
channelId: channel.id,
|
|
previous: existing ? new Map([[targetId, existing]]) : null,
|
|
next: new Map([[targetId, nextOverwrite]]),
|
|
reason: params.auditLogReason,
|
|
});
|
|
}
|
|
|
|
async deleteChannelPermissionOverwrite(params: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
overwriteId: bigint;
|
|
requestCache: RequestCache;
|
|
auditLogReason: string | null;
|
|
}): Promise<void> {
|
|
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
|
if (!channel?.guildId) throw new UnknownChannelError();
|
|
const canManageRoles = await this.gatewayService.checkPermission({
|
|
guildId: channel.guildId,
|
|
userId: params.userId,
|
|
channelId: channel.id,
|
|
permission: Permissions.MANAGE_ROLES,
|
|
});
|
|
if (!canManageRoles) throw new MissingPermissionsError();
|
|
const previousPermissionOverwrites = channel.permissionOverwrites;
|
|
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
|
const removedRole = overwrites.get(createRoleID(params.overwriteId));
|
|
const removedUser = overwrites.get(createUserID(params.overwriteId));
|
|
const removed = removedRole ?? removedUser;
|
|
if (removed) {
|
|
const userPermissions = await this.gatewayService.getUserPermissions({
|
|
guildId: channel.guildId,
|
|
userId: params.userId,
|
|
channelId: channel.id,
|
|
});
|
|
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
|
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
|
}
|
|
overwrites.delete(createRoleID(params.overwriteId));
|
|
overwrites.delete(createUserID(params.overwriteId));
|
|
const updated = await this.channelRepository.channelData.upsert({
|
|
...channel.toRow(),
|
|
permission_overwrites: new Map(
|
|
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
|
|
),
|
|
});
|
|
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
|
|
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
|
await this.propagatePermissionsToSyncedChildren({
|
|
categoryChannel: updated,
|
|
previousPermissionOverwrites,
|
|
guildId: channel.guildId,
|
|
requestCache: params.requestCache,
|
|
});
|
|
}
|
|
if (removed) {
|
|
const removedTargetId = removed.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
|
|
await this.guildAuditLogService.recordPermissionOverwriteDiff({
|
|
guildId: channel.guildId,
|
|
userId: params.userId,
|
|
channelId: channel.id,
|
|
previous: new Map([[removedTargetId, removed]]),
|
|
next: null,
|
|
reason: params.auditLogReason,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function isWritableGuildChannel(type: number): boolean {
|
|
return (
|
|
type === ChannelTypes.GUILD_TEXT ||
|
|
type === ChannelTypes.GUILD_VOICE ||
|
|
type === ChannelTypes.GUILD_LINK ||
|
|
type === ChannelTypes.GUILD_CATEGORY
|
|
);
|
|
}
|
|
|
|
function resolveNsfwOverrideWrite(channel: Channel, data: ChannelUpdateData): boolean | null {
|
|
if (!isWritableGuildChannel(channel.type)) {
|
|
return channel.nsfwOverride;
|
|
}
|
|
if (data.nsfw_override !== undefined) {
|
|
return data.nsfw_override;
|
|
}
|
|
if (data.nsfw !== undefined) {
|
|
return data.nsfw === true ? true : null;
|
|
}
|
|
return channel.nsfwOverride;
|
|
}
|
|
|
|
function resolveContentWarningLevelWrite(channel: Channel, data: ChannelUpdateData): number {
|
|
if (!isWritableGuildChannel(channel.type)) {
|
|
return channel.contentWarningLevel;
|
|
}
|
|
if (data.content_warning_level === undefined) {
|
|
return channel.contentWarningLevel;
|
|
}
|
|
return data.content_warning_level === ContentWarningLevel.CONTENT_WARNING
|
|
? ContentWarningLevel.CONTENT_WARNING
|
|
: ContentWarningLevel.INHERIT;
|
|
}
|
|
|
|
function resolveContentWarningTextWrite(channel: Channel, data: ChannelUpdateData): string | null {
|
|
if (!isWritableGuildChannel(channel.type)) {
|
|
return channel.contentWarningText;
|
|
}
|
|
if (data.content_warning_text === undefined) {
|
|
return channel.contentWarningText;
|
|
}
|
|
const trimmed = data.content_warning_text == null ? null : data.content_warning_text.trim();
|
|
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
}
|