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(/