diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs index aa1b9bea69..7fc5b11e39 100644 --- a/.lintstagedrc.mjs +++ b/.lintstagedrc.mjs @@ -1,4 +1,9 @@ export default { "*.{j,t}s": [() => "npm run build:src:tsgo", "eslint --concurrency 4" /* sweet spot it seems */, "prettier --write"], - "src/schemas/{*,**/*}.ts": [() => "npm run build:src:tsgo", () => "node scripts/schema.js", () => "node scripts/openapi.js", () => "git add assets/schemas.json assets/openapi.json"], + "src/schemas/{*,**/*}.ts": [ + () => "npm run build:src:tsgo", + () => "node scripts/schema.js", + () => "node scripts/openapi.js", + () => "git add assets/schemas.json assets/openapi.json", + ], }; diff --git a/assets/openapi.json b/assets/openapi.json index aa965def46..a39dd25fe6 100644 --- a/assets/openapi.json +++ b/assets/openapi.json @@ -6930,6 +6930,109 @@ } } }, + "WebhookMessageEditSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "nullable": true + }, + "embeds": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/Embed" + } + }, + { + "type": "null" + } + ] + }, + "allowed_mentions": { + "anyOf": [ + { + "$ref": "#/components/schemas/AllowedMentions" + }, + { + "type": "null" + } + ] + }, + "components": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseMessageComponents" + } + }, + { + "type": "null" + } + ] + }, + "payload_json": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "filename", + "id" + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "uploaded_filename": { + "type": "string" + }, + "original_content_type": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "filename", + "uploaded_filename" + ] + } + ] + } + }, + { + "type": "null" + } + ] + }, + "flags": { + "type": "integer" + } + } + }, "WebhookUpdateSchema": { "type": "object", "properties": { @@ -14801,6 +14904,201 @@ ] } }, + "/webhooks/{webhook_id}/{token}/messages/{message_id}/": { + "get": { + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "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" + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "message_id" + } + ], + "tags": [ + "webhooks" + ] + }, + "patch": { + "security": [ + { + "bearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookMessageEditSchema" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "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" + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "message_id" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Edit a webhook message in the specified thread." + } + ], + "tags": [ + "webhooks" + ] + }, + "delete": { + "security": [ + { + "bearer": [] + } + ], + "responses": { + "204": { + "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" + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "message_id" + }, + { + "name": "thread_id", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Delete a webhook message in the specified thread." + } + ], + "tags": [ + "webhooks" + ] + } + }, "/webhooks/{webhook_id}/{token}/": { "get": { "security": [ diff --git a/assets/schemas.json b/assets/schemas.json index e28447eb84..a42f838680 100644 --- a/assets/schemas.json +++ b/assets/schemas.json @@ -7376,6 +7376,113 @@ "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, + "WebhookMessageEditSchema": { + "type": "object", + "properties": { + "content": { + "type": [ + "null", + "string" + ] + }, + "embeds": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/Embed" + } + }, + { + "type": "null" + } + ] + }, + "allowed_mentions": { + "anyOf": [ + { + "$ref": "#/definitions/AllowedMentions" + }, + { + "type": "null" + } + ] + }, + "components": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/BaseMessageComponents" + } + }, + { + "type": "null" + } + ] + }, + "payload_json": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "filename", + "id" + ] + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "uploaded_filename": { + "type": "string" + }, + "original_content_type": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "filename", + "uploaded_filename" + ] + } + ] + } + }, + { + "type": "null" + } + ] + }, + "flags": { + "type": "integer" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, "WebhookUpdateSchema": { "type": "object", "properties": { diff --git a/eslint.config.mjs b/eslint.config.mjs index 83ff1e783e..cd8f785e43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -66,7 +66,7 @@ export default defineConfig([ // "sort-imports": ["error", {}], "default-case": "error", "default-case-last": "error", - "yoda": "error", + yoda: "error", // unsure what the defaults are here, but we want them to error "for-direction": "error", "constructor-super": "error", diff --git a/src/api/middlewares/Authentication.test.ts b/src/api/middlewares/Authentication.test.ts new file mode 100644 index 0000000000..795db249ab --- /dev/null +++ b/src/api/middlewares/Authentication.test.ts @@ -0,0 +1,50 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 + MERCHANTIBILITY 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 assert from "node:assert/strict"; +import { before, describe, it } from "node:test"; + +let NO_AUTHORIZATION_ROUTES: (typeof import("./Authentication"))["NO_AUTHORIZATION_ROUTES"]; + +function skipsAuthentication(method: string, url: string) { + return NO_AUTHORIZATION_ROUTES.some((route) => { + if (typeof route === "string") { + return route === `${method} ${url}`; + } + + return route.test(`${method} ${url}`); + }); +} + +before(async () => { + process.env.DATABASE ??= "postgres://user:pass@localhost:5432/test"; + ({ NO_AUTHORIZATION_ROUTES } = await import("./Authentication.js")); +}); + +describe("Authentication webhook bypass routes", () => { + it("allows unauthenticated webhook requests with base64url tokens that start with a dash", () => { + assert.equal(skipsAuthentication("GET", "/webhooks/123/-abc_DEF/messages/456"), true); + assert.equal(skipsAuthentication("PATCH", "/webhooks/123/-abc_DEF/messages/456"), true); + assert.equal(skipsAuthentication("DELETE", "/webhooks/123/-abc_DEF/messages/456"), true); + }); + + it("bounds webhook token segments before the next slash or end of URL", () => { + assert.equal(skipsAuthentication("GET", "/webhooks/123/abc_DEF"), true); + assert.equal(skipsAuthentication("GET", "/webhooks/123/abc.DEF/messages/456"), false); + }); +}); diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index a35c5fbca7..6af532fa26 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -32,7 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [ "POST /auth/fingerprint", "GET /invites/", // Routes with a seperate auth system - /^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/\w+\/?/, // no token requires auth + /^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/[A-Za-z0-9_-]+(?:\/|$)/, // no token requires auth /^POST \/interactions\/\d+\/[A-Za-z0-9_-]+\/callback/, // Public information endpoints "GET /ping", diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts index eed36691d0..768ee23f2e 100644 --- a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts +++ b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts @@ -98,6 +98,7 @@ router.patch( channel_id, id: message_id, edited_timestamp: new Date(), + is_edit: true, }); await new_message.save(); diff --git a/src/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.ts b/src/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.ts new file mode 100644 index 0000000000..74e38b65af --- /dev/null +++ b/src/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.ts @@ -0,0 +1,127 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 + MERCHANTIBILITY 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 } from "@spacebar/util"; +import { WebhookMessageEditSchema } from "@spacebar/schemas"; +import { Request, Response, Router } from "express"; +import multer from "multer"; +import { buildWebhookMessageEditBody, deleteWebhookMessage, editWebhookMessage, getWebhookForToken, getWebhookMessage } from "../../../../../../util/handlers/WebhookMessage"; + +const router = Router({ mergeParams: true }); + +const messageUpload = multer({ + limits: { + fileSize: Config.get().limits.message.maxAttachmentSize, + fields: 10, + }, + storage: multer.memoryStorage(), +}); + +function getThreadId(req: Request) { + return typeof req.query.thread_id === "string" ? req.query.thread_id : undefined; +} + +router.get( + "/", + route({ + responses: { + 200: { + body: "Message", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token, message_id } = req.params as { [key: string]: string }; + + await getWebhookForToken(webhook_id, token); + const message = await getWebhookMessage(webhook_id, message_id, getThreadId(req)); + + return res.json(message.toJSON()); + }, +); + +router.patch( + "/", + messageUpload.any(), + (req, _res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); + } + + next(); + }, + route({ + requestBody: "WebhookMessageEditSchema", + query: { + thread_id: { + type: "string", + required: false, + description: "Edit a webhook message in the specified thread.", + }, + }, + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token, message_id } = req.params as { [key: string]: string }; + const body = req.body as WebhookMessageEditSchema; + + await getWebhookForToken(webhook_id, token); + const message = await getWebhookMessage(webhook_id, message_id, getThreadId(req)); + const updated = await editWebhookMessage(message, await buildWebhookMessageEditBody(message, body, (req.files as Express.Multer.File[]) ?? [])); + + return res.json(updated.toJSON()); + }, +); + +router.delete( + "/", + route({ + query: { + thread_id: { + type: "string", + required: false, + description: "Delete a webhook message in the specified thread.", + }, + }, + responses: { + 204: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token, message_id } = req.params as { [key: string]: string }; + + await getWebhookForToken(webhook_id, token); + const message = await getWebhookMessage(webhook_id, message_id, getThreadId(req)); + await deleteWebhookMessage(message); + + return res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index 32f1e3d78d..5e7751ac4f 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -291,9 +291,18 @@ export function handleComps(components: BaseMessageComponents[], flags: number) (await Promise.all(medias.map((m, index) => processMedia(m, messageId, batchId, user, channel, index + "")))).forEach((_) => _?.()); }; } +export function isMessageEditOperation(opts: Pick): boolean { + return opts.is_edit === true; +} + +export function shouldResolveMessageAuthor(opts: Pick): boolean { + return !!opts.author_id && !opts.webhook_id; +} + export async function handleMessage(opts: MessageOptions): Promise { const conf = Config.get(); const handle = opts.components ? handleComps(opts.components, opts.flags || 0) : undefined; + const isEdit = isMessageEditOperation(opts); const channel = await Channel.findOneOrFail({ where: { id: opts.channel_id }, @@ -304,7 +313,7 @@ export async function handleMessage(opts: MessageOptions): Promise { let permission: null | Permissions = null; const limit = channel.rate_limit_per_user; - if (limit) { + if (!isEdit && limit) { const lastMsgTime = (await Message.findOne({ where: { channel_id: channel.id, author_id: opts.author_id }, select: { timestamp: true }, order: { timestamp: "DESC" } })) ?.timestamp; if (lastMsgTime && Date.now() - limit * 1000 < +lastMsgTime) { @@ -335,22 +344,23 @@ export async function handleMessage(opts: MessageOptions): Promise { message.channel = channel; await processMessageOptionAttachments(opts, message); - if (opts.author_id) { + if (shouldResolveMessageAuthor(opts)) { + const author_id = opts.author_id!; message.author = await User.findOneOrFail({ - where: { id: opts.author_id }, + where: { id: author_id }, }); - const rights = await getRights(opts.author_id); + const rights = await getRights(author_id); message.author.clean_data(); rights.hasThrow("SEND_MESSAGES"); } const ephermal = (message.flags & (1 << 6)) !== 0; - if (!ephermal && channel.type === ChannelType.GUILD_PUBLIC_THREAD) { + if (!isEdit && !ephermal && channel.type === ChannelType.GUILD_PUBLIC_THREAD) { const rep = Channel.getRepository(); await rep.increment({ id: channel.id }, "message_count", 1); await rep.increment({ id: channel.id }, "total_message_sent", 1); } - if (!ephermal) { + if (!isEdit && !ephermal) { channel.last_message_id = message.id; await channel.save(); } @@ -579,7 +589,9 @@ export async function handleMessage(opts: MessageOptions): Promise { } return Promise.all([...users].map((user_id) => ReadState.create({ user_id, channel_id: channel.id }).save())); } - if (ephermal) { + if (isEdit) { + // Edits recalculate mentions for serialization but must not create new mention notifications. + } else if (ephermal) { const id = message.interaction_metadata?.user_id; if (id) { let pinged = mention_everyone || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM; @@ -722,6 +734,7 @@ interface MessageOptions extends MessageCreateSchema { timestamp?: Date; username?: string; avatar_url?: string; + is_edit?: boolean; } // Makes for concise code, inspired by Nix' lib.trace diff --git a/src/api/util/handlers/Webhook.ts b/src/api/util/handlers/Webhook.ts index 73e609d853..78c76417bc 100644 --- a/src/api/util/handlers/Webhook.ts +++ b/src/api/util/handlers/Webhook.ts @@ -1,9 +1,10 @@ import { handleMessage, postHandleMessage } from "@spacebar/api"; -import { Attachment, Channel, Config, DiscordApiErrors, emitEvent, FieldErrors, Message, MessageCreateEvent, Snowflake, uploadFile, ValidateName, Webhook } from "@spacebar/util"; +import { Attachment, Channel, Config, DiscordApiErrors, emitEvent, FieldErrors, Message, MessageCreateEvent, Snowflake, ValidateName } from "@spacebar/util"; import { Request, Response } from "express"; import { HTTPError } from "lambert-server"; import { MoreThan } from "typeorm"; import { WebhookExecuteSchema } from "@spacebar/schemas"; +import { getWebhookForToken, uploadWebhookMessageFiles } from "./WebhookMessage"; export const executeWebhook = async (req: Request, res: Response) => { const body = req.body as WebhookExecuteSchema; @@ -11,20 +12,7 @@ export const executeWebhook = async (req: Request, res: Response) => { const { webhook_id, token } = req.params as { [key: string]: string }; - const webhook = await Webhook.findOne({ - where: { - id: webhook_id, - }, - relations: { channel: true, guild: true, application: true }, - }); - - if (!webhook) { - throw DiscordApiErrors.UNKNOWN_WEBHOOK; - } - - if (webhook.token !== token) { - throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED; - } + const webhook = await getWebhookForToken(webhook_id, token, { channel: true, guild: true, application: true }); if (body.username) { ValidateName(body.username); @@ -86,14 +74,11 @@ export const executeWebhook = async (req: Request, res: Response) => { } const files = (req.files as Express.Multer.File[]) ?? []; - for (const currFile of files) { - try { - const file = await uploadFile(`/attachments/${sendChannel.id}/${messageId}`, currFile); - attachments.push(Attachment.create(file)); - } catch (error) { - if (wait) res.status(400).json({ message: error?.toString() }); - return; - } + try { + attachments.push(...(await uploadWebhookMessageFiles(sendChannel.id, messageId, files))); + } catch (error) { + if (wait) res.status(400).json({ message: error?.toString() }); + return; } const embeds = body.embeds || []; diff --git a/src/api/util/handlers/WebhookMessage.test.ts b/src/api/util/handlers/WebhookMessage.test.ts new file mode 100644 index 0000000000..6518e8b901 --- /dev/null +++ b/src/api/util/handlers/WebhookMessage.test.ts @@ -0,0 +1,197 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 + MERCHANTIBILITY 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 assert from "node:assert/strict"; +import { before, describe, it } from "node:test"; +import type { Attachment, Webhook } from "@spacebar/util"; +import type * as MessageModule from "./Message"; +import type * as WebhookMessageModule from "./WebhookMessage"; + +let DiscordApiErrors: (typeof import("@spacebar/util"))["DiscordApiErrors"]; +let ChannelType: (typeof import("@spacebar/schemas"))["ChannelType"]; +let assertWebhookToken: typeof WebhookMessageModule.assertWebhookToken; +let buildWebhookMessageEditBody: typeof WebhookMessageModule.buildWebhookMessageEditBody; +let getWebhookMessageWhere: typeof WebhookMessageModule.getWebhookMessageWhere; +let normalizeWebhookMessageEditBody: typeof WebhookMessageModule.normalizeWebhookMessageEditBody; +let resolveWebhookMessageEditAttachments: typeof WebhookMessageModule.resolveWebhookMessageEditAttachments; +let shouldDecrementWebhookMessageChannel: typeof WebhookMessageModule.shouldDecrementWebhookMessageChannel; +let uploadWebhookMessageFiles: typeof WebhookMessageModule.uploadWebhookMessageFiles; +let isMessageEditOperation: typeof MessageModule.isMessageEditOperation; +let shouldResolveMessageAuthor: typeof MessageModule.shouldResolveMessageAuthor; + +before(async () => { + process.env.DATABASE ??= "postgres://user:pass@localhost:5432/test"; + + ({ DiscordApiErrors } = require("@spacebar/util") as typeof import("@spacebar/util")); + ({ ChannelType } = require("@spacebar/schemas") as typeof import("@spacebar/schemas")); + ({ + assertWebhookToken, + buildWebhookMessageEditBody, + getWebhookMessageWhere, + normalizeWebhookMessageEditBody, + resolveWebhookMessageEditAttachments, + shouldDecrementWebhookMessageChannel, + uploadWebhookMessageFiles, + } = await import("./WebhookMessage.js")); + ({ isMessageEditOperation, shouldResolveMessageAuthor } = await import("./Message.js")); +}); + +function attachment(id: string): Attachment { + return { id, filename: `${id}.txt` } as Attachment; +} + +describe("WebhookMessage handlers", () => { + it("accepts matching webhook tokens", () => { + assert.doesNotThrow(() => assertWebhookToken({ token: "secret" } as Webhook, "secret")); + }); + + it("rejects missing webhooks as unknown webhooks", () => { + assert.throws(() => assertWebhookToken(null, "secret"), { + code: DiscordApiErrors.UNKNOWN_WEBHOOK.code, + message: DiscordApiErrors.UNKNOWN_WEBHOOK.message, + }); + }); + + it("rejects mismatched webhook tokens", () => { + assert.throws(() => assertWebhookToken({ token: "secret" } as Webhook, "wrong"), { + code: DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED.code, + message: DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED.message, + }); + }); + + it("scopes webhook message lookups to the webhook id", () => { + assert.deepEqual(getWebhookMessageWhere("webhook", "message"), { + id: "message", + webhook_id: "webhook", + }); + }); + + it("scopes threaded webhook message lookups to the thread id", () => { + assert.deepEqual(getWebhookMessageWhere("webhook", "message", "thread"), { + id: "message", + webhook_id: "webhook", + channel_id: "thread", + }); + }); + + it("keeps existing attachments when webhook message edits omit attachments", () => { + const existing = [attachment("keep"), attachment("also-keep")]; + const uploaded = [attachment("uploaded")]; + + assert.deepEqual(resolveWebhookMessageEditAttachments(existing, undefined, uploaded), [...existing, ...uploaded]); + }); + + it("retains requested existing attachments and appends new multipart uploads", () => { + const keep = attachment("keep"); + const remove = attachment("remove"); + const uploaded = attachment("uploaded"); + + assert.deepEqual( + resolveWebhookMessageEditAttachments( + [keep, remove], + [ + { id: "keep", filename: "keep.txt" }, + { id: "0", filename: "new-file-placeholder.txt" }, + ], + [uploaded], + ), + [keep, uploaded], + ); + }); + + it("clears retained attachments when webhook message edits send null attachments", () => { + assert.deepEqual(resolveWebhookMessageEditAttachments([attachment("remove")], null, []), []); + }); + + it("preserves cloud attachments for message edit processing", () => { + const cloudAttachment = { + id: "cloud", + filename: "cloud.txt", + uploaded_filename: "cloud-upload-key", + }; + + assert.deepEqual(resolveWebhookMessageEditAttachments([], [cloudAttachment], []), [cloudAttachment]); + }); + + it("uploads webhook files under the target channel and message path", async () => { + const file = { + buffer: Buffer.from("hello"), + mimetype: "text/plain", + originalname: "hello.txt", + }; + const calls: { path: string; originalname: string }[] = []; + + const uploaded = await uploadWebhookMessageFiles("channel", "message", [file], async (path, uploadedFile) => { + calls.push({ path, originalname: uploadedFile.originalname }); + return attachment("uploaded"); + }); + + assert.deepEqual(calls, [{ path: "/attachments/channel/message", originalname: "hello.txt" }]); + assert.equal(uploaded[0].id, "uploaded"); + }); + + it("normalizes nullable webhook message edit fields before message processing", () => { + assert.deepEqual(normalizeWebhookMessageEditBody({ allowed_mentions: null, components: null, content: null, embeds: null }), { + components: [], + content: "", + embeds: [], + }); + }); + + it("marks edit operations so message processing can skip create-only side effects", () => { + assert.equal(isMessageEditOperation({ is_edit: true }), true); + assert.equal(isMessageEditOperation({ is_edit: false }), false); + assert.equal(isMessageEditOperation({}), false); + }); + + it("does not run normal user author checks for webhook-authenticated message operations", () => { + assert.equal(shouldResolveMessageAuthor({ author_id: "user" }), true); + assert.equal(shouldResolveMessageAuthor({ author_id: "webhook", webhook_id: "webhook" }), false); + assert.equal(shouldResolveMessageAuthor({ webhook_id: "webhook" }), false); + }); + + it("builds webhook message edit bodies with retained and uploaded attachments", async () => { + const existing = attachment("existing"); + + const body = await buildWebhookMessageEditBody( + { id: "message", channel_id: "channel", attachments: [existing] }, + { content: "edited" }, + [{ buffer: Buffer.from("new"), mimetype: "text/plain", originalname: "new.txt" }], + async () => attachment("uploaded"), + ); + + assert.equal(body.content, "edited"); + assert.deepEqual( + body.attachments?.map((current) => current.id), + ["existing", "uploaded"], + ); + }); + + it("rejects edit body construction for messages without a channel", async () => { + await assert.rejects(() => buildWebhookMessageEditBody({ id: "message", attachments: [] }, {}, []), { + code: DiscordApiErrors.UNKNOWN_MESSAGE.code, + message: DiscordApiErrors.UNKNOWN_MESSAGE.message, + }); + }); + + it("only decrements public thread counters during webhook message deletion", () => { + assert.equal(shouldDecrementWebhookMessageChannel({ type: ChannelType.GUILD_PUBLIC_THREAD }), true); + assert.equal(shouldDecrementWebhookMessageChannel({ type: ChannelType.GUILD_TEXT }), false); + assert.equal(shouldDecrementWebhookMessageChannel(undefined), false); + }); +}); diff --git a/src/api/util/handlers/WebhookMessage.ts b/src/api/util/handlers/WebhookMessage.ts new file mode 100644 index 0000000000..e326606a14 --- /dev/null +++ b/src/api/util/handlers/WebhookMessage.ts @@ -0,0 +1,223 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 + MERCHANTIBILITY 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 { handleMessage, postHandleMessage } from "@spacebar/api"; +import { Attachment, Channel, DiscordApiErrors, emitEvent, Message, MessageDeleteEvent, MessageUpdateEvent, uploadFile, Webhook } from "@spacebar/util"; +import { ChannelType, WebhookMessageEditSchema } from "@spacebar/schemas"; +import { FindOptionsRelations, FindOptionsWhere } from "typeorm"; + +type WebhookMessageUploadFile = Pick; +type WebhookMessageUploader = (path: string, file: WebhookMessageUploadFile) => Promise; +type WebhookMessageEditAttachment = NonNullable[number] | Attachment; + +export type PreparedWebhookMessageEdit = Omit & { + attachments?: WebhookMessageEditAttachment[]; + allowed_mentions?: Exclude; + components?: Exclude; + content?: Exclude; + embeds?: Exclude; +}; + +export function assertWebhookToken(webhook: Pick | null | undefined, token: string): asserts webhook is Pick { + if (!webhook) { + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } + + if (webhook.token !== token) { + throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED; + } +} + +export function getWebhookMessageWhere(webhook_id: string, message_id: string, thread_id?: string): FindOptionsWhere { + return { + id: message_id, + webhook_id, + ...(thread_id ? { channel_id: thread_id } : {}), + }; +} + +export async function getWebhookForToken(webhook_id: string, token: string, relations?: FindOptionsRelations): Promise { + const webhook = await Webhook.findOne({ + where: { + id: webhook_id, + }, + ...(relations ? { relations } : {}), + }); + + assertWebhookToken(webhook, token); + return webhook; +} + +export async function getWebhookMessage(webhook_id: string, message_id: string, thread_id?: string): Promise { + const message = await Message.findOne({ + where: getWebhookMessageWhere(webhook_id, message_id, thread_id), + relations: { + attachments: true, + application: true, + author: true, + channel: true, + member: true, + mention_channels: true, + mention_roles: true, + mentions: true, + sticker_items: true, + thread: true, + webhook: true, + }, + }); + + if (!message) { + throw DiscordApiErrors.UNKNOWN_MESSAGE; + } + + return message; +} + +export async function uploadWebhookMessageFiles( + channel_id: string, + message_id: string, + files: readonly WebhookMessageUploadFile[] = [], + uploader: WebhookMessageUploader = uploadFile, +): Promise { + const attachments: Attachment[] = []; + + for (const file of files) { + attachments.push(Object.assign(new Attachment(), await uploader(`/attachments/${channel_id}/${message_id}`, file))); + } + + return attachments; +} + +export function resolveWebhookMessageEditAttachments( + existingAttachments: readonly Attachment[] = [], + requestedAttachments: WebhookMessageEditSchema["attachments"], + uploadedAttachments: readonly Attachment[] = [], +): WebhookMessageEditAttachment[] { + const retainedAttachments: WebhookMessageEditAttachment[] = []; + + if (requestedAttachments === undefined) { + retainedAttachments.push(...existingAttachments); + } else if (requestedAttachments !== null) { + for (const attachment of requestedAttachments) { + if ("uploaded_filename" in attachment) { + retainedAttachments.push(attachment); + continue; + } + + const existing = existingAttachments.find((current) => current.id === attachment.id); + if (existing) { + retainedAttachments.push(existing); + } + } + } + + return [...retainedAttachments, ...uploadedAttachments]; +} + +export function normalizeWebhookMessageEditBody(body: WebhookMessageEditSchema): Omit { + const { allowed_mentions, attachments: _attachments, components, content, embeds, ...rest } = body; + + return { + ...rest, + ...(allowed_mentions !== null && allowed_mentions !== undefined ? { allowed_mentions } : {}), + ...(components !== null && components !== undefined ? { components } : {}), + ...(components === null ? { components: [] } : {}), + ...(content !== null && content !== undefined ? { content } : {}), + ...(content === null ? { content: "" } : {}), + ...(embeds !== null && embeds !== undefined ? { embeds } : {}), + ...(embeds === null ? { embeds: [] } : {}), + }; +} + +export async function buildWebhookMessageEditBody( + message: Pick, + body: WebhookMessageEditSchema, + files: readonly WebhookMessageUploadFile[] = [], + uploader: WebhookMessageUploader = uploadFile, +): Promise { + if (!message.channel_id) { + throw DiscordApiErrors.UNKNOWN_MESSAGE; + } + + return { + ...normalizeWebhookMessageEditBody(body), + attachments: resolveWebhookMessageEditAttachments( + message.attachments ?? [], + body.attachments, + await uploadWebhookMessageFiles(message.channel_id, message.id, files, uploader), + ), + }; +} + +export async function editWebhookMessage(message: Message, body: PreparedWebhookMessageEdit): Promise { + const newMessage = await handleMessage({ + ...message, + message_reference: message.message_reference, + ...body, + author_id: message.author_id, + channel_id: message.channel_id, + id: message.id, + edited_timestamp: new Date(), + is_edit: true, + }); + + await newMessage.save(); + await emitEvent({ + event: "MESSAGE_UPDATE", + channel_id: newMessage.channel_id, + data: { + ...newMessage.toJSON(), + nonce: undefined, + }, + } satisfies MessageUpdateEvent); + + postHandleMessage(newMessage).catch((e) => console.error("[WebhookMessage] post-message handler failed", e)); + return newMessage; +} + +export function shouldDecrementWebhookMessageChannel(channel?: Pick | null): boolean { + return channel?.type === ChannelType.GUILD_PUBLIC_THREAD; +} + +export async function deleteWebhookMessage(message: Message): Promise { + if (!message.channel_id || !message.webhook_id) { + throw DiscordApiErrors.UNKNOWN_MESSAGE; + } + + if (shouldDecrementWebhookMessageChannel(message.channel)) { + if (message.channel.message_count !== undefined) { + message.channel.message_count = Math.max(0, message.channel.message_count - 1); + } + await message.channel.save(); + } + + if (message.attachments?.length) { + await Attachment.remove(message.attachments); + } + + await Message.delete({ id: message.id, webhook_id: message.webhook_id }); + await emitEvent({ + event: "MESSAGE_DELETE", + channel_id: message.channel_id, + data: { + id: message.id, + channel_id: message.channel_id, + guild_id: message.guild_id, + }, + } satisfies MessageDeleteEvent); +} diff --git a/src/api/util/handlers/WebhookMessageRoute.test.ts b/src/api/util/handlers/WebhookMessageRoute.test.ts new file mode 100644 index 0000000000..2ce2035dd2 --- /dev/null +++ b/src/api/util/handlers/WebhookMessageRoute.test.ts @@ -0,0 +1,186 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 + MERCHANTIBILITY 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 assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, it } from "node:test"; + +const execFileAsync = promisify(execFile); + +const routeHarness = String.raw` +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +process.env.DATABASE ??= "postgres://user:pass@localhost:5432/test"; +process.env.NODE_ENV = "test"; +process.env.NODE_PATH = path.join(process.cwd(), "node_modules"); +require("node:module").Module._initPaths(); +require("module-alias/register"); + +const express = require("express"); +const { Server } = require("lambert-server"); + +const message = { + id: "message", + toJSON: () => ({ id: "message", content: "original" }), +}; +const updatedMessage = { + id: "message", + toJSON: () => ({ id: "message", content: "edited" }), +}; +let calls = []; +function record(name, ...args) { + calls.push({ name, args }); +} +function callNames() { + return calls.map((call) => call.name); +} + +global.__webhookMessageHelpers = { + getWebhookForToken: async (...args) => { + record("getWebhookForToken", ...args); + return { id: args[0], token: args[1] }; + }, + getWebhookMessage: async (...args) => { + record("getWebhookMessage", ...args); + return message; + }, + buildWebhookMessageEditBody: async (...args) => { + record("buildWebhookMessageEditBody", ...args); + return args[1]; + }, + editWebhookMessage: async (...args) => { + record("editWebhookMessage", ...args); + return updatedMessage; + }, + deleteWebhookMessage: async (...args) => { + record("deleteWebhookMessage", ...args); + }, +}; + +async function main() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "spacebar-webhook-route-")); + const tempRoutesRoot = path.join(tempRoot, "routes"); + const tempRouteDir = path.join(tempRoutesRoot, "webhooks", "#webhook_id", "#token", "messages", "#message_id"); + const tempHelperDir = path.join(tempRoot, "util", "handlers"); + fs.mkdirSync(tempRouteDir, { recursive: true }); + fs.mkdirSync(tempHelperDir, { recursive: true }); + fs.writeFileSync(path.join(tempHelperDir, "WebhookMessage.js"), "module.exports = global.__webhookMessageHelpers;\n"); + + const sourceRouteFile = path.join(process.cwd(), "dist/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.js"); + const sourceRoute = fs.readFileSync(sourceRouteFile, "utf8").replace(/\n\/\/# sourceMappingURL=.*\n?$/, "\n"); + fs.writeFileSync(path.join(tempRouteDir, "index.js"), sourceRoute); + + const app = express(); + app.use(express.json()); + + const server = new Server({ app, serverInitLogging: false }); + const routesRoot = tempRoutesRoot; + const routeFile = path.join(routesRoot, "webhooks", "#webhook_id", "#token", "messages", "#message_id", "index.js"); + assert.ok(server.registerRoute(routesRoot, routeFile), "webhook message route should register"); + + const listener = app.listen(0); + await new Promise((resolve) => listener.once("listening", resolve)); + const baseUrl = "http://127.0.0.1:" + listener.address().port; + + try { + let response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message?thread_id=thread"); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { id: "message", content: "original" }); + assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage"]); + assert.deepEqual(calls[0].args, ["webhook", "token"]); + assert.deepEqual(calls[1].args, ["webhook", "message", "thread"]); + + calls = []; + response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "edited" }), + }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { id: "message", content: "edited" }); + assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage", "buildWebhookMessageEditBody", "editWebhookMessage"]); + assert.equal(calls[2].args[0], message); + assert.deepEqual({ ...calls[2].args[1] }, { content: "edited" }); + assert.equal(calls[3].args[0], message); + assert.deepEqual({ ...calls[3].args[1] }, { content: "edited" }); + + calls = []; + const nullableBody = { attachments: null, content: null, embeds: null }; + response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(nullableBody), + }); + assert.equal(response.status, 200); + assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage", "buildWebhookMessageEditBody", "editWebhookMessage"]); + assert.equal(calls[2].args[0], message); + assert.deepEqual({ ...calls[2].args[1] }, nullableBody); + assert.equal(calls[3].args[0], message); + assert.deepEqual({ ...calls[3].args[1] }, nullableBody); + + calls = []; + response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message", { method: "DELETE" }); + assert.equal(response.status, 204); + assert.equal(await response.text(), ""); + assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage", "deleteWebhookMessage"]); + assert.deepEqual(calls[0].args, ["webhook", "token"]); + assert.deepEqual(calls[1].args, ["webhook", "message", undefined]); + assert.deepEqual(calls[2].args, [message]); + } finally { + await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve())); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); +`; + +describe("webhook message route registration", () => { + it("registers and dispatches GET/PATCH/DELETE webhook message requests", async () => { + const { NODE_V8_COVERAGE: _nodeV8Coverage, ...env } = process.env; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "spacebar-webhook-route-harness-")); + const harnessFile = path.join(tempRoot, "route-harness.js"); + fs.writeFileSync(harnessFile, routeHarness); + + try { + const { stderr, stdout } = await execFileAsync(process.execPath, ["--enable-source-maps", harnessFile], { + cwd: process.cwd(), + env: { + ...env, + DATABASE: process.env.DATABASE ?? "postgres://user:pass@localhost:5432/test", + }, + timeout: 10_000, + }); + + assert.equal(stdout, ""); + assert.equal(stderr, ""); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cdn/util/Storage.ts b/src/cdn/util/Storage.ts index 7579e2d610..3245f65572 100644 --- a/src/cdn/util/Storage.ts +++ b/src/cdn/util/Storage.ts @@ -86,7 +86,9 @@ if (process.env.STORAGE_PROVIDER === "file" || !process.env.STORAGE_PROVIDER) { const forcePathStyle = process.env.STORAGE_FORCE_PATH_STYLE === "true"; if (process.env.STORAGE_FORCE_PATH_STYLE === undefined) { - console.warn(`[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`); + console.warn( + `[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`, + ); } const { S3Storage } = require("./S3Storage"); diff --git a/src/schemas/uncategorised/WebhookMessageEditSchema.ts b/src/schemas/uncategorised/WebhookMessageEditSchema.ts new file mode 100644 index 0000000000..6452a18339 --- /dev/null +++ b/src/schemas/uncategorised/WebhookMessageEditSchema.ts @@ -0,0 +1,30 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 + MERCHANTIBILITY 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 { AllowedMentions, BaseMessageComponents, Embed } from "@spacebar/schemas"; +import { MessageCreateAttachment, MessageCreateCloudAttachment } from "./MessageCreateSchema"; + +export interface WebhookMessageEditSchema { + content?: string | null; + embeds?: Embed[] | null; + allowed_mentions?: AllowedMentions | null; + components?: BaseMessageComponents[] | null; + payload_json?: string; + attachments?: (MessageCreateAttachment | MessageCreateCloudAttachment)[] | null; + flags?: number; +} diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts index 28ac51d5ad..b439c57cd1 100644 --- a/src/schemas/uncategorised/index.ts +++ b/src/schemas/uncategorised/index.ts @@ -88,6 +88,7 @@ export * from "./VoiceStateUpdateSchema"; export * from "./WebAuthnSchema"; export * from "./WebhookCreateSchema"; export * from "./WebhookExecuteSchema"; +export * from "./WebhookMessageEditSchema"; export * from "./WebhookUpdateSchema"; export * from "./WidgetModifySchema"; export * from "./MessageThreadCreationSchema";