refactor(notifications): use native URL builder
This commit is contained in:
parent
e58f82d7ed
commit
a2d34a027e
9 changed files with 309 additions and 80 deletions
|
|
@ -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} \
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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("/?", "?");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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("/?", "?");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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("/?", "?");
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue