fix(api): enforce scoped perms, age gates, audit logs (#2076)

This commit is contained in:
Hampus
2026-08-29 14:55:13 +02:00
committed by GitHub
parent f2ea10f951
commit cf7ec06d85
14 changed files with 228 additions and 49 deletions
@@ -174,18 +174,19 @@ export abstract class BaseChannelAuthService {
userId,
memberData: guildMemberResult.memberData!,
});
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
};
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult!,
userId,
});
const hasPermission = async (permission: bigint): Promise<boolean> => {
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
if (!allowed) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
await checkPermission(Permissions.VIEW_CHANNEL);
const parentCategory = await this.getParentCategoryContentWarningView({
@@ -17,6 +17,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
import type {IChannelRepository} from '../IChannelRepository';
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
import {MessagePinAuthService} from './interaction/MessagePinAuthService';
import {MessagePinService} from './interaction/MessagePinService';
import {MessageReactionService} from './interaction/MessageReactionService';
import {MessageReadStateService} from './interaction/MessageReadStateService';
@@ -25,6 +26,7 @@ import type {MessagePersistenceService} from './message/MessagePersistenceServic
export class MessageInteractionService {
readonly authService: MessageInteractionAuthService;
private pinAuthService: MessagePinAuthService;
private readStateService: MessageReadStateService;
private pinService: MessagePinService;
private reactionService: MessageReactionService;
@@ -45,6 +47,12 @@ export class MessageInteractionService {
guildRepository,
gatewayService,
);
this.pinAuthService = new MessagePinAuthService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
);
this.readStateService = new MessageReadStateService(gatewayService);
this.pinService = new MessagePinService(
gatewayService,
@@ -85,7 +93,7 @@ export class MessageInteractionService {
items: Array<ChannelPinResponse>;
has_more: boolean;
}> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId});
return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
}
@@ -2,7 +2,6 @@
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import type {MessageID, UserID} from '../../../BrandedTypes';
import {Config} from '../../../Config';
import type {IPurgeQueue} from '../../../infrastructure/BunnyPurgeQueue';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IStorageService} from '../../../infrastructure/IStorageService';
@@ -13,7 +12,7 @@ import type {Message} from '../../../models/Message';
import {mapChannelToResponse} from '../../ChannelMappers';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import {dispatchMessageCreateBroadcast} from '../message/MessageGatewayDispatch';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '../message/MessageHelpers';
import {purgeMessageAttachments} from '../message/MessageHelpers';
export class ChannelUtilsService {
constructor(
@@ -44,20 +43,7 @@ export class ChannelUtilsService {
}
private async purgeMessageAttachments(message: Message): Promise<void> {
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await this.purgeQueue.addUrls(cdnUrls);
}
await purgeMessageAttachments(message, this.storageService, this.purgeQueue);
}
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
export class MessagePinAuthService extends BaseChannelAuthService {
protected readonly options: ChannelAuthOptions = {
errorOnMissingGuild: 'unknown_channel',
validateNsfw: true,
};
}
@@ -116,6 +116,16 @@ export class AttachmentProcessingService {
);
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
if (hasVirusDetected) {
await Promise.all(
results.map(async (result) => {
if (result.sourceLocalPath) {
await fs.promises.unlink(result.sourceLocalPath).catch(() => undefined);
}
}),
);
for (const result of results) {
this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
}
return {attachments: [], hasVirusDetected: true};
}
const copyResults = await mapWithConcurrency(results, ATTACHMENT_PROCESSING_CONCURRENCY, (result) =>
@@ -25,6 +25,7 @@ import type {LimitConfigService} from '../../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
import {Attachment} from '../../../models/Attachment';
import type {Embed} from '../../../models/Embed';
import type {Message} from '../../../models/Message';
import {MessageSnapshot as MessageSnapshotModel} from '../../../models/MessageSnapshot';
import type {User} from '../../../models/User';
@@ -307,22 +308,60 @@ export async function createMessageSnapshotsForForward(
return [new MessageSnapshotModel(snapshotData)];
}
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
const mediaPrefix = `${Config.endpoints.media}/`;
const keys = new Set<string>();
const consider = (url: string | null | undefined): void => {
if (!url || !url.startsWith(mediaPrefix)) {
return;
}
const key = url.slice(mediaPrefix.length);
if (key.startsWith('attachments/')) {
keys.add(key);
}
};
const scanEmbeds = (embeds: Array<Embed>): void => {
for (const embed of embeds) {
consider(embed.image?.url);
consider(embed.thumbnail?.url);
consider(embed.video?.url);
consider(embed.audio?.url);
}
};
scanEmbeds(message.embeds);
for (const snapshot of message.messageSnapshots) {
scanEmbeds(snapshot.embeds);
}
return [...keys];
}
export async function purgeMessageAttachments(
message: Message,
storageService: IStorageService,
purgeQueue: IPurgeQueue,
): Promise<void> {
const cdnKeys = new Set<string>();
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
for (const attachment of collectMessageAttachments(message)) {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
if (cdnKeys.has(cdnKey)) {
continue;
}
cdnKeys.add(cdnKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
}
}
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
if (cdnKeys.has(embedKey)) {
continue;
}
cdnKeys.add(embedKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
}
}
await Promise.all([...cdnKeys].map((cdnKey) => storageService.deleteObject(Config.s3.buckets.cdn, cdnKey)));
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await purgeQueue.addUrls(cdnUrls);
}
@@ -248,9 +248,10 @@ export class MessageValidationService {
}
const isAuthor = message.authorId === userId;
if (!guild) return isAuthor;
if (isAuthor) return true;
const canManageMessages =
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
return isAuthor || canManageMessages;
return canManageMessages;
}
private validateVoiceMessageConstraints(
@@ -308,7 +308,7 @@ export class GuildRoleService {
position?: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -319,7 +319,7 @@ export class GuildRoleService {
throw new ResourceLockedError();
}
try {
await this.updateRolePositionsByList({userId, guildId, updates});
await this.updateRolePositionsByList({userId, guildId, updates, auditLogReason: auditLogReason ?? null});
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
@@ -345,7 +345,7 @@ export class GuildRoleService {
hoistPosition: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
@@ -403,6 +403,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -414,7 +415,7 @@ export class GuildRoleService {
userId: UserID;
guildId: GuildID;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -426,6 +427,7 @@ export class GuildRoleService {
}
try {
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const changedRoles: Array<GuildRole> = [];
for (const role of allRoles) {
if (role.hoistPosition === null) continue;
@@ -441,6 +443,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -559,8 +562,9 @@ export class GuildRoleService {
roleId: RoleID;
position?: number;
}>;
auditLogReason?: string | null;
}): Promise<void> {
const {userId, guildId, updates} = params;
const {userId, guildId, updates, auditLogReason} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
@@ -625,6 +629,7 @@ export class GuildRoleService {
});
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
}
@@ -699,6 +704,30 @@ export class GuildRoleService {
return newRoles;
}
private async recordRolePositionAuditLogs(params: {
guildId: GuildID;
userId: UserID;
roleMap: Map<RoleID, GuildRole>;
changedRoles: Array<GuildRole>;
auditLogReason?: string | null;
}): Promise<void> {
const {guildId, userId, roleMap, changedRoles, auditLogReason} = params;
for (const role of changedRoles) {
const oldRole = roleMap.get(role.id);
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.ROLE_UPDATE,
targetId: role.id,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(
oldRole ? this.serializeRoleForAudit(oldRole) : null,
this.serializeRoleForAudit(role),
),
});
}
}
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
return {
role_id: role.id.toString(),
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
@@ -22,6 +22,7 @@ import {buildMessageSearchFilters} from '../../search/BuildMessageSearchFilters'
import {channelNeedsReindexing} from '../../search/ChannelIndexingUtils';
import {MessageSearchResponseMapper} from '../../search/MessageSearchResponseMapper';
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from '../../search/SearchNsfwUtils';
import type {IUserRepository} from '../../user/IUserRepository';
import {canUserAccessNsfwContent} from '../../utils/AgeUtils';
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
@@ -84,6 +85,7 @@ export class GuildSearchService {
}
}
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
const guildNsfw = guildData?.nsfw ?? false;
const channels = await this.channelRepository.listChannels(channelIds);
const channelMap = new Map<string, Channel>();
for (const channel of channels) {
@@ -96,9 +98,10 @@ export class GuildSearchService {
throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD);
}
}
const categoryLookup = await this.buildParentCategoryLookup(channelMap);
const nsfwFilteredIds = channelIds.filter((id) => {
const channel = channelMap.get(id.toString())!;
return !(channel.isNsfw && !canIncludeNsfw);
return !(channelRequiresAgeVerification(channel, categoryLookup, guildNsfw) && !canIncludeNsfw);
});
const permissionResults = await mapWithConcurrency(nsfwFilteredIds, PERMISSION_CHECK_CONCURRENCY, (channelId) =>
this.gatewayService.checkPermission({
@@ -188,7 +191,7 @@ export class GuildSearchService {
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} =
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} =
await this.collectAccessibleGuildChannels(userId);
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
@@ -219,7 +222,7 @@ export class GuildSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -259,6 +262,24 @@ export class GuildSearchService {
};
}
private async buildParentCategoryLookup(channelMap: Map<string, Channel>): Promise<Map<string, Channel>> {
const lookup = new Map<string, Channel>(channelMap);
const missingParentIds: Array<ChannelID> = [];
for (const channel of channelMap.values()) {
const parentId = channel.parentId;
if (parentId != null && !lookup.has(parentId.toString())) {
missingParentIds.push(parentId);
}
}
if (missingParentIds.length > 0) {
const parents = await this.channelRepository.listChannels(missingParentIds);
for (const parent of parents) {
lookup.set(parent.id.toString(), parent);
}
}
return lookup;
}
private async getCanUserAccessNsfw(userId: UserID): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -284,11 +305,13 @@ export class GuildSearchService {
accessibleChannels: Map<string, Channel>;
unindexedChannelIds: Set<string>;
guildNsfwLevels: Map<string, number>;
parentCategories: Map<string, Channel>;
}> {
const guildIds = await this.userRepository.getUserGuildIds(userId);
const accessibleChannels = new Map<string, Channel>();
const unindexedChannelIds = new Set<string>();
const guildNsfwLevels = new Map<string, number>();
const parentCategories = new Map<string, Channel>();
const permissionChecks: Array<{
channel: Channel;
guildId: GuildID;
@@ -304,6 +327,9 @@ export class GuildSearchService {
}
const viewableChannelIds = new Set(viewableChannels.map((channelId) => channelId.toString()));
for (const channel of guildChannels) {
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
parentCategories.set(channel.id.toString(), channel);
}
if (viewableChannelIds.has(channel.id.toString())) {
permissionChecks.push({channel, guildId});
}
@@ -331,6 +357,6 @@ export class GuildSearchService {
unindexedChannelIds.add(channelIdStr);
}
}
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels};
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories};
}
}
@@ -57,7 +57,7 @@ import {Guild} from '../../../models/Guild';
import type {User} from '../../../models/User';
import {getGuildSearchService} from '../../../SearchFactory';
import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer';
import {deleteGuildMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables';
import type {IUserRepository} from '../../../user/IUserRepository';
import {mapUserSettingsToResponse} from '../../../user/UserMappers';
@@ -858,7 +858,11 @@ export class GuildOperationsService {
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
const channels = await this.channelRepository.listGuildChannels(guildId);
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
await deleteGuildMessageSearchDocuments(guildId, {context: {source: 'guild_delete'}});
await Promise.all(
channels.map((channel) =>
deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}}),
),
);
await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
if (discoveryRow) {
@@ -37,8 +37,11 @@ export class GuildMemberAuthService {
if (!canManage) throw new MissingPermissionsError();
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
const hasPermission = async (permission: bigint) =>
this.gatewayService.checkPermission({guildId, userId, permission});
const hasPermission = async (permission: bigint) => {
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
return {
@@ -30,6 +30,7 @@ import {channelNeedsReindexing} from './ChannelIndexingUtils';
import type {IMessageSearchService} from './IMessageSearchService';
import {MessageSearchResponseMapper} from './MessageSearchResponseMapper';
import {searchExistingMessages} from './MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from './SearchNsfwUtils';
const CHANNEL_INDEX_CHECK_CONCURRENCY = 32;
const CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY = 16;
@@ -101,7 +102,7 @@ export class GlobalSearchService {
this.guildService.search.collectAccessibleGuildChannels(params.userId),
this.findDmScopeContextChannel(params.userId, params.includeChannelId),
]);
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} = guildAccess;
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = guildAccess;
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
return {indexing: true};
@@ -133,7 +134,7 @@ export class GlobalSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
import {channelToContentWarningView, computeEffectiveChannelNsfw} from '../channel/utils/EffectiveContentWarning';
import type {Channel} from '../models/Channel';
export function channelRequiresAgeVerification(
channel: Channel,
channelsById: ReadonlyMap<string, Channel>,
guildNsfw: boolean,
): boolean {
const parentCategory =
channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null;
return computeEffectiveChannelNsfw(
channelToContentWarningView(channel),
parentCategory ? channelToContentWarningView(parentCategory) : null,
{nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null},
);
}
+43 -1
View File
@@ -142,7 +142,15 @@ export class WebhookService {
async getGuildWebhooks({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<Array<Webhook>> {
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
return await this.repository.listByGuild(guildId);
const webhooks = await this.repository.listByGuild(guildId);
const visibility = await Promise.all(
webhooks.map((webhook) =>
webhook.channelId
? this.canManageChannelWebhooks({userId, guildId, channelId: webhook.channelId})
: Promise.resolve(false),
),
);
return webhooks.filter((_webhook, index) => visibility[index]);
}
async getChannelWebhooks({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Array<Webhook>> {
@@ -153,6 +161,7 @@ export class WebhookService {
guildId: channel.guildId,
});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
return await this.repository.listByChannel(channelId);
}
@@ -172,6 +181,7 @@ export class WebhookService {
guildId: channel.guildId,
});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
const guildLimit = this.resolveWebhookLimit(guildData.features, 'max_webhooks_per_guild', MAX_WEBHOOKS_PER_GUILD);
const guildWebhookCount = await this.repository.countByGuild(channel.guildId);
if (guildWebhookCount >= guildLimit) {
@@ -451,9 +461,41 @@ export class WebhookService {
if (!webhook) throw new UnknownWebhookError();
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!});
await checkPermission(Permissions.MANAGE_WEBHOOKS);
if (webhook.guildId && webhook.channelId) {
await this.assertChannelWebhookPermission({
userId,
guildId: webhook.guildId,
channelId: webhook.channelId,
});
}
return webhook;
}
private async canManageChannelWebhooks({
userId,
guildId,
channelId,
}: {
userId: UserID;
guildId: GuildID;
channelId: ChannelID;
}): Promise<boolean> {
const [canView, canManage] = await Promise.all([
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.VIEW_CHANNEL, channelId}),
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.MANAGE_WEBHOOKS, channelId}),
]);
return canView && canManage;
}
private async assertChannelWebhookPermission(params: {
userId: UserID;
guildId: GuildID;
channelId: ChannelID;
}): Promise<void> {
const allowed = await this.canManageChannelWebhooks(params);
if (!allowed) throw new MissingPermissionsError();
}
private async getTokenAuthenticatedWebhook({webhookId, token}: WebhookTokenParams): Promise<Webhook> {
const webhook = await this.repository.findByToken(webhookId, token);
if (!webhook) throw new UnknownWebhookError();