Files
fluxer/fluxer_api/src/api/guild/services/GuildService.ts
T

493 lines
18 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import {
createChannelID,
createWebhookID,
type EmojiID,
type GuildID,
type StickerID,
type UserID,
} from '@app/api/BrandedTypes';
import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers';
import {isTainted, type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate';
import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild';
import {
collectAuditLogChannelIds,
collectGuildAuditLogUserIds,
isNoopGuildAuditLog,
isThreadScopedAuditLog,
mapGuildAuditLogEntry,
maskThreadAuditLog,
type StoredGuildAuditLogEntryResponse,
THREAD_AUDIT_LOG_ACTION_TYPES,
} from '@app/api/guild/GuildAuditLogEntryMapper';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {withGuildUpdateLock} from '@app/api/guild/GuildUpdateLock';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {GuildChannelService} from '@app/api/guild/services/GuildChannelService';
import {GuildContentService} from '@app/api/guild/services/GuildContentService';
import {GuildDataService} from '@app/api/guild/services/GuildDataService';
import {GuildMemberService} from '@app/api/guild/services/GuildMemberService';
import {getGuildAuth} from '@app/api/guild/services/GuildMfaEnforcement';
import {GuildModerationService} from '@app/api/guild/services/GuildModerationService';
import {GuildRoleService} from '@app/api/guild/services/GuildRoleService';
import {GuildSearchService} from '@app/api/guild/services/GuildSearchService';
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
import type {IAssetDeletionQueue} from '@app/api/infrastructure/IAssetDeletionQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {InviteRepository} from '@app/api/invite/InviteRepository';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {GuildAuditLog} from '@app/api/models/GuildAuditLog';
import type {Webhook} from '@app/api/models/Webhook';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import type {WebhookRepository} from '@app/api/webhook/WebhookRepository';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
import type {
AuditLogWebhookResponse,
GuildAuditLogListResponse,
} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import type {
GuildEmojiMetadataResponse,
GuildExpressionSourceGuildResponse,
GuildStickerMetadataResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
interface StoredAuditLogWebhookResponse extends Omit<AuditLogWebhookResponse, 'type'> {
type: number;
}
interface StoredGuildAuditLogListResponse extends Omit<GuildAuditLogListResponse, 'audit_log_entries' | 'webhooks'> {
audit_log_entries: Array<StoredGuildAuditLogEntryResponse>;
webhooks: Array<StoredAuditLogWebhookResponse>;
}
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
public readonly roles: GuildRoleService;
public readonly moderation: GuildModerationService;
public readonly content: GuildContentService;
public readonly channels: GuildChannelService;
public readonly search: GuildSearchService;
private readonly guildRepository: GuildRepository;
private readonly cacheService: ICacheService;
private readonly userCacheService: UserCacheService;
private readonly webhookRepository: WebhookRepository;
private readonly guildAuditLogService: GuildAuditLogService;
private readonly gatewayService: IGatewayService;
private readonly userRepository: UserRepository;
constructor(
apiContext: ApiContext,
guildRepository: GuildRepository,
private readonly channelRepository: ChannelRepository,
inviteRepository: InviteRepository,
channelService: ChannelService,
userCacheService: UserCacheService,
entityAssetService: EntityAssetService,
avatarService: AvatarService,
assetDeletionQueue: IAssetDeletionQueue,
webhookRepository: WebhookRepository,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
) {
const {
cache: cacheService,
gateway: gatewayService,
users: userRepository,
worker: workerService,
snowflake: snowflakeService,
rateLimit: rateLimitService,
} = apiContext.services;
this.gatewayService = gatewayService;
this.guildRepository = guildRepository;
this.cacheService = cacheService;
this.userCacheService = userCacheService;
this.webhookRepository = webhookRepository;
this.guildAuditLogService = guildAuditLogService;
this.userRepository = userRepository;
this.data = new GuildDataService(
guildRepository,
channelRepository,
inviteRepository,
channelService,
gatewayService,
entityAssetService,
userRepository,
snowflakeService,
webhookRepository,
guildAuditLogService,
limitConfigService,
workerService,
);
this.members = new GuildMemberService(
guildRepository,
channelService,
userCacheService,
gatewayService,
entityAssetService,
userRepository,
rateLimitService,
guildAuditLogService,
limitConfigService,
);
this.roles = new GuildRoleService(
guildRepository,
snowflakeService,
cacheService,
gatewayService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.moderation = new GuildModerationService(
guildRepository,
userRepository,
gatewayService,
userCacheService,
workerService,
guildAuditLogService,
);
this.content = new GuildContentService(
guildRepository,
userCacheService,
gatewayService,
avatarService,
snowflakeService,
guildAuditLogService,
assetDeletionQueue,
limitConfigService,
);
this.channels = new GuildChannelService(
channelRepository,
guildRepository,
userCacheService,
gatewayService,
cacheService,
snowflakeService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.search = new GuildSearchService(
channelRepository,
userCacheService,
gatewayService,
userRepository,
workerService,
);
}
async updateGuild(
params: {
userId: UserID;
guildId: GuildID;
data: GuildUpdateRequest;
requestCache: RequestCache;
},
auditLogReason?: string | null,
): Promise<GuildResponse> {
const {guildId, requestCache} = params;
const {guild, previousFeatures, updatedFeatures} = await this.withUpdateLock(guildId, () => {
requestCache.guilds.delete(guildId);
return this.data.operations.updateGuild(params, auditLogReason);
});
if (
previousFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) &&
!updatedFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES)
) {
await this.channels.sanitizeTextChannelNames({guildId, requestCache});
}
return guild;
}
withUpdateLock<T>(guildId: GuildID, fn: () => Promise<T>): Promise<T> {
return withGuildUpdateLock(this.cacheService, guildId, fn);
}
async getEmojiMetadata(emojiId: EmojiID): Promise<GuildEmojiMetadataResponse> {
const emoji = await this.guildRepository.getEmojiById(emojiId);
if (!emoji) throw new UnknownGuildEmojiError();
const guild = await this.data.operations.getGuildSystem(emoji.guildId);
return {
id: emoji.id.toString(),
guild_id: guild.id.toString(),
name: emoji.name,
animated: emoji.isAnimated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED),
};
}
async getStickerMetadata(stickerId: StickerID): Promise<GuildStickerMetadataResponse> {
const sticker = await this.guildRepository.getStickerById(stickerId);
if (!sticker) throw new UnknownGuildStickerError();
const guild = await this.data.operations.getGuildSystem(sticker.guildId);
return {
id: sticker.id.toString(),
guild_id: guild.id.toString(),
name: sticker.name,
animated: sticker.animated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_STICKER_ENABLED),
};
}
async getEmojiSource(emojiId: EmojiID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
const emoji = await this.guildRepository.getEmojiById(emojiId);
if (!emoji) throw new UnknownGuildEmojiError();
return this.resolveExpressionSourceGuild(emoji.guildId, userId);
}
async getStickerSource(stickerId: StickerID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
const sticker = await this.guildRepository.getStickerById(stickerId);
if (!sticker) throw new UnknownGuildStickerError();
return this.resolveExpressionSourceGuild(sticker.guildId, userId);
}
private resolveExpressionSourceGuild(guildId: GuildID, userId: UserID): Promise<GuildExpressionSourceGuildResponse> {
return resolveExpressionSourceGuild({
loadGuild: () => this.gatewayService.getGuildData({guildId, userId, skipMembershipCheck: true}),
isMember: async () => (await this.gatewayService.getGuildMember({guildId, userId})).success,
});
}
async listGuildAuditLogs(params: {
userId: UserID;
viewer: ThreadViewer;
guildId: GuildID;
requestCache: RequestCache;
limit?: number;
beforeLogId?: bigint;
afterLogId?: bigint;
filterUserId?: UserID;
actionType?: AuditLogActionType;
}): Promise<StoredGuildAuditLogListResponse> {
const {userId, guildId} = params;
const [hasPermission, guild] = await Promise.all([
this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.VIEW_AUDIT_LOG,
}),
this.guildRepository.findUnique(guildId),
]);
if (!guild) {
throw new UnknownGuildError();
}
if (!hasPermission) {
throw new MissingPermissionsError();
}
return this.fetchGuildAuditLogs(params);
}
async fetchGuildAuditLogs(params: {
guildId: GuildID;
requestCache: RequestCache;
limit?: number;
beforeLogId?: bigint;
afterLogId?: bigint;
filterUserId?: UserID;
actionType?: AuditLogActionType;
viewer?: ThreadViewer;
}): Promise<StoredGuildAuditLogListResponse> {
const {guildId, requestCache, limit = 50, beforeLogId, afterLogId, filterUserId, actionType} = params;
const threadsVisible = params.viewer === undefined || viewerActive(params.viewer, guildId);
const hideThreadEntries = !threadsVisible && (await isTainted(guildId));
if (beforeLogId !== undefined && afterLogId !== undefined) {
throw InputValidationError.fromCode('before', ValidationErrorCodes.CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER);
}
const effectiveLimit = Math.max(1, Math.min(limit, 100));
const shouldBatch = actionType === undefined && !filterUserId;
let processedLogs: Array<GuildAuditLog> = [];
let currentBeforeLogId = beforeLogId;
let currentAfterLogId = afterLogId;
while (processedLogs.length < effectiveLimit) {
const fetchLimit = Math.min(effectiveLimit * 2, 200);
const logs = await this.guildRepository.listAuditLogs({
guildId,
limit: fetchLimit,
beforeLogId: currentBeforeLogId,
afterLogId: currentAfterLogId,
userId: filterUserId,
actionType,
});
if (logs.length === 0) {
break;
}
const batchedLogs = shouldBatch
? (await this.guildAuditLogService.batchConsecutiveMessageDeleteLogs(guildId, logs)).processedLogs
: logs;
const visibleLogs = hideThreadEntries
? (await this.dropThreadScopedAuditLogs(batchedLogs)).map(maskThreadAuditLog)
: batchedLogs;
for (const log of visibleLogs) {
if (processedLogs.length < effectiveLimit && !isNoopGuildAuditLog(log.actionType, log.changes)) {
processedLogs.push(log);
}
}
if (logs.length < fetchLimit) {
break;
}
const lastLog = logs[logs.length - 1];
if (afterLogId !== undefined) {
currentAfterLogId = lastLog.logId;
} else {
currentBeforeLogId = lastLog.logId;
}
}
processedLogs = processedLogs.slice(0, effectiveLimit);
const userIdSet = new Set<UserID>();
for (const log of processedLogs) {
for (const referencedUserId of collectGuildAuditLogUserIds(log)) {
userIdSet.add(referencedUserId);
}
}
const [userPartials, webhookRecords] = await Promise.all([
this.userCacheService.getUserPartialResponses(Array.from(userIdSet), requestCache),
this.loadAuditLogWebhooks(processedLogs),
]);
const entries = processedLogs.map((log) => mapGuildAuditLogEntry(log));
const users = Array.from(userPartials.values());
const webhooks = this.buildAuditLogWebhookResponses(
hideThreadEntries ? await this.dropForumWebhooks(webhookRecords.webhooks) : webhookRecords.webhooks,
);
const response: StoredGuildAuditLogListResponse = {
audit_log_entries: entries,
users,
webhooks,
};
if (params.viewer !== undefined && threadsVisible) {
response.threads = await this.loadAuditLogThreads(processedLogs);
}
return response;
}
private async dropThreadScopedAuditLogs(logs: Array<GuildAuditLog>): Promise<Array<GuildAuditLog>> {
const candidateIds = [...new Set(logs.flatMap((log) => collectAuditLogChannelIds(log)))].filter((id) =>
/^\d{1,20}$/.test(id),
);
const channels =
candidateIds.length > 0
? await this.channelRepository.channelData.listChannels(candidateIds.map((id) => createChannelID(BigInt(id))))
: [];
const gatedIds = new Set(
channels
.filter((channel) => THREAD_FEATURE_CHANNEL_TYPES.has(channel.type))
.map((channel) => channel.id.toString()),
);
return logs.filter((log) => !isThreadScopedAuditLog(log, gatedIds));
}
private async dropForumWebhooks(webhooks: Array<Webhook>): Promise<Array<Webhook>> {
const channelIds = [...new Set(webhooks.flatMap((webhook) => (webhook.channelId ? [webhook.channelId] : [])))];
if (channelIds.length === 0) return webhooks;
const forumIds = new Set(
(await this.channelRepository.channelData.listChannels(channelIds))
.filter((channel) => channel.isThreadOnly())
.map((channel) => channel.id),
);
return webhooks.filter((webhook) => !webhook.channelId || !forumIds.has(webhook.channelId));
}
private async loadAuditLogThreads(logs: Array<GuildAuditLog>): Promise<Array<ThreadChannelResponse>> {
const threadIds = [
...new Set(
logs
.filter((log) => THREAD_AUDIT_LOG_ACTION_TYPES.has(log.actionType) && log.targetId)
.map((log) => log.targetId!),
),
]
.filter((id) => /^\d{1,20}$/.test(id))
.map((id) => createChannelID(BigInt(id)));
if (threadIds.length === 0) return [];
const [channels, states, stats] = await Promise.all([
this.channelRepository.channelData.listChannels(threadIds),
this.channelRepository.threads.getStates(threadIds),
this.channelRepository.threads.getStatsMany(threadIds),
]);
const stateById = new Map(states.map((state) => [state.threadId.toString(), state]));
const parents = await this.channelRepository.channelData.listChannels([
...new Set(states.map((state) => state.parentId)),
]);
const parentTypeById = new Map(parents.map((parent) => [parent.id.toString(), parent.type]));
return channels.flatMap((channel) => {
const state = stateById.get(channel.id.toString());
const threadStats = stats.get(channel.id);
if (!state || !threadStats || !channel.isThread()) return [];
return [
mapThreadToResponse({
channel,
state,
stats: threadStats,
parentType: parentTypeById.get(state.parentId.toString()) ?? null,
}),
];
});
}
private async loadAuditLogWebhooks(logs: Array<GuildAuditLog>): Promise<{
webhooks: Array<Webhook>;
}> {
const webhookIds = new Set<string>();
for (const log of logs) {
if (this.isWebhookAction(log.actionType) && log.targetId) {
webhookIds.add(log.targetId);
}
}
if (webhookIds.size === 0) {
return {webhooks: []};
}
const webhookPromises = Array.from(webhookIds, (id) => {
try {
return this.webhookRepository.findUnique(createWebhookID(BigInt(id)));
} catch {
return Promise.resolve(null);
}
});
const results = await Promise.all(webhookPromises);
const foundWebhooks = results.filter((webhook): webhook is Webhook => webhook !== null);
return {webhooks: foundWebhooks};
}
private buildAuditLogWebhookResponses(webhooks: Array<Webhook>): Array<StoredAuditLogWebhookResponse> {
return webhooks.map((webhook) => ({
id: webhook.id.toString(),
type: webhook.type,
guild_id: webhook.guildId?.toString() ?? null,
channel_id: webhook.channelId?.toString() ?? null,
name: webhook.name,
avatar_hash: webhook.avatarHash,
}));
}
private isWebhookAction(actionType: AuditLogActionType): boolean {
return (
actionType === AuditLogActionType.WEBHOOK_CREATE ||
actionType === AuditLogActionType.WEBHOOK_UPDATE ||
actionType === AuditLogActionType.WEBHOOK_DELETE
);
}
async getGuildAuthenticated(params: {userId: UserID; guildId: GuildID}) {
return getGuildAuth(this.gatewayService, this.userRepository, params);
}
}