mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(notifications): send content and text so Discord and Slack webhooks deliver
The generic webhook body carried title, message, eventType and priority only. Discord refuses a body without content (400, code 50006 "Cannot send an empty message") and a Slack incoming webhook one without text, so the test button and every reminder failed against the targets the settings card names. The generic body now appends content (cut to Discord's 2000 characters) and text, both the masked title and message on two lines, plus allowed_mentions with an empty parse list so Discord resolves no mentions. The four original fields keep their values and order for Home Assistant and n8n rules. The Gotify body is unchanged. The card names what works and shows a hint for the generic format.
This commit is contained in:
1 parent
d8a3614120
commit
d029e4e1dc
16 files changed
+336
-32
No files matched your search
@@ -42,8 +42,7 @@ const NOT_GRANTABLE_REFUSAL =
|
||||
* 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 URL and an optional shared-secret header. SSRF
|
||||
* Covers Discord / Slack / Gotify / Home Assistant / n8n in one channel: the user supplies a URL and an optional shared-secret header. SSRF
|
||||
* is enforced at input time (the schema's target predicate) and again at
|
||||
* dispatch time (`safeFetch` with the connect-time pin); a private origin
|
||||
* passes both only when the operator listed it in
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The webhook test button against a Discord webhook URL, through the real
|
||||
* sender with only the network mocked.
|
||||
*
|
||||
* The mocked target answers the way Discord does: 400 with code 50006
|
||||
* ("Cannot send an empty message") for a body without `content` or `embeds`,
|
||||
* 204 otherwise. The generic body used to carry neither, so the test button
|
||||
* and every reminder failed against a Discord URL the settings card names.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
notificationChannel: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `encrypted:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
|
||||
const safeFetchMock = 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/notifications/senders/push-attempt-record", () => ({
|
||||
recordPushAttempt: vi.fn(),
|
||||
recordPushAttemptForPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
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 { POST as postTest } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const DISCORD_URL =
|
||||
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
|
||||
|
||||
/** Answers the way Discord's execute-webhook endpoint does. */
|
||||
function discordAnswer(_url: string, init: { body: string }): Response {
|
||||
const body = JSON.parse(init.body) as Record<string, unknown>;
|
||||
const content = typeof body.content === "string" ? body.content : "";
|
||||
if (content.trim().length === 0 && body.embeds === undefined) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Cannot send an empty message", code: 50006 }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (content.length > 2000) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Invalid Form Body", code: 50035 }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const POST = postTest as (request: Request) => Promise<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" },
|
||||
} as never);
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`,
|
||||
} as never);
|
||||
safeFetchMock.mockImplementation(
|
||||
async (url: string, init: { body: string }) => discordAnswer(url, init),
|
||||
);
|
||||
});
|
||||
|
||||
describe("POST /api/settings/webhook/test — a Discord webhook URL", () => {
|
||||
it("delivers: the generic body carries the content Discord requires", async () => {
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/settings/webhook/test", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).data).toEqual({ sent: true });
|
||||
|
||||
expect(safeFetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = safeFetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ body: string },
|
||||
];
|
||||
expect(url).toBe(DISCORD_URL);
|
||||
const sent = JSON.parse(init.body);
|
||||
expect(sent.content).toBe(
|
||||
"HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.",
|
||||
);
|
||||
expect(sent.allowed_mentions).toEqual({ parse: [] });
|
||||
});
|
||||
|
||||
it("passes Discord's own error through when the target still refuses", async () => {
|
||||
safeFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ message: "Unknown Webhook", code: 10015 }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/settings/webhook/test", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta.upstreamStatus).toBe(404);
|
||||
expect(json.meta.upstreamBody).toContain("Unknown Webhook");
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,8 @@ export function NotificationChannelsPanel() {
|
||||
<WebPushCard />
|
||||
</div>
|
||||
)}
|
||||
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
|
||||
Matrix / Home Assistant in one channel). */}
|
||||
{/* v1.17.1 — generic outbound webhook (Discord / Slack / Gotify /
|
||||
Home Assistant / n8n in one channel). */}
|
||||
<div id="webhook" className="scroll-mt-28">
|
||||
<WebhookCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
|
||||
@@ -209,11 +209,11 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
{t("settings.webhookFormatGotify")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
{format === "gotify" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.webhookFormatGotifyHint")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{format === "gotify"
|
||||
? t("settings.webhookFormatGotifyHint")
|
||||
: t("settings.webhookFormatGenericHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-header-name">
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
) => recordPushAttemptMock(attempt),
|
||||
}));
|
||||
|
||||
import { sendViaWebhook } from "../webhook";
|
||||
import { buildWebhookBody, sendViaWebhook } from "../webhook";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
const config = {
|
||||
@@ -345,30 +345,39 @@ function sentBody(): string {
|
||||
return (safeFetchMock.mock.calls[0][1] as { body: string }).body;
|
||||
}
|
||||
|
||||
// The chat-target fields appended after the four original ones (v1.42.0).
|
||||
const CHAT_TAIL =
|
||||
',"content":"Title\\nBody","text":"Title\\nBody","allowed_mentions":{"parse":[]}}';
|
||||
|
||||
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.
|
||||
// was added. The four original fields keep their values and their order;
|
||||
// the Discord and Slack fields only follow them.
|
||||
it.each([
|
||||
[
|
||||
"a routine event",
|
||||
{},
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"}',
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
[
|
||||
"a medication reminder",
|
||||
{ eventType: "MEDICATION_REMINDER" },
|
||||
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"}',
|
||||
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
[
|
||||
"an urgent event",
|
||||
{ urgent: true },
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"}',
|
||||
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
[
|
||||
"a discreet cycle event",
|
||||
{ eventType: "CYCLE_PERIOD_SOON", discreet: true },
|
||||
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"}',
|
||||
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"' +
|
||||
CHAT_TAIL,
|
||||
],
|
||||
])("%s", async (_label, over, expected) => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
@@ -382,6 +391,107 @@ describe("sendViaWebhook — generic body stays byte for byte", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWebhookBody — Discord and Slack", () => {
|
||||
const generic = { format: "generic" as const };
|
||||
|
||||
it("carries content for Discord and text for Slack, as plain title and message", () => {
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(
|
||||
generic,
|
||||
payload({ title: "Reminder", message: "<b>Take</b> your dose" }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(body.content).toBe("Reminder\nTake your dose");
|
||||
expect(body.text).toBe("Reminder\nTake your dose");
|
||||
expect(body).toMatchObject({
|
||||
title: "Reminder",
|
||||
message: "Take your dose",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
priority: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("tells Discord to resolve no mentions", () => {
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(
|
||||
generic,
|
||||
payload({ message: "@everyone @here <@123456789012345678>" }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(body.allowed_mentions).toEqual({ parse: [] });
|
||||
// The text itself is left alone; the empty parse list is what disarms it.
|
||||
expect(body.content).toContain("@everyone");
|
||||
});
|
||||
|
||||
it("keeps content within Discord's 2000 characters, ending in an ellipsis", () => {
|
||||
const long = "x".repeat(5000);
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(generic, payload({ message: long })),
|
||||
);
|
||||
|
||||
expect(Array.from(body.content as string)).toHaveLength(2000);
|
||||
expect(body.content.startsWith("Title\nxxx")).toBe(true);
|
||||
expect(body.content.endsWith("\u2026")).toBe(true);
|
||||
// Slack and the relay fields keep the whole message.
|
||||
expect(body.text).toBe(`Title\n${long}`);
|
||||
expect(body.message).toBe(long);
|
||||
});
|
||||
|
||||
it("leaves content alone at exactly 2000 characters", () => {
|
||||
const message = "y".repeat(2000 - "Title\n".length);
|
||||
const body = JSON.parse(buildWebhookBody(generic, payload({ message })));
|
||||
|
||||
expect(body.content).toBe(`Title\n${message}`);
|
||||
expect(body.content).not.toContain("\u2026");
|
||||
});
|
||||
|
||||
it("never splits a surrogate pair when it cuts", () => {
|
||||
const body = JSON.parse(
|
||||
buildWebhookBody(
|
||||
generic,
|
||||
payload({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
message: "\u{1F600}".repeat(3000),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const points = Array.from(body.content as string);
|
||||
expect(points).toHaveLength(2000);
|
||||
expect(body.content).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
|
||||
});
|
||||
|
||||
it("puts only the masked text into content and text in discreet mode", () => {
|
||||
// The dispatcher masks title and message upstream; the body must not
|
||||
// reintroduce the cycle event anywhere.
|
||||
const raw = buildWebhookBody(
|
||||
generic,
|
||||
payload({
|
||||
eventType: "CYCLE_PERIOD_SOON",
|
||||
discreet: true,
|
||||
title: "HealthLog",
|
||||
message: "HealthLog reminder",
|
||||
}),
|
||||
);
|
||||
const body = JSON.parse(raw);
|
||||
|
||||
expect(raw).not.toContain("CYCLE");
|
||||
expect(body.eventType).toBe("reminder");
|
||||
expect(body.content).toBe("HealthLog\nHealthLog reminder");
|
||||
expect(body.text).toBe("HealthLog\nHealthLog reminder");
|
||||
});
|
||||
|
||||
it("leaves the Gotify body without the chat fields", () => {
|
||||
const body = JSON.parse(buildWebhookBody({ format: "gotify" }, payload()));
|
||||
|
||||
expect(body).not.toHaveProperty("content");
|
||||
expect(body).not.toHaveProperty("text");
|
||||
expect(body).not.toHaveProperty("allowed_mentions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendViaWebhook — Gotify format (#947)", () => {
|
||||
const gotify = {
|
||||
url: "https://gotify.example.com/message",
|
||||
|
||||
@@ -57,8 +57,9 @@ const GOTIFY_PRIORITY: Record<PriorityBand, number> = {
|
||||
* 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`.
|
||||
* and message arrive already masked upstream, every text field (including the
|
||||
* generic body's `content` and `text`) is built from them, and the event name
|
||||
* a relay could route on is replaced by the generic `reminder`.
|
||||
*/
|
||||
export function buildWebhookBody(
|
||||
config: Pick<WebhookChannelConfig, "format">,
|
||||
@@ -83,22 +84,50 @@ export function buildWebhookBody(
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Generic envelope. The first four fields are unchanged since v1.18.4, in
|
||||
// the same order: an existing Home Assistant or n8n rule parses them.
|
||||
// Discord and Slack do not read them. Discord answers 400 "Cannot send an
|
||||
// empty message" unless the body carries `content` (or `embeds`), and a
|
||||
// Slack incoming webhook needs `text`. Both get the same plain text,
|
||||
// built from the already masked title and message, so discreet mode
|
||||
// holds. `allowed_mentions` with an empty `parse` stops Discord from
|
||||
// resolving `@everyone`, `@here` or `<@id>` that a message might contain.
|
||||
// Each target ignores the keys meant for the others.
|
||||
const text = [title, message].filter((part) => part.length > 0).join("\n");
|
||||
return JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
eventType,
|
||||
priority: band,
|
||||
content: truncateCodePoints(text, DISCORD_CONTENT_LIMIT),
|
||||
text,
|
||||
allowed_mentions: { parse: [] },
|
||||
});
|
||||
}
|
||||
|
||||
/** Discord rejects a `content` longer than 2000 characters. */
|
||||
const DISCORD_CONTENT_LIMIT = 2000;
|
||||
|
||||
/**
|
||||
* Cut `value` to at most `limit` code points, the last one an ellipsis when
|
||||
* anything was dropped. Counts code points, not UTF-16 units, so a cut never
|
||||
* splits a surrogate pair.
|
||||
*/
|
||||
function truncateCodePoints(value: string, limit: number): string {
|
||||
const points = Array.from(value);
|
||||
if (points.length <= limit) return value;
|
||||
return `${points
|
||||
.slice(0, limit - 1)
|
||||
.join("")
|
||||
.trimEnd()}\u2026`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification via a generic outbound webhook (v1.17.1).
|
||||
*
|
||||
* 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
|
||||
* body shape: HealthLog's generic JSON envelope (which a Discord or Slack
|
||||
* webhook URL also accepts as is), 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
|
||||
|
||||
@@ -398,7 +398,7 @@ export function webhookSettingsSchemaWith(
|
||||
.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.",
|
||||
"Body shape. `generic` sends HealthLog's own JSON envelope (`title`, `message`, `eventType`, `priority`, plus `content` and `text` so a Discord or Slack webhook URL accepts it as is); `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