mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(threads): thread and forum API, admin, schemas and docs (#3251)
This commit is contained in:
1 parent
5f4295e399
commit
3f4160b138
495 files changed
+39232
-1215
No files matched your search
@@ -10,14 +10,38 @@ import {
|
||||
} from '@app/api/channel/services/ChannelFollowers';
|
||||
import type {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService';
|
||||
import type {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService';
|
||||
import {dispatchThreadEvents} from '@app/api/channel/services/thread/ThreadDispatch';
|
||||
import {
|
||||
loadConvertibleParentThreads,
|
||||
PARENT_CONVERSION_LOCK_TTL_SECONDS,
|
||||
retypedThreadEvents,
|
||||
retypeParentThreads,
|
||||
} from '@app/api/channel/services/thread/ThreadParentConversion';
|
||||
import {
|
||||
buildThreadParentPatch,
|
||||
loadThreadParentConfig,
|
||||
serializeThreadParentForAudit,
|
||||
type ThreadParentSettingsInput,
|
||||
} from '@app/api/channel/services/thread/ThreadParentSettings';
|
||||
import {enqueueDeleteChannelThreads} from '@app/api/channel/threads/ThreadJobs';
|
||||
import {
|
||||
everEnabled,
|
||||
guildActive,
|
||||
isTainted,
|
||||
THREAD_FEATURE_CHANNEL_TYPES,
|
||||
type ThreadViewer,
|
||||
viewerActive,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
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 {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
|
||||
import {hasThreadPermissionBits, resolveProtectedBitActor} from '@app/api/guild/services/ThreadPermissionBits';
|
||||
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 {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
|
||||
import type {IInviteRepository} from '@app/api/invite/IInviteRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
@@ -26,17 +50,17 @@ 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 type {ThreadState} from '@app/api/models/ThreadState';
|
||||
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 {applyProtectedOverwriteBits, permissionWriteMask, protectedThreadBits} 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,
|
||||
ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES,
|
||||
ChannelTypes,
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
||||
@@ -45,6 +69,8 @@ import {
|
||||
} 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 {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
|
||||
import {withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {ChannelHasFollowedChannelsError} from '@fluxer/errors/src/domains/channel/ChannelHasFollowedChannelsError';
|
||||
import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError';
|
||||
@@ -84,6 +110,13 @@ export interface ChannelUpdateData {
|
||||
nicks?: Record<string, string | null> | null;
|
||||
}
|
||||
|
||||
function assertOverwriteTarget(channel: Channel, viewer: ThreadViewer | undefined): void {
|
||||
if (!THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) return;
|
||||
const active = viewer !== undefined && channel.guildId !== null && viewerActive(viewer, channel.guildId);
|
||||
if (!active) throw new UnknownChannelError();
|
||||
if (channel.isThread()) throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
export class ChannelOperationsService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
@@ -101,20 +134,24 @@ export class ChannelOperationsService {
|
||||
private limitConfigService: LimitConfigService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private cacheService: ICacheService,
|
||||
private snowflakeService: ISnowflakeService,
|
||||
) {}
|
||||
|
||||
async getChannel({
|
||||
userId,
|
||||
viewer,
|
||||
channelId,
|
||||
skipNsfwValidation,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
skipNsfwValidation?: boolean;
|
||||
}): Promise<Channel> {
|
||||
const {channel} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
viewer,
|
||||
skipNsfwValidation,
|
||||
});
|
||||
return channel;
|
||||
@@ -138,24 +175,29 @@ export class ChannelOperationsService {
|
||||
|
||||
async editChannel({
|
||||
userId,
|
||||
viewer,
|
||||
channelId,
|
||||
data,
|
||||
clientFeatures,
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
threadParent,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
data: ChannelUpdateData;
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
threadParent?: ThreadParentSettingsInput | null;
|
||||
}): Promise<Channel> {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
viewer,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
@@ -165,6 +207,18 @@ export class ChannelOperationsService {
|
||||
await checkPermission(Permissions.MANAGE_CHANNELS);
|
||||
const nextType = resolveNextChannelType(channel, typeConversion ?? null);
|
||||
const guildIdValue = createGuildID(BigInt(guild.id));
|
||||
const parentConfig = await loadThreadParentConfig(this.channelRepository.threads, channel);
|
||||
const parentPatch =
|
||||
threadParent && guildActive(guildIdValue)
|
||||
? await buildThreadParentPatch({
|
||||
channelType: channel.type,
|
||||
guildId: guildIdValue,
|
||||
input: threadParent,
|
||||
current: parentConfig,
|
||||
guildRepository: this.guildRepository,
|
||||
generateId: () => this.snowflakeService.generate(),
|
||||
})
|
||||
: null;
|
||||
contentModerationService.scanText(data.name ?? null, {
|
||||
userId,
|
||||
guildId: guildIdValue,
|
||||
@@ -180,7 +234,7 @@ export class ChannelOperationsService {
|
||||
surface: 'profile_field',
|
||||
});
|
||||
let channelName = data.name ?? channel.name;
|
||||
if (data.name !== undefined && isTextNamedChannelType(channel.type)) {
|
||||
if (data.name !== undefined && (isTextNamedChannelType(channel.type) || channel.isThreadOnly())) {
|
||||
const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false;
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(data.name);
|
||||
@@ -219,25 +273,34 @@ export class ChannelOperationsService {
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
const isOwner = guild.owner_id === userId.toString();
|
||||
const channelPermissions = await this.gatewayService.getUserPermissions({
|
||||
const gatewayPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId,
|
||||
userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
const actor = await resolveProtectedBitActor({
|
||||
guildId,
|
||||
userId,
|
||||
clientFeatures,
|
||||
viewer,
|
||||
isBot: async () => viewer.kind === 'user' && viewer.bot,
|
||||
});
|
||||
const writeMask = permissionWriteMask(actor);
|
||||
const channelPermissions = actor.threadBits ? withImplicitThreadBits(gatewayPermissions) : gatewayPermissions;
|
||||
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: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & writeMask,
|
||||
deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & writeMask,
|
||||
},
|
||||
{
|
||||
allow: existing?.allow ?? 0n,
|
||||
deny: existing?.deny ?? 0n,
|
||||
},
|
||||
clientFeatures,
|
||||
actor,
|
||||
);
|
||||
permissionOverwrites.set(
|
||||
targetId,
|
||||
@@ -248,6 +311,19 @@ export class ChannelOperationsService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const keptBits = protectedThreadBits(actor);
|
||||
if (keptBits !== 0n) {
|
||||
for (const [targetId, previous] of previousPermissionOverwrites ?? []) {
|
||||
if (permissionOverwrites.has(targetId)) continue;
|
||||
const allow = previous.allow & keptBits;
|
||||
const deny = previous.deny & keptBits;
|
||||
if (allow === 0n && deny === 0n) continue;
|
||||
permissionOverwrites.set(
|
||||
targetId,
|
||||
new ChannelPermissionOverwrite({type: previous.type, allow_: allow, deny_: deny}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!isOwner) {
|
||||
const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]);
|
||||
for (const targetId of targetIds) {
|
||||
@@ -255,7 +331,7 @@ export class ChannelOperationsService {
|
||||
previousPermissionOverwrites?.get(targetId),
|
||||
permissionOverwrites.get(targetId),
|
||||
);
|
||||
if ((grantedBits & ~channelPermissions) !== 0n) {
|
||||
if ((grantedBits & ~keptBits & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
@@ -292,7 +368,7 @@ export class ChannelOperationsService {
|
||||
? 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 !== undefined && acceptsRateLimit(channel.type)
|
||||
? data.rate_limit_per_user
|
||||
: channel.rateLimitPerUser,
|
||||
nsfw: resolveNsfwOverrideWrite(channel, data),
|
||||
@@ -309,26 +385,54 @@ export class ChannelOperationsService {
|
||||
]),
|
||||
),
|
||||
};
|
||||
let retypedThreads: Array<ThreadState> = [];
|
||||
const toAnnouncement =
|
||||
nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT;
|
||||
const updatedChannel =
|
||||
nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT
|
||||
? await withChannelFollowLock(this.cacheService, channelId, async () => {
|
||||
const webhooks = await this.webhookRepository.listByChannel(channelId);
|
||||
if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) {
|
||||
throw new ChannelHasFollowedChannelsError();
|
||||
}
|
||||
return await this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
})
|
||||
toAnnouncement || (nextType !== channel.type && everEnabled())
|
||||
? await withChannelFollowLock(
|
||||
this.cacheService,
|
||||
channelId,
|
||||
async () => {
|
||||
if (toAnnouncement) {
|
||||
const webhooks = await this.webhookRepository.listByChannel(channelId);
|
||||
if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) {
|
||||
throw new ChannelHasFollowedChannelsError();
|
||||
}
|
||||
}
|
||||
const threads = await loadConvertibleParentThreads(
|
||||
this.channelRepository,
|
||||
guildIdValue,
|
||||
channel,
|
||||
nextType,
|
||||
);
|
||||
if (threads.length === 0) return this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
const {parent, active} = await retypeParentThreads(this.channelRepository, threads, nextType, () =>
|
||||
this.channelRepository.channelData.upsert(updatedChannelData),
|
||||
);
|
||||
retypedThreads = active;
|
||||
return parent;
|
||||
},
|
||||
everEnabled() ? PARENT_CONVERSION_LOCK_TTL_SECONDS : undefined,
|
||||
)
|
||||
: await this.channelRepository.channelData.upsert(updatedChannelData);
|
||||
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT && nextType !== ChannelTypes.GUILD_ANNOUNCEMENT) {
|
||||
await enqueueChannelFollowerRemoval({sourceChannelId: channelId, reason: 'converted'});
|
||||
}
|
||||
if (parentPatch) await this.channelRepository.threads.patchParentConfig(guildIdValue, channel.id, parentPatch);
|
||||
const nextParentConfig = parentPatch
|
||||
? await loadThreadParentConfig(this.channelRepository.threads, updatedChannel)
|
||||
: parentConfig;
|
||||
if (
|
||||
data.rate_limit_per_user !== undefined &&
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
|
||||
acceptsRateLimit(channel.type) &&
|
||||
data.rate_limit_per_user !== channel.rateLimitPerUser
|
||||
) {
|
||||
try {
|
||||
await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode:${channelId}:`);
|
||||
if (everEnabled() && (await isTainted(guildIdValue))) {
|
||||
await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode-thread:${channelId}:`);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString()},
|
||||
@@ -337,6 +441,11 @@ export class ChannelOperationsService {
|
||||
}
|
||||
}
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
|
||||
await dispatchThreadEvents(
|
||||
this.gatewayService,
|
||||
guildIdValue,
|
||||
await retypedThreadEvents(this.channelRepository, updatedChannel, retypedThreads),
|
||||
);
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY && data.permission_overwrites !== undefined && guild) {
|
||||
await this.propagatePermissionsToSyncedChildren({
|
||||
categoryChannel: updatedChannel,
|
||||
@@ -356,8 +465,14 @@ export class ChannelOperationsService {
|
||||
channelId,
|
||||
});
|
||||
}
|
||||
const beforeSnapshot = serializeChannelForAudit(channel);
|
||||
const afterSnapshot = serializeChannelForAudit(updatedChannel);
|
||||
const beforeSnapshot = {
|
||||
...serializeChannelForAudit(channel),
|
||||
...serializeThreadParentForAudit(channel.type, parentConfig),
|
||||
};
|
||||
const afterSnapshot = {
|
||||
...serializeChannelForAudit(updatedChannel),
|
||||
...serializeThreadParentForAudit(updatedChannel.type, nextParentConfig),
|
||||
};
|
||||
const changes = this.guildAuditLogService.computeChanges(beforeSnapshot, afterSnapshot);
|
||||
if (changes.length > 0) {
|
||||
const builder = this.guildAuditLogService
|
||||
@@ -398,11 +513,13 @@ export class ChannelOperationsService {
|
||||
|
||||
async deleteChannel({
|
||||
userId,
|
||||
viewer,
|
||||
channelId,
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
@@ -410,6 +527,7 @@ export class ChannelOperationsService {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
viewer,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (this.channelAuthService.isPersonalNotesChannel({userId, channelId})) {
|
||||
@@ -419,13 +537,14 @@ export class ChannelOperationsService {
|
||||
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 guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance');
|
||||
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,
|
||||
});
|
||||
if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) continue;
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
|
||||
}
|
||||
}
|
||||
@@ -470,7 +589,11 @@ export class ChannelOperationsService {
|
||||
'Failed to record guild audit log',
|
||||
);
|
||||
}
|
||||
await this.channelRepository.channelData.delete(channelId, guildId);
|
||||
await this.channelRepository.channelData.delete(channelId, guildId, channel.type);
|
||||
if (channel.isThreadParent() && everEnabled() && (await isTainted(guildId, {fresh: true}))) {
|
||||
await enqueueDeleteChannelThreads(guildId, channelId);
|
||||
await this.channelRepository.threads.deleteParentConfig(guildId, channelId);
|
||||
}
|
||||
const guildModel = await this.guildRepository.findUnique(guildId);
|
||||
if (guildModel) {
|
||||
const guildRow = guildModel.toRow();
|
||||
@@ -495,9 +618,11 @@ export class ChannelOperationsService {
|
||||
|
||||
async getAvailableRtcRegions({
|
||||
userId,
|
||||
viewer,
|
||||
channelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
}): Promise<Array<VoiceRegionAvailability>> {
|
||||
if (this.voiceAvailabilityService === null) {
|
||||
@@ -506,6 +631,7 @@ export class ChannelOperationsService {
|
||||
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
viewer,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (channel.type !== ChannelTypes.GUILD_VOICE) {
|
||||
@@ -535,7 +661,7 @@ export class ChannelOperationsService {
|
||||
guildId: GuildID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
|
||||
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance');
|
||||
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === categoryChannel.id);
|
||||
const syncedChannels: Array<Channel> = [];
|
||||
for (const child of childChannels) {
|
||||
@@ -555,6 +681,7 @@ export class ChannelOperationsService {
|
||||
]),
|
||||
),
|
||||
});
|
||||
if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) return;
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
|
||||
}),
|
||||
);
|
||||
@@ -617,7 +744,7 @@ export class ChannelOperationsService {
|
||||
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 guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId, 'enrolled');
|
||||
const parentChannel = guildChannels.find((channel) => channel.id === params.parentId);
|
||||
if (!parentChannel) {
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
|
||||
@@ -661,11 +788,13 @@ export class ChannelOperationsService {
|
||||
deny_: bigint;
|
||||
};
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
viewer?: ThreadViewer;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
assertOverwriteTarget(channel, params.viewer);
|
||||
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: channel.guildId,
|
||||
@@ -674,22 +803,31 @@ export class ChannelOperationsService {
|
||||
});
|
||||
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
|
||||
const existing = channel.permissionOverwrites?.get(targetId);
|
||||
const actor = await resolveProtectedBitActor({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
clientFeatures: params.clientFeatures,
|
||||
viewer: params.viewer,
|
||||
isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false,
|
||||
});
|
||||
const writeMask = permissionWriteMask(actor);
|
||||
const protectedBits = applyProtectedOverwriteBits(
|
||||
{
|
||||
allow: params.overwrite.allow_ & ALL_PERMISSIONS,
|
||||
deny: params.overwrite.deny_ & ALL_PERMISSIONS,
|
||||
allow: params.overwrite.allow_ & writeMask,
|
||||
deny: params.overwrite.deny_ & writeMask,
|
||||
},
|
||||
{
|
||||
allow: existing?.allow ?? 0n,
|
||||
deny: existing?.deny ?? 0n,
|
||||
},
|
||||
params.clientFeatures,
|
||||
actor,
|
||||
);
|
||||
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 effectivePermissions = actor.threadBits ? withImplicitThreadBits(userPermissions) : userPermissions;
|
||||
if (!hasAdministrator && (grantedBits & ~effectivePermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
const nextOverwrite = new ChannelPermissionOverwrite({
|
||||
type: params.overwrite.type,
|
||||
@@ -698,12 +836,15 @@ export class ChannelOperationsService {
|
||||
});
|
||||
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()]),
|
||||
),
|
||||
});
|
||||
const updated = await this.channelRepository.channelData.upsert(
|
||||
{
|
||||
...channel.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
|
||||
),
|
||||
},
|
||||
channel.toRow(),
|
||||
);
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
await this.propagatePermissionsToSyncedChildren({
|
||||
@@ -727,17 +868,32 @@ export class ChannelOperationsService {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
overwriteId: bigint;
|
||||
clientFeatures?: ReadonlySet<string>;
|
||||
viewer?: ThreadViewer;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
assertOverwriteTarget(channel, params.viewer);
|
||||
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
|
||||
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;
|
||||
const kept =
|
||||
removed && (hasThreadPermissionBits(removed.allow) || hasThreadPermissionBits(removed.deny))
|
||||
? protectedThreadBits(
|
||||
await resolveProtectedBitActor({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
clientFeatures: params.clientFeatures ?? new Set(),
|
||||
viewer: params.viewer,
|
||||
isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false,
|
||||
}),
|
||||
)
|
||||
: 0n;
|
||||
if (removed) {
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: channel.guildId,
|
||||
@@ -745,16 +901,30 @@ export class ChannelOperationsService {
|
||||
channelId: channel.id,
|
||||
});
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
if (!hasAdministrator && (removed.deny & ~kept & ~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()]),
|
||||
),
|
||||
});
|
||||
if (removed && kept !== 0n) {
|
||||
const removedTargetId = removed.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
|
||||
overwrites.set(
|
||||
removedTargetId,
|
||||
new ChannelPermissionOverwrite({
|
||||
type: removed.type,
|
||||
allow_: removed.allow & kept,
|
||||
deny_: removed.deny & kept,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const updated = await this.channelRepository.channelData.upsert(
|
||||
{
|
||||
...channel.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
|
||||
),
|
||||
},
|
||||
channel.toRow(),
|
||||
);
|
||||
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
await this.propagatePermissionsToSyncedChildren({
|
||||
@@ -807,10 +977,15 @@ function isWritableGuildChannel(type: number): boolean {
|
||||
type === ChannelTypes.GUILD_ANNOUNCEMENT ||
|
||||
type === ChannelTypes.GUILD_VOICE ||
|
||||
type === ChannelTypes.GUILD_LINK ||
|
||||
type === ChannelTypes.GUILD_CATEGORY
|
||||
type === ChannelTypes.GUILD_CATEGORY ||
|
||||
THREAD_ONLY_CHANNEL_TYPES.has(type)
|
||||
);
|
||||
}
|
||||
|
||||
function acceptsRateLimit(type: number): boolean {
|
||||
return GUILD_TEXT_BASED_CHANNEL_TYPES.has(type) || THREAD_ONLY_CHANNEL_TYPES.has(type);
|
||||
}
|
||||
|
||||
function resolveNsfwOverrideWrite(channel: Channel, data: ChannelUpdateData): boolean | null {
|
||||
if (!isWritableGuildChannel(channel.type)) {
|
||||
return channel.nsfwOverride;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC
|
||||
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/MessageGatewayDispatch';
|
||||
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {withThreadParentFields} from '@app/api/channel/services/thread/ThreadParentSettings';
|
||||
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
@@ -48,12 +49,16 @@ export class ChannelUtilsService {
|
||||
|
||||
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
|
||||
if (channel.guildId) {
|
||||
const channelResponse = await mapChannelToResponse({
|
||||
const channelResponse = await withThreadParentFields(
|
||||
this.channelRepository.threads,
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: null,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
await dispatchChannelEvent({
|
||||
gatewayService: this.gatewayService,
|
||||
channel,
|
||||
|
||||
Reference in new issue
Block a user