From fafb5cdc085fb7c19c351ee2a808feedc6b023d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 14:19:48 +0200 Subject: [PATCH] feat(notifications): add a Gotify payload format to the webhook Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras. Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does. The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter. Refs #947 --- docs/api/openapi.yaml | 17 +- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/ko.json | 4 + messages/pl.json | 4 + .../__tests__/webhook-format-route.test.ts | 194 ++++++++++++++++++ src/app/api/settings/webhook/route.ts | 39 ++-- .../notification-channel-cards.test.tsx | 23 +++ src/components/settings/webhook-card.tsx | 32 ++- .../senders/__tests__/webhook.test.ts | 116 +++++++++++ src/lib/notifications/senders/webhook.ts | 105 +++++++--- src/lib/notifications/types.ts | 33 ++- src/lib/openapi/routes/settings.ts | 8 +- src/lib/validations/notifications.ts | 11 +- 17 files changed, 556 insertions(+), 50 deletions(-) create mode 100644 src/app/api/settings/__tests__/webhook-format-route.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 90730c227..71260c858 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25917,6 +25917,14 @@ components: maxLength: 500 - 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. + type: string + enum: + - generic + - gotify enabled: type: boolean required: @@ -25926,7 +25934,7 @@ components: re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this - endpoint." + endpoint. An omitted `format` keeps the stored body shape." DeleteAccountRequest: type: object properties: @@ -57501,11 +57509,18 @@ components: hasHeaderValue: type: boolean description: Whether a header value (the shared secret) is stored. The value itself is never returned. + format: + type: string + enum: + - generic + - gotify + description: Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice. required: - enabled - url - headerName - hasHeaderValue + - format additionalProperties: false description: Stored generic-webhook channel config, with the secret header value reduced to a presence flag. PutWebhookSettingsResponse: diff --git a/messages/de.json b/messages/de.json index b5ac510ae..02e6afce7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5590,6 +5590,10 @@ "webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.", "webhookHeaderName": "Header-Name (optional)", "webhookHeaderValue": "Header-Wert (optional)", + "webhookFormat": "Nachrichtenformat", + "webhookFormatGeneric": "Generisches JSON", + "webhookFormatGotify": "Gotify", + "webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.", "email": "E-Mail", "emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.", "emailEnable": "E-Mail aktivieren", diff --git a/messages/en.json b/messages/en.json index e54cffb55..1e7ea2328 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5590,6 +5590,10 @@ "webhookUrlHint": "The full URL to POST to. Must be publicly reachable.", "webhookHeaderName": "Header name (optional)", "webhookHeaderValue": "Header value (optional)", + "webhookFormat": "Payload format", + "webhookFormatGeneric": "Generic JSON", + "webhookFormatGotify": "Gotify", + "webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.", "email": "Email", "emailDescription": "Receive alerts and reminders by email.", "emailEnable": "Enable email", diff --git a/messages/es.json b/messages/es.json index 1ca650b2a..29f3f81b0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5590,6 +5590,10 @@ "webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.", "webhookHeaderName": "Nombre de cabecera (opcional)", "webhookHeaderValue": "Valor de cabecera (opcional)", + "webhookFormat": "Formato del mensaje", + "webhookFormatGeneric": "JSON genérico", + "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", "emailDescription": "Recibe alertas y recordatorios por correo electrónico.", "emailEnable": "Activar correo electrónico", diff --git a/messages/fr.json b/messages/fr.json index aba616f1d..48c1a548e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5590,6 +5590,10 @@ "webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.", "webhookHeaderName": "Nom d’en-tête (facultatif)", "webhookHeaderValue": "Valeur d’en-tête (facultatif)", + "webhookFormat": "Format du message", + "webhookFormatGeneric": "JSON générique", + "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", "emailDescription": "Recevoir les alertes et les rappels par e-mail.", "emailEnable": "Activer l’e-mail", diff --git a/messages/it.json b/messages/it.json index 834d5da3d..9876034aa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5590,6 +5590,10 @@ "webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.", "webhookHeaderName": "Nome intestazione (opzionale)", "webhookHeaderValue": "Valore intestazione (opzionale)", + "webhookFormat": "Formato del messaggio", + "webhookFormatGeneric": "JSON generico", + "webhookFormatGotify": "Gotify", + "webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.", "email": "Email", "emailDescription": "Ricevi avvisi e promemoria via email.", "emailEnable": "Attiva email", diff --git a/messages/ko.json b/messages/ko.json index 48722e917..f99ab9a28 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5590,6 +5590,10 @@ "webhookUrlHint": "POST를 보낼 전체 URL이에요. 외부에서 접근할 수 있어야 해요.", "webhookHeaderName": "헤더 이름 (선택)", "webhookHeaderValue": "헤더 값 (선택)", + "webhookFormat": "메시지 형식", + "webhookFormatGeneric": "일반 JSON", + "webhookFormatGotify": "Gotify", + "webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.", "email": "이메일", "emailDescription": "경고와 알림을 이메일로 받아요.", "emailEnable": "이메일 켜기", diff --git a/messages/pl.json b/messages/pl.json index 4689c536b..d401d3f51 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5590,6 +5590,10 @@ "webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.", "webhookHeaderName": "Nazwa nagłówka (opcjonalnie)", "webhookHeaderValue": "Wartość nagłówka (opcjonalnie)", + "webhookFormat": "Format wiadomości", + "webhookFormatGeneric": "Ogólny JSON", + "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", "emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.", "emailEnable": "Włącz e-mail", diff --git a/src/app/api/settings/__tests__/webhook-format-route.test.ts b/src/app/api/settings/__tests__/webhook-format-route.test.ts new file mode 100644 index 000000000..d20ba38b8 --- /dev/null +++ b/src/app/api/settings/__tests__/webhook-format-route.test.ts @@ -0,0 +1,194 @@ +/** + * The webhook payload format at the settings route (#947). + * + * Absent means generic, so a config saved before the choice existed keeps + * sending the body it always sent. Only the Gotify choice is written, an + * omitted format keeps the stored one, and the GET reports the resolved + * value rather than leaving the client to guess the default. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findUnique: vi.fn(), + updateMany: vi.fn(), + upsert: vi.fn(), + }, + }, +})); +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `encrypted:${value}`), + decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +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 { GET, PUT } from "../webhook/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +type Route = (request: Request) => Promise; + +function put(body: unknown): Promise { + return (PUT as Route)( + new Request("http://localhost/api/settings/webhook", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function get(): Promise { + return (GET as Route)(new Request("http://localhost/api/settings/webhook")); +} + +function stored(): string { + const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0]; + return call.create.config as string; +} + +function existing(config: Record) { + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ + enabled: true, + config: `encrypted:${JSON.stringify(config)}`, + } as never); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); +}); + +describe("PUT /api/settings/webhook — payload format", () => { + it("stores the Gotify choice", async () => { + const response = await put({ + url: "https://gotify.example.com/message", + headerName: "X-Gotify-Key", + headerValue: "AbCdEfGh123", + format: "gotify", + enabled: true, + }); + + expect(response.status).toBe(200); + expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({ + url: "https://gotify.example.com/message", + headerName: "X-Gotify-Key", + headerValue: "AbCdEfGh123", + format: "gotify", + }); + }); + + it("writes nothing for generic, so the stored config keeps its old shape", async () => { + await put({ + url: "https://relay.example.com/hook", + format: "generic", + enabled: true, + }); + + expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}'); + }); + + it("keeps a stored Gotify choice when the body omits the field", async () => { + existing({ + url: "https://gotify.example.com/message", + headerValue: "AbCdEfGh123", + format: "gotify", + }); + + await put({ + url: "https://gotify.example.com/message", + headerValue: "NewToken456", + enabled: true, + }); + + expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({ + headerValue: "NewToken456", + format: "gotify", + }); + }); + + it("switches a Gotify channel back to generic when asked", async () => { + existing({ url: "https://gotify.example.com/message", format: "gotify" }); + + await put({ + url: "https://gotify.example.com/message", + format: "generic", + enabled: true, + }); + + expect(stored()).toBe( + 'encrypted:{"url":"https://gotify.example.com/message"}', + ); + }); + + it("refuses a format it does not know", async () => { + const response = await put({ + url: "https://relay.example.com/hook", + format: "discord", + enabled: true, + }); + + expect(response.status).toBe(422); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/settings/webhook — payload format", () => { + it("reports generic for a config saved before the choice existed", async () => { + existing({ url: "https://relay.example.com/hook" }); + + const json = await (await get()).json(); + + expect(json.data.format).toBe("generic"); + }); + + it("reports the stored Gotify choice", async () => { + existing({ url: "https://gotify.example.com/message", format: "gotify" }); + + const json = await (await get()).json(); + + expect(json.data.format).toBe("gotify"); + }); + + it("reports generic when no channel exists", async () => { + const json = await (await get()).json(); + + expect(json.data).toEqual({ + enabled: false, + url: "", + headerName: "", + hasHeaderValue: false, + format: "generic", + }); + }); +}); diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 1f1b072b3..49cccdec3 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -20,6 +20,7 @@ import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import type { WebhookChannelConfig } from "@/lib/notifications/types"; /** * The save-time schema evaluates the same policy the sender does: the public @@ -61,22 +62,23 @@ export const GET = apiHandler(async () => { url: "", headerName: "", hasHeaderValue: false, + format: "generic", }); } - const config = JSON.parse(decrypt(channel.config)) as { - url: string; - headerName?: string; - headerValue?: string; - }; + const config = JSON.parse( + decrypt(channel.config), + ) as Partial; annotate({ action: { name: "settings.webhook.get" } }); return apiSuccess({ enabled: channel.enabled, - url: config.url, + url: config.url ?? "", headerName: config.headerName ?? "", hasHeaderValue: !!config.headerValue, + // A config saved before the choice existed carries no format: generic. + format: config.format === "gotify" ? "gotify" : "generic", }); }); @@ -166,7 +168,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { return apiValidationError("Invalid data", issues, 422); } - const { url, headerName, headerValue, enabled } = parsed.data; + const { url, headerName, headerValue, format, enabled } = parsed.data; if (enabled && !url) { return apiError("Webhook URL is required when the webhook is enabled", 422); @@ -174,24 +176,30 @@ export const PUT = apiHandler(async (request: NextRequest) => { // Preserve an existing header value when the client sends an empty one (the // GET path never returns the secret, so a save round-trip would otherwise - // wipe it). A non-empty value replaces it. + // wipe it). A non-empty value replaces it. An omitted format keeps the + // stored one too, so a client that does not know the field cannot flip a + // Gotify channel back to the generic body by saving. let nextHeaderValue = headerValue || undefined; - if (!nextHeaderValue) { + let nextFormat = format; + if (!nextHeaderValue || nextFormat === undefined) { const existing = await prisma.notificationChannel.findUnique({ where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, }); if (existing) { - const prev = JSON.parse(decrypt(existing.config)) as { - headerValue?: string; - }; - nextHeaderValue = prev.headerValue || undefined; + const prev = JSON.parse( + decrypt(existing.config), + ) as Partial; + if (!nextHeaderValue) nextHeaderValue = prev.headerValue || undefined; + if (nextFormat === undefined) nextFormat = prev.format; } } + // Absent means generic, so only the Gotify choice is written. const config = JSON.stringify({ url: url || "", ...(headerName ? { headerName } : {}), ...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}), + ...(nextFormat === "gotify" ? { format: "gotify" } : {}), }); const encryptedConfig = encrypt(config); @@ -207,7 +215,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { update: { enabled, config: encryptedConfig }, }); - annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } }); + annotate({ + action: { name: "settings.webhook.update" }, + meta: { enabled, format: nextFormat === "gotify" ? "gotify" : "generic" }, + }); return apiSuccess({ saved: true }); }); diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx index a7cda1b36..81499de41 100644 --- a/src/components/settings/__tests__/notification-channel-cards.test.tsx +++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx @@ -154,4 +154,27 @@ describe("notification channel cards", () => { ); }, ); + + it("webhook card offers the payload format and sends it with the full save (#947)", async () => { + const html = render(); + + expect(html).toContain('id="webhook-format"'); + expect(html).toMatch(/