Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions apps/bot/src/webhooks.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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. */
Expand Down Expand Up @@ -85,9 +90,11 @@ async function hydratePayload(stored: unknown): Promise<unknown> {
}

/**
* 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<void> {
if (!row.webhook) {
Expand All @@ -99,8 +106,20 @@ async function deliverOne(row: DeliveryRow): Promise<void> {
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<string, string> = {
"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;
Expand All @@ -110,12 +129,7 @@ async function deliverOne(row: DeliveryRow): Promise<void> {
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,
});
Expand Down Expand Up @@ -161,7 +175,7 @@ export async function deliverPendingWebhooks(): Promise<void> {
event: true,
payload: true,
attempts: true,
webhook: { select: { url: true, secret: true } },
webhook: { select: { url: true, secret: true, format: true } },
},
})) as DeliveryRow[];

Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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 });
}
2 changes: 1 addition & 1 deletion apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
91 changes: 58 additions & 33 deletions apps/web/src/components/webhooks/webhooks-manager.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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. */
Expand Down Expand Up @@ -44,6 +46,7 @@ export function WebhooksManager({
}) {
const [webhooks, setWebhooks] = useState<WebhookRow[]>(initial);
const [url, setUrl] = useState("");
const [format, setFormat] = useState<WebhookFormat>("json");
const [events, setEvents] = useState<Set<WebhookEvent>>(new Set(WEBHOOK_EVENTS));
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -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 }
Expand All @@ -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);
Expand Down Expand Up @@ -132,10 +136,20 @@ export function WebhooksManager({
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t.addTitle}
</h3>
<Field label={t.url}>
<Field label={t.format}>
<Select
value={format}
onChange={(e) => setFormat(e.target.value as WebhookFormat)}
options={[
{ value: "json", label: t.formatJson },
{ value: "discord", label: t.formatDiscord },
]}
/>
</Field>
<Field label={t.url} hint={format === "discord" ? t.discordHint : undefined}>
<Input
value={url}
placeholder={t.urlPlaceholder}
placeholder={format === "discord" ? t.discordUrlPlaceholder : t.urlPlaceholder}
onChange={(e) => setUrl(e.target.value)}
/>
</Field>
Expand Down Expand Up @@ -173,6 +187,11 @@ export function WebhooksManager({
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="break-all font-mono text-sm text-foreground">{hook.url}</span>
<div className="flex shrink-0 items-center gap-1.5">
{hook.format === "discord" && (
<span className="rounded bg-[#5865F2]/15 px-2 py-0.5 text-xs font-medium text-[#5865F2]">
{t.formatDiscord}
</span>
)}
{hook.source !== "manual" && (
<span
className="rounded bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground"
Expand Down Expand Up @@ -202,34 +221,40 @@ export function WebhooksManager({
</span>
))}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">{t.secret}:</span>
<code className="break-all font-mono">
{revealed.has(hook.id) ? hook.secret : "•".repeat(24)}
</code>
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
setRevealed((prev) => {
const next = new Set(prev);
if (next.has(hook.id)) next.delete(hook.id);
else next.add(hook.id);
return next;
})
}
>
{revealed.has(hook.id) ? t.hide : t.show}
</button>
<button
type="button"
className="text-primary hover:underline"
onClick={() => copySecret(hook)}
>
{copied === hook.id ? t.copied : t.copy}
</button>
</div>
<p className="text-xs text-muted-foreground">{t.secretHint}</p>
{/* The signing secret only applies to generic JSON hooks; a
Discord webhook does not verify a signature. */}
{hook.format !== "discord" && (
<>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">{t.secret}:</span>
<code className="break-all font-mono">
{revealed.has(hook.id) ? hook.secret : "•".repeat(24)}
</code>
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
setRevealed((prev) => {
const next = new Set(prev);
if (next.has(hook.id)) next.delete(hook.id);
else next.add(hook.id);
return next;
})
}
>
{revealed.has(hook.id) ? t.hide : t.show}
</button>
<button
type="button"
className="text-primary hover:underline"
onClick={() => copySecret(hook)}
>
{copied === hook.id ? t.copied : t.copy}
</button>
</div>
<p className="text-xs text-muted-foreground">{t.secretHint}</p>
</>
)}
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => toggleActive(hook)}>
{hook.active ? t.disable : t.enable}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,9 @@ const en = {
url: "Endpoint URL", urlPlaceholder: "https://example.com/hooks/msk-forms",
events: "Events",
eventCreated: "Submission created", eventStatusChanged: "Status changed",
format: "Delivery format", formatJson: "Generic JSON (signed)", formatDiscord: "Discord webhook",
discordUrlPlaceholder: "https://discord.com/api/webhooks/…",
discordHint: "Paste a Discord channel webhook URL (Server Settings, Integrations, Webhooks). New submissions are posted there as an embed, so you can log them into any Discord server.",
managedBadge: "Integration", managedHint: "Created by an integration — manage it from Zapier/Make.",
add: "Add webhook", adding: "Adding…",
yourWebhooks: "Your endpoints", empty: "No webhooks yet.",
Expand Down Expand Up @@ -913,6 +916,9 @@ const de: Dictionary = {
url: "Endpunkt-URL", urlPlaceholder: "https://example.com/hooks/msk-forms",
events: "Ereignisse",
eventCreated: "Einreichung erstellt", eventStatusChanged: "Status geändert",
format: "Zustellformat", formatJson: "Generisches JSON (signiert)", formatDiscord: "Discord-Webhook",
discordUrlPlaceholder: "https://discord.com/api/webhooks/…",
discordHint: "Füge eine Discord-Kanal-Webhook-URL ein (Servereinstellungen, Integrationen, Webhooks). Neue Einreichungen werden dort als Embed gepostet, du kannst sie also in jeden Discord-Server loggen.",
managedBadge: "Integration", managedHint: "Von einer Integration erstellt — verwalte ihn in Zapier/Make.",
add: "Webhook hinzufügen", adding: "Wird hinzugefügt…",
yourWebhooks: "Deine Endpunkte", empty: "Noch keine Webhooks.",
Expand Down Expand Up @@ -1462,6 +1468,9 @@ const hu: Dictionary = {
url: "Végpont URL", urlPlaceholder: "https://example.com/hooks/msk-forms",
events: "Események",
eventCreated: "Beküldés létrehozva", eventStatusChanged: "Állapot megváltozott",
format: "Kézbesítési formátum", formatJson: "Általános JSON (aláírt)", formatDiscord: "Discord webhook",
discordUrlPlaceholder: "https://discord.com/api/webhooks/…",
discordHint: "Illessz be egy Discord csatorna-webhook URL-t (Szerverbeállítások, Integrációk, Webhookok). Az új beküldések ott embedként jelennek meg, így bármelyik Discord szerverre naplózhatod őket.",
managedBadge: "Integráció", managedHint: "Integráció hozta létre — a Zapier/Make-ből kezeld.",
add: "Webhook hozzáadása", adding: "Hozzáadás…",
yourWebhooks: "Végpontjaid", empty: "Még nincs webhook.",
Expand Down Expand Up @@ -2011,6 +2020,9 @@ const fr: Dictionary = {
url: "URL de l'endpoint", urlPlaceholder: "https://example.com/hooks/msk-forms",
events: "Événements",
eventCreated: "Soumission créée", eventStatusChanged: "Statut changé",
format: "Format de livraison", formatJson: "JSON générique (signé)", formatDiscord: "Webhook Discord",
discordUrlPlaceholder: "https://discord.com/api/webhooks/…",
discordHint: "Collez une URL de webhook de salon Discord (Paramètres du serveur, Intégrations, Webhooks). Les nouvelles soumissions y sont publiées sous forme d'embed, vous pouvez donc les journaliser sur n'importe quel serveur Discord.",
managedBadge: "Intégration", managedHint: "Créé par une intégration — gérez-le depuis Zapier/Make.",
add: "Ajouter le webhook", adding: "Ajout…",
yourWebhooks: "Vos endpoints", empty: "Aucun webhook pour l'instant.",
Expand Down Expand Up @@ -2561,6 +2573,9 @@ const es: Dictionary = {
url: "URL del endpoint", urlPlaceholder: "https://example.com/hooks/msk-forms",
events: "Eventos",
eventCreated: "Envío creado", eventStatusChanged: "Estado cambiado",
format: "Formato de entrega", formatJson: "JSON genérico (firmado)", formatDiscord: "Webhook de Discord",
discordUrlPlaceholder: "https://discord.com/api/webhooks/…",
discordHint: "Pega una URL de webhook de canal de Discord (Ajustes del servidor, Integraciones, Webhooks). Los nuevos envíos se publican allí como un embed, así que puedes registrarlos en cualquier servidor de Discord.",
managedBadge: "Integración", managedHint: "Creado por una integración — adminístralo desde Zapier/Make.",
add: "Agregar webhook", adding: "Agregando…",
yourWebhooks: "Tus endpoints", empty: "Aún no hay webhooks.",
Expand Down Expand Up @@ -3111,6 +3126,9 @@ const pt: Dictionary = {
url: "URL do endpoint", urlPlaceholder: "https://example.com/hooks/msk-forms",
events: "Eventos",
eventCreated: "Candidatura criada", eventStatusChanged: "Status alterado",
format: "Formato de entrega", formatJson: "JSON genérico (assinado)", formatDiscord: "Webhook do Discord",
discordUrlPlaceholder: "https://discord.com/api/webhooks/…",
discordHint: "Cole uma URL de webhook de canal do Discord (Configurações do servidor, Integrações, Webhooks). Novas candidaturas são publicadas ali como um embed, então você pode registrá-las em qualquer servidor do Discord.",
managedBadge: "Integração", managedHint: "Criado por uma integração — gerencie-o pelo Zapier/Make.",
add: "Adicionar webhook", adding: "Adicionando…",
yourWebhooks: "Seus endpoints", empty: "Nenhum webhook ainda.",
Expand Down Expand Up @@ -3661,6 +3679,9 @@ const pl: Dictionary = {
url: "URL endpointu", urlPlaceholder: "https://example.com/hooks/msk-forms",
events: "Zdarzenia",
eventCreated: "Zgłoszenie utworzone", eventStatusChanged: "Status zmieniony",
format: "Format dostarczania", formatJson: "Ogólny JSON (podpisany)", formatDiscord: "Webhook Discord",
discordUrlPlaceholder: "https://discord.com/api/webhooks/…",
discordHint: "Wklej URL webhooka kanału Discord (Ustawienia serwera, Integracje, Webhooki). Nowe zgłoszenia są tam publikowane jako embed, więc możesz je logować na dowolnym serwerze Discord.",
managedBadge: "Integracja", managedHint: "Utworzone przez integrację — zarządzaj nim w Zapier/Make.",
add: "Dodaj webhook", adding: "Dodawanie…",
yourWebhooks: "Twoje endpointy", empty: "Brak webhooków.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Delivery format for a webhook: "json" (generic HMAC-signed POST) or "discord"
-- (a Discord channel webhook that receives a formatted embed, so submissions can
-- be logged into any Discord server).
ALTER TABLE "webhooks" ADD COLUMN "format" TEXT NOT NULL DEFAULT 'json';
3 changes: 3 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ model Webhook {
active Boolean @default(true)
/// Who manages this hook: "manual" (dashboard) or an integration ("zapier"/"make").
source String @default("manual")
/// Delivery format: "json" (generic HMAC-signed POST) or "discord" (a Discord
/// channel webhook receiving a formatted embed — log submissions to any server).
format String @default("json")
createdAt DateTime @default(now()) @map("created_at")

guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
Expand Down
Loading