Merge pull request #1265 from thedudedies21/Update_Base_Webhooks

Finish Webhooks, and add config endpoint for client
This commit is contained in:
Puyodead1 2025-03-30 23:26:31 -04:00 committed by GitHub
commit 89abd2ec12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 5752 additions and 82 deletions

View File

@ -4620,7 +4620,9 @@
"channel": {
"$ref": "#/components/schemas/RateLimitOptions"
},
"auth": {}
"auth": {
"$ref": "#/components/schemas/AuthRateLimit"
}
},
"required": [
"auth",
@ -4629,6 +4631,21 @@
"webhook"
]
},
"AuthRateLimit": {
"type": "object",
"properties": {
"login": {
"$ref": "#/components/schemas/RateLimitOptions"
},
"register": {
"$ref": "#/components/schemas/RateLimitOptions"
}
},
"required": [
"login",
"register"
]
},
"GlobalRateLimits": {
"type": "object",
"properties": {
@ -5857,6 +5874,15 @@
},
"mention_count": {
"type": "integer"
},
"flags": {
"type": "integer"
},
"last_viewed": {
"type": "integer"
},
"token": {
"type": "string"
}
}
},
@ -8872,18 +8898,6 @@
"type": "string"
}
},
"enforce_nonce": {
"type": "boolean"
},
"nonce": {
"type": "string"
},
"poll": {
"$ref": "#/definitions/Poll"
},
"sticker_ids": {
"type": "string"
},
"message_reference": {
"type": "object",
"properties": {
@ -8895,12 +8909,44 @@
},
"guild_id": {
"type": "string"
},
"fail_if_not_exists": {
"type": "boolean"
}
},
"additionalProperties": false,
"required": [
"message_id"
]
},
"sticker_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"nonce": {
"type": "string"
},
"enforce_nonce": {
"type": "boolean"
},
"poll": {
"$ref": "#/components/schemas/PollCreationSchema"
}
}
},
"WebhookUpdateSchema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"avatar": {
"type": "string"
},
"channel_id": {
"type": "string"
}
}
},
@ -9144,6 +9190,104 @@
"tags": [
"webhooks"
]
},
"delete": {
"security": [
{
"bearer": []
}
],
"responses": {
"204": {
"description": "No description available"
},
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIErrorResponse"
}
}
}
},
"404": {
"description": "No description available"
}
},
"parameters": [
{
"name": "webhook_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "webhook_id"
}
],
"tags": [
"webhooks"
]
},
"patch": {
"security": [
{
"bearer": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookUpdateSchema"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookCreateResponse"
}
}
}
},
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIErrorResponse"
}
}
}
},
"403": {
"description": "No description available"
},
"404": {
"description": "No description available"
}
},
"parameters": [
{
"name": "webhook_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "webhook_id"
}
],
"tags": [
"webhooks"
]
}
},
"/webhooks/{webhook_id}/{token}/": {
@ -9268,6 +9412,122 @@
"tags": [
"webhooks"
]
},
"delete": {
"security": [
{
"bearer": []
}
],
"responses": {
"204": {
"description": "No description available"
},
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIErrorResponse"
}
}
}
},
"404": {
"description": "No description available"
}
},
"parameters": [
{
"name": "webhook_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "webhook_id"
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "token"
}
],
"tags": [
"webhooks"
]
},
"patch": {
"security": [
{
"bearer": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookUpdateSchema"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Message"
}
}
}
},
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIErrorResponse"
}
}
}
},
"403": {
"description": "No description available"
},
"404": {
"description": "No description available"
}
},
"parameters": [
{
"name": "webhook_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "webhook_id"
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "token"
}
],
"tags": [
"webhooks"
]
}
},
"/voice/regions/": {

4979
assets/schemas.json Normal file → Executable file

File diff suppressed because it is too large Load Diff

118
flake.lock generated
View File

@ -1,61 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1737062831,
"narHash": "sha256-Tbk1MZbtV2s5aG+iM99U8FqwxU/YNArMcWAv6clcsBc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5df43628fdf08d642be8ba5b3625a6c70731c19c",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1737062831,
"narHash": "sha256-Tbk1MZbtV2s5aG+iM99U8FqwxU/YNArMcWAv6clcsBc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5df43628fdf08d642be8ba5b3625a6c70731c19c",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -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",

View File

@ -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);

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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;

View File

@ -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;

View File

@ -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;

View File

@ -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();
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
export class UserConfiguration {
blockedContains: string[] = ["discord", "clyde", "spacebar"];
blockedEquals: string[] = ["everyone", "here"];
}

View File

@ -37,3 +37,4 @@ export * from "./SecurityConfiguration";
export * from "./SentryConfiguration";
export * from "./subconfigurations";
export * from "./TemplateConfiguration";
export * from "./UsersConfiguration";

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
export interface WebhookUpdateSchema {
name?: string;
avatar?: string;
channel_id?: string;
}

View File

@ -86,4 +86,5 @@ export * from "./VoiceVideoSchema";
export * from "./WebAuthnSchema";
export * from "./WebhookCreateSchema";
export * from "./WebhookExecuteSchema";
export * from "./WebhookUpdateSchema";
export * from "./WidgetModifySchema";

57
src/util/util/NameValidation.ts Executable file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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;
}

View File

@ -43,3 +43,4 @@ export * from "./TraverseDirectory";
export * from "./WebAuthn";
export * from "./Gifs";
export * from "./Application";
export * from "./NameValidation";