mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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
This commit is contained in:
1 parent
92606f6409
commit
fafb5cdc08
17 files changed
+556
-50
No files matched your search
+16
-1
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5590,6 +5590,10 @@
|
||||
"webhookUrlHint": "POST를 보낼 전체 URL이에요. 외부에서 접근할 수 있어야 해요.",
|
||||
"webhookHeaderName": "헤더 이름 (선택)",
|
||||
"webhookHeaderValue": "헤더 값 (선택)",
|
||||
"webhookFormat": "메시지 형식",
|
||||
"webhookFormatGeneric": "일반 JSON",
|
||||
"webhookFormatGotify": "Gotify",
|
||||
"webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.",
|
||||
"email": "이메일",
|
||||
"emailDescription": "경고와 알림을 이메일로 받아요.",
|
||||
"emailEnable": "이메일 켜기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Response>;
|
||||
|
||||
function put(body: unknown): Promise<Response> {
|
||||
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<Response> {
|
||||
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<string, unknown>) {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<WebhookChannelConfig>;
|
||||
|
||||
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<WebhookChannelConfig>;
|
||||
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 });
|
||||
});
|
||||
@@ -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(<WebhookCard isAuthenticated />);
|
||||
|
||||
expect(html).toContain('id="webhook-format"');
|
||||
expect(html).toMatch(/<option value="generic" selected="">/);
|
||||
expect(html).toContain('<option value="gotify">');
|
||||
|
||||
// The full save is the first mutation of the last render, whose closure
|
||||
// holds the values seeded from the server.
|
||||
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
|
||||
await save.mutationFn(false);
|
||||
|
||||
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
|
||||
string,
|
||||
{ body: string },
|
||||
];
|
||||
expect(JSON.parse(init.body)).toMatchObject({
|
||||
url: "https://saved.example/hook",
|
||||
format: "generic",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ 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 { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
|
||||
|
||||
interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
headerName: string;
|
||||
hasHeaderValue: boolean;
|
||||
format?: WebhookPayloadFormat;
|
||||
}
|
||||
|
||||
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
@@ -35,6 +38,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [headerName, setHeaderName] = useState("");
|
||||
const [headerValue, setHeaderValue] = useState("");
|
||||
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
@@ -50,13 +54,14 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.url}|${settings.headerName}`
|
||||
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setUrl(settings!.url);
|
||||
setHeaderName(settings!.headerName);
|
||||
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
@@ -68,6 +73,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
url,
|
||||
headerName: headerName || undefined,
|
||||
headerValue: headerValue || undefined,
|
||||
format,
|
||||
enabled,
|
||||
}),
|
||||
});
|
||||
@@ -180,6 +186,30 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
{t("settings.webhookUrlHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="webhook-format">
|
||||
{t("settings.webhookFormat")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="webhook-format"
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(e.target.value === "gotify" ? "gotify" : "generic")
|
||||
}
|
||||
>
|
||||
<option value="generic">
|
||||
{t("settings.webhookFormatGeneric")}
|
||||
</option>
|
||||
<option value="gotify">
|
||||
{t("settings.webhookFormatGotify")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
{format === "gotify" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.webhookFormatGotifyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-header-name">
|
||||
{t("settings.webhookHeaderName")}
|
||||
|
||||
@@ -340,3 +340,119 @@ describe("sendViaWebhook — what the relay said (#947)", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function sentBody(): string {
|
||||
return (safeFetchMock.mock.calls[0][1] as { body: string }).body;
|
||||
}
|
||||
|
||||
describe("sendViaWebhook — generic body stays byte for byte", () => {
|
||||
// Literal strings, not re-parsed objects: a Home Assistant or n8n rule
|
||||
// that matches on the raw body must keep matching after the format choice
|
||||
// was added.
|
||||
it.each([
|
||||
[
|
||||
"a routine event",
|
||||
{},
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"}',
|
||||
],
|
||||
[
|
||||
"a medication reminder",
|
||||
{ eventType: "MEDICATION_REMINDER" },
|
||||
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"}',
|
||||
],
|
||||
[
|
||||
"an urgent event",
|
||||
{ urgent: true },
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"}',
|
||||
],
|
||||
[
|
||||
"a discreet cycle event",
|
||||
{ eventType: "CYCLE_PERIOD_SOON", discreet: true },
|
||||
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"}',
|
||||
],
|
||||
])("%s", async (_label, over, expected) => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(
|
||||
{ url: "https://relay.example.com/hook" },
|
||||
payload(over),
|
||||
);
|
||||
|
||||
expect(sentBody()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaWebhook — Gotify format (#947)", () => {
|
||||
const gotify = {
|
||||
url: "https://gotify.example.com/message",
|
||||
headerName: "X-Gotify-Key",
|
||||
headerValue: "AbCdEfGh123",
|
||||
format: "gotify" as const,
|
||||
};
|
||||
|
||||
it("sends the body Gotify binds, with an integer priority and no eventType field", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(gotify, payload({ message: "<b>Body</b>" }));
|
||||
|
||||
expect(JSON.parse(sentBody())).toEqual({
|
||||
title: "Title",
|
||||
message: "Body",
|
||||
priority: 5,
|
||||
extras: {
|
||||
"client::display": { contentType: "text/plain" },
|
||||
"healthlog::event": { type: "SYSTEM_ALERT" },
|
||||
},
|
||||
});
|
||||
const [url, init] = safeFetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
];
|
||||
expect(url).toBe("https://gotify.example.com/message");
|
||||
expect(init.headers["X-Gotify-Key"]).toBe("AbCdEfGh123");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a routine event", 5, {}],
|
||||
["a medication reminder", 8, { eventType: "MEDICATION_REMINDER" }],
|
||||
["an urgent event", 10, { urgent: true }],
|
||||
[
|
||||
"an urgent medication reminder",
|
||||
10,
|
||||
{ eventType: "MEDICATION_REMINDER", urgent: true },
|
||||
],
|
||||
])("maps %s to priority %i", async (_label, priority, over) => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(gotify, payload(over));
|
||||
|
||||
const body = JSON.parse(sentBody());
|
||||
expect(body.priority).toBe(priority);
|
||||
expect(Number.isInteger(body.priority)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the cycle event name out of a discreet message", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(
|
||||
gotify,
|
||||
payload({ eventType: "CYCLE_PERIOD_SOON", discreet: true }),
|
||||
);
|
||||
|
||||
expect(sentBody()).not.toContain("CYCLE");
|
||||
expect(JSON.parse(sentBody()).extras["healthlog::event"]).toEqual({
|
||||
type: "reminder",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an unknown stored format as generic", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await sendViaWebhook(
|
||||
{ url: "https://relay.example.com/hook", format: "bogus" as never },
|
||||
payload(),
|
||||
);
|
||||
|
||||
expect(JSON.parse(sentBody()).priority).toBe("default");
|
||||
});
|
||||
});
|
||||
@@ -21,15 +21,88 @@ import {
|
||||
secretsInUrl,
|
||||
} from "@/lib/notifications/upstream-body";
|
||||
|
||||
type PriorityBand = "urgent" | "high" | "default";
|
||||
|
||||
/**
|
||||
* v1.18.4: an explicitly urgent event is `urgent` so a relay rule can
|
||||
* escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
|
||||
*/
|
||||
function priorityBand(payload: NotificationPayload): PriorityBand {
|
||||
if (payload.urgent === true) return "urgent";
|
||||
if (payload.eventType === "MEDICATION_REMINDER") return "high";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gotify's priority is an integer, and its Android client files a message
|
||||
* into a notification channel by it (`convertPriorityToChannel` in
|
||||
* gotify/android): below 1 minimum importance, 1-3 low (no sound), 4-7
|
||||
* default (sound), 8 and above high (sound, vibration, and a heads-up banner).
|
||||
*
|
||||
* The bands mirror what the ntfy sender asks for:
|
||||
* - `default` → 5: audible, like ntfy's default. Not 1-3, which would
|
||||
* make every routine reminder silent.
|
||||
* - `high` (medication reminders) → 8: the lowest value that reaches the
|
||||
* heads-up channel, as ntfy's `high` pops over.
|
||||
* - `urgent` → 10: the same Android channel as 8, and the top of the scale
|
||||
* for any client or rule that orders or filters by priority, as ntfy's 5.
|
||||
*/
|
||||
const GOTIFY_PRIORITY: Record<PriorityBand, number> = {
|
||||
default: 5,
|
||||
high: 8,
|
||||
urgent: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* The request body for the configured format.
|
||||
*
|
||||
* Discreet mode (cycle privacy) is honoured the same way in both: the title
|
||||
* and message arrive already masked upstream, and the event name a relay
|
||||
* could route on is replaced by the generic `reminder`.
|
||||
*/
|
||||
export function buildWebhookBody(
|
||||
config: Pick<WebhookChannelConfig, "format">,
|
||||
payload: NotificationPayload,
|
||||
): string {
|
||||
const title = plainPushText(payload.title, payload.eventType);
|
||||
const message = plainPushText(stripHtml(payload.message), payload.eventType);
|
||||
const eventType = payload.discreet ? "reminder" : payload.eventType;
|
||||
const band = priorityBand(payload);
|
||||
|
||||
if (config.format === "gotify") {
|
||||
return JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority: GOTIFY_PRIORITY[band],
|
||||
extras: {
|
||||
// Plain text, stated rather than left to the client default: the
|
||||
// body is never markdown (hard rule).
|
||||
"client::display": { contentType: "text/plain" },
|
||||
"healthlog::event": { type: eventType },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Generic envelope. Discord and Slack ignore unknown keys, so one shape
|
||||
// covers the common JSON relays. Unchanged since v1.18.4: an existing
|
||||
// Home Assistant or n8n rule parses these exact fields.
|
||||
return JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
eventType,
|
||||
priority: band,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The user supplies a URL and optionally one custom header, and chooses the
|
||||
* body shape: HealthLog's generic JSON envelope, or the body Gotify's
|
||||
* `POST /message` binds (see `buildWebhookBody`). Gotify takes its app token
|
||||
* as an `X-Gotify-Key` header or a `token` query parameter. The body is plain
|
||||
* text (no markdown — hard rule); `title`/`message` are stripped of HTML and
|
||||
* decorative emoji on routine reminders exactly like the ntfy sender.
|
||||
*
|
||||
* Outbound goes through `safeFetch` with the connect-time DNS-rebinding pin
|
||||
* because the host is user-supplied; the optional shared-secret header would
|
||||
@@ -63,25 +136,7 @@ export async function sendViaWebhook(
|
||||
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(stripHtml(payload.message), payload.eventType),
|
||||
eventType: payload.discreet ? "reminder" : payload.eventType,
|
||||
// v1.18.4 — an explicitly urgent event maps to `urgent` so a relay
|
||||
// rule (Gotify priority, Discord mention, Home Assistant automation)
|
||||
// can escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
|
||||
priority:
|
||||
payload.urgent === true
|
||||
? "urgent"
|
||||
: payload.eventType === "MEDICATION_REMINDER"
|
||||
? "high"
|
||||
: "default",
|
||||
});
|
||||
const body = buildWebhookBody(config, payload);
|
||||
|
||||
const policy = evaluateNotificationTarget(config.url);
|
||||
if (!policy.allowed || !policy.canonicalOrigin) {
|
||||
|
||||
@@ -238,12 +238,26 @@ export interface NtfyChannelConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic-webhook channel config (v1.17.1). The user supplies a public URL and
|
||||
* an OPTIONAL custom header (e.g. `Authorization: Bearer <token>` for Gotify,
|
||||
* or a Discord/Slack incoming-webhook URL needs no header). The dispatcher
|
||||
* POSTs a JSON body — see `sendViaWebhook` — through `safeFetch` with
|
||||
* `requirePublicHost: true` so the SSRF floor + DNS-rebinding pin apply to the
|
||||
* user-supplied host exactly as they do for ntfy.
|
||||
* The body shapes the generic webhook can send.
|
||||
*
|
||||
* - `generic`: HealthLog's own JSON envelope (`title`, `message`,
|
||||
* `eventType`, `priority` as a word), for Home Assistant, n8n, a Discord
|
||||
* or Slack relay, or anything that reads arbitrary JSON.
|
||||
* - `gotify`: the body Gotify's `POST /message` binds, with `priority` as
|
||||
* the integer Gotify types it as. The generic body is refused there with
|
||||
* a 400.
|
||||
*/
|
||||
export const WEBHOOK_PAYLOAD_FORMATS = ["generic", "gotify"] as const;
|
||||
export type WebhookPayloadFormat = (typeof WEBHOOK_PAYLOAD_FORMATS)[number];
|
||||
|
||||
/**
|
||||
* Generic-webhook channel config (v1.17.1). The user supplies a URL and an
|
||||
* OPTIONAL custom header (Gotify accepts its app token as `X-Gotify-Key`, or
|
||||
* as a `token` query parameter on the URL; a Discord or Slack incoming-webhook
|
||||
* URL needs no header). The dispatcher POSTs a JSON body — see
|
||||
* `sendViaWebhook` — through `safeFetch` with the connect-time pin, so the
|
||||
* SSRF floor and the DNS-rebinding defence apply to the user-supplied host
|
||||
* exactly as they do for ntfy.
|
||||
*/
|
||||
export interface WebhookChannelConfig {
|
||||
url: string;
|
||||
@@ -251,6 +265,11 @@ export interface WebhookChannelConfig {
|
||||
headerName?: string;
|
||||
/** Optional header value (e.g. "Bearer <token>"). Encrypted at rest. */
|
||||
headerValue?: string;
|
||||
/**
|
||||
* Body shape. Absent means `generic`, which is every config saved before
|
||||
* the choice existed; only `gotify` is written to the stored config.
|
||||
*/
|
||||
format?: WebhookPayloadFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,7 +333,7 @@ export interface NotificationPayload {
|
||||
* does not).
|
||||
* - ntfy → `Priority: 5` (max) + an `urgent` tag.
|
||||
* - WebPush → `Urgency: high` header + `requireInteraction`.
|
||||
* - Webhook → `priority: "urgent"` in the JSON body.
|
||||
* - Webhook → `priority: "urgent"` in the generic body, `10` in Gotify's.
|
||||
* - Telegram → normal delivery (no urgency tier exists; never silent).
|
||||
* Normal events leave `urgent` unset and behave exactly as before.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SHARING_NOT_PERMITTED_DESCRIPTION,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
|
||||
|
||||
// v1.16.11 — the one threshold read every dose-status consumer makes
|
||||
// (cards, table, take-all-due derivation). `lateMinutes` /
|
||||
@@ -255,7 +256,7 @@ const telegramSettingsRequest = telegramSettingsSchema.meta({
|
||||
const webhookSettingsRequest = webhookSettingsSchema.meta({
|
||||
id: "WebhookSettingsRequest",
|
||||
description:
|
||||
"Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is 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.",
|
||||
"Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is 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. An omitted `format` keeps the stored body shape.",
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -343,6 +344,11 @@ const webhookSettingsResponse = z
|
||||
.describe(
|
||||
"Whether a header value (the shared secret) is stored. The value itself is never returned.",
|
||||
),
|
||||
format: z
|
||||
.enum(WEBHOOK_PAYLOAD_FORMATS)
|
||||
.describe(
|
||||
"Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "WebhookSettingsResponse",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isIP } from "node:net";
|
||||
import { EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import {
|
||||
EVENT_TYPES,
|
||||
WEBHOOK_PAYLOAD_FORMATS,
|
||||
} from "@/lib/notifications/types";
|
||||
|
||||
export const notificationPreferenceSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
@@ -391,6 +394,12 @@ export function webhookSettingsSchemaWith(
|
||||
.or(z.literal("")),
|
||||
headerName: z.string().max(100).optional().or(z.literal("")),
|
||||
headerValue: z.string().max(500).optional().or(z.literal("")),
|
||||
format: z
|
||||
.enum(WEBHOOK_PAYLOAD_FORMATS)
|
||||
.optional()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user