mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
import { eq, and } from "drizzle-orm";
|
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
|
import slugify from "slugify";
|
|
import { db } from "../../db/db";
|
|
import {
|
|
notificationDestinationsTable,
|
|
backupScheduleNotificationsTable,
|
|
type NotificationDestination,
|
|
} from "../../db/schema";
|
|
import { cryptoUtils } from "../../utils/crypto";
|
|
import { logger } from "../../utils/logger";
|
|
import { sendNotification } from "../../utils/shoutrrr";
|
|
import { buildShoutrrrUrl } from "./builders";
|
|
import type { NotificationConfig, NotificationEvent } from "~/schemas/notifications";
|
|
import { toMessage } from "../../utils/errors";
|
|
|
|
const listDestinations = async () => {
|
|
const destinations = await db.query.notificationDestinationsTable.findMany({
|
|
orderBy: (destinations, { asc }) => [asc(destinations.name)],
|
|
});
|
|
return destinations;
|
|
};
|
|
|
|
const getDestination = async (id: number) => {
|
|
const destination = await db.query.notificationDestinationsTable.findFirst({
|
|
where: eq(notificationDestinationsTable.id, id),
|
|
});
|
|
|
|
if (!destination) {
|
|
throw new NotFoundError("Notification destination not found");
|
|
}
|
|
|
|
return destination;
|
|
};
|
|
|
|
async function encryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
|
|
switch (config.type) {
|
|
case "email":
|
|
return {
|
|
...config,
|
|
password: await cryptoUtils.encrypt(config.password),
|
|
};
|
|
case "slack":
|
|
return {
|
|
...config,
|
|
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
|
|
};
|
|
case "discord":
|
|
return {
|
|
...config,
|
|
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl),
|
|
};
|
|
case "gotify":
|
|
return {
|
|
...config,
|
|
token: await cryptoUtils.encrypt(config.token),
|
|
};
|
|
case "ntfy":
|
|
return {
|
|
...config,
|
|
token: config.token ? await cryptoUtils.encrypt(config.token) : undefined,
|
|
};
|
|
case "pushover":
|
|
return {
|
|
...config,
|
|
apiToken: await cryptoUtils.encrypt(config.apiToken),
|
|
};
|
|
case "custom":
|
|
return {
|
|
...config,
|
|
shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl),
|
|
};
|
|
default:
|
|
return config;
|
|
}
|
|
}
|
|
|
|
async function decryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
|
|
switch (config.type) {
|
|
case "email":
|
|
return {
|
|
...config,
|
|
password: await cryptoUtils.decrypt(config.password),
|
|
};
|
|
case "slack":
|
|
return {
|
|
...config,
|
|
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
|
|
};
|
|
case "discord":
|
|
return {
|
|
...config,
|
|
webhookUrl: await cryptoUtils.decrypt(config.webhookUrl),
|
|
};
|
|
case "gotify":
|
|
return {
|
|
...config,
|
|
token: await cryptoUtils.decrypt(config.token),
|
|
};
|
|
case "ntfy":
|
|
return {
|
|
...config,
|
|
token: config.token ? await cryptoUtils.decrypt(config.token) : undefined,
|
|
};
|
|
case "pushover":
|
|
return {
|
|
...config,
|
|
apiToken: await cryptoUtils.decrypt(config.apiToken),
|
|
};
|
|
case "custom":
|
|
return {
|
|
...config,
|
|
shoutrrrUrl: await cryptoUtils.decrypt(config.shoutrrrUrl),
|
|
};
|
|
default:
|
|
return config;
|
|
}
|
|
}
|
|
|
|
const createDestination = async (name: string, config: NotificationConfig) => {
|
|
const slug = slugify(name, { lower: true, strict: true });
|
|
|
|
const existing = await db.query.notificationDestinationsTable.findFirst({
|
|
where: eq(notificationDestinationsTable.name, slug),
|
|
});
|
|
|
|
if (existing) {
|
|
throw new ConflictError("Notification destination with this name already exists");
|
|
}
|
|
|
|
const encryptedConfig = await encryptSensitiveFields(config);
|
|
|
|
const [created] = await db
|
|
.insert(notificationDestinationsTable)
|
|
.values({
|
|
name: slug,
|
|
type: config.type,
|
|
config: encryptedConfig,
|
|
})
|
|
.returning();
|
|
|
|
if (!created) {
|
|
throw new InternalServerError("Failed to create notification destination");
|
|
}
|
|
|
|
return created;
|
|
};
|
|
|
|
const updateDestination = async (
|
|
id: number,
|
|
updates: { name?: string; enabled?: boolean; config?: NotificationConfig },
|
|
) => {
|
|
const existing = await getDestination(id);
|
|
|
|
if (!existing) {
|
|
throw new NotFoundError("Notification destination not found");
|
|
}
|
|
|
|
const updateData: Partial<NotificationDestination> = {
|
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
};
|
|
|
|
if (updates.name !== undefined) {
|
|
const slug = slugify(updates.name, { lower: true, strict: true });
|
|
|
|
const conflict = await db.query.notificationDestinationsTable.findFirst({
|
|
where: and(eq(notificationDestinationsTable.name, slug), eq(notificationDestinationsTable.id, id)),
|
|
});
|
|
|
|
if (conflict && conflict.id !== id) {
|
|
throw new ConflictError("Notification destination with this name already exists");
|
|
}
|
|
updateData.name = slug;
|
|
}
|
|
|
|
if (updates.enabled !== undefined) {
|
|
updateData.enabled = updates.enabled;
|
|
}
|
|
|
|
if (updates.config !== undefined) {
|
|
const encryptedConfig = await encryptSensitiveFields(updates.config);
|
|
updateData.config = encryptedConfig;
|
|
updateData.type = updates.config.type;
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(notificationDestinationsTable)
|
|
.set(updateData)
|
|
.where(eq(notificationDestinationsTable.id, id))
|
|
.returning();
|
|
|
|
if (!updated) {
|
|
throw new InternalServerError("Failed to update notification destination");
|
|
}
|
|
|
|
return updated;
|
|
};
|
|
|
|
const deleteDestination = async (id: number) => {
|
|
await db.delete(notificationDestinationsTable).where(eq(notificationDestinationsTable.id, id));
|
|
};
|
|
|
|
const testDestination = async (id: number) => {
|
|
const destination = await getDestination(id);
|
|
|
|
if (!destination.enabled) {
|
|
throw new ConflictError("Cannot test disabled notification destination");
|
|
}
|
|
|
|
const decryptedConfig = await decryptSensitiveFields(destination.config);
|
|
|
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
|
|
|
console.log("Testing notification with Shoutrrr URL:", shoutrrrUrl);
|
|
|
|
const result = await sendNotification({
|
|
shoutrrrUrl,
|
|
title: "Zerobyte Test Notification",
|
|
body: `This is a test notification from Zerobyte for destination: ${destination.name}`,
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new InternalServerError(`Failed to send test notification: ${result.error}`);
|
|
}
|
|
|
|
return { success: true };
|
|
};
|
|
|
|
const getScheduleNotifications = async (scheduleId: number) => {
|
|
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
|
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
|
with: {
|
|
destination: true,
|
|
},
|
|
});
|
|
|
|
return assignments;
|
|
};
|
|
|
|
const updateScheduleNotifications = async (
|
|
scheduleId: number,
|
|
assignments: Array<{
|
|
destinationId: number;
|
|
notifyOnStart: boolean;
|
|
notifyOnSuccess: boolean;
|
|
notifyOnFailure: boolean;
|
|
}>,
|
|
) => {
|
|
await db.delete(backupScheduleNotificationsTable).where(eq(backupScheduleNotificationsTable.scheduleId, scheduleId));
|
|
|
|
if (assignments.length > 0) {
|
|
await db.insert(backupScheduleNotificationsTable).values(
|
|
assignments.map((assignment) => ({
|
|
scheduleId,
|
|
...assignment,
|
|
})),
|
|
);
|
|
}
|
|
|
|
return getScheduleNotifications(scheduleId);
|
|
};
|
|
|
|
const sendBackupNotification = async (
|
|
scheduleId: number,
|
|
event: NotificationEvent,
|
|
context: {
|
|
volumeName: string;
|
|
repositoryName: string;
|
|
scheduleName?: string;
|
|
error?: string;
|
|
duration?: number;
|
|
filesProcessed?: number;
|
|
bytesProcessed?: string;
|
|
snapshotId?: string;
|
|
},
|
|
) => {
|
|
try {
|
|
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
|
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
|
with: {
|
|
destination: true,
|
|
},
|
|
});
|
|
|
|
const relevantAssignments = assignments.filter((assignment) => {
|
|
if (!assignment.destination.enabled) return false;
|
|
|
|
switch (event) {
|
|
case "start":
|
|
return assignment.notifyOnStart;
|
|
case "success":
|
|
return assignment.notifyOnSuccess;
|
|
case "failure":
|
|
return assignment.notifyOnFailure;
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (!relevantAssignments.length) {
|
|
logger.debug(`No notification destinations configured for backup ${scheduleId} event ${event}`);
|
|
return;
|
|
}
|
|
|
|
const { title, body } = buildNotificationMessage(event, context);
|
|
|
|
for (const assignment of relevantAssignments) {
|
|
try {
|
|
const decryptedConfig = await decryptSensitiveFields(assignment.destination.config);
|
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
|
|
|
const result = await sendNotification({
|
|
shoutrrrUrl,
|
|
title,
|
|
body,
|
|
});
|
|
|
|
if (result.success) {
|
|
logger.info(
|
|
`Notification sent successfully to ${assignment.destination.name} for backup ${scheduleId} event ${event}`,
|
|
);
|
|
} else {
|
|
logger.error(
|
|
`Failed to send notification to ${assignment.destination.name} for backup ${scheduleId}: ${result.error}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error sending notification to ${assignment.destination.name} for backup ${scheduleId}: ${toMessage(error)}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Error processing backup notifications for schedule ${scheduleId}: ${toMessage(error)}`);
|
|
}
|
|
};
|
|
|
|
function buildNotificationMessage(
|
|
event: NotificationEvent,
|
|
context: {
|
|
volumeName: string;
|
|
repositoryName: string;
|
|
scheduleName?: string;
|
|
error?: string;
|
|
duration?: number;
|
|
filesProcessed?: number;
|
|
bytesProcessed?: string;
|
|
snapshotId?: string;
|
|
},
|
|
) {
|
|
const date = new Date().toLocaleDateString();
|
|
const time = new Date().toLocaleTimeString();
|
|
|
|
switch (event) {
|
|
case "start":
|
|
return {
|
|
title: "🔵 Backup Started",
|
|
body: [
|
|
`Volume: ${context.volumeName}`,
|
|
`Repository: ${context.repositoryName}`,
|
|
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
|
`Time: ${date} - ${time}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
};
|
|
|
|
case "success":
|
|
return {
|
|
title: "✅ Backup Completed Successfully",
|
|
body: [
|
|
`Volume: ${context.volumeName}`,
|
|
`Repository: ${context.repositoryName}`,
|
|
context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null,
|
|
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null,
|
|
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
|
|
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
|
|
`Time: ${date} - ${time}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
};
|
|
|
|
case "failure":
|
|
return {
|
|
title: "❌ Backup Failed",
|
|
body: [
|
|
`Volume: ${context.volumeName}`,
|
|
`Repository: ${context.repositoryName}`,
|
|
context.error ? `Error: ${context.error}` : null,
|
|
`Time: ${date} - ${time}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
};
|
|
|
|
default:
|
|
return {
|
|
title: "Backup Notification",
|
|
body: `Volume: ${context.volumeName}\nRepository: ${context.repositoryName}\nTime: ${date} - ${time}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const notificationsService = {
|
|
listDestinations,
|
|
getDestination,
|
|
createDestination,
|
|
updateDestination,
|
|
deleteDestination,
|
|
testDestination,
|
|
getScheduleNotifications,
|
|
updateScheduleNotifications,
|
|
sendBackupNotification,
|
|
};
|