mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
fix(api): enforce scoped perms, age gates, audit logs (#2076)
This commit is contained in:
@@ -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},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user