diff --git a/assets/openapi.json b/assets/openapi.json index edd49baa..c9fdd76c 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json old mode 100644 new mode 100755 index eee1b9e0..7af77953 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/flake.lock b/flake.lock index 2537edd5..73906b90 100644 Binary files a/flake.lock and b/flake.lock differ diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index b4f48477..f0f01252 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -32,7 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [ "POST /auth/reset", "GET /invites/", // Routes with a seperate auth system - /^(POST|HEAD) \/webhooks\/\d+\/\w+\/?/, // no token requires auth + /^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/\w+\/?/, // no token requires auth // Public information endpoints "GET /ping", "GET /gateway", diff --git a/src/api/routes/channels/#channel_id/webhooks.ts b/src/api/routes/channels/#channel_id/webhooks.ts index 2060760d..0df53a86 100644 --- a/src/api/routes/channels/#channel_id/webhooks.ts +++ b/src/api/routes/channels/#channel_id/webhooks.ts @@ -28,6 +28,8 @@ import { handleFile, isTextChannel, trimSpecial, + FieldErrors, + ValidateName, } from "@spacebar/util"; import crypto from "crypto"; import { Request, Response, Router } from "express"; @@ -111,8 +113,9 @@ router.post( name = trimSpecial(name); // TODO: move this - if (name === "clyde") throw new HTTPError("Invalid name", 400); - if (name === "Spacebar Ghost") throw new HTTPError("Invalid name", 400); + if (name) { + ValidateName(name); + } if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar); diff --git a/src/api/routes/policies/instance/config.ts b/src/api/routes/policies/instance/config.ts new file mode 100755 index 00000000..6cd1dc2d --- /dev/null +++ b/src/api/routes/policies/instance/config.ts @@ -0,0 +1,74 @@ +/* + 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 { route } from "@spacebar/api"; +import { Config, getRights } from "@spacebar/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.get( + "/", + route({ + responses: { + 200: { + body: "Object", + }, + }, + }), + async (req: Request, res: Response) => { + const general = Config.get(); + let outputtedConfig; + if (req.user_id) { + const rights = await getRights(req.user_id); + if (rights.has("OPERATOR")) outputtedConfig = general; + } else { + outputtedConfig = { + limits_user_maxGuilds: general.limits.user.maxGuilds, + limits_user_maxBio: general.limits.user.maxBio, + limits_guild_maxEmojis: general.limits.guild.maxEmojis, + limits_guild_maxRoles: general.limits.guild.maxRoles, + limits_message_maxCharacters: + general.limits.message.maxCharacters, + limits_message_maxAttachmentSize: + general.limits.message.maxAttachmentSize, + limits_message_maxEmbedDownloadSize: + general.limits.message.maxEmbedDownloadSize, + limits_channel_maxWebhooks: general.limits.channel.maxWebhooks, + register_dateOfBirth_requiredc: + general.register.dateOfBirth.required, + register_password_required: general.register.password.required, + register_disabled: general.register.disabled, + register_requireInvite: general.register.requireInvite, + register_allowNewRegistration: + general.register.allowNewRegistration, + register_allowMultipleAccounts: + general.register.allowMultipleAccounts, + guild_autoJoin_canLeave: general.guild.autoJoin.canLeave, + guild_autoJoin_guilds_x: general.guild.autoJoin.guilds, + register_email_required: general.register.email.required, + can_recover_account: + general.email.provider != null && + general.general.frontPage != null, + }; + } + res.send(outputtedConfig); + }, +); + +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 index 8e0ad0dd..0de43af3 100644 --- a/src/api/routes/webhooks/#webhook_id/#token/index.ts +++ b/src/api/routes/webhooks/#webhook_id/#token/index.ts @@ -10,6 +10,10 @@ import { WebhookExecuteSchema, emitEvent, uploadFile, + WebhooksUpdateEvent, + WebhookUpdateSchema, + handleFile, + ValidateName, } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; @@ -129,13 +133,8 @@ router.post( // 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}"`], - }); - } + if (body.username) { + ValidateName(body.username); } // block username from being certain words @@ -248,4 +247,109 @@ router.post( }, ); +router.delete( + "/", + route({ + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 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; + } + const channel_id = webhook.channel_id; + await Webhook.delete({ id: webhook_id }); + + await emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent); + + res.sendStatus(204); + }, +); + +router.patch( + "/", + route({ + requestBody: "WebhookUpdateSchema", + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token } = req.params; + const body = req.body as WebhookUpdateSchema; + + const webhook = await Webhook.findOneOrFail({ + where: { id: webhook_id }, + relations: [ + "user", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + const channel_id = webhook.channel_id; + if (!body.name && !body.avatar) { + throw new HTTPError("Empty webhook updates are not allowed", 50006); + } + if (body.avatar) + body.avatar = await handleFile( + `/avatars/${webhook_id}`, + body.avatar as string, + ); + + if (body.name) { + ValidateName(body.name); + } + + webhook.assign(body); + + await Promise.all([ + webhook.save(), + emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent), + ]); + res.status(204); + }, +); + export default router; diff --git a/src/api/routes/webhooks/#webhook_id/index.ts b/src/api/routes/webhooks/#webhook_id/index.ts index 59fdb76d..baedc7f0 100644 --- a/src/api/routes/webhooks/#webhook_id/index.ts +++ b/src/api/routes/webhooks/#webhook_id/index.ts @@ -4,8 +4,16 @@ import { DiscordApiErrors, getPermission, Webhook, + WebhooksUpdateEvent, + emitEvent, + WebhookUpdateSchema, + Channel, + handleFile, + FieldErrors, + ValidateName, } from "@spacebar/util"; import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; const router = Router(); router.get( @@ -54,4 +62,139 @@ router.get( }, ); +router.delete( + "/", + route({ + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 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 channel_id = webhook.channel_id; + await Webhook.delete({ id: webhook_id }); + + await emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent); + + res.sendStatus(204); + }, +); + +router.patch( + "/", + route({ + requestBody: "WebhookUpdateSchema", + responses: { + 200: { + body: "WebhookCreateResponse", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id } = req.params; + const body = req.body as WebhookUpdateSchema; + + 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; + + if (!body.name && !body.avatar && !body.channel_id) { + throw new HTTPError("Empty webhook updates are not allowed", 50006); + } + + if (body.avatar) + body.avatar = await handleFile( + `/avatars/${webhook_id}`, + body.avatar as string, + ); + + if (body.name) { + ValidateName(body.name); + } + + const channel_id = body.channel_id || webhook.channel_id; + webhook.assign(body); + + if (body.channel_id) + webhook.assign({ + channel: await Channel.findOneOrFail({ + where: { id: channel_id }, + }), + }); + + await Promise.all([ + webhook.save(), + emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent), + ]); + + res.json(webhook); + }, +); + export default router; diff --git a/src/util/config/Config.ts b/src/util/config/Config.ts index 90b98b7a..56054641 100644 --- a/src/util/config/Config.ts +++ b/src/util/config/Config.ts @@ -37,6 +37,7 @@ import { SecurityConfiguration, SentryConfiguration, TemplateConfiguration, + UserConfiguration, } from "../config"; export class ConfigValue { @@ -61,4 +62,5 @@ export class ConfigValue { email: EmailConfiguration = new EmailConfiguration(); passwordReset: PasswordResetConfiguration = new PasswordResetConfiguration(); + user: UserConfiguration = new UserConfiguration(); } diff --git a/src/util/config/types/UsersConfiguration.ts b/src/util/config/types/UsersConfiguration.ts new file mode 100755 index 00000000..03e866d9 --- /dev/null +++ b/src/util/config/types/UsersConfiguration.ts @@ -0,0 +1,22 @@ +/* + 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 . +*/ + +export class UserConfiguration { + blockedContains: string[] = ["discord", "clyde", "spacebar"]; + blockedEquals: string[] = ["everyone", "here"]; +} diff --git a/src/util/config/types/index.ts b/src/util/config/types/index.ts index 782ebfc3..a141bacf 100644 --- a/src/util/config/types/index.ts +++ b/src/util/config/types/index.ts @@ -37,3 +37,4 @@ export * from "./SecurityConfiguration"; export * from "./SentryConfiguration"; export * from "./subconfigurations"; export * from "./TemplateConfiguration"; +export * from "./UsersConfiguration"; diff --git a/src/util/schemas/WebhookUpdateSchema.ts b/src/util/schemas/WebhookUpdateSchema.ts new file mode 100755 index 00000000..bc276e44 --- /dev/null +++ b/src/util/schemas/WebhookUpdateSchema.ts @@ -0,0 +1,23 @@ +/* + 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 . +*/ + +export interface WebhookUpdateSchema { + name?: string; + avatar?: string; + channel_id?: string; +} diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index 2972b1a5..ca473dce 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -86,4 +86,5 @@ export * from "./VoiceVideoSchema"; export * from "./WebAuthnSchema"; export * from "./WebhookCreateSchema"; export * from "./WebhookExecuteSchema"; +export * from "./WebhookUpdateSchema"; export * from "./WidgetModifySchema"; diff --git a/src/util/util/NameValidation.ts b/src/util/util/NameValidation.ts new file mode 100755 index 00000000..a6304535 --- /dev/null +++ b/src/util/util/NameValidation.ts @@ -0,0 +1,57 @@ +/* + 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 { Config } from "./Config"; +import { FieldErrors } from "./FieldError"; +import { HTTPError } from "lambert-server"; + +export function ValidateName(name: string) { + const check_username = name.replace(/\s/g, ""); + if (!check_username) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_REQUIRED", + message: "common:field.BASE_TYPE_REQUIRED", + }, + }); + } + const general = Config.get(); + const { maxUsername } = general.limits.user; + if (check_username.length > maxUsername || check_username.length < 2) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_BAD_LENGTH", + message: `Must be between 2 and ${maxUsername} in length.`, + }, + }); + } + + const { blockedContains, blockedEquals } = general.user; + for (const word of blockedContains) { + if (name.toLowerCase().includes(word)) { + throw new HTTPError(`Username cannot contain "${word}"`, 400); + } + } + + for (const word of blockedEquals) { + if (name.toLowerCase() === word) { + throw new HTTPError(`Username cannot be "${word}"`, 400); + } + } + return name; +} diff --git a/src/util/util/index.ts b/src/util/util/index.ts index 10e09b5c..f55315e3 100644 --- a/src/util/util/index.ts +++ b/src/util/util/index.ts @@ -43,3 +43,4 @@ export * from "./TraverseDirectory"; export * from "./WebAuthn"; export * from "./Gifs"; export * from "./Application"; +export * from "./NameValidation";