diff --git a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts index b921b396d..1770a3797 100644 --- a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts +++ b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts @@ -174,18 +174,19 @@ export abstract class BaseChannelAuthService { userId, memberData: guildMemberResult.memberData!, }); - const hasPermission = async (permission: bigint): Promise => { - 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 => { + const allowed = await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id}); + if (allowed) enforceGuildMfa(permission); + return allowed; + }; const checkPermission = async (permission: bigint): Promise => { const allowed = await hasPermission(permission); if (!allowed) throw new MissingPermissionsError(); - enforceGuildMfa(permission); }; await checkPermission(Permissions.VIEW_CHANNEL); const parentCategory = await this.getParentCategoryContentWarningView({ diff --git a/fluxer_api/src/api/channel/services/MessageInteractionService.ts b/fluxer_api/src/api/channel/services/MessageInteractionService.ts index e58fa955f..8bc3f2fd1 100644 --- a/fluxer_api/src/api/channel/services/MessageInteractionService.ts +++ b/fluxer_api/src/api/channel/services/MessageInteractionService.ts @@ -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; 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}); } diff --git a/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts b/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts index 3b9fe935a..84d29b7ab 100644 --- a/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts +++ b/fluxer_api/src/api/channel/services/channel_data/ChannelUtilsService.ts @@ -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 { - const cdnUrls: Array = []; - 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 { diff --git a/fluxer_api/src/api/channel/services/interaction/MessagePinAuthService.ts b/fluxer_api/src/api/channel/services/interaction/MessagePinAuthService.ts new file mode 100644 index 000000000..4f3078bc1 --- /dev/null +++ b/fluxer_api/src/api/channel/services/interaction/MessagePinAuthService.ts @@ -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, + }; +} diff --git a/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts b/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts index b7f19fa04..20eab45c7 100644 --- a/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts +++ b/fluxer_api/src/api/channel/services/message/AttachmentProcessingService.ts @@ -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) => diff --git a/fluxer_api/src/api/channel/services/message/MessageHelpers.ts b/fluxer_api/src/api/channel/services/message/MessageHelpers.ts index 844a1595d..c1c9f3c6b 100644 --- a/fluxer_api/src/api/channel/services/message/MessageHelpers.ts +++ b/fluxer_api/src/api/channel/services/message/MessageHelpers.ts @@ -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 { + const mediaPrefix = `${Config.endpoints.media}/`; + const keys = new Set(); + 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): 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 { + const cdnKeys = new Set(); const cdnUrls: Array = []; - 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); } diff --git a/fluxer_api/src/api/channel/services/message/MessageValidationService.ts b/fluxer_api/src/api/channel/services/message/MessageValidationService.ts index 213559d69..1c999790d 100644 --- a/fluxer_api/src/api/channel/services/message/MessageValidationService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageValidationService.ts @@ -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( diff --git a/fluxer_api/src/api/guild/services/GuildRoleService.ts b/fluxer_api/src/api/guild/services/GuildRoleService.ts index b1feec578..50bd0ce76 100644 --- a/fluxer_api/src/api/guild/services/GuildRoleService.ts +++ b/fluxer_api/src/api/guild/services/GuildRoleService.ts @@ -308,7 +308,7 @@ export class GuildRoleService { position?: number; }>; }, - _auditLogReason?: string | null, + auditLogReason?: string | null, ): Promise { 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 { 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 { 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 = []; 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 { - 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; + changedRoles: Array; + auditLogReason?: string | null; + }): Promise { + 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 { return { role_id: role.id.toString(), diff --git a/fluxer_api/src/api/guild/services/GuildSearchService.ts b/fluxer_api/src/api/guild/services/GuildSearchService.ts index 849fdd7f6..324b86a83 100644 --- a/fluxer_api/src/api/guild/services/GuildSearchService.ts +++ b/fluxer_api/src/api/guild/services/GuildSearchService.ts @@ -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(); 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): Promise> { + const lookup = new Map(channelMap); + const missingParentIds: Array = []; + 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 { const user = await this.userRepository.findUnique(userId); if (!user) { @@ -284,11 +305,13 @@ export class GuildSearchService { accessibleChannels: Map; unindexedChannelIds: Set; guildNsfwLevels: Map; + parentCategories: Map; }> { const guildIds = await this.userRepository.getUserGuildIds(userId); const accessibleChannels = new Map(); const unindexedChannelIds = new Set(); const guildNsfwLevels = new Map(); + const parentCategories = new Map(); 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}; } } diff --git a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts index b4eaed680..9b49fae1b 100644 --- a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts +++ b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts @@ -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) { diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts index 7fdb49f41..0624dc6f8 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberAuthService.ts @@ -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 { diff --git a/fluxer_api/src/api/search/GlobalSearchService.ts b/fluxer_api/src/api/search/GlobalSearchService.ts index d14da450c..fe81cad4c 100644 --- a/fluxer_api/src/api/search/GlobalSearchService.ts +++ b/fluxer_api/src/api/search/GlobalSearchService.ts @@ -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; diff --git a/fluxer_api/src/api/search/SearchNsfwUtils.ts b/fluxer_api/src/api/search/SearchNsfwUtils.ts new file mode 100644 index 000000000..f0381541c --- /dev/null +++ b/fluxer_api/src/api/search/SearchNsfwUtils.ts @@ -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, + 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}, + ); +} diff --git a/fluxer_api/src/api/webhook/WebhookService.ts b/fluxer_api/src/api/webhook/WebhookService.ts index 77aefd02e..651cb44bf 100644 --- a/fluxer_api/src/api/webhook/WebhookService.ts +++ b/fluxer_api/src/api/webhook/WebhookService.ts @@ -142,7 +142,15 @@ export class WebhookService { async getGuildWebhooks({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise> { 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> { @@ -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 { + 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 { + const allowed = await this.canManageChannelWebhooks(params); + if (!allowed) throw new MissingPermissionsError(); + } + private async getTokenAuthenticatedWebhook({webhookId, token}: WebhookTokenParams): Promise { const webhook = await this.repository.findByToken(webhookId, token); if (!webhook) throw new UnknownWebhookError();