summary refs log tree commit diff
path: root/src/api
diff options
context:
space:
mode:
authorCyber <mateusz.hf18@gmail.com>2024-08-10 20:22:59 +0200
committerGitHub <noreply@github.com>2024-08-10 20:22:59 +0200
commitd9b4b569bdf6a4fb68a7b286c2896b48716738ee (patch)
treee480b52a5678ea3770bd273f33aef3a740a155a5 /src/api
parentfix: reaction removing (diff)
parentMerge pull request #1149 from DEVTomatoCake/feat/webhooks-3 (diff)
downloadserver-ts-d9b4b569bdf6a4fb68a7b286c2896b48716738ee.tar.xz
Merge branch 'master' into fix/reaction-removing
Diffstat (limited to 'src/api')
-rw-r--r--src/api/middlewares/Authentication.ts4
-rw-r--r--src/api/routes/channels/#channel_id/webhooks.ts45
-rw-r--r--src/api/routes/guilds/#guild_id/webhooks.ts55
-rw-r--r--src/api/routes/webhooks/#webhook_id/#token/index.ts251
-rw-r--r--src/api/routes/webhooks/#webhook_id/index.ts57
-rw-r--r--src/api/util/handlers/Message.ts133
6 files changed, 489 insertions, 56 deletions
diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts

index 9e41b453f..a6cad51cf 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts
@@ -16,8 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -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..2060760d5 100644 --- a/src/api/routes/channels/#channel_id/webhooks.ts +++ b/src/api/routes/channels/#channel_id/webhooks.ts
@@ -1,17 +1,17 @@ /* 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 <https://www.gnu.org/licenses/>. */ @@ -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,32 @@ router.get( }, }), async (req: Request, res: Response) => { - res.json([]); + const { channel_id } = req.params; + const webhooks = await Webhook.find({ + where: { channel_id }, + relations: [ + "user", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + + const instanceUrl = + Config.get().api.endpointPublic || "http://localhost:3001"; + return res.json( + webhooks.map((webhook) => ({ + ...webhook, + url: + instanceUrl + + "/webhooks/" + + webhook.id + + "/" + + webhook.token, + })), + ); }, ); @@ -89,15 +116,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..47e199475 100644 --- a/src/api/routes/guilds/#guild_id/webhooks.ts +++ b/src/api/routes/guilds/#guild_id/webhooks.ts
@@ -1,27 +1,66 @@ /* 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 <https://www.gnu.org/licenses/>. */ -import { Router, Response, Request } from "express"; import { route } from "@spacebar/api"; +import { Config, 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", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + + const instanceUrl = + Config.get().api.endpointPublic || "http://localhost:3001"; + return res.json( + webhooks.map((webhook) => ({ + ...webhook, + url: + instanceUrl + + "/webhooks/" + + webhook.id + + "/" + + webhook.token, + })), + ); + }, +); + 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..8e0ad0dd3 --- /dev/null +++ b/src/api/routes/webhooks/#webhook_id/#token/index.ts
@@ -0,0 +1,251 @@ +import { handleMessage, postHandleMessage, route } from "@spacebar/api"; +import { + Attachment, + Config, + DiscordApiErrors, + FieldErrors, + Message, + MessageCreateEvent, + Webhook, + WebhookExecuteSchema, + emitEvent, + 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 and token.", + 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: [ + "user", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + + if (!webhook) { + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } + + if (webhook.token !== token) { + throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED; + } + + const instanceUrl = + Config.get().api.endpointPublic || "http://localhost:3001"; + return res.json({ + ...webhook, + url: instanceUrl + "/webhooks/" + webhook.id + "/" + webhook.token, + }); + }, +); + +// 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 +// TODO: GitHub/Slack compatible hooks +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 { wait } = req.query; + if (!wait) return res.status(204).send(); + + 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, + // TODO: Support thread_id/thread_name once threads are implemented + 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; + + await Promise.all([ + message.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: webhook.channel_id, + data: message, + } as MessageCreateEvent), + ]); + + // no await as it shouldnt block the message send function and silently catch error + postHandleMessage(message).catch((e) => + console.error("[Message] post-message handler failed", e), + ); + + return res.json(message); + }, +); + +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..59fdb76df --- /dev/null +++ b/src/api/routes/webhooks/#webhook_id/index.ts
@@ -0,0 +1,57 @@ +import { route } from "@spacebar/api"; +import { + Config, + DiscordApiErrors, + getPermission, + 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. Requires the MANAGE_WEBHOOKS permission or to be the owner of the webhook.", + 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", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + + if (webhook.guild_id) { + const permission = await getPermission( + req.user_id, + webhook.guild_id, + ); + + if (!permission.has("MANAGE_WEBHOOKS")) + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } else if (webhook.user_id != req.user_id) + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + + const instanceUrl = + Config.get().api.endpointPublic || "http://localhost:3001"; + return res.json({ + ...webhook, + url: instanceUrl + "/webhooks/" + webhook.id + "/" + webhook.token, + }); + }, +); + +export default router; diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts
index c36586682..f037417a5 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts
@@ -43,9 +43,12 @@ import { //CHANNEL_MENTION, USER_MENTION, Webhook, + handleFile, + Permissions, } from "@spacebar/util"; import { HTTPError } from "lambert-server"; import { In } from "typeorm"; +import fetch from "node-fetch"; const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images @@ -93,52 +96,102 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> { where: { id: opts.application_id }, }); } + + let permission: undefined | Permissions; if (opts.webhook_id) { message.webhook = await Webhook.findOneOrFail({ where: { id: opts.webhook_id }, }); - } - const permission = await getPermission( - opts.author_id, - channel.guild_id, - opts.channel_id, - ); - permission.hasThrow("SEND_MESSAGES"); - if (permission.cache.member) { - message.member = permission.cache.member; - } + message.author = + (await User.findOne({ + where: { id: opts.webhook_id }, + })) || undefined; - if (opts.tts) permission.hasThrow("SEND_TTS_MESSAGES"); - if (opts.message_reference) { - permission.hasThrow("READ_MESSAGE_HISTORY"); - // code below has to be redone when we add custom message routing - if (message.guild_id !== null) { - const guild = await Guild.findOneOrFail({ - where: { id: channel.guild_id }, + if (!message.author) { + message.author = User.create({ + id: opts.webhook_id, + username: message.webhook.name, + discriminator: "0000", + avatar: message.webhook.avatar, + public_flags: 0, + premium: false, + premium_type: 0, + bot: true, + created_at: new Date(), + verified: true, + rights: "0", + data: { + valid_tokens_since: new Date(), + }, }); - if (!opts.message_reference.guild_id) - opts.message_reference.guild_id = channel.guild_id; - if (!opts.message_reference.channel_id) - opts.message_reference.channel_id = opts.channel_id; + await message.author.save(); + } - if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) { - if (opts.message_reference.guild_id !== channel.guild_id) - throw new HTTPError( - "You can only reference messages from this guild", - ); - if (opts.message_reference.channel_id !== opts.channel_id) - throw new HTTPError( - "You can only reference messages from this channel", - ); - } + if (opts.username) { + message.username = opts.username; + message.author.username = message.username; + } + if (opts.avatar_url) { + const avatarData = await fetch(opts.avatar_url); + const base64 = await avatarData + .buffer() + .then((x) => x.toString("base64")); + + const dataUri = + "data:" + + avatarData.headers.get("content-type") + + ";base64," + + base64; - message.message_reference = opts.message_reference; + message.avatar = await handleFile( + `/avatars/${opts.webhook_id}`, + dataUri as string, + ); + message.author.avatar = message.avatar; + } + } else { + permission = await getPermission( + opts.author_id, + channel.guild_id, + opts.channel_id, + ); + permission.hasThrow("SEND_MESSAGES"); + if (permission.cache.member) { + message.member = permission.cache.member; + } + + if (opts.tts) permission.hasThrow("SEND_TTS_MESSAGES"); + if (opts.message_reference) { + permission.hasThrow("READ_MESSAGE_HISTORY"); + // code below has to be redone when we add custom message routing + if (message.guild_id !== null) { + const guild = await Guild.findOneOrFail({ + where: { id: channel.guild_id }, + }); + if (!opts.message_reference.guild_id) + opts.message_reference.guild_id = channel.guild_id; + if (!opts.message_reference.channel_id) + opts.message_reference.channel_id = opts.channel_id; + + if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) { + if (opts.message_reference.guild_id !== channel.guild_id) + throw new HTTPError( + "You can only reference messages from this guild", + ); + if (opts.message_reference.channel_id !== opts.channel_id) + throw new HTTPError( + "You can only reference messages from this channel", + ); + } + + message.message_reference = opts.message_reference; + } + /** Q: should be checked if the referenced message exists? ANSWER: NO + otherwise backfilling won't work **/ + message.type = MessageType.REPLY; } - /** Q: should be checked if the referenced message exists? ANSWER: NO - otherwise backfilling won't work **/ - message.type = MessageType.REPLY; } // TODO: stickers/activity @@ -183,14 +236,18 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> { const role = await Role.findOneOrFail({ where: { id: mention, guild_id: channel.guild_id }, }); - if (role.mentionable || permission.has("MANAGE_ROLES")) { + if ( + role.mentionable || + opts.webhook_id || + permission?.has("MANAGE_ROLES") + ) { mention_role_ids.push(mention); } }, ), ); - if (permission.has("MENTION_EVERYONE")) { + if (opts.webhook_id || permission?.has("MENTION_EVERYONE")) { mention_everyone = !!content.match(EVERYONE_MENTION) || !!content.match(HERE_MENTION); @@ -316,4 +373,6 @@ interface MessageOptions extends MessageCreateSchema { attachments?: Attachment[]; edited_timestamp?: Date; timestamp?: Date; + username?: string; + avatar_url?: string; }