From d029e4e1dcac500960c94394e36edca6eb025f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 13:43:28 +0200 Subject: [PATCH] fix(notifications): send content and text so Discord and Slack webhooks deliver The generic webhook body carried title, message, eventType and priority only. Discord refuses a body without content (400, code 50006 "Cannot send an empty message") and a Slack incoming webhook one without text, so the test button and every reminder failed against the targets the settings card names. The generic body now appends content (cut to Discord's 2000 characters) and text, both the masked title and message on two lines, plus allowed_mentions with an empty parse list so Discord resolves no mentions. The four original fields keep their values and order for Home Assistant and n8n rules. The Gotify body is unchanged. The card names what works and shows a hint for the generic format. --- CHANGELOG.md | 8 + docs/api/openapi.yaml | 7 +- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- src/app/api/settings/webhook/route.ts | 3 +- .../webhook/test/__tests__/route.test.ts | 150 ++++++++++++++++++ .../notification-channels-panel.tsx | 4 +- src/components/settings/webhook-card.tsx | 10 +- .../senders/__tests__/webhook.test.ts | 122 +++++++++++++- src/lib/notifications/senders/webhook.ts | 41 ++++- src/lib/validations/notifications.ts | 2 +- 16 files changed, 336 insertions(+), 32 deletions(-) create mode 100644 src/app/api/settings/webhook/test/__tests__/route.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 297cbf8d7..f942fb915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,6 +155,14 @@ an earlier fold computed from part of a day. - **Push after an update.** Reload on the new-version hint unregistered the service worker, which ended the Web Push subscription; it now updates the worker instead. +- **Discord and Slack webhooks.** A Discord webhook URL answered the test + and every reminder with "Cannot send an empty message", and a Slack + incoming webhook refused the body too: the generic format carried neither + Discord's `content` nor Slack's `text`. It now sends both as plain text + (title and message, cut to Discord's 2000 characters), tells Discord to + resolve no mentions, and keeps `title`, `message`, `eventType` and + `priority` unchanged for Home Assistant and n8n rules. Discreet mode + covers the new fields. - **MCP deep links.** `/insights?metric=` and `/labs?analyte=` open the metric's page and the marker's page. - **Apple Health status** no longer reads "waiting for first data" after an diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 77fa87cf8..e163cc563 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29670,9 +29670,10 @@ components: - type: string const: "" format: - description: Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message` - expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved - before the choice existed. + description: Body shape. `generic` sends HealthLog's own JSON envelope (`title`, `message`, `eventType`, `priority`, + plus `content` and `text` so a Discord or Slack webhook URL accepts it as is); `gotify` sends the body + Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is + `generic` for a config saved before the choice existed. type: string enum: - generic diff --git a/messages/de.json b/messages/de.json index 018ff5319..c205062b2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6017,7 +6017,7 @@ "ntfyAuthToken": "Auth-Token (optional)", "ntfyAuthTokenHint": "Nur nötig bei privaten Topics mit Zugangsschutz.", "webhook": "Webhook", - "webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.", + "webhookDescription": "Benachrichtigungen per POST an eine URL: Discord, Slack, Gotify, Home Assistant, n8n oder jedes Relay, das JSON annimmt.", "webhookEnable": "Webhook aktivieren", "webhookUrl": "Webhook-URL", "webhookUrlHint": "Die vollständige URL für den POST. Eine Adresse im privaten Netz muss der Betreiber des Servers freigeben.", @@ -6025,6 +6025,7 @@ "webhookHeaderValue": "Header-Wert (optional)", "webhookFormat": "Nachrichtenformat", "webhookFormatGeneric": "Generisches JSON", + "webhookFormatGenericHint": "Eine Discord- oder Slack-Webhook-URL einfach so einfügen. Home Assistant und n8n lesen die Felder title, message, eventType und priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.", "email": "E-Mail", diff --git a/messages/en.json b/messages/en.json index b965c2afe..a2f0e1596 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6017,7 +6017,7 @@ "ntfyAuthToken": "Auth Token (optional)", "ntfyAuthTokenHint": "Only needed for private topics with access control.", "webhook": "Webhook", - "webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.", + "webhookDescription": "POST notifications to any URL: Discord, Slack, Gotify, Home Assistant, n8n, or any relay that takes JSON.", "webhookEnable": "Enable webhook", "webhookUrl": "Webhook URL", "webhookUrlHint": "The full URL to POST to. An address on a private network needs approval from the server operator.", @@ -6025,6 +6025,7 @@ "webhookHeaderValue": "Header value (optional)", "webhookFormat": "Payload format", "webhookFormatGeneric": "Generic JSON", + "webhookFormatGenericHint": "Paste a Discord or Slack webhook URL as it is. Home Assistant and n8n read the fields title, message, eventType and priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.", "email": "Email", diff --git a/messages/es.json b/messages/es.json index aa5917aa9..abfa9aad0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6017,7 +6017,7 @@ "ntfyAuthToken": "Token de autenticación (opcional)", "ntfyAuthTokenHint": "Solo necesario para temas privados con control de acceso.", "webhook": "Webhook", - "webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.", + "webhookDescription": "Envía notificaciones por POST a una URL: Discord, Slack, Gotify, Home Assistant, n8n o un relé que acepte JSON.", "webhookEnable": "Activar webhook", "webhookUrl": "URL del webhook", "webhookUrlHint": "La URL completa para el POST. Una dirección de una red privada necesita la aprobación del administrador del servidor.", @@ -6025,6 +6025,7 @@ "webhookHeaderValue": "Valor de cabecera (opcional)", "webhookFormat": "Formato del mensaje", "webhookFormatGeneric": "JSON genérico", + "webhookFormatGenericHint": "Pega una URL de webhook de Discord o Slack tal cual. Home Assistant y n8n leen los campos title, message, eventType y priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Usa la URL que termina en /message y pon el token de la aplicación en la cabecera X-Gotify-Key.", "email": "Correo electrónico", diff --git a/messages/fr.json b/messages/fr.json index 50a5b0c48..7c3bd9532 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6017,7 +6017,7 @@ "ntfyAuthToken": "Jeton d’authentification (facultatif)", "ntfyAuthTokenHint": "Nécessaire uniquement pour les sujets privés avec contrôle d’accès.", "webhook": "Webhook", - "webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.", + "webhookDescription": "Notifications par POST vers une URL : Discord, Slack, Gotify, Home Assistant, n8n ou tout relais acceptant du JSON.", "webhookEnable": "Activer le webhook", "webhookUrl": "URL du webhook", "webhookUrlHint": "L’URL complète pour le POST. Une adresse sur un réseau privé doit être autorisée par l’administrateur du serveur.", @@ -6025,6 +6025,7 @@ "webhookHeaderValue": "Valeur d’en-tête (facultatif)", "webhookFormat": "Format du message", "webhookFormatGeneric": "JSON générique", + "webhookFormatGenericHint": "Collez telle quelle une URL de webhook Discord ou Slack. Home Assistant et n8n lisent les champs title, message, eventType et priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Utilisez l’URL se terminant par /message et placez le jeton de l’application dans l’en-tête X-Gotify-Key.", "email": "E-mail", diff --git a/messages/it.json b/messages/it.json index 4db7ce4b9..776b9c7d1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6017,7 +6017,7 @@ "ntfyAuthToken": "Token di autenticazione (facoltativo)", "ntfyAuthTokenHint": "Necessario solo per topic privati con controllo degli accessi.", "webhook": "Webhook", - "webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.", + "webhookDescription": "Invia le notifiche via POST a un URL: Discord, Slack, Gotify, Home Assistant, n8n o un relay che accetti JSON.", "webhookEnable": "Attiva webhook", "webhookUrl": "URL del webhook", "webhookUrlHint": "L’URL completo per il POST. Un indirizzo su una rete privata deve essere autorizzato dall’amministratore del server.", @@ -6025,6 +6025,7 @@ "webhookHeaderValue": "Valore intestazione (opzionale)", "webhookFormat": "Formato del messaggio", "webhookFormatGeneric": "JSON generico", + "webhookFormatGenericHint": "Incolla così com’è un URL webhook di Discord o Slack. Home Assistant e n8n leggono i campi title, message, eventType e priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.", "email": "Email", diff --git a/messages/ko.json b/messages/ko.json index 95e1b73b4..9ad9fa95d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6017,7 +6017,7 @@ "ntfyAuthToken": "인증 토큰 (선택)", "ntfyAuthTokenHint": "접근 제어가 걸린 비공개 토픽에만 필요해요.", "webhook": "웹훅", - "webhookDescription": "Gotify, Discord, Slack, Matrix, Home Assistant 등 어떤 URL로든 알림을 POST해요.", + "webhookDescription": "Discord, Slack, Gotify, Home Assistant, n8n 등 JSON을 받는 어떤 URL로든 알림을 POST해요.", "webhookEnable": "웹훅 켜기", "webhookUrl": "웹훅 URL", "webhookUrlHint": "POST를 보낼 전체 URL이에요. 사설 네트워크 주소는 서버 운영자가 허용해야 해요.", @@ -6025,6 +6025,7 @@ "webhookHeaderValue": "헤더 값 (선택)", "webhookFormat": "메시지 형식", "webhookFormatGeneric": "일반 JSON", + "webhookFormatGenericHint": "Discord나 Slack 웹후크 URL은 그대로 붙여 넣으면 돼요. Home Assistant와 n8n은 title, message, eventType, priority 필드를 읽어요.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.", "email": "이메일", diff --git a/messages/pl.json b/messages/pl.json index 3cfb2d900..927d701fd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6017,7 +6017,7 @@ "ntfyAuthToken": "Token uwierzytelniania (opcjonalny)", "ntfyAuthTokenHint": "Wymagany tylko dla prywatnych tematów z kontrolą dostępu.", "webhook": "Webhook", - "webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.", + "webhookDescription": "Powiadomienia metodą POST na dowolny URL: Discord, Slack, Gotify, Home Assistant, n8n lub przekaźnik przyjmujący JSON.", "webhookEnable": "Włącz webhook", "webhookUrl": "Adres URL webhooka", "webhookUrlHint": "Pełny adres URL dla żądania POST. Adres w sieci prywatnej musi zatwierdzić administrator serwera.", @@ -6025,6 +6025,7 @@ "webhookHeaderValue": "Wartość nagłówka (opcjonalnie)", "webhookFormat": "Format wiadomości", "webhookFormatGeneric": "Ogólny JSON", + "webhookFormatGenericHint": "Wklej adres webhooka Discorda lub Slacka bez zmian. Home Assistant i n8n odczytują pola title, message, eventType i priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Użyj adresu URL kończącego się na /message, a token aplikacji wpisz w nagłówku X-Gotify-Key.", "email": "E-mail", diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 49cccdec3..0c583c34e 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -42,8 +42,7 @@ const NOT_GRANTABLE_REFUSAL = * GET: current config (header value redacted — only presence flagged). * PUT: upsert config. * - * Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one - * channel: the user supplies a URL and an optional shared-secret header. SSRF + * Covers Discord / Slack / Gotify / Home Assistant / n8n in one channel: the user supplies a URL and an optional shared-secret header. SSRF * is enforced at input time (the schema's target predicate) and again at * dispatch time (`safeFetch` with the connect-time pin); a private origin * passes both only when the operator listed it in diff --git a/src/app/api/settings/webhook/test/__tests__/route.test.ts b/src/app/api/settings/webhook/test/__tests__/route.test.ts new file mode 100644 index 000000000..b317e1dd5 --- /dev/null +++ b/src/app/api/settings/webhook/test/__tests__/route.test.ts @@ -0,0 +1,150 @@ +/** + * The webhook test button against a Discord webhook URL, through the real + * sender with only the network mocked. + * + * The mocked target answers the way Discord does: 400 with code 50006 + * ("Cannot send an empty message") for a body without `content` or `embeds`, + * 204 otherwise. The generic body used to carry neither, so the test button + * and every reminder failed against a Discord URL the settings card names. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `encrypted:${value}`), + decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), +})); + +const safeFetchMock = vi.fn(); +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...args: unknown[]) => safeFetchMock(...args), + SafeFetchError: class SafeFetchError extends Error { + kind: string; + constructor(message: string, kind: string) { + super(message); + this.kind = kind; + } + }, +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: vi.fn(), + recordPushAttemptForPayload: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST as postTest } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const DISCORD_URL = + "https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"; + +/** Answers the way Discord's execute-webhook endpoint does. */ +function discordAnswer(_url: string, init: { body: string }): Response { + const body = JSON.parse(init.body) as Record; + const content = typeof body.content === "string" ? body.content : ""; + if (content.trim().length === 0 && body.embeds === undefined) { + return new Response( + JSON.stringify({ message: "Cannot send an empty message", code: 50006 }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + if (content.length > 2000) { + return new Response( + JSON.stringify({ message: "Invalid Form Body", code: 50035 }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(null, { status: 204 }); +} + +const POST = postTest as (request: Request) => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" }, + } as never); + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ + config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`, + } as never); + safeFetchMock.mockImplementation( + async (url: string, init: { body: string }) => discordAnswer(url, init), + ); +}); + +describe("POST /api/settings/webhook/test — a Discord webhook URL", () => { + it("delivers: the generic body carries the content Discord requires", async () => { + const response = await POST( + new Request("http://localhost/api/settings/webhook/test", { + method: "POST", + }), + ); + + expect(response.status).toBe(200); + expect((await response.json()).data).toEqual({ sent: true }); + + expect(safeFetchMock).toHaveBeenCalledTimes(1); + const [url, init] = safeFetchMock.mock.calls[0] as [ + string, + { body: string }, + ]; + expect(url).toBe(DISCORD_URL); + const sent = JSON.parse(init.body); + expect(sent.content).toBe( + "HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.", + ); + expect(sent.allowed_mentions).toEqual({ parse: [] }); + }); + + it("passes Discord's own error through when the target still refuses", async () => { + safeFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ message: "Unknown Webhook", code: 10015 }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ), + ); + + const response = await POST( + new Request("http://localhost/api/settings/webhook/test", { + method: "POST", + }), + ); + + expect(response.status).toBe(502); + const json = await response.json(); + expect(json.meta.upstreamStatus).toBe(404); + expect(json.meta.upstreamBody).toContain("Unknown Webhook"); + }); +}); diff --git a/src/components/settings/integrations/notification-channels-panel.tsx b/src/components/settings/integrations/notification-channels-panel.tsx index bd5552851..fddef0ae4 100644 --- a/src/components/settings/integrations/notification-channels-panel.tsx +++ b/src/components/settings/integrations/notification-channels-panel.tsx @@ -71,8 +71,8 @@ export function NotificationChannelsPanel() { )} - {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack / - Matrix / Home Assistant in one channel). */} + {/* v1.17.1 — generic outbound webhook (Discord / Slack / Gotify / + Home Assistant / n8n in one channel). */}
diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index 609f64e49..632fd3691 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -209,11 +209,11 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { {t("settings.webhookFormatGotify")} - {format === "gotify" && ( -

- {t("settings.webhookFormatGotifyHint")} -

- )} +

+ {format === "gotify" + ? t("settings.webhookFormatGotifyHint") + : t("settings.webhookFormatGenericHint")} +