diff --git a/apps/bot/src/webhooks.ts b/apps/bot/src/webhooks.ts index d2977db..5faf34a 100644 --- a/apps/bot/src/webhooks.ts +++ b/apps/bot/src/webhooks.ts @@ -1,7 +1,12 @@ import { createHmac } from "node:crypto"; import { prisma } from "@msk-forms/db"; -import { buildSubmissionWebhookPayload, type WebhookEvent } from "@msk-forms/shared"; +import { + buildDiscordWebhookBody, + buildSubmissionWebhookPayload, + type SubmissionWebhookPayload, + type WebhookEvent, +} from "@msk-forms/shared"; /** How many pending deliveries to drain per tick. */ const BATCH = 25; @@ -18,7 +23,7 @@ type DeliveryRow = { event: string; payload: unknown; attempts: number; - webhook: { url: string; secret: string } | null; + webhook: { url: string; secret: string; format: string } | null; }; /** Exponential-ish backoff (minutes) keyed by the attempt just made. */ @@ -85,9 +90,11 @@ async function hydratePayload(stored: unknown): Promise { } /** - * POST one delivery to its endpoint, HMAC-signing the raw JSON body. On a 2xx the - * row is marked `success`; otherwise it's retried with backoff until MAX_ATTEMPTS, - * then marked `failed` with the last error. + * POST one delivery to its endpoint. Generic (`json`) hooks get the raw JSON body + * HMAC-signed via `X-MSK-Signature`; `discord` hooks get a formatted embed body + * (no signature — Discord doesn't verify one). On a 2xx the row is marked + * `success`; otherwise it's retried with backoff until MAX_ATTEMPTS, then marked + * `failed` with the last error. */ async function deliverOne(row: DeliveryRow): Promise { if (!row.webhook) { @@ -99,8 +106,20 @@ async function deliverOne(row: DeliveryRow): Promise { return; } - const body = JSON.stringify(await hydratePayload(row.payload)); - const signature = createHmac("sha256", row.webhook.secret).update(body).digest("hex"); + const hydrated = await hydratePayload(row.payload); + const isDiscord = row.webhook.format === "discord"; + const body = isDiscord + ? JSON.stringify(buildDiscordWebhookBody(hydrated as SubmissionWebhookPayload)) + : JSON.stringify(hydrated); + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "MSK-Forms-Webhook/1", + }; + if (!isDiscord) { + const signature = createHmac("sha256", row.webhook.secret).update(body).digest("hex"); + headers["X-MSK-Event"] = row.event; + headers["X-MSK-Signature"] = `sha256=${signature}`; + } const attempts = row.attempts + 1; let ok = false; @@ -110,12 +129,7 @@ async function deliverOne(row: DeliveryRow): Promise { try { const res = await fetch(row.webhook.url, { method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "MSK-Forms-Webhook/1", - "X-MSK-Event": row.event, - "X-MSK-Signature": `sha256=${signature}`, - }, + headers, body, signal: controller.signal, }); @@ -161,7 +175,7 @@ export async function deliverPendingWebhooks(): Promise { event: true, payload: true, attempts: true, - webhook: { select: { url: true, secret: true } }, + webhook: { select: { url: true, secret: true, format: true } }, }, })) as DeliveryRow[]; diff --git a/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts b/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts index 71e0728..95a7721 100644 --- a/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts @@ -30,7 +30,15 @@ export async function GET( const webhooks = await prisma.webhook.findMany({ where: { guildId }, orderBy: { createdAt: "asc" }, - select: { id: true, url: true, secret: true, events: true, active: true, createdAt: true }, + select: { + id: true, + url: true, + secret: true, + events: true, + active: true, + format: true, + createdAt: true, + }, }); return NextResponse.json({ webhooks }); } @@ -69,9 +77,18 @@ export async function POST( url: parsed.data.url, events: parsed.data.events, active: parsed.data.active, + format: parsed.data.format, secret: randomBytes(24).toString("hex"), }, - select: { id: true, url: true, secret: true, events: true, active: true, createdAt: true }, + select: { + id: true, + url: true, + secret: true, + events: true, + active: true, + format: true, + createdAt: true, + }, }); return NextResponse.json({ webhook }, { status: 201 }); } diff --git a/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx b/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx index 9c8c6d4..0a27f37 100644 --- a/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx @@ -55,7 +55,7 @@ export default async function WebhooksPage({ const webhooks = (await prisma.webhook.findMany({ where: { guildId }, orderBy: { createdAt: "asc" }, - select: { id: true, url: true, secret: true, events: true, active: true, source: true }, + select: { id: true, url: true, secret: true, events: true, active: true, source: true, format: true }, })) as WebhookRow[]; return ( diff --git a/apps/web/src/components/webhooks/webhooks-manager.tsx b/apps/web/src/components/webhooks/webhooks-manager.tsx index 7e2f0bf..4d5f22d 100644 --- a/apps/web/src/components/webhooks/webhooks-manager.tsx +++ b/apps/web/src/components/webhooks/webhooks-manager.tsx @@ -1,7 +1,7 @@ "use client"; -import { WEBHOOK_EVENTS, type WebhookEvent } from "@msk-forms/shared"; -import { Button, Card, Checkbox, Field, Input } from "@msk-forms/ui"; +import { WEBHOOK_EVENTS, type WebhookEvent, type WebhookFormat } from "@msk-forms/shared"; +import { Button, Card, Checkbox, Field, Input, Select } from "@msk-forms/ui"; import { useState } from "react"; import { ConfirmDialog } from "@/components/confirm-dialog"; @@ -15,6 +15,8 @@ export interface WebhookRow { active: boolean; /** "manual" (added here) or an integration provider ("zapier"/"make"). */ source: string; + /** "json" (generic signed POST) or "discord" (Discord embed). */ + format: string; } /** Badge label for an integration-managed hook; brand names stay verbatim. */ @@ -44,6 +46,7 @@ export function WebhooksManager({ }) { const [webhooks, setWebhooks] = useState(initial); const [url, setUrl] = useState(""); + const [format, setFormat] = useState("json"); const [events, setEvents] = useState>(new Set(WEBHOOK_EVENTS)); const [adding, setAdding] = useState(false); const [error, setError] = useState(null); @@ -68,7 +71,7 @@ export function WebhooksManager({ const res = await fetch(`/api/guilds/${guildId}/webhooks`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: url.trim(), events: [...events] }), + body: JSON.stringify({ url: url.trim(), events: [...events], format }), }); const data = (await res.json().catch(() => null)) as | { webhook?: WebhookRow; error?: string } @@ -77,6 +80,7 @@ export function WebhooksManager({ // Hooks added here are always manually managed. setWebhooks((prev) => [...prev, { ...data.webhook!, source: "manual" }]); setUrl(""); + setFormat("json"); setEvents(new Set(WEBHOOK_EVENTS)); } catch (err) { setError(err instanceof Error ? err.message : t.errAdd); @@ -132,10 +136,20 @@ export function WebhooksManager({

{t.addTitle}

- + + setUrl(e.target.value)} /> @@ -173,6 +187,11 @@ export function WebhooksManager({
{hook.url}
+ {hook.format === "discord" && ( + + {t.formatDiscord} + + )} {hook.source !== "manual" && ( ))}
-
- {t.secret}: - - {revealed.has(hook.id) ? hook.secret : "•".repeat(24)} - - - -
-

{t.secretHint}

+ {/* The signing secret only applies to generic JSON hooks; a + Discord webhook does not verify a signature. */} + {hook.format !== "discord" && ( + <> +
+ {t.secret}: + + {revealed.has(hook.id) ? hook.secret : "•".repeat(24)} + + + +
+

{t.secretHint}

+ + )}