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")} +