diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts new file mode 100644 index 000000000..591691437 --- /dev/null +++ b/src/app/api/settings/webhook/route.ts @@ -0,0 +1,106 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { webhookSettingsSchema } from "@/lib/validations/notifications"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * Generic-webhook channel config (v1.17.1). + * 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 public URL and an optional shared-secret + * header. SSRF is enforced at input time (`isPublicUrl` in the schema) and + * again at dispatch time (`safeFetch({ requirePublicHost: true })`). + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, + }); + + if (!channel) { + return apiSuccess({ + enabled: false, + url: "", + headerName: "", + hasHeaderValue: false, + }); + } + + const config = JSON.parse(decrypt(channel.config)) as { + url: string; + headerName?: string; + headerValue?: string; + }; + + annotate({ action: { name: "settings.webhook.get" } }); + + return apiSuccess({ + enabled: channel.enabled, + url: config.url, + headerName: config.headerName ?? "", + hasHeaderValue: !!config.headerValue, + }); +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + + if (jsonError) return jsonError; + const parsed = webhookSettingsSchema.safeParse(body); + if (!parsed.success) return apiError("Invalid data", 422); + + const { url, headerName, headerValue, enabled } = parsed.data; + + if (enabled && !url) { + return apiError("Webhook URL is required when the webhook is enabled", 422); + } + + // 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. + let nextHeaderValue = headerValue || undefined; + if (!nextHeaderValue) { + 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 config = JSON.stringify({ + url: url || "", + ...(headerName ? { headerName } : {}), + ...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}), + }); + + const encryptedConfig = encrypt(config); + + await prisma.notificationChannel.upsert({ + where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, + create: { + userId: user.id, + type: "WEBHOOK", + enabled, + config: encryptedConfig, + }, + update: { enabled, config: encryptedConfig }, + }); + + annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } }); + + return apiSuccess({ saved: true }); +}); diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts new file mode 100644 index 000000000..884a648e0 --- /dev/null +++ b/src/app/api/settings/webhook/test/route.ts @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { decrypt } from "@/lib/crypto"; +import { sendViaWebhook } from "@/lib/notifications/senders/webhook"; +import type { WebhookChannelConfig } from "@/lib/notifications/types"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; + +/** + * POST: send a test notification via the configured generic webhook. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`webhook-test:${user.id}`, 5, 5 * 60 * 1000); + if (!rl.allowed) { + return apiError("Maximum 5 tests in 5 minutes", 429); + } + + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId: user.id, type: "WEBHOOK" } }, + }); + + if (!channel) { + return apiError("Webhook is not configured", 400); + } + + const config = JSON.parse(decrypt(channel.config)) as WebhookChannelConfig; + + if (!config.url) { + return apiError("Webhook URL is required", 400); + } + + const result = await sendViaWebhook(config, { + eventType: "SYSTEM_ALERT", + userId: user.id, + title: "HealthLog Test", + message: "HealthLog: Connection successful! Webhook notifications are active.", + }); + + if (!result.ok) { + return apiError("Failed to send test message", 500); + } + + annotate({ + action: { name: "settings.webhook.test" }, + meta: { success: true }, + }); + + return apiSuccess({ sent: true }); +}); diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx new file mode 100644 index 000000000..bf2a17b69 --- /dev/null +++ b/src/components/settings/webhook-card.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Webhook } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { PasswordInput } from "@/components/ui/password-input"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; + +interface WebhookSettings { + enabled: boolean; + url: string; + headerName: string; + hasHeaderValue: boolean; +} + +export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [url, setUrl] = useState(""); + const [headerName, setHeaderName] = useState(""); + const [headerValue, setHeaderValue] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + + const { data: settings } = useQuery({ + queryKey: queryKeys.settingsWebhook(), + queryFn: async () => { + return apiGet("/api/settings/webhook"); + }, + enabled: isAuthenticated, + }); + + // React-recommended sync-from-server pattern (no setState-in-effect). + const settingsKey = settings ? `${settings.url}|${settings.headerName}` : null; + const [seededKey, setSeededKey] = useState(null); + if (settingsKey && settingsKey !== seededKey) { + setSeededKey(settingsKey); + setUrl(settings!.url); + setHeaderName(settings!.headerName); + } + + const save = useMutation({ + mutationFn: async (enabled: boolean) => { + const res = await apiFetchRaw("/api/settings/webhook", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url, + headerName: headerName || undefined, + headerValue: headerValue || undefined, + enabled, + }), + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); + setSaveMsg(t("settings.saved")); + setSaveMsgType("success"); + setHeaderValue(""); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + return ( +
+ + +
+
+ + save.mutate(checked)} + disabled={save.isPending} + /> +
+ +
{ + e.preventDefault(); + save.mutate(settings?.enabled ?? false); + }} + className="space-y-4" + > +
+
+ + setUrl(e.target.value)} + /> +

+ {t("settings.webhookUrlHint")} +

+
+
+ + setHeaderName(e.target.value)} + /> +
+
+ + setHeaderValue(e.target.value)} + /> +
+
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts new file mode 100644 index 000000000..eaddccc2b --- /dev/null +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -0,0 +1,140 @@ +/** + * Generic-webhook sender unit tests (v1.17.1). + * + * Covers: success, hard-reject classification (404/410/401/403), SSRF block + * (safeFetch throws `private_host`), cooldown-relevant transient classification + * (5xx), and the push_attempts ledger write per outcome. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetchMock = vi.fn(); +const recordPushAttemptMock = 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/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), +})); + +import { sendViaWebhook } from "../webhook"; +import { SafeFetchError } from "@/lib/safe-fetch"; + +const config = { + url: "https://gotify.example.com/message", + headerName: "Authorization", + headerValue: "Bearer secret", +}; + +function payload(over?: Record) { + return { + eventType: "SYSTEM_ALERT" as const, + userId: "user-1", + title: "Title", + message: "Body", + ...over, + }; +} + +beforeEach(() => { + safeFetchMock.mockReset(); + recordPushAttemptMock.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("sendViaWebhook", () => { + it("POSTs through safeFetch with requirePublicHost and returns ok on 200", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(true); + expect(result.statusCode).toBe(200); + // SSRF floor + DNS-rebinding pin: requirePublicHost must be set. + const [, init, opts] = safeFetchMock.mock.calls[0]; + expect((opts as { requirePublicHost?: boolean }).requirePublicHost).toBe( + true, + ); + expect((init as { method?: string }).method).toBe("POST"); + // Custom header is attached. + expect( + (init as { headers?: Record }).headers?.Authorization, + ).toBe("Bearer secret"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "WEBHOOK", result: "ok" }), + ); + }); + + it("hard-rejects on 410 (endpoint gone)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 410 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("webhook_410_gone"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "WEBHOOK", result: "error" }), + ); + }); + + it("hard-rejects on 401/403 (shared secret wrong)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 403 }); + const result = await sendViaWebhook(config, payload()); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("webhook_auth_rejected"); + }); + + it("soft-fails on 503 (transient — eligible for backoff)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 503 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("webhook_503"); + }); + + it("returns a transient network error when safeFetch blocks a private host (SSRF)", async () => { + safeFetchMock.mockRejectedValue( + new SafeFetchError("refused private host", "private_host"), + ); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(false); + expect(result.reason).toBe("webhook_network_error"); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "WEBHOOK", + result: "error", + reason: "webhook_network_error", + }), + ); + }); + + it("omits the custom header when not configured", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaWebhook({ url: "https://example.com/hook" }, payload()); + const [, init] = safeFetchMock.mock.calls[0]; + expect( + (init as { headers?: Record }).headers?.Authorization, + ).toBeUndefined(); + }); +}); diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts new file mode 100644 index 000000000..68add9be9 --- /dev/null +++ b/src/lib/notifications/senders/webhook.ts @@ -0,0 +1,122 @@ +import type { + WebhookChannelConfig, + NotificationPayload, +} from "@/lib/notifications/types"; +import type { SendOutcome } from "@/lib/notifications/retry-policy"; +import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; +import { getEvent } from "@/lib/logging/context"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { safeFetch } from "@/lib/safe-fetch"; +import { plainPushText } from "@/lib/notifications/strip-emoji"; + +/** + * Send a notification via a generic outbound webhook (v1.17.1). + * + * The user supplies a public URL (and optionally one custom header — e.g. + * `Authorization: Bearer ` for Gotify). We POST a small JSON envelope + * that Gotify / Discord / Slack / Matrix-bridge / Home Assistant / any homelab + * relay can consume. The body is plain text (no markdown — hard rule); the + * `title`/`message` fields are stripped of HTML + decorative emoji on routine + * reminders exactly like the ntfy sender. + * + * Outbound goes through `safeFetch({ requirePublicHost: true })`: the SSRF + * floor plus the connect-time DNS-rebinding pin apply because the host is + * user-supplied. The optional shared-secret header would otherwise leak on a + * rebinding flip to a private address. + * + * Returns a `SendOutcome` so the dispatcher can distinguish hard rejects + * (404/410 endpoint gone, 401/403 secret wrong) from soft errors (5xx, 429, + * network timeout) and apply the right retry / auto-disable policy. + */ +export async function sendViaWebhook( + config: WebhookChannelConfig, + payload: NotificationPayload, +): Promise { + const start = performance.now(); + try { + const headers: Record = { + "Content-Type": "application/json", + }; + if (config.headerName && config.headerValue) { + headers[config.headerName] = config.headerValue; + } + + // Plain-text envelope. The `title` field doubles as Gotify's `title` and + // Discord/Slack ignore unknown keys, so a single shape covers the common + // relays. Discreet mode (cycle privacy) is honoured by the title/body + // already being masked upstream; we also send a generic `eventType` tag so + // a relay rule can route without leaking the cycle event name. + const body = JSON.stringify({ + title: plainPushText(payload.title, payload.eventType), + message: plainPushText( + payload.message.replace(/<[^>]*>/g, ""), + payload.eventType, + ), + eventType: payload.discreet ? "reminder" : payload.eventType, + priority: + payload.eventType === "MEDICATION_REMINDER" ? "high" : "default", + }); + + const res = await safeFetch( + config.url, + { + method: "POST", + headers, + body, + }, + { timeoutMs: 5_000, requirePublicHost: true }, + ); + + getEvent()?.addExternalCall({ + service: "webhook", + method: "sendNotification", + duration_ms: Math.round(performance.now() - start), + status: res.status, + }); + + if (res.ok) { + recordPushAttempt({ + userId: payload.userId, + channel: "WEBHOOK", + eventType: payload.eventType, + result: "ok", + }); + return { ok: true, statusCode: res.status }; + } + const classified = classifyHttpStatus(res.status, "webhook"); + recordPushAttempt({ + userId: payload.userId, + channel: "WEBHOOK", + eventType: payload.eventType, + result: "error", + reason: classified.reason, + }); + return { + ok: false, + statusCode: res.status, + hardReject: classified.hardReject, + reason: classified.reason, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "request_failed"; + getEvent()?.addExternalCall({ + service: "webhook", + method: "sendNotification", + duration_ms: Math.round(performance.now() - start), + error: message, + }); + recordPushAttempt({ + userId: payload.userId, + channel: "WEBHOOK", + eventType: payload.eventType, + result: "error", + reason: "webhook_network_error", + }); + return { + ok: false, + hardReject: false, + reason: "webhook_network_error", + message, + }; + } +}