diff --git a/backend/src/services/notifications/action-renderer.ts b/backend/src/services/notifications/action-renderer.ts new file mode 100644 index 0000000..1240b5a --- /dev/null +++ b/backend/src/services/notifications/action-renderer.ts @@ -0,0 +1,175 @@ +import type { Language } from "../../i18n/translations.js"; + +export type PushNotificationAction = + | { + kind: "taken"; + label: string; + url: string; + method: "POST"; + } + | { + kind: "skip"; + label: string; + url: string; + method: "POST"; + } + | { + kind: "view"; + label: string; + url: string; + method: "GET"; + }; + +export type PushNotificationOptions = { + actions?: PushNotificationAction[]; + respondUrl?: string; + viewUrl?: string; + clickUrl?: string; + tags?: string[]; + priority?: number; + sequenceId?: string; +}; + +type NtfyActionPayload = { + action: "http" | "view"; + label: string; + url: string; + method?: "POST"; + clear: boolean; +}; + +function encodeHeaderValue(value: string): string { + if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) { + return value; + } + + return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`; +} + +export function isNtfyNotificationUrl(urlStr: string): boolean { + if (urlStr.startsWith("ntfy://")) { + return true; + } + + try { + const parsed = new URL(urlStr); + if (!["http:", "https:"].includes(parsed.protocol)) { + return false; + } + + const hostname = parsed.hostname.toLowerCase(); + return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy."); + } catch { + return false; + } +} + +export function getNotificationProvider(urlStr: string): string { + if (isNtfyNotificationUrl(urlStr)) { + return "ntfy"; + } + + try { + return new URL(urlStr).protocol.replace(":", "").toLowerCase(); + } catch { + return "unknown"; + } +} + +export function getNotificationActionLabels(language: Language): { + taken: string; + skip: string; + respond: string; + view: string; +} { + if (language === "de") { + return { + taken: "Einnehmen", + skip: "Überspringen", + respond: "Antworten", + view: "Öffnen", + }; + } + + return { + taken: "Take", + skip: "Skip", + respond: "Respond", + view: "View", + }; +} + +export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] { + const actions = options.actions ?? []; + + return actions.map((action) => { + if (action.kind === "view") { + return { + action: "view", + label: action.label, + url: action.url, + clear: false, + }; + } + + return { + action: "http", + label: action.label, + url: action.url, + method: "POST", + // Clear the original actionable ntfy notification locally after a successful mutation. + clear: true, + }; + }); +} + +export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string { + if (!options.respondUrl && !options.viewUrl) { + return message; + } + + const lines = [message.trimEnd()]; + + if (options.respondUrl) { + lines.push("", "Respond:", options.respondUrl); + } + + if (options.viewUrl) { + lines.push("", "View:", options.viewUrl); + } + + return lines.join("\n"); +} + +export function renderNotificationActionPayload( + urlStr: string, + message: string, + options: PushNotificationOptions +): { message: string; headers: Record } { + if (!isNtfyNotificationUrl(urlStr)) { + return { + message: appendFallbackActionLinks(message, options), + headers: {}, + }; + } + + const headers: Record = {}; + const ntfyActions = buildNtfyActions(options); + if (ntfyActions.length > 0) { + headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions)); + } + if (options.clickUrl && ntfyActions.length === 0) { + headers.Click = options.clickUrl; + } + if (options.tags && options.tags.length > 0) { + headers.Tags = options.tags.join(","); + } + if (typeof options.priority === "number") { + headers.Priority = String(options.priority); + } + if (options.sequenceId) { + headers["X-Sequence-ID"] = options.sequenceId; + } + + return { message, headers }; +} diff --git a/backend/src/test/notification-action-renderer.test.ts b/backend/src/test/notification-action-renderer.test.ts new file mode 100644 index 0000000..14dbaac --- /dev/null +++ b/backend/src/test/notification-action-renderer.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { + getNotificationActionLabels, + isNtfyNotificationUrl, + type PushNotificationAction, + renderNotificationActionPayload, +} from "../services/notifications/action-renderer.js"; + +function decodeRfc2047Base64(value: string): string { + const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value); + if (!match) { + return value; + } + + return Buffer.from(match[1], "base64").toString("utf-8"); +} + +const actions: PushNotificationAction[] = [ + { + kind: "taken", + label: "Take", + url: "https://app.example.com/api/notification-actions/taken-token", + method: "POST", + }, + { + kind: "skip", + label: "Skip", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + }, + { kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" }, +]; + +describe("notification action renderer", () => { + it("builds ntfy native actions without duplicate click headers", () => { + const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", { + actions, + clickUrl: "https://app.example.com/api/notification-actions/respond-token", + respondUrl: "https://app.example.com/api/notification-actions/respond-token", + viewUrl: "https://app.example.com/?date=2026-01-05", + tags: ["pill"], + priority: 4, + sequenceId: "medassist-sequence", + }); + + expect(result.message).toBe("Body"); + expect(result.headers).toMatchObject({ + Tags: "pill", + Priority: "4", + "X-Sequence-ID": "medassist-sequence", + }); + expect(result.headers.Click).toBeUndefined(); + + const parsedActions = JSON.parse(result.headers.Actions ?? "[]"); + expect(parsedActions).toEqual([ + { + action: "http", + label: "Take", + url: "https://app.example.com/api/notification-actions/taken-token", + method: "POST", + clear: true, + }, + { + action: "http", + label: "Skip", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + clear: true, + }, + { + action: "view", + label: "View", + url: "https://app.example.com/?date=2026-01-05", + clear: false, + }, + ]); + }); + + it("keeps the ntfy click header when there are no native actions", () => { + const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", { + clickUrl: "https://app.example.com/api/notification-actions/respond-token", + }); + + expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token"); + }); + + it("treats direct https ntfy URLs as ntfy targets with native actions", () => { + const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", { + actions, + clickUrl: "https://app.example.com/api/notification-actions/respond-token", + respondUrl: "https://app.example.com/api/notification-actions/respond-token", + viewUrl: "https://app.example.com/?date=2026-01-05", + }); + + expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true); + expect(result.message).toBe("Body"); + expect(result.headers.Actions).toBeTruthy(); + expect(result.message).not.toContain("Respond:"); + }); + + it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => { + const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", { + actions: [ + { + kind: "taken", + label: "Take", + url: "http://192.168.0.113:5173/api/notification-actions/taken-token", + method: "POST", + }, + ], + }); + + expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([ + { + action: "http", + label: "Take", + url: "http://192.168.0.113:5173/api/notification-actions/taken-token", + method: "POST", + clear: true, + }, + ]); + }); + + it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => { + const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", { + actions: [ + { + kind: "skip", + label: "Überspringen", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + }, + { + kind: "view", + label: "Öffnen", + url: "https://app.example.com/?date=2026-01-05", + method: "GET", + }, + ], + }); + + expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/); + expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([ + { + action: "http", + label: "Überspringen", + url: "https://app.example.com/api/notification-actions/skip-token", + method: "POST", + clear: true, + }, + { + action: "view", + label: "Öffnen", + url: "https://app.example.com/?date=2026-01-05", + clear: false, + }, + ]); + }); + + it("uses consistent action-form labels for English and German", () => { + expect(getNotificationActionLabels("en")).toEqual({ + taken: "Take", + skip: "Skip", + respond: "Respond", + view: "View", + }); + expect(getNotificationActionLabels("de")).toEqual({ + taken: "Einnehmen", + skip: "Überspringen", + respond: "Antworten", + view: "Öffnen", + }); + }); + + it("appends respond and view fallback links for non-ntfy providers", () => { + const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", { + respondUrl: "https://app.example.com/api/notification-actions/respond-token", + viewUrl: "https://app.example.com/?date=2026-01-05", + }); + + expect(result.headers).toEqual({}); + expect(result.message).toBe( + "Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05" + ); + }); +});