From 4ff9aa7e390f602ebac136ee2e226ce6005740ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 13:43:35 +0200 Subject: [PATCH] fix(notifications): publish ntfy messages as JSON so non-ASCII labels need no header encoding --- docs/self-hosting/notifications.md | 9 +- .../senders/__tests__/ntfy.test.ts | 130 ++++++++++---- .../senders/__tests__/urgency.test.ts | 47 ++---- src/lib/notifications/senders/ntfy.ts | 158 ++++++++++-------- src/lib/notifications/tap-target.ts | 2 +- 5 files changed, 206 insertions(+), 140 deletions(-) diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 0aa0d0761..0d4ca9eb0 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -179,6 +179,12 @@ HealthLog publishes to that topic. **Server URL** (defaults to `https://ntfy.sh`; point it at your own server if you self-host ntfy) and **Topic**, then save. +HealthLog publishes each message as JSON to the server's root URL (the topic +travels in the body, see ntfy's +[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles +and button labels in any language arrive as written. A reverse proxy in front +of your own ntfy has to pass `POST /` through, not only `POST /`. + If your ntfy server requires auth, an access token can be attached; the server URL is validated at save time and dialled through the egress guard (`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own @@ -474,7 +480,8 @@ once per event type, and every channel carries the same target: How each channel carries it: -- **ntfy**: the `Click` header, so tapping the notification opens the page. +- **ntfy**: the message's `click` field, so tapping the notification opens + the page. - **Telegram**: an **Open in HealthLog** button under the message, below any action buttons. Telegram only accepts `https` button links on a public host, so a plain-HTTP instance gets a text link instead, and an instance diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index 8a28200fb..9463ff98a 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -1,8 +1,8 @@ /** * ntfy sender unit tests. * - * Covers the outbound shape (topic appended to the server URL, priority and - * tag headers, bearer token), the outcome classification, and (#947) the + * Covers the outbound shape (a JSON publish to the server root carrying + * topic, priority and tags, bearer token), the outcome classification, and (#947) the * NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the * operator-approved pin, an unlisted one keeps the public pin, and a listed * origin admits nothing beside itself. @@ -85,7 +85,7 @@ afterEach(() => { }); describe("sendViaNtfy", () => { - it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => { + it("POSTs a JSON message to the server root through safeFetch under the public pin and returns ok on 200", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); const result = await sendViaNtfy(config, payload()); @@ -96,10 +96,17 @@ describe("sendViaNtfy", () => { { method?: string; headers?: Record; body?: string }, EgressOpts, ]; - expect(url).toBe("https://ntfy.example.com/health"); + expect(url).toBe("https://ntfy.example.com/"); expect(init.method).toBe("POST"); expect(init.headers?.Authorization).toBe("Bearer tk_secret"); - expect(init.body).toBe("Body"); + expect(init.headers?.["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body!)).toEqual({ + topic: "health", + title: "Title", + message: "Body", + priority: 3, + tags: ["system-alert"], + }); expect(opts.requirePublicHost).toBe(true); expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); expect(recordPushAttemptMock).toHaveBeenCalledWith( @@ -164,7 +171,7 @@ describe("sendViaNtfy", () => { unknown, EgressOpts, ]; - expect(url).toBe("http://ntfy.lan:8080/health"); + expect(url).toBe("http://ntfy.lan:8080/"); expect(opts.requirePublicHost).toBe(false); expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080"); expect(annotateMock).toHaveBeenCalledWith({ @@ -270,15 +277,19 @@ describe("sendViaNtfy — what the server said (#947)", () => { }); describe("sendViaNtfy — tap target and quick actions", () => { - function headersOfFirstCall(): Record { + function messageOfFirstCall(): Record { const [, init] = safeFetchMock.mock.calls[0] as [ string, - { headers: Record }, + { headers: Record; body: string }, ]; - return init.headers; + // Nothing of the message rides a header any more. + expect(Object.keys(init.headers).sort()).toEqual( + ["Authorization", "Content-Type"].sort(), + ); + return JSON.parse(init.body) as Record; } - it("sets Click to the resolved absolute tap URL", async () => { + it("sets click to the resolved absolute tap URL", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaNtfy( config, @@ -291,17 +302,18 @@ describe("sendViaNtfy — tap target and quick actions", () => { }, }), ); - expect(headersOfFirstCall().Click).toBe("https://health.example/"); - expect(headersOfFirstCall().Actions).toBeUndefined(); + const message = messageOfFirstCall(); + expect(message.click).toBe("https://health.example/"); + expect(message.actions).toBeUndefined(); }); - it("omits Click when no public origin produced a URL", async () => { + it("omits click when no public origin produced a URL", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaNtfy( config, payload({ tap: { path: "/", url: null, label: "Open" } }), ); - expect(headersOfFirstCall().Click).toBeUndefined(); + expect(messageOfFirstCall().click).toBeUndefined(); }); it("sends the quick answers as three POST http actions that clear the notification", async () => { @@ -317,18 +329,66 @@ describe("sendViaNtfy — tap target and quick actions", () => { ], }), ); - expect(headersOfFirstCall().Actions).toBe( - "http, Good, https://h.example/a/1, method=POST, clear=true; " + - "http, Okay, https://h.example/a/2, method=POST, clear=true; " + - "http, Bad, https://h.example/a/3, method=POST, clear=true", + expect(messageOfFirstCall().actions).toEqual([ + { + action: "http", + label: "Good", + url: "https://h.example/a/1", + method: "POST", + clear: true, + }, + { + action: "http", + label: "Okay", + url: "https://h.example/a/2", + method: "POST", + clear: true, + }, + { + action: "http", + label: "Bad", + url: "https://h.example/a/3", + method: "POST", + clear: true, + }, + ]); + }); + + it("carries non-ASCII titles and labels as plain UTF-8, URLs untouched", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + config, + payload({ + eventType: "MOOD_REMINDER", + title: "기분은 어떠세요?", + quickActions: [ + { id: "mood-good", label: "Świetnie", url: "https://h.example/a/1" }, + { id: "mood-okay", label: "보통", url: "https://h.example/a/2" }, + { id: "mood-bad", label: "Così così", url: "https://h.example/a/3" }, + ], + }), ); + const message = messageOfFirstCall(); + expect(message.title).toBe("기분은 어떠세요?"); + const actions = message.actions as { label: string; url: string }[]; + expect(actions.map((a) => a.label)).toEqual([ + "Świetnie", + "보통", + "Così così", + ]); + expect(actions.map((a) => a.url)).toEqual([ + "https://h.example/a/1", + "https://h.example/a/2", + "https://h.example/a/3", + ]); + expect(JSON.stringify(message)).not.toContain("=?UTF-8?"); }); }); -describe("ntfy header helpers", () => { - it("caps the actions at three and strips separators from labels", async () => { - const { buildNtfyActionsHeader } = await import("../ntfy"); - const header = buildNtfyActionsHeader({ +describe("ntfy message helpers", () => { + it("caps the actions at three and keeps labels verbatim", async () => { + const { buildNtfyActions } = await import("../ntfy"); + const actions = buildNtfyActions({ quickActions: [ { id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" }, { id: "mood-okay", label: "B", url: "https://h/2" }, @@ -336,20 +396,20 @@ describe("ntfy header helpers", () => { { id: "mood-bad", label: "D", url: "https://h/4" }, ], }); - expect(header?.split("; ")).toHaveLength(3); - expect(header).toContain("http, Go od yes, https://h/1"); - expect(header).not.toContain("https://h/4"); - expect(buildNtfyActionsHeader({ quickActions: [] })).toBeNull(); + expect(actions).toHaveLength(3); + // JSON needs no separator escaping: the label reaches ntfy as written. + expect(actions[0]!.label).toBe('Go;od, "yes"'); + expect(actions.map((a) => a.url)).not.toContain("https://h/4"); + expect(buildNtfyActions({ quickActions: [] })).toEqual([]); }); - it("RFC 2047-encodes a non-ASCII header value and leaves ASCII alone", async () => { - const { ntfyHeaderValue } = await import("../ntfy"); - expect(ntfyHeaderValue("Morning score 78")).toBe("Morning score 78"); - const encoded = ntfyHeaderValue("아침 점수 78"); - expect(encoded).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/); - const inner = encoded.slice("=?UTF-8?B?".length, -2); - expect(Buffer.from(inner, "base64").toString("utf8")).toBe("아침 점수 78"); - // `ü` is Latin-1, which fetch would send as one invalid UTF-8 byte. - expect(ntfyHeaderValue("Morgenüberblick")).toMatch(/^=\?UTF-8\?B\?/); + it("maps a medication reminder to high priority (4)", async () => { + const { buildNtfyMessage } = await import("../ntfy"); + expect( + buildNtfyMessage( + "t", + payload({ eventType: "MEDICATION_REMINDER" }) as never, + ).priority, + ).toBe(4); }); }); diff --git a/src/lib/notifications/senders/__tests__/urgency.test.ts b/src/lib/notifications/senders/__tests__/urgency.test.ts index a66b01831..29273456e 100644 --- a/src/lib/notifications/senders/__tests__/urgency.test.ts +++ b/src/lib/notifications/senders/__tests__/urgency.test.ts @@ -104,6 +104,15 @@ beforeEach(() => { afterEach(() => vi.clearAllMocks()); +/** The JSON message the ntfy sender published on call `i`. */ +function ntfyMessage(i: number): { + priority: number; + tags: string[]; + actions?: unknown; +} { + return JSON.parse((safeFetchMock.mock.calls[i][1] as { body: string }).body); +} + describe("ntfy urgent mapping", () => { it("urgent → Priority 5 (max) + warning tag", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); @@ -111,10 +120,8 @@ describe("ntfy urgent mapping", () => { { serverUrl: "https://ntfy.example.com", topic: "t" }, payload({ urgent: true }), ); - const [, init] = safeFetchMock.mock.calls[0]; - const headers = (init as { headers: Record }).headers; - expect(headers.Priority).toBe("5"); - expect(headers.Tags).toContain("warning"); + expect(ntfyMessage(0).priority).toBe(5); + expect(ntfyMessage(0).tags).toContain("warning"); }); it("non-urgent SYSTEM_ALERT → default priority, no warning tag", async () => { @@ -123,13 +130,8 @@ describe("ntfy urgent mapping", () => { { serverUrl: "https://ntfy.example.com", topic: "t" }, payload(), ); - const headers = ( - safeFetchMock.mock.calls[0][1] as { - headers: Record; - } - ).headers; - expect(headers.Priority).toBe("default"); - expect(headers.Tags).not.toContain("warning"); + expect(ntfyMessage(0).priority).toBe(3); + expect(ntfyMessage(0).tags).not.toContain("warning"); }); it("discreet urgent keeps the generic tag (no warning leak)", async () => { @@ -138,16 +140,11 @@ describe("ntfy urgent mapping", () => { { serverUrl: "https://ntfy.example.com", topic: "t" }, payload({ urgent: true, discreet: true }), ); - const headers = ( - safeFetchMock.mock.calls[0][1] as { - headers: Record; - } - ).headers; - expect(headers.Priority).toBe("5"); - expect(headers.Tags).toBe("reminder"); + expect(ntfyMessage(0).priority).toBe(5); + expect(ntfyMessage(0).tags).toEqual(["reminder"]); }); - it("does not send ntfy action headers to a managed Guardian", async () => { + it("does not send ntfy actions to a managed Guardian", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaNtfy( { serverUrl: "https://ntfy.example.com", topic: "t" }, @@ -157,12 +154,7 @@ describe("ntfy urgent mapping", () => { recipientUserId: "guardian-1", }), ); - const headers = ( - safeFetchMock.mock.calls[0][1] as { - headers: Record; - } - ).headers; - expect(headers.Actions).toBeUndefined(); + expect(ntfyMessage(0).actions).toBeUndefined(); }); }); @@ -245,10 +237,7 @@ describe("APNs-less instance degrades gracefully", () => { expect(ntfy.ok).toBe(true); expect(web.ok).toBe(true); - expect( - (safeFetchMock.mock.calls[0][1] as { headers: Record }) - .headers.Priority, - ).toBe("5"); + expect(ntfyMessage(0).priority).toBe(5); expect( (generateRequestDetailsMock.mock.calls[0][2] as { urgency?: string }) .urgency, diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 13b1fab78..96c905670 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -41,48 +41,24 @@ export async function sendViaNtfy( // can open. let policyReason: OriginReason | null = null; try { - const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`; - - // v1.18.4 — an explicitly urgent payload escalates to ntfy's top - // priority (`5` / max) so it bypasses the relay's batching and surfaces - // loudest; MEDICATION_REMINDER keeps its long-standing `high` (4); the - // rest stay `default` (3). `urgent` also adds a tag so the lock-screen - // entry reads as an alert (unless discreet privacy collapses tags). - const urgent = isUrgentPayload(payload) && payload.urgent === true; - const priority = urgent - ? "5" - : payload.eventType === "MEDICATION_REMINDER" - ? "high" - : "default"; - const eventTag = payload.discreet - ? "reminder" - : payload.eventType.toLowerCase().replace(/_/g, "-"); + // Published as JSON to the server root rather than as a body with + // headers to the topic URL. A header carries only Latin-1 (`fetch` + // refuses anything above U+00FF, and ntfy reads a Latin-1 byte as invalid + // UTF-8), so a Korean title or a Polish action label had to travel as an + // RFC 2047 encoded word, and an `Actions` header encoded whole, URLs and + // all, is not a form ntfy's action parser is documented to accept, so a + // reminder with an Italian, Korean or Polish label risked a 400 and never + // arrived. A JSON body is UTF-8 end to end and has no header encoding + // at all (https://docs.ntfy.sh/publish/#publish-as-json). + const url = `${config.serverUrl.replace(/\/$/, "")}/`; + const message = buildNtfyMessage(config.topic, payload); const headers: Record = { - Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)), - Priority: priority, - // Discreet mode (cycle privacy): the X-Tags header is visible on the - // lock screen, so collapse it to a generic tag instead of leaking the - // cycle event name. An urgent (non-discreet) event also carries a - // `warning` tag so the entry renders as an alert. - Tags: urgent && !payload.discreet ? `warning,${eventTag}` : eventTag, + "Content-Type": "application/json", }; - if (config.authToken) { headers["Authorization"] = `Bearer ${config.authToken}`; } - - // Tapping the notification opens this URL (ntfy `Click`). Absent when - // the operator has not configured a public origin. - if (payload.tap?.url) { - headers["Click"] = payload.tap.url; - } - const actions = buildNtfyActionsHeader(payload); - if (actions) { - headers["Actions"] = ntfyHeaderValue(actions); - } - - // Strip HTML tags for ntfy (plain text only) + emoji on routine reminders - const body = plainPushText(stripHtml(payload.message), payload.eventType); + const body = JSON.stringify(message); // The DNS-rebinding pin (issue #217) sits on top of the input-time // guard enforced when the user saved the serverUrl; the authToken would @@ -201,47 +177,81 @@ export async function sendViaNtfy( /** ntfy shows at most three actions per notification. */ const NTFY_MAX_ACTIONS = 3; -/** - * A label safe inside the `Actions` header's short format: `,` and `;` - * separate fields and actions there, and a quote would open a quoted value. - */ -function ntfyActionLabel(label: string): string { - return label.replace(/[,;"'\r\n]/g, " ").trim() || "?"; +/** One `http` action button in ntfy's JSON form. */ +export interface NtfyHttpAction { + action: "http"; + label: string; + url: string; + method: "POST"; + clear: true; +} + +/** The JSON publish body (https://docs.ntfy.sh/publish/#publish-as-json). */ +export interface NtfyMessage { + topic: string; + title: string; + message: string; + priority: 3 | 4 | 5; + tags: string[]; + click?: string; + actions?: NtfyHttpAction[]; } /** - * The `Actions` header for the payload's quick answers, in ntfy's short - * format (https://docs.ntfy.sh/publish/#action-buttons): + * The message ntfy publishes for a payload. * - * http,