mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
493 lines
18 KiB
TypeScript
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);
|
|
}
|
|
}
|