feat(notifications): add generic webhook channel

A single WEBHOOK channel covers Gotify, Discord, Slack, Matrix, and Home
Assistant: the user supplies a public URL and an optional shared-secret
header, and the dispatcher POSTs a small plain-text JSON envelope. The
outbound goes through safeFetch with requirePublicHost so the SSRF floor
and the connect-time DNS-rebinding pin apply to the user-supplied host,
exactly as they do for ntfy. Responses classify 404/410/401/403 as hard
rejects (auto-disable) and 5xx/429/timeout as transient (backoff).

Settings card mirrors the ntfy card with a save + test-connection flow;
the header value is never returned by the GET and is preserved across a
save round-trip when left blank.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:45:50 +02:00
1 parent aec41e154f
commit 7532bbcc05
5 files changed
+594

No files matched your search

+106
View File
@@ -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 });
});
@@ -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 });
});
+174
View File
@@ -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<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsWebhook(),
queryFn: async () => {
return apiGet<WebhookSettings>("/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<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Webhook}
title={t("settings.webhook")}
description={t("settings.webhookDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-url">{t("settings.webhookUrl")}</Label>
<Input
id="webhook-url"
placeholder="https://gotify.example.com/message?token=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.webhookUrlHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
{t("settings.webhookHeaderName")}
</Label>
<Input
id="webhook-header-name"
placeholder="Authorization"
value={headerName}
onChange={(e) => setHeaderName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-value">
{t("settings.webhookHeaderValue")}
</Label>
<PasswordInput
id="webhook-header-value"
placeholder={
settings?.hasHeaderValue
? t("settings.withingsCredentialsSavedPlaceholder")
: t("common.optional")
}
value={headerValue}
onChange={(e) => setHeaderValue(e.target.value)}
/>
</div>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
/>
<Button type="submit" disabled={save.isPending} className="min-h-11">
{save.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -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<string, unknown>) {
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<string, string> }).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<string, string> }).headers?.Authorization,
).toBeUndefined();
});
});
+122
View File
@@ -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 <token>` 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<SendOutcome> {
const start = performance.now();
try {
const headers: Record<string, string> = {
"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,
};
}
}