Files
fluxer/fluxer_api/src/api/webhook/WebhookController.ts
T

545 lines
20 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {
ChannelIdParam,
GuildIdParam,
WebhookIdParam,
WebhookIdTokenMessageIdParam,
WebhookIdTokenParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {MessageRequestSchema} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
import {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
import {
SlackWebhookRequest,
WebhookCreateRequest,
WebhookExecuteQueryRequest,
WebhookMessageEditRequest,
WebhookMessageRequest,
WebhookMultipartMessageRequest,
WebhookTokenUpdateRequest,
WebhookUpdateRequest,
} from '@fluxer/schema/src/domains/webhook/WebhookRequestSchemas';
import {WebhookResponse, WebhookTokenResponse} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
import type {Context} from 'hono';
import {z} from 'zod';
import {createChannelID, createGuildID, createMessageID, createWebhookID, createWebhookToken} from '../BrandedTypes';
import type {MessageRequest} from '../channel/MessageTypes';
import {normalizeMessageRequestPayload} from '../channel/services/message/MessageRequestCompatibility';
import {parseMultipartMessageData} from '../channel/services/message/MessageRequestParser';
import {LoginRequired} from '../middleware/AuthMiddleware';
import {BlockAppOriginMiddleware} from '../middleware/BlockAppOriginMiddleware';
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../RateLimitConfig';
import type {HonoApp, HonoEnv} from '../types/HonoEnv';
import {parseJsonPreservingLargeIntegers} from '../utils/LosslessJsonParser';
import {Validator} from '../Validator';
import type {WebhookExecuteMessageData} from './WebhookService';
function validateWebhookMessagePayload(data: unknown): WebhookMessageRequest {
const validationResult = WebhookMessageRequest.safeParse(normalizeMessageRequestPayload(data));
if (!validationResult.success) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
return validationResult.data;
}
function validateWebhookMultipartMessagePayload(data: unknown): WebhookMultipartMessageRequest {
const validationResult = WebhookMultipartMessageRequest.safeParse(normalizeMessageRequestPayload(data));
if (!validationResult.success) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
return validationResult.data;
}
async function parseWebhookJsonMessageData(ctx: Context<HonoEnv>): Promise<WebhookExecuteMessageData> {
let data: unknown;
try {
const raw = await ctx.req.text();
data = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
} catch {
data = {};
}
return validateWebhookMessagePayload(data);
}
async function parseWebhookMultipartMessageData(
ctx: Context<HonoEnv>,
webhookId: bigint,
token: string,
): Promise<WebhookExecuteMessageData> {
const webhook = await ctx.get('webhookService').getWebhookByToken({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
});
if (!webhook.creatorId) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
const creator = await ctx.get('userRepository').findUnique(webhook.creatorId);
if (!creator) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
let parsedPayload: unknown = null;
const messageData: MessageRequest = await parseMultipartMessageData(
ctx,
creator,
webhook.channelId!,
MessageRequestSchema,
{
onPayloadParsed(payload) {
parsedPayload = payload;
},
},
);
if (!parsedPayload) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
const webhookData = validateWebhookMultipartMessagePayload(parsedPayload);
return {
...webhookData,
...messageData,
attachments: messageData.attachments,
username: webhookData.username,
avatar_url: webhookData.avatar_url,
};
}
export function WebhookController(app: HonoApp) {
app.get(
'/guilds/:guild_id/webhooks',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_LIST_GUILD),
LoginRequired,
OpenAPI({
operationId: 'list_guild_webhooks',
summary: 'List guild webhooks',
description:
'Returns a list of all webhooks configured in the specified guild. Requires the user to have appropriate permissions to view webhooks in the guild.',
responseSchema: z.array(WebhookResponse),
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
}),
Validator('param', GuildIdParam),
async (ctx) => {
const response = await ctx.get('webhookRequestService').listGuildWebhooks({
userId: ctx.get('user').id,
guildId: createGuildID(ctx.req.valid('param').guild_id),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.get(
'/channels/:channel_id/webhooks',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_LIST_CHANNEL),
LoginRequired,
OpenAPI({
operationId: 'list_channel_webhooks',
summary: 'List channel webhooks',
description:
'Returns a list of all webhooks configured in the specified channel. Requires the user to have appropriate permissions to view webhooks in the channel.',
responseSchema: z.array(WebhookResponse),
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
}),
Validator('param', ChannelIdParam),
async (ctx) => {
const response = await ctx.get('webhookRequestService').listChannelWebhooks({
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.post(
'/channels/:channel_id/webhooks',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_CREATE),
LoginRequired,
OpenAPI({
operationId: 'create_webhook',
summary: 'Create webhook',
description:
'Creates a new webhook in the specified channel with the provided name and optional avatar. Returns the newly created webhook object including its ID and token.',
responseSchema: WebhookResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
}),
Validator('param', ChannelIdParam),
Validator('json', WebhookCreateRequest),
async (ctx) => {
const auditLogReason = ctx.get('auditLogReason') ?? null;
const response = await ctx.get('webhookRequestService').createWebhook({
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
auditLogReason,
});
return ctx.json(response);
},
);
app.get(
'/webhooks/:webhook_id',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GET),
LoginRequired,
OpenAPI({
operationId: 'get_webhook',
summary: 'Get webhook',
description:
'Retrieves detailed information about a specific webhook by its ID. Requires authentication and appropriate permissions to access the webhook.',
responseSchema: WebhookResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
}),
Validator('param', WebhookIdParam),
async (ctx) => {
const response = await ctx.get('webhookRequestService').getWebhook({
userId: ctx.get('user').id,
webhookId: createWebhookID(ctx.req.valid('param').webhook_id),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.patch(
'/webhooks/:webhook_id',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_UPDATE),
LoginRequired,
OpenAPI({
operationId: 'update_webhook',
summary: 'Update webhook',
description:
'Updates the specified webhook with new settings such as name, avatar, or target channel. All fields are optional. Returns the updated webhook object.',
responseSchema: WebhookResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
}),
Validator('param', WebhookIdParam),
Validator('json', WebhookUpdateRequest),
async (ctx) => {
const auditLogReason = ctx.get('auditLogReason') ?? null;
const response = await ctx.get('webhookRequestService').updateWebhook({
userId: ctx.get('user').id,
webhookId: createWebhookID(ctx.req.valid('param').webhook_id),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
auditLogReason,
});
return ctx.json(response);
},
);
app.delete(
'/webhooks/:webhook_id',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_DELETE),
LoginRequired,
OpenAPI({
operationId: 'delete_webhook',
summary: 'Delete webhook',
description:
'Permanently deletes the specified webhook. This action cannot be undone. Returns a 204 status code on successful deletion.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Webhooks'],
}),
Validator('param', WebhookIdParam),
async (ctx) => {
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('webhookRequestService').deleteWebhook({
userId: ctx.get('user').id,
webhookId: createWebhookID(ctx.req.valid('param').webhook_id),
auditLogReason,
});
return ctx.body(null, 204);
},
);
app.get(
'/webhooks/:webhook_id/:token',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GET),
OpenAPI({
operationId: 'get_webhook_with_token',
summary: 'Get webhook with token',
description:
'Retrieves detailed information about a specific webhook using its ID and token. No authentication required as the token serves as the credential. Returns the webhook object without creator user data.',
responseSchema: WebhookTokenResponse,
statusCode: 200,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
const response = await ctx.get('webhookRequestService').getWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.patch(
'/webhooks/:webhook_id/:token',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_UPDATE),
OpenAPI({
operationId: 'update_webhook_with_token',
summary: 'Update webhook with token',
description:
'Updates the specified webhook using its ID and token for authentication. Allows modification of name or avatar. Returns the updated webhook object without creator user data.',
responseSchema: WebhookTokenResponse,
statusCode: 200,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
Validator('json', WebhookTokenUpdateRequest),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
const response = await ctx.get('webhookRequestService').updateWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.delete(
'/webhooks/:webhook_id/:token',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_DELETE),
OpenAPI({
operationId: 'delete_webhook_with_token',
summary: 'Delete webhook with token',
description:
'Permanently deletes the specified webhook using its ID and token for authentication. This action cannot be undone. Returns a 204 status code on successful deletion.',
responseSchema: null,
statusCode: 204,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
await ctx.get('webhookRequestService').deleteWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
});
return ctx.body(null, 204);
},
);
app.post(
'/webhooks/:webhook_id/:token',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_EXECUTE),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'execute_webhook',
summary: 'Execute webhook',
description:
'Executes the webhook by sending a message to its configured channel. If the wait query parameter is true, returns the created message object; otherwise returns a 204 status with no content.',
responseSchema: MessageResponseSchema,
requestSchema: WebhookMessageRequest,
requestFormSchema: WebhookMultipartMessageRequest,
statusCode: 200,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
Validator('query', WebhookExecuteQueryRequest),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
const {wait} = ctx.req.valid('query');
const contentType = ctx.req.header('content-type') ?? '';
const data = contentType.includes('multipart/form-data')
? await parseWebhookMultipartMessageData(ctx, webhookId, token)
: await parseWebhookJsonMessageData(ctx);
const response = await ctx.get('webhookRequestService').executeWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
data,
wait,
requestCache: ctx.get('requestCache'),
});
if (!response) {
return ctx.body(null, 204);
}
return ctx.json(response);
},
);
app.get(
'/webhooks/:webhook_id/:token/messages/:message_id',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_MESSAGE_GET),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'get_webhook_message',
summary: 'Get webhook message',
description:
'Retrieves a message previously sent by the webhook. Only messages authored by the webhook can be retrieved.',
responseSchema: MessageResponseSchema,
statusCode: 200,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenMessageIdParam),
async (ctx) => {
const {webhook_id: webhookId, token, message_id: messageId} = ctx.req.valid('param');
const response = await ctx.get('webhookRequestService').getWebhookMessage({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
messageId: createMessageID(messageId),
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.patch(
'/webhooks/:webhook_id/:token/messages/:message_id',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_MESSAGE_EDIT),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'edit_webhook_message',
summary: 'Edit webhook message',
description:
'Edits a message previously sent by the webhook. Only messages authored by the webhook can be edited. Returns the updated message object.',
responseSchema: MessageResponseSchema,
statusCode: 200,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenMessageIdParam),
Validator('json', WebhookMessageEditRequest),
async (ctx) => {
const {webhook_id: webhookId, token, message_id: messageId} = ctx.req.valid('param');
const data = ctx.req.valid('json');
const response = await ctx.get('webhookRequestService').editWebhookMessage({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
messageId: createMessageID(messageId),
data,
requestCache: ctx.get('requestCache'),
});
return ctx.json(response);
},
);
app.delete(
'/webhooks/:webhook_id/:token/messages/:message_id',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_MESSAGE_DELETE),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'delete_webhook_message',
summary: 'Delete webhook message',
description:
'Deletes a message previously sent by the webhook. Only messages authored by the webhook can be deleted. Returns a 204 status code on successful deletion.',
responseSchema: null,
statusCode: 204,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenMessageIdParam),
async (ctx) => {
const {webhook_id: webhookId, token, message_id: messageId} = ctx.req.valid('param');
await ctx.get('webhookRequestService').deleteWebhookMessage({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
messageId: createMessageID(messageId),
requestCache: ctx.get('requestCache'),
});
return ctx.body(null, 204);
},
);
app.post(
'/webhooks/:webhook_id/:token/github',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GITHUB),
OpenAPI({
operationId: 'execute_github_webhook',
summary: 'Execute GitHub webhook',
description:
'Receives and processes GitHub webhook events, formatting them as messages in the configured channel. Reads event type from X-GitHub-Event header and delivery ID from X-GitHub-Delivery header.',
responseSchema: null,
statusCode: 204,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
Validator('json', GitHubWebhook),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
await ctx.get('webhookRequestService').executeGitHubWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
event: ctx.req.header('X-GitHub-Event') ?? '',
delivery: ctx.req.header('X-GitHub-Delivery') ?? '',
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.body(null, 204);
},
);
app.post(
'/webhooks/:webhook_id/:token/slack',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_EXECUTE),
OpenAPI({
operationId: 'execute_slack_webhook',
summary: 'Execute Slack webhook',
description:
'Receives and processes Slack-formatted webhook payloads, converting them to messages in the configured channel. Returns "ok" as plain text with a 200 status code.',
responseSchema: z.string(),
statusCode: 200,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
Validator('json', SlackWebhookRequest),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
await ctx.get('webhookRequestService').executeSlackWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
ctx.header('Content-Type', 'text/html; charset=utf-8');
return ctx.body('ok', 200);
},
);
app.post(
'/webhooks/:webhook_id/:token/instatus',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_INSTATUS),
OpenAPI({
operationId: 'execute_instatus_webhook',
summary: 'Execute Instatus webhook',
description:
'Receives and processes Instatus status page webhook events, formatting them as messages in the configured channel.',
responseSchema: null,
statusCode: 204,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
Validator('json', InstatusWebhook),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
await ctx.get('webhookRequestService').executeInstatusWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.body(null, 204);
},
);
app.post('/webhooks/livekit', async (ctx) => {
const response = await ctx.get('webhookRequestService').handleLiveKitWebhook({
body: await ctx.req.text(),
authHeader: ctx.req.header('Authorization') ?? undefined,
});
return response;
});
app.post('/webhooks/sweego', async (ctx) => {
const response = await ctx.get('webhookRequestService').handleSweegoWebhook({
body: await ctx.req.text(),
webhookId: ctx.req.header('webhook-id') ?? undefined,
timestamp: ctx.req.header('webhook-timestamp') ?? undefined,
signature: ctx.req.header('webhook-signature') ?? undefined,
});
return response;
});
}