refactor(notifications): use native URL builder

This commit is contained in:
Nicolas Meienberger 2026-03-15 11:38:02 +01:00
parent e58f82d7ed
commit a2d34a027e
9 changed files with 309 additions and 80 deletions

View file

@ -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} \

View file

@ -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");
});
});

View file

@ -10,24 +10,20 @@ export const buildDiscordShoutrrrUrl = (config: Extract<NotificationConfig, { ty
const [, , webhookId, webhookToken] = pathParts;
let shoutrrrUrl = `discord://${webhookToken}@${webhookId}`;
const shoutrrrUrl = new URL("discord://placeholder");
shoutrrrUrl.username = webhookToken;
shoutrrrUrl.hostname = webhookId;
const params = new URLSearchParams();
params.append("splitLines", "false");
shoutrrrUrl.searchParams.append("splitLines", "false");
if (config.username) {
params.append("username", config.username);
shoutrrrUrl.searchParams.append("username", config.username);
}
if (config.avatarUrl) {
params.append("avatarurl", config.avatarUrl);
shoutrrrUrl.searchParams.append("avatarurl", config.avatarUrl);
}
if (config.threadId) {
params.append("thread_id", config.threadId);
shoutrrrUrl.searchParams.append("thread_id", config.threadId);
}
if (params.toString()) {
shoutrrrUrl += `?${params.toString()}`;
}
return shoutrrrUrl;
return shoutrrrUrl.toString().replace("/?", "?");
};

View file

@ -1,14 +1,23 @@
import type { NotificationConfig } from "~/schemas/notifications";
export const buildEmailShoutrrrUrl = (config: Extract<NotificationConfig, { type: "email" }>) => {
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();
};

View file

@ -2,26 +2,21 @@ import type { NotificationConfig } from "~/schemas/notifications";
export const buildGotifyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "gotify" }>) => {
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();
};

View file

@ -1,41 +1,35 @@
import type { NotificationConfig } from "~/schemas/notifications";
export const buildNtfyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "ntfy" }>) => {
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();
};

View file

@ -1,22 +1,19 @@
import type { NotificationConfig } from "~/schemas/notifications";
export const buildPushoverShoutrrrUrl = (config: Extract<NotificationConfig, { type: "pushover" }>) => {
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();
};

View file

@ -9,23 +9,20 @@ export const buildSlackShoutrrrUrl = (config: Extract<NotificationConfig, { type
}
const [, tokenA, tokenB, tokenC] = pathParts;
const shoutrrrUrl = new URL("slack://placeholder");
shoutrrrUrl.username = "hook";
shoutrrrUrl.password = `${tokenA}-${tokenB}-${tokenC}`;
shoutrrrUrl.hostname = "webhook";
let shoutrrrUrl = `slack://hook:${tokenA}-${tokenB}-${tokenC}@webhook`;
const params = new URLSearchParams();
if (config.channel) {
params.append("channel", config.channel);
shoutrrrUrl.searchParams.append("channel", config.channel);
}
if (config.username) {
params.append("username", config.username);
shoutrrrUrl.searchParams.append("username", config.username);
}
if (config.iconEmoji) {
params.append("icon_emoji", config.iconEmoji);
shoutrrrUrl.searchParams.append("icon_emoji", config.iconEmoji);
}
if (params.toString()) {
shoutrrrUrl += `?${params.toString()}`;
}
return shoutrrrUrl;
return shoutrrrUrl.toString().replace("/?", "?");
};

View file

@ -1,9 +1,8 @@
import type { NotificationConfig } from "~/schemas/notifications";
export const buildTelegramShoutrrrUrl = (config: Extract<NotificationConfig, { type: "telegram" }>) => {
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("/?", "?");
};