From a2d34a027efa9ec7cfb59f5b4221c5e51d88d1b9 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 15 Mar 2026 11:38:02 +0100 Subject: [PATCH] refactor(notifications): use native URL builder --- Dockerfile | 2 +- .../__tests__/notification-builders.test.ts | 242 ++++++++++++++++++ .../modules/notifications/builders/discord.ts | 20 +- .../modules/notifications/builders/email.ts | 27 +- .../modules/notifications/builders/gotify.ts | 19 +- .../modules/notifications/builders/ntfy.ts | 32 +-- .../notifications/builders/pushover.ts | 19 +- .../modules/notifications/builders/slack.ts | 19 +- .../notifications/builders/telegram.ts | 9 +- 9 files changed, 309 insertions(+), 80 deletions(-) create mode 100644 app/server/modules/notifications/__tests__/notification-builders.test.ts diff --git a/Dockerfile b/Dockerfile index 1fd552b8..134986c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM oven/bun:${BUN_VERSION}-alpine AS base ARG RESTIC_VERSION="0.18.1" ARG RCLONE_VERSION="1.73.1" -ARG SHOUTRRR_VERSION="0.13.2" +ARG SHOUTRRR_VERSION="0.14.0" ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \ diff --git a/app/server/modules/notifications/__tests__/notification-builders.test.ts b/app/server/modules/notifications/__tests__/notification-builders.test.ts new file mode 100644 index 00000000..13fac313 --- /dev/null +++ b/app/server/modules/notifications/__tests__/notification-builders.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test"; +import { buildCustomShoutrrrUrl } from "../builders/custom"; +import { buildDiscordShoutrrrUrl } from "../builders/discord"; +import { buildEmailShoutrrrUrl } from "../builders/email"; +import { buildGenericShoutrrrUrl } from "../builders/generic"; +import { buildGotifyShoutrrrUrl } from "../builders/gotify"; +import { buildNtfyShoutrrrUrl } from "../builders/ntfy"; +import { buildPushoverShoutrrrUrl } from "../builders/pushover"; +import { buildSlackShoutrrrUrl } from "../builders/slack"; +import { buildTelegramShoutrrrUrl } from "../builders/telegram"; + +describe("notification shoutrrr URL builders", () => { + test("builds email URLs with and without optional auth fields", () => { + expect( + buildEmailShoutrrrUrl({ + type: "email", + smtpHost: "smtp.example.com", + smtpPort: 587, + from: "alerts@example.com", + to: ["ops@example.com", "dev@example.com"], + useTLS: true, + }), + ).toBe( + "smtp://smtp.example.com:587/?from=alerts%40example.com&to=ops%40example.com%2Cdev%40example.com&starttls=yes", + ); + + expect( + buildEmailShoutrrrUrl({ + type: "email", + smtpHost: "smtp.example.com", + smtpPort: 465, + username: "user name", + password: "p@ss word", + from: "alerts+team@example.com", + fromName: "Ops Team", + to: ["ops@example.com"], + useTLS: false, + }), + ).toBe( + "smtp://user%20name:p%40ss%20word@smtp.example.com:465/?from=alerts%2Bteam%40example.com&fromname=Ops+Team&to=ops%40example.com&starttls=no", + ); + }); + + test("builds discord URLs and rejects invalid webhook formats", () => { + expect( + buildDiscordShoutrrrUrl({ + type: "discord", + webhookUrl: "https://discord.com/api/webhooks/123/token", + }), + ).toBe("discord://token@123?splitLines=false"); + + expect( + buildDiscordShoutrrrUrl({ + type: "discord", + webhookUrl: "https://discord.com/api/webhooks/123/token", + username: "Bot Name", + avatarUrl: "https://example.com/avatar.png", + threadId: "999", + }), + ).toBe( + "discord://token@123?splitLines=false&username=Bot+Name&avatarurl=https%3A%2F%2Fexample.com%2Favatar.png&thread_id=999", + ); + + expect(() => + buildDiscordShoutrrrUrl({ + type: "discord", + webhookUrl: "https://discord.com/invalid", + }), + ).toThrow("Invalid Discord webhook URL format"); + }); + + test("builds gotify URLs for https and http servers", () => { + expect( + buildGotifyShoutrrrUrl({ + type: "gotify", + serverUrl: "https://gotify.example.com", + token: "secret-token", + priority: 0, + }), + ).toBe("gotify://gotify.example.com/secret-token?priority=0"); + + expect( + buildGotifyShoutrrrUrl({ + type: "gotify", + serverUrl: "http://gotify.example.com:8080", + token: "secret-token", + path: "/custom/path/", + priority: 7, + }), + ).toBe("gotify://gotify.example.com:8080/custom/path/secret-token?DisableTLS=true&priority=7"); + }); + + test("builds ntfy URLs for default host, custom server auth, and access tokens", () => { + expect( + buildNtfyShoutrrrUrl({ + type: "ntfy", + topic: "topic-name", + priority: "default", + }), + ).toBe("ntfy://ntfy.sh/topic-name?priority=default"); + + expect( + buildNtfyShoutrrrUrl({ + type: "ntfy", + serverUrl: "http://ntfy.example.com:8080", + topic: "topic-name", + priority: "high", + username: "user name", + password: "p@ss word", + }), + ).toBe("ntfy://user%20name:p%40ss%20word@ntfy.example.com:8080/topic-name?scheme=http&priority=high"); + + expect( + buildNtfyShoutrrrUrl({ + type: "ntfy", + topic: "topic-name", + priority: "min", + accessToken: "token value", + }), + ).toBe("ntfy://:token%20value@ntfy.sh/topic-name?priority=min"); + + expect( + buildNtfyShoutrrrUrl({ + type: "ntfy", + topic: "topic-name", + priority: "max", + username: "user name", + password: "p@ss word", + accessToken: "token value", + }), + ).toBe("ntfy://:token%20value@ntfy.sh/topic-name?priority=max"); + }); + + test("builds pushover URLs with and without optional query params", () => { + expect( + buildPushoverShoutrrrUrl({ + type: "pushover", + userKey: "user-key", + apiToken: "api-token", + priority: 0, + }), + ).toBe("pushover://shoutrrr:api-token@user-key/?priority=0"); + + expect( + buildPushoverShoutrrrUrl({ + type: "pushover", + userKey: "user-key", + apiToken: "api-token", + devices: "iphone,ipad", + priority: 1, + }), + ).toBe("pushover://shoutrrr:api-token@user-key/?devices=iphone%2Cipad&priority=1"); + }); + + test("builds slack URLs and rejects invalid webhook formats", () => { + expect( + buildSlackShoutrrrUrl({ + type: "slack", + webhookUrl: "https://hooks.slack.com/services/T000/B000/XXX", + }), + ).toBe("slack://hook:T000-B000-XXX@webhook"); + + expect( + buildSlackShoutrrrUrl({ + type: "slack", + webhookUrl: "https://hooks.slack.com/services/T000/B000/XXX", + channel: "#alerts", + username: "Alert Bot", + iconEmoji: ":robot_face:", + }), + ).toBe("slack://hook:T000-B000-XXX@webhook?channel=%23alerts&username=Alert+Bot&icon_emoji=%3Arobot_face%3A"); + + expect(() => + buildSlackShoutrrrUrl({ + type: "slack", + webhookUrl: "https://hooks.slack.com/not-services/T/B/C", + }), + ).toThrow("Invalid Slack webhook URL format"); + }); + + test("builds telegram URLs with and without thread ids", () => { + expect( + buildTelegramShoutrrrUrl({ + type: "telegram", + botToken: "bot-token", + chatId: "chat-id", + }), + ).toBe("telegram://bot-token@telegram?channels=chat-id"); + + expect( + buildTelegramShoutrrrUrl({ + type: "telegram", + botToken: "bot-token", + chatId: "chat-id", + threadId: "thread-id", + }), + ).toBe("telegram://bot-token@telegram?channels=chat-id%3Athread-id"); + }); + + test("builds generic URLs with reserved params, transport flags, and headers", () => { + expect( + buildGenericShoutrrrUrl({ + type: "generic", + url: "https://example.com/hooks/path?foo=bar&title=kept", + method: "POST", + }), + ).toBe("generic://example.com/hooks/path?foo=bar&_title=kept&method=POST"); + + expect( + buildGenericShoutrrrUrl({ + type: "generic", + url: "http://example.com/hooks/path?contenttype=text/plain&foo=bar", + method: "GET", + contentType: "application/json", + headers: ["X-Test: one", "X-Trace: two:three"], + useJson: true, + titleKey: "title", + messageKey: "message", + }), + ).toBe( + "generic://example.com/hooks/path?_contenttype=text%2Fplain&foo=bar&disabletls=yes&method=GET&contenttype=application%2Fjson&template=json&titlekey=title&messagekey=message&%40X-Test=one&%40X-Trace=two%3Athree", + ); + + expect( + buildGenericShoutrrrUrl({ + type: "generic", + url: "https://example.com/hooks/path", + method: "POST", + headers: ["Malformed", "X-Test: one"], + }), + ).toBe("generic://example.com/hooks/path?method=POST&%40X-Test=one"); + }); + + test("returns custom URLs as-is", () => { + expect( + buildCustomShoutrrrUrl({ + type: "custom", + shoutrrrUrl: "custom://already-built", + }), + ).toBe("custom://already-built"); + }); +}); diff --git a/app/server/modules/notifications/builders/discord.ts b/app/server/modules/notifications/builders/discord.ts index 6ef68cf1..a5e45af7 100644 --- a/app/server/modules/notifications/builders/discord.ts +++ b/app/server/modules/notifications/builders/discord.ts @@ -10,24 +10,20 @@ export const buildDiscordShoutrrrUrl = (config: Extract) => { - const auth = - config.username && config.password - ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@` - : ""; - const host = `${config.smtpHost}:${config.smtpPort}`; - const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(","); - const useStartTLS = config.useTLS ? "yes" : "no"; - const fromNameParam = config.fromName ? `&fromname=${encodeURIComponent(config.fromName)}` : ""; + const shoutrrrUrl = new URL("smtp://placeholder"); - return `smtp://${auth}${host}/?from=${encodeURIComponent(config.from)}${fromNameParam}&to=${toRecipients}&starttls=${useStartTLS}`; + shoutrrrUrl.hostname = config.smtpHost; + shoutrrrUrl.port = String(config.smtpPort); + shoutrrrUrl.pathname = "/"; + + if (config.username && config.password) { + shoutrrrUrl.username = config.username; + shoutrrrUrl.password = config.password; + } + + shoutrrrUrl.searchParams.set("from", config.from); + if (config.fromName) { + shoutrrrUrl.searchParams.set("fromname", config.fromName); + } + shoutrrrUrl.searchParams.set("to", config.to.join(",")); + shoutrrrUrl.searchParams.set("starttls", config.useTLS ? "yes" : "no"); + + return shoutrrrUrl.toString(); }; diff --git a/app/server/modules/notifications/builders/gotify.ts b/app/server/modules/notifications/builders/gotify.ts index 36dd00ed..999c53c4 100644 --- a/app/server/modules/notifications/builders/gotify.ts +++ b/app/server/modules/notifications/builders/gotify.ts @@ -2,26 +2,21 @@ import type { NotificationConfig } from "~/schemas/notifications"; export const buildGotifyShoutrrrUrl = (config: Extract) => { const url = new URL(config.serverUrl); - const hostname = url.hostname; - const port = url.port ? `:${url.port}` : ""; const path = config.path ? `/${config.path.replace(/^\/+|\/+$/g, "")}` : ""; const disableTLS = url.protocol === "http:"; - let shoutrrrUrl = `gotify://${hostname}${port}${path}/${config.token}`; - - const params = new URLSearchParams(); + const shoutrrrUrl = new URL("gotify://placeholder"); + shoutrrrUrl.hostname = url.hostname; + shoutrrrUrl.port = url.port; + shoutrrrUrl.pathname = `${path}/${config.token}`; if (disableTLS) { - params.set("DisableTLS", "true"); + shoutrrrUrl.searchParams.set("DisableTLS", "true"); } if (config.priority !== undefined) { - params.set("priority", String(config.priority)); + shoutrrrUrl.searchParams.set("priority", String(config.priority)); } - if (params.toString()) { - shoutrrrUrl += `?${params.toString()}`; - } - - return shoutrrrUrl; + return shoutrrrUrl.toString(); }; diff --git a/app/server/modules/notifications/builders/ntfy.ts b/app/server/modules/notifications/builders/ntfy.ts index a1e3325e..ad4666ae 100644 --- a/app/server/modules/notifications/builders/ntfy.ts +++ b/app/server/modules/notifications/builders/ntfy.ts @@ -1,41 +1,35 @@ import type { NotificationConfig } from "~/schemas/notifications"; export const buildNtfyShoutrrrUrl = (config: Extract) => { - let shoutrrrUrl: string; - - const params = new URLSearchParams(); const { username, password, accessToken } = config; - - let auth = ""; + const shoutrrrUrl = new URL("ntfy://placeholder"); if (username && password) { - auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; + shoutrrrUrl.username = username; + shoutrrrUrl.password = password; } if (accessToken) { - auth = `:${encodeURIComponent(accessToken)}@`; + shoutrrrUrl.username = ""; + shoutrrrUrl.password = accessToken; } if (config.serverUrl) { const url = new URL(config.serverUrl); - const hostname = url.hostname; - const port = url.port ? `:${url.port}` : ""; const scheme = url.protocol === "https:" ? "https" : "http"; - params.append("scheme", scheme); - - shoutrrrUrl = `ntfy://${auth}${hostname}${port}/${config.topic}`; + shoutrrrUrl.hostname = url.hostname; + shoutrrrUrl.port = url.port; + shoutrrrUrl.pathname = `/${config.topic}`; + shoutrrrUrl.searchParams.append("scheme", scheme); } else { - shoutrrrUrl = `ntfy://${auth}ntfy.sh/${config.topic}`; + shoutrrrUrl.hostname = "ntfy.sh"; + shoutrrrUrl.pathname = `/${config.topic}`; } if (config.priority) { - params.append("priority", config.priority); + shoutrrrUrl.searchParams.append("priority", config.priority); } - if (params.toString()) { - shoutrrrUrl += `?${params.toString()}`; - } - - return shoutrrrUrl; + return shoutrrrUrl.toString(); }; diff --git a/app/server/modules/notifications/builders/pushover.ts b/app/server/modules/notifications/builders/pushover.ts index b4114972..3408e293 100644 --- a/app/server/modules/notifications/builders/pushover.ts +++ b/app/server/modules/notifications/builders/pushover.ts @@ -1,22 +1,19 @@ import type { NotificationConfig } from "~/schemas/notifications"; export const buildPushoverShoutrrrUrl = (config: Extract) => { - const params = new URLSearchParams(); + const shoutrrrUrl = new URL("pushover://placeholder"); + shoutrrrUrl.username = "shoutrrr"; + shoutrrrUrl.password = config.apiToken; + shoutrrrUrl.hostname = config.userKey; + shoutrrrUrl.pathname = "/"; if (config.devices) { - params.append("devices", config.devices); + shoutrrrUrl.searchParams.append("devices", config.devices); } if (config.priority !== undefined) { - params.append("priority", config.priority.toString()); + shoutrrrUrl.searchParams.append("priority", config.priority.toString()); } - const queryString = params.toString(); - let shoutrrrUrl = `pushover://shoutrrr:${config.apiToken}@${config.userKey}/`; - - if (queryString) { - shoutrrrUrl += `?${queryString}`; - } - - return shoutrrrUrl; + return shoutrrrUrl.toString(); }; diff --git a/app/server/modules/notifications/builders/slack.ts b/app/server/modules/notifications/builders/slack.ts index dda6fb74..f7a5d337 100644 --- a/app/server/modules/notifications/builders/slack.ts +++ b/app/server/modules/notifications/builders/slack.ts @@ -9,23 +9,20 @@ export const buildSlackShoutrrrUrl = (config: Extract) => { - let shoutrrrUrl = `telegram://${config.botToken}@telegram?channels=${config.chatId}`; - if (config.threadId) { - shoutrrrUrl += `:${config.threadId}`; - } - return shoutrrrUrl; + const shoutrrrUrl = new URL("telegram://telegram"); + shoutrrrUrl.username = config.botToken; + shoutrrrUrl.searchParams.set("channels", `${config.chatId}${config.threadId ? `:${config.threadId}` : ""}`); + return shoutrrrUrl.toString().replace("/?", "?"); };