mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge v142/webhook-discord into release/v1.42.0
This commit is contained in:
commit
745bcb64b7
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