ntfy servers that require auth rejected every Checkmate alert because the
provider posted without an Authorization header, so alerts to protected
topics failed silently.
Adds ntfyAuthType ("none" | "token" | "basic"):
- token: Authorization: Bearer <accessToken>
- basic: Authorization: Basic base64(ntfyUsername:accessToken)
Both types store their secret in the existing accessToken field rather than
introducing a new secret, so credential handling stays in one place.
Incomplete credentials fail the send instead of falling back to an anonymous
post, and validation rejects partial auth config up front so a channel can't
be saved looking authenticated while posting anonymously.
Backend only; frontend follows once this lands.
Closes #3717
198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
import { z } from "zod";
|
|
import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js";
|
|
|
|
//****************************************
|
|
// Notification Validations
|
|
//****************************************
|
|
|
|
// ntfy stores its secret in accessToken for both auth types: the bearer token for
|
|
// "token", the password for "basic". Reject partially filled credentials so a channel
|
|
// can't be saved looking authenticated while posting anonymously.
|
|
const refineNtfyAuth = (body: { ntfyAuthType?: string; ntfyUsername?: string; accessToken?: string }, ctx: z.RefinementCtx) => {
|
|
const authType = body.ntfyAuthType ?? "none";
|
|
|
|
if (authType === "none") {
|
|
if (body.accessToken) {
|
|
ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Select an authentication type to use an access token" });
|
|
}
|
|
if (body.ntfyUsername) {
|
|
ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Select an authentication type to use a username" });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (authType === "token") {
|
|
if (!body.accessToken) {
|
|
ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Access token is required for token authentication" });
|
|
}
|
|
if (body.ntfyUsername) {
|
|
ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is only used with basic authentication" });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!body.ntfyUsername) {
|
|
ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is required for basic authentication" });
|
|
}
|
|
if (!body.accessToken) {
|
|
ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Password is required for basic authentication" });
|
|
}
|
|
};
|
|
|
|
export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
|
// Email notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("email"),
|
|
address: z.email("Please enter a valid e-mail address"),
|
|
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
|
roomId: z.union([z.string(), z.literal("")]).optional(),
|
|
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
|
}),
|
|
// Webhook notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("webhook"),
|
|
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
|
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
|
roomId: z.union([z.string(), z.literal("")]).optional(),
|
|
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
|
}),
|
|
// Rocket.Chat notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("rocket_chat"),
|
|
address: z.url({
|
|
protocol: /^https?$/,
|
|
message: "Please enter a valid Rocket.Chat webhook URL",
|
|
}),
|
|
}),
|
|
// Slack notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("slack"),
|
|
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
|
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
|
roomId: z.union([z.string(), z.literal("")]).optional(),
|
|
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
|
}),
|
|
// Discord notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("discord"),
|
|
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
|
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
|
roomId: z.union([z.string(), z.literal("")]).optional(),
|
|
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
|
}),
|
|
// PagerDuty notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("pager_duty"),
|
|
address: z.string().min(1, "PagerDuty integration key is required"),
|
|
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
|
roomId: z.union([z.string(), z.literal("")]).optional(),
|
|
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
|
}),
|
|
// Matrix notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("matrix"),
|
|
address: z.union([z.string(), z.literal("")]).optional(),
|
|
homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }),
|
|
roomId: z.string().min(1, "Room ID is required"),
|
|
accessToken: z.string().min(1, "Access Token is required"),
|
|
}),
|
|
// Teams notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("teams"),
|
|
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
|
}),
|
|
// Telegram notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("telegram"),
|
|
address: z.string().min(1, "Chat ID is required"),
|
|
accessToken: z.string().min(1, "Bot token is required"),
|
|
}),
|
|
// Pushover notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("pushover"),
|
|
address: z.string().min(1, "User key is required"),
|
|
accessToken: z.string().min(1, "App token is required"),
|
|
}),
|
|
// Twilio SMS notification
|
|
z.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("twilio"),
|
|
accountSid: z.string().min(1, "Account SID is required"),
|
|
accessToken: z.string().min(1, "Auth token is required"),
|
|
phone: z.string().min(1, "Recipient phone number is required"),
|
|
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
|
|
}),
|
|
// ntfy notification
|
|
z
|
|
.object({
|
|
notificationName: z.string().min(1, "Notification name is required"),
|
|
type: z.literal("ntfy"),
|
|
address: z.url({ message: "Please enter a valid ntfy server URL" }),
|
|
topic: z.string().min(1, "Topic is required"),
|
|
ntfyAuthType: z.enum(NtfyAuthTypes).optional(),
|
|
ntfyUsername: z.union([z.string(), z.literal("")]).optional(),
|
|
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
|
})
|
|
.superRefine(refineNtfyAuth),
|
|
]);
|
|
|
|
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
|
|
|
export const deleteNotificationParamValidation = z.object({
|
|
id: z.string().min(1, "Notification ID is required"),
|
|
});
|
|
export const getNotificationByIdParamValidation = z.object({
|
|
id: z.string().min(1, "Notification ID is required"),
|
|
});
|
|
export const editNotificationParamValidation = z.object({
|
|
id: z.string().min(1, "Notification ID is required"),
|
|
});
|
|
|
|
export const testAllNotificationsBodyValidation = z.object({
|
|
monitorId: z.string().min(1, "Monitor ID is required"),
|
|
});
|
|
|
|
export const sendTestEmailBodyValidation = z.object({
|
|
to: z.string().min(1, "To field is required"),
|
|
systemEmailHost: z.string().optional(),
|
|
systemEmailPort: z.number().optional(),
|
|
systemEmailSecure: z.boolean().optional(),
|
|
systemEmailPool: z.boolean().optional(),
|
|
systemEmailAddress: z.string().optional(),
|
|
systemEmailDisplayName: z.string().optional(),
|
|
systemEmailPassword: z.string().optional(),
|
|
systemEmailUser: z.string().optional(),
|
|
systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(),
|
|
systemEmailIgnoreTLS: z.boolean().optional(),
|
|
systemEmailRequireTLS: z.boolean().optional(),
|
|
systemEmailRejectUnauthorized: z.boolean().optional(),
|
|
systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(),
|
|
});
|
|
|
|
export const updateNotificationsValidation = z
|
|
.object({
|
|
monitorIds: z.array(z.string()).min(1, "At least one monitor ID is required").max(100, "Cannot update more than 100 monitors at once"),
|
|
notificationIds: z.array(z.string()).max(100, "Cannot specify more than 100 notification IDs at once"),
|
|
action: z.enum(["add", "remove", "set"] as const),
|
|
})
|
|
.refine(
|
|
(data) => {
|
|
if (data.action !== "set" && data.notificationIds.length === 0) return false;
|
|
return true;
|
|
},
|
|
{
|
|
message: "Notification IDs cannot be empty unless action is 'set'",
|
|
path: ["notificationIds"],
|
|
}
|
|
);
|