This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs/promises';import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';import {GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';import {MAX_WEBHOOKS_PER_CHANNEL, MAX_WEBHOOKS_PER_GUILD} from '@fluxer/constants/src/LimitConstants';import {MaxWebhooksPerChannelError} from '@fluxer/errors/src/domains/channel/MaxWebhooksPerChannelError';import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';import {MaxWebhooksPerGuildError} from '@fluxer/errors/src/domains/guild/MaxWebhooksPerGuildError';import {UnknownWebhookError} from '@fluxer/errors/src/domains/webhook/UnknownWebhookError';import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';import type { WebhookCreateRequest, WebhookMessageRequest, WebhookTokenUpdateRequest, WebhookUpdateRequest,} from '@fluxer/schema/src/domains/webhook/WebhookRequestSchemas';import type {ICacheService} from '@pkgs/cache/src/ICacheService';import {seconds} from 'itty-time';import type {ChannelID, GuildID, MessageID, UserID, WebhookID, WebhookToken} from '../BrandedTypes';import {createChannelID, createGuildID, createWebhookID, createWebhookToken} from '../BrandedTypes';import type {IChannelRepository} from '../channel/IChannelRepository';import type {MessageRequest, MessageUpdateRequest} from '../channel/MessageTypes';import type {ChannelService} from '../channel/services/ChannelService';import type {GuildAuditLogService} from '../guild/GuildAuditLogService';import type {GuildService} from '../guild/services/GuildService';import type {AvatarService} from '../infrastructure/AvatarService';import {contentModerationService} from '../infrastructure/ContentModerationService';import type {IGatewayService} from '../infrastructure/IGatewayService';import type {IMediaService} from '../infrastructure/IMediaService';import type {ISnowflakeService} from '../infrastructure/ISnowflakeService';import {Logger} from '../Logger';import type {LimitConfigService} from '../limits/LimitConfigService';import {resolveLimitSafe} from '../limits/LimitConfigUtils';import {createLimitMatchContext} from '../limits/LimitMatchContextBuilder';import type {RequestCache} from '../middleware/RequestCacheMiddleware';import type {Channel} from '../models/Channel';import type {Message} from '../models/Message';import type {Webhook} from '../models/Webhook';import * as RandomUtils from '../utils/RandomUtils';import type {IWebhookRepository} from './IWebhookRepository';import {transform as GitHubTransform} from './transformers/GitHubTransformer';
export interface WebhookExecuteMessageData extends Omit<WebhookMessageRequest, 'attachments'> { attachments?: WebhookMessageRequest['attachments'] | MessageRequest['attachments']; username?: string | null; avatar_url?: string | null;}
interface WebhookUserParams { userId: UserID; webhookId: WebhookID;}
interface WebhookTokenParams { webhookId: WebhookID; token: WebhookToken;}
interface WebhookTokenUpdateParams extends WebhookTokenParams { data: WebhookTokenUpdateRequest;}
interface WebhookExecuteParams extends WebhookTokenParams { data: WebhookExecuteMessageData; requestCache: RequestCache;}
interface WebhookMessageLookupParams extends WebhookTokenParams { messageId: MessageID;}
interface WebhookMessageParams extends WebhookMessageLookupParams { requestCache: RequestCache;}
interface WebhookMessageUpdateParams extends WebhookMessageParams { data: MessageUpdateRequest;}
interface WebhookExecuteGitHubParams extends WebhookTokenParams { event: string; delivery: string; data: GitHubWebhook; requestCache: RequestCache;}
const WEBHOOK_AVATAR_MISSING_CACHE_VALUE = '__fluxer_webhook_avatar_missing__';
export class WebhookService { private static readonly NO_ALLOWED_MENTIONS: AllowedMentionsRequest = {parse: []};
private isUploadedAttachmentData( attachment: NonNullable<WebhookExecuteMessageData['attachments']>[number], ): attachment is Extract< NonNullable<MessageRequest['attachments']>[number], { upload_filename: string; } > { return ( typeof attachment === 'object' && attachment !== null && 'upload_filename' in attachment && typeof attachment.upload_filename === 'string' ); }
constructor( private repository: IWebhookRepository, private guildService: GuildService, private channelService: ChannelService, private channelRepository: IChannelRepository, private cacheService: ICacheService, private gatewayService: IGatewayService, private avatarService: AvatarService, private mediaService: IMediaService, private snowflakeService: ISnowflakeService, private readonly guildAuditLogService: GuildAuditLogService, private readonly limitConfigService: LimitConfigService, ) {}
async getWebhook({userId, webhookId}: WebhookUserParams): Promise<Webhook> { return this.getAuthenticatedWebhook({userId, webhookId}); }
async getWebhookByToken(params: WebhookTokenParams): Promise<Webhook> { return this.getTokenAuthenticatedWebhook(params); }
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); }
async getChannelWebhooks({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Array<Webhook>> { const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); this.assertWebhookTargetChannel(channel); const {checkPermission} = await this.guildService.getGuildAuthenticated({ userId, guildId: channel.guildId, }); await checkPermission(Permissions.MANAGE_WEBHOOKS); return await this.repository.listByChannel(channelId); }
async createWebhook( params: { userId: UserID; channelId: ChannelID; data: WebhookCreateRequest; }, auditLogReason?: string | null, ): Promise<Webhook> { const {userId, channelId, data} = params; const channel = await this.channelService.channelData.operations.getChannel({userId, channelId}); this.assertWebhookTargetChannel(channel); const {checkPermission, guildData} = await this.guildService.getGuildAuthenticated({ userId, guildId: channel.guildId, }); await checkPermission(Permissions.MANAGE_WEBHOOKS); 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) { throw new MaxWebhooksPerGuildError(guildLimit); } const channelLimit = this.resolveWebhookLimit( guildData.features, 'max_webhooks_per_channel', MAX_WEBHOOKS_PER_CHANNEL, ); const channelWebhookCount = await this.repository.countByChannel(channelId); if (channelWebhookCount >= channelLimit) { throw new MaxWebhooksPerChannelError(channelLimit); } contentModerationService.scanText(data.name, { userId, guildId: channel.guildId, channelId, messageId: null, surface: 'webhook', }); const webhookId = createWebhookID(await this.snowflakeService.generate()); const webhook = await this.repository.create({ webhookId, token: createWebhookToken(RandomUtils.randomString(64)), type: 1, guildId: channel.guildId, channelId, creatorId: userId, name: data.name, avatarHash: data.avatar ? await this.updateAvatar({webhookId, avatar: data.avatar}) : null, }); await this.dispatchWebhooksUpdate({guildId: channel.guildId, channelId}); await this.recordWebhookAuditLog({ guildId: channel.guildId, userId, action: 'create', webhook, auditLogReason, }); return webhook; }
async updateWebhook( params: { userId: UserID; webhookId: WebhookID; data: WebhookUpdateRequest; }, auditLogReason?: string | null, ): Promise<Webhook> { const {userId, webhookId, data} = params; const webhook = await this.getAuthenticatedWebhook({userId, webhookId}); const {checkPermission, guildData} = await this.guildService.getGuildAuthenticated({ userId, guildId: webhook.guildId ? webhook.guildId : createGuildID(0n), }); await checkPermission(Permissions.MANAGE_WEBHOOKS); if (data.channel_id && data.channel_id !== webhook.channelId) { const targetChannel = await this.channelService.channelData.operations.getChannel({ userId, channelId: createChannelID(data.channel_id), }); this.assertWebhookTargetChannel(targetChannel); if (targetChannel.guildId !== webhook.guildId) { throw new UnknownChannelError(); } const canManageTargetChannel = await this.gatewayService.checkPermission({ guildId: targetChannel.guildId, userId, permission: Permissions.MANAGE_WEBHOOKS, channelId: createChannelID(data.channel_id), }); if (!canManageTargetChannel) { throw new MissingPermissionsError(); } const channelLimit = this.resolveWebhookLimit( guildData.features, 'max_webhooks_per_channel', MAX_WEBHOOKS_PER_CHANNEL, ); const channelWebhookCount = await this.repository.countByChannel(createChannelID(data.channel_id)); if (channelWebhookCount >= channelLimit) { throw new MaxWebhooksPerChannelError(channelLimit); } } const updatedData = await this.updateWebhookData({webhook, data}); const updatedWebhook = await this.repository.update(webhookId, { name: updatedData.name, avatarHash: updatedData.avatarHash, channelId: updatedData.channelId, }); if (!updatedWebhook) throw new UnknownWebhookError(); await this.dispatchWebhooksUpdate({ guildId: webhook.guildId, channelId: webhook.channelId, }); if (webhook.guildId) { const previousSnapshot = this.serializeWebhookForAudit(webhook); await this.recordWebhookAuditLog({ guildId: webhook.guildId, userId, action: 'update', webhook: updatedWebhook, previousSnapshot, auditLogReason, }); } return updatedWebhook; }
async updateWebhookByToken({webhookId, token, data}: WebhookTokenUpdateParams): Promise<Webhook> { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); const updatedData = await this.updateWebhookData({webhook, data}); const updatedWebhook = await this.repository.update(webhookId, { name: updatedData.name, avatarHash: updatedData.avatarHash, channelId: updatedData.channelId, }); if (!updatedWebhook) throw new UnknownWebhookError(); await this.dispatchWebhooksUpdate({ guildId: webhook.guildId, channelId: webhook.channelId, }); return updatedWebhook; }
async deleteWebhook( { userId, webhookId, }: { userId: UserID; webhookId: WebhookID; }, auditLogReason?: string | null, ): Promise<void> { const webhook = await this.getAuthenticatedWebhook({userId, webhookId}); const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!}); await checkPermission(Permissions.MANAGE_WEBHOOKS); await this.repository.delete(webhookId); await this.dispatchWebhooksUpdate({ guildId: webhook.guildId, channelId: webhook.channelId, }); if (webhook.guildId) { await this.recordWebhookAuditLog({ guildId: webhook.guildId, userId, action: 'delete', webhook, auditLogReason, }); } }
async deleteWebhookByToken({webhookId, token}: WebhookTokenParams): Promise<void> { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); await this.repository.delete(webhookId); await this.dispatchWebhooksUpdate({ guildId: webhook.guildId, channelId: webhook.channelId, }); }
async executeWebhook({webhookId, token, data, requestCache}: WebhookExecuteParams): Promise<Message> { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); await this.assertWebhookGuildChannel(webhook); const attachments = data.attachments?.filter((attachment) => this.isUploadedAttachmentData(attachment)); return this.channelService.messages.send.sendWebhookMessage({ webhook, data: { content: data.content, embeds: data.embeds, attachments, message_reference: data.message_reference, allowed_mentions: data.allowed_mentions ?? WebhookService.NO_ALLOWED_MENTIONS, flags: data.flags, nonce: data.nonce, favorite_meme_id: data.favorite_meme_id, sticker_ids: data.sticker_ids, tts: data.tts, }, username: data.username, avatar: data.avatar_url ? await this.getWebhookAvatar({webhookId: webhook.id, avatarUrl: data.avatar_url}) : null, requestCache, }); }
async editWebhookMessage({ webhookId, token, messageId, data, requestCache, }: WebhookMessageUpdateParams): Promise<Message> { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); return this.channelService.messages.send.editWebhookMessage({ webhook, messageId, data, requestCache, }); }
async deleteWebhookMessage({webhookId, token, messageId, requestCache}: WebhookMessageParams): Promise<void> { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); await this.channelService.messages.deletion.deleteWebhookMessage({ webhook, messageId, requestCache, }); }
async getWebhookMessage({webhookId, token, messageId}: WebhookMessageLookupParams): Promise<Message> { const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); if (!webhook.channelId) throw new UnknownChannelError(); const message = await this.channelRepository.getMessage(webhook.channelId, messageId); if (!message) throw new UnknownMessageError(); if (message.webhookId !== webhook.id) throw new MissingPermissionsError(); return message; }
async executeGitHubWebhook(params: WebhookExecuteGitHubParams): Promise<void> { const {webhookId, token, event, delivery, data, requestCache} = params; const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); await this.assertWebhookGuildChannel(webhook); if (delivery) { const isCached = await this.cacheService.get<number>(`github:${webhookId}:${delivery}`); if (isCached) return; } const embed = await GitHubTransform(event, data); if (!embed) return; await this.channelService.messages.send.sendWebhookMessage({ webhook, data: {embeds: [embed], allowed_mentions: WebhookService.NO_ALLOWED_MENTIONS}, username: 'GitHub', avatar: await this.getGitHubWebhookAvatar(webhook.id), requestCache, }); if (delivery) await this.cacheService.set(`github:${webhookId}:${delivery}`, 1, seconds('1 day')); }
async dispatchWebhooksUpdate({ guildId, channelId, }: { guildId: GuildID | null; channelId: ChannelID | null; }): Promise<void> { if (guildId && channelId) { await this.gatewayService.dispatchGuild({ guildId: guildId, event: 'WEBHOOKS_UPDATE', data: {channel_id: channelId.toString()}, }); } }
private async getAuthenticatedWebhook({userId, webhookId}: WebhookUserParams): Promise<Webhook> { const webhook = await this.repository.findUnique(webhookId); if (!webhook) throw new UnknownWebhookError(); const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!}); await checkPermission(Permissions.MANAGE_WEBHOOKS); return webhook; }
private async getTokenAuthenticatedWebhook({webhookId, token}: WebhookTokenParams): Promise<Webhook> { const webhook = await this.repository.findByToken(webhookId, token); if (!webhook) throw new UnknownWebhookError(); return webhook; }
private async assertWebhookGuildChannel(webhook: Webhook): Promise<void> { if (!webhook.channelId) throw new UnknownChannelError(); const channel = await this.channelRepository.findUnique(webhook.channelId); if (!channel) throw new UnknownChannelError(); this.assertWebhookTargetChannel(channel); }
private assertWebhookTargetChannel(channel: Channel): asserts channel is Channel & {guildId: GuildID} { if (!channel.guildId || !GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { throw new UnknownChannelError(); } }
private async updateWebhookData({webhook, data}: {webhook: Webhook; data: WebhookUpdateRequest}): Promise<{ name: string; avatarHash: string | null; channelId: ChannelID | null; }> { contentModerationService.scanText(data.name ?? null, { userId: webhook.creatorId, guildId: webhook.guildId, channelId: webhook.channelId, messageId: null, surface: 'webhook', }); const name = data.name !== undefined ? data.name : webhook.name; const avatarHash = data.avatar !== undefined ? await this.updateAvatar({webhookId: webhook.id, avatar: data.avatar}) : webhook.avatarHash; let channelId = webhook.channelId; if (data.channel_id !== undefined && data.channel_id !== webhook.channelId) { const channel = await this.channelRepository.findUnique(createChannelID(data.channel_id)); if (!channel) { throw new UnknownChannelError(); } this.assertWebhookTargetChannel(channel); if (channel.guildId !== webhook.guildId) { throw new UnknownChannelError(); } channelId = channel.id; } return {name: name!, avatarHash, channelId}; }
private async updateAvatar({ webhookId, avatar, }: { webhookId: WebhookID; avatar: string | null; }): Promise<string | null> { return this.avatarService.uploadAvatar({ prefix: 'avatars', entityId: webhookId, errorPath: 'avatar', base64Image: avatar, }); }
private async getWebhookAvatar({ webhookId, avatarUrl, }: { webhookId: WebhookID; avatarUrl: string | null; }): Promise<string | null> { if (!avatarUrl) return null; try { const cacheKey = `webhook:${webhookId}:avatar:${avatarUrl}`; const avatarCache = await this.cacheService.get<string>(cacheKey); if (avatarCache === WEBHOOK_AVATAR_MISSING_CACHE_VALUE) return null; if (avatarCache) return avatarCache; const metadata = await this.mediaService.getMetadata({ type: 'external', url: avatarUrl, with_base64: true, nsfw: 'block', }); if (!metadata?.base64) { await this.cacheService.set(cacheKey, WEBHOOK_AVATAR_MISSING_CACHE_VALUE, seconds('5 minutes')); return null; } const avatar = await this.avatarService.uploadAvatar({ prefix: 'avatars', entityId: webhookId, errorPath: 'avatar', base64Image: metadata.base64, }); await this.cacheService.set(cacheKey, avatar, seconds('1 day')); return avatar; } catch (error) { Logger.warn( {error, webhookId: webhookId.toString(), avatarUrl}, 'Failed to fetch webhook avatar, proceeding without custom avatar', ); return null; } }
private async getGitHubWebhookAvatar(webhookId: WebhookID): Promise<string | null> { return this.getStaticWebhookAvatar({ webhookId, provider: 'github', assetFileName: 'github.webp', }); }
private async getStaticWebhookAvatar({ webhookId, provider, assetFileName, }: { webhookId: WebhookID; provider: 'github'; assetFileName: 'github.webp'; }): Promise<string | null> { const cacheKey = `webhook:${webhookId}:avatar:${provider}`; const avatarCache = await this.cacheService.get<string | null>(cacheKey); if (avatarCache) return avatarCache; const avatarFile = await fs.readFile(new URL(`../assets/${assetFileName}`, import.meta.url)); const avatar = await this.avatarService.uploadAvatar({ prefix: 'avatars', entityId: webhookId, errorPath: 'avatar', base64Image: avatarFile.toString('base64'), }); await this.cacheService.set(cacheKey, avatar, seconds('1 day')); return avatar; }
private getWebhookMetadata(webhook: Webhook): Record<string, string> | undefined { if (!webhook.channelId) { return undefined; } return {channel_id: webhook.channelId.toString()}; }
private serializeWebhookForAudit(webhook: Webhook): Record<string, unknown> { return { id: webhook.id.toString(), guild_id: webhook.guildId?.toString() ?? null, channel_id: webhook.channelId?.toString() ?? null, name: webhook.name, creator_id: webhook.creatorId?.toString() ?? null, avatar_hash: webhook.avatarHash, type: webhook.type, }; }
private async recordWebhookAuditLog(params: { guildId: GuildID; userId: UserID; action: 'create' | 'update' | 'delete'; webhook: Webhook; previousSnapshot?: Record<string, unknown> | null; auditLogReason?: string | null; }): Promise<void> { const actionName = params.action === 'create' ? 'guild_webhook_create' : params.action === 'update' ? 'guild_webhook_update' : 'guild_webhook_delete'; const previousSnapshot = params.action === 'create' ? null : (params.previousSnapshot ?? this.serializeWebhookForAudit(params.webhook)); const nextSnapshot = params.action === 'delete' ? null : this.serializeWebhookForAudit(params.webhook); const changes = this.guildAuditLogService.computeChanges(previousSnapshot, nextSnapshot); const actionType = params.action === 'create' ? AuditLogActionType.WEBHOOK_CREATE : params.action === 'update' ? AuditLogActionType.WEBHOOK_UPDATE : AuditLogActionType.WEBHOOK_DELETE; try { await this.guildAuditLogService .createBuilder(params.guildId, params.userId) .withAction(actionType, params.webhook.id.toString()) .withReason(params.auditLogReason ?? null) .withMetadata(this.getWebhookMetadata(params.webhook)) .withChanges(changes) .commit(); } catch (error) { Logger.error( { error, guildId: params.guildId.toString(), userId: params.userId.toString(), action: actionName, targetId: params.webhook.id.toString(), }, 'Failed to record guild webhook audit log', ); } }
private resolveWebhookLimit(guildFeatures: Iterable<string> | null, key: LimitKey, fallback: number): number { const ctx = createLimitMatchContext({guildFeatures}); return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback); }}