From 99d9bf563fb5e157600824776b149ca03cbea47c Mon Sep 17 00:00:00 2001 From: Puyodead1 Date: Sun, 10 Dec 2023 17:02:27 -0500 Subject: [PATCH] Start implementing webhooks --- assets/schemas.json | Bin 18300690 -> 18445000 bytes src/api/middlewares/Authentication.ts | 4 +- .../routes/channels/#channel_id/webhooks.ts | 25 +- src/api/routes/guilds/#guild_id/webhooks.ts | 35 ++- .../webhooks/#webhook_id/#token/index.ts | 215 ++++++++++++++++++ src/api/routes/webhooks/#webhook_id/index.ts | 32 +++ src/util/schemas/MessageCreateSchema.ts | 4 +- src/util/schemas/WebhookCreateSchema.ts | 1 - src/util/schemas/WebhookExecuteSchema.ts | 46 ++++ src/util/schemas/index.ts | 1 + src/util/util/Constants.ts | 2 +- 11 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 src/api/routes/webhooks/#webhook_id/#token/index.ts create mode 100644 src/api/routes/webhooks/#webhook_id/index.ts create mode 100644 src/util/schemas/WebhookExecuteSchema.ts diff --git a/assets/schemas.json b/assets/schemas.json index a0ab1697c99e4bad5533af3b5d5feaa87e003ecc..018ddacace716f96c6f3733a521e7a5daaa385ef 100644 GIT binary patch delta 6760 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 a#WB1*bvZHo?I$P3jXR1}%F. */ -import { checkToken, Rights } from "@spacebar/util"; import * as Sentry from "@sentry/node"; +import { checkToken, Rights } from "@spacebar/util"; import { NextFunction, Request, Response } from "express"; import { HTTPError } from "lambert-server"; @@ -32,7 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [ "/auth/forgot", "/auth/reset", // Routes with a seperate auth system - "/webhooks/", + /\/webhooks\/\d+\/\w+\/?/, // no token requires auth // Public information endpoints "/ping", "/gateway", diff --git a/src/api/routes/channels/#channel_id/webhooks.ts b/src/api/routes/channels/#channel_id/webhooks.ts index d54756a16..4c1ccbdf2 100644 --- a/src/api/routes/channels/#channel_id/webhooks.ts +++ b/src/api/routes/channels/#channel_id/webhooks.ts @@ -26,8 +26,8 @@ import { WebhookCreateSchema, WebhookType, handleFile, - trimSpecial, isTextChannel, + trimSpecial, } from "@spacebar/util"; import crypto from "crypto"; import { Request, Response, Router } from "express"; @@ -35,10 +35,12 @@ import { HTTPError } from "lambert-server"; const router: Router = Router(); -//TODO: implement webhooks router.get( "/", route({ + description: + "Returns a list of channel webhook objects. Requires the MANAGE_WEBHOOKS permission.", + permission: "MANAGE_WEBHOOKS", responses: { 200: { body: "APIWebhookArray", @@ -46,7 +48,18 @@ router.get( }, }), async (req: Request, res: Response) => { - res.json([]); + const { channel_id } = req.params; + const webhooks = await Webhook.find({ + where: { channel_id }, + relations: [ + "user", + "guild", + "source_guild", + "application" /*"source_channel"*/, + ], + }); + + return res.json(webhooks); }, ); @@ -89,15 +102,15 @@ router.post( if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar); - const hook = Webhook.create({ + const hook = await Webhook.create({ type: WebhookType.Incoming, name, avatar, guild_id: channel.guild_id, channel_id: channel.id, user_id: req.user_id, - token: crypto.randomBytes(24).toString("base64"), - }); + token: crypto.randomBytes(24).toString("base64url"), + }).save(); const user = await User.getPublicUser(req.user_id); diff --git a/src/api/routes/guilds/#guild_id/webhooks.ts b/src/api/routes/guilds/#guild_id/webhooks.ts index d58659a41..a2ef7d699 100644 --- a/src/api/routes/guilds/#guild_id/webhooks.ts +++ b/src/api/routes/guilds/#guild_id/webhooks.ts @@ -16,12 +16,37 @@ along with this program. If not, see . */ -import { Router, Response, Request } from "express"; import { route } from "@spacebar/api"; +import { Webhook } from "@spacebar/util"; +import { Request, Response, Router } from "express"; const router = Router(); -//TODO: implement webhooks -router.get("/", route({}), async (req: Request, res: Response) => { - res.json([]); -}); +router.get( + "/", + route({ + description: + "Returns a list of guild webhook objects. Requires the MANAGE_WEBHOOKS permission.", + permission: "MANAGE_WEBHOOKS", + responses: { + 200: { + body: "APIWebhookArray", + }, + }, + }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const webhooks = await Webhook.find({ + where: { guild_id }, + relations: [ + "user", + "guild", + "source_guild", + "application" /*"source_channel"*/, + ], + }); + + return res.json(webhooks); + }, +); + export default router; diff --git a/src/api/routes/webhooks/#webhook_id/#token/index.ts b/src/api/routes/webhooks/#webhook_id/#token/index.ts new file mode 100644 index 000000000..b47502b44 --- /dev/null +++ b/src/api/routes/webhooks/#webhook_id/#token/index.ts @@ -0,0 +1,215 @@ +import { handleMessage, route } from "@spacebar/api"; +import { + Attachment, + Config, + DiscordApiErrors, + FieldErrors, + Message, + Webhook, + WebhookExecuteSchema, + uploadFile, +} from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; +import multer from "multer"; +import { MoreThan } from "typeorm"; +const router = Router(); + +router.get( + "/", + route({ + description: "Returns a webhook object for the given id.", + responses: { + 200: { + body: "APIWebhook", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token } = req.params; + const webhook = await Webhook.findOne({ + where: { + id: webhook_id, + }, + relations: ["channel", "guild", "application"], + }); + + if (!webhook) { + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } + + if (webhook.token !== token) { + throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED; + } + + return res.json(webhook); + }, +); + +// TODO: config max upload size +const messageUpload = multer({ + limits: { + fileSize: Config.get().limits.message.maxAttachmentSize, + fields: 10, + // files: 1 + }, + storage: multer.memoryStorage(), +}); // max upload 50 mb + +// https://discord.com/developers/docs/resources/webhook#execute-webhook +router.post( + "/", + messageUpload.any(), + (req, res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); + } + + next(); + }, + route({ + requestBody: "WebhookExecuteSchema", + query: { + wait: { + type: "boolean", + required: false, + description: + "waits for server confirmation of message send before response, and returns the created message body", + }, + thread_id: { + type: "string", + required: false, + description: + "Send a message to the specified thread within a webhook's channel.", + }, + }, + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token } = req.params; + const body = req.body as WebhookExecuteSchema; + const attachments: Attachment[] = []; + + // ensure one of content, embeds, components, or file is present + if ( + !body.content && + !body.embeds && + !body.components && + !body.file && + !body.attachments + ) { + throw DiscordApiErrors.CANNOT_SEND_EMPTY_MESSAGE; + } + + // block username from containing certain words + // TODO: configurable additions + const blockedContains = ["discord", "clyde", "spacebar"]; + for (const word of blockedContains) { + if (body.username?.toLowerCase().includes(word)) { + return res.status(400).json({ + username: [`Username cannot contain "${word}"`], + }); + } + } + + // block username from being certain words + // TODO: configurable additions + const blockedEquals = ["everyone", "here"]; + for (const word of blockedEquals) { + if (body.username?.toLowerCase() === word) { + return res.status(400).json({ + username: [`Username cannot be "${word}"`], + }); + } + } + + const webhook = await Webhook.findOne({ + where: { + id: webhook_id, + }, + relations: ["channel", "guild", "application"], + }); + + if (!webhook) { + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } + + if (!webhook.channel.isWritable()) { + throw new HTTPError( + `Cannot send messages to channel of type ${webhook.channel.type}`, + 400, + ); + } + + if (webhook.token !== token) { + throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED; + } + + // TODO: creating messages by users checks if the user can bypass rate limits, we cant do that on webhooks, but maybe we could check the application if there is one? + const limits = Config.get().limits; + if (limits.absoluteRate.register.enabled) { + const count = await Message.count({ + where: { + channel_id: webhook.channel_id, + timestamp: MoreThan( + new Date( + Date.now() - limits.absoluteRate.sendMessage.window, + ), + ), + }, + }); + + if (count >= limits.absoluteRate.sendMessage.limit) + throw FieldErrors({ + channel_id: { + code: "TOO_MANY_MESSAGES", + message: req.t("common:toomany.MESSAGE"), + }, + }); + } + + const files = (req.files as Express.Multer.File[]) ?? []; + for (const currFile of files) { + try { + const file = await uploadFile( + `/attachments/${webhook.channel.id}`, + currFile, + ); + attachments.push( + Attachment.create({ ...file, proxy_url: file.url }), + ); + } catch (error) { + return res.status(400).json({ message: error?.toString() }); + } + } + + // TODO: set username and avatar based on body + + const embeds = body.embeds || []; + const message = await handleMessage({ + ...body, + type: 0, + pinned: false, + webhook_id: webhook.id, + application_id: webhook.application?.id, + embeds, + channel_id: webhook.channel_id, + attachments, + timestamp: new Date(), + }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore dont care2 + message.edited_timestamp = null; + + webhook.channel.last_message_id = message.id; + }, +); + +export default router; diff --git a/src/api/routes/webhooks/#webhook_id/index.ts b/src/api/routes/webhooks/#webhook_id/index.ts new file mode 100644 index 000000000..cc8c03865 --- /dev/null +++ b/src/api/routes/webhooks/#webhook_id/index.ts @@ -0,0 +1,32 @@ +import { route } from "@spacebar/api"; +import { Webhook } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +const router = Router(); + +router.get( + "/", + route({ + description: "Returns a webhook object for the given id.", + responses: { + 200: { + body: "APIWebhook", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id } = req.params; + const webhook = await Webhook.findOneOrFail({ + where: { id: webhook_id }, + relations: [ + "user", + "guild", + "source_guild", + "application" /*"source_channel"*/, + ], + }); + return res.json(webhook); + }, +); + +export default router; diff --git a/src/util/schemas/MessageCreateSchema.ts b/src/util/schemas/MessageCreateSchema.ts index 57abf62f9..8093a10a3 100644 --- a/src/util/schemas/MessageCreateSchema.ts +++ b/src/util/schemas/MessageCreateSchema.ts @@ -18,7 +18,7 @@ import { Embed } from "@spacebar/util"; -type Attachment = { +export type MessageCreateAttachment = { id: string; filename: string; }; @@ -52,7 +52,7 @@ export interface MessageCreateSchema { TODO: we should create an interface for attachments TODO: OpenWAAO<-->attachment-style metadata conversion **/ - attachments?: Attachment[]; + attachments?: MessageCreateAttachment[]; sticker_ids?: string[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any components?: any[]; diff --git a/src/util/schemas/WebhookCreateSchema.ts b/src/util/schemas/WebhookCreateSchema.ts index f92cb63e7..7bd0afa8d 100644 --- a/src/util/schemas/WebhookCreateSchema.ts +++ b/src/util/schemas/WebhookCreateSchema.ts @@ -16,7 +16,6 @@ along with this program. If not, see . */ -// TODO: webhooks export interface WebhookCreateSchema { /** * @maxLength 80 diff --git a/src/util/schemas/WebhookExecuteSchema.ts b/src/util/schemas/WebhookExecuteSchema.ts new file mode 100644 index 000000000..943cbe9ed --- /dev/null +++ b/src/util/schemas/WebhookExecuteSchema.ts @@ -0,0 +1,46 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Embed } from "../entities"; +import { MessageCreateAttachment } from "./MessageCreateSchema"; + +export interface WebhookExecuteSchema { + content?: string; + username?: string; + avatar_url?: string; + tts?: boolean; + embeds?: Embed[]; + allowed_mentions?: { + parse?: string[]; + roles?: string[]; + users?: string[]; + replied_user?: boolean; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + components?: any[]; + file?: { filename: string }; + payload_json?: string; + /** + TODO: we should create an interface for attachments + TODO: OpenWAAO<-->attachment-style metadata conversion + **/ + attachments?: MessageCreateAttachment[]; + flags?: number; + thread_name?: string; + applied_tags?: string[]; +} diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index 44a504cda..4812b5356 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -79,5 +79,6 @@ export * from "./VoiceStateUpdateSchema"; export * from "./VoiceVideoSchema"; export * from "./WebAuthnSchema"; export * from "./WebhookCreateSchema"; +export * from "./WebhookExecuteSchema"; export * from "./WidgetModifySchema"; export * from "./responses"; diff --git a/src/util/util/Constants.ts b/src/util/util/Constants.ts index e68bb0b7e..112b0cc42 100644 --- a/src/util/util/Constants.ts +++ b/src/util/util/Constants.ts @@ -576,7 +576,7 @@ export const DiscordApiErrors = { UNKNOWN_TOKEN: new ApiError("Unknown token", 10012), UNKNOWN_USER: new ApiError("Unknown user", 10013), UNKNOWN_EMOJI: new ApiError("Unknown emoji", 10014), - UNKNOWN_WEBHOOK: new ApiError("Unknown webhook", 10015), + UNKNOWN_WEBHOOK: new ApiError("Unknown webhook", 10015, 404), UNKNOWN_WEBHOOK_SERVICE: new ApiError("Unknown webhook service", 10016), UNKNOWN_CONNECTION: new ApiError("Unknown connection", 10017, 400), UNKNOWN_SESSION: new ApiError("Unknown session", 10020),