CATS Team 2 weeks ago committed by GitHub
commit 430af2f362
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,58 @@
/**
* Add status_page_subscriber management system for status page notifications
* - Email subscription to status pages
* - Email verification workflow
* - Subscription preferences
* - Notification queue
*/
exports.up = function (knex) {
return (
knex.schema
// Create status_page_subscriber table
.createTable("status_page_subscriber", (table) => {
table.increments("id").primary();
table.string("email", 255).notNullable().unique();
table.string("unsubscribe_token", 255).unique();
table.timestamps(false, true);
table.index("email", "subscriber_email");
table.index("unsubscribe_token", "subscriber_unsubscribe_token");
})
// Create status_page_subscription table (links subscribers to status pages)
.createTable("status_page_subscription", (table) => {
table.increments("id").primary();
table
.integer("subscriber_id")
.unsigned()
.notNullable()
.references("id")
.inTable("status_page_subscriber")
.onDelete("CASCADE")
.onUpdate("CASCADE");
table
.integer("status_page_id")
.unsigned()
.notNullable()
.references("id")
.inTable("status_page")
.onDelete("CASCADE")
.onUpdate("CASCADE");
table.boolean("notify_incidents").defaultTo(true);
table.boolean("notify_maintenance").defaultTo(true);
table.boolean("notify_status_changes").defaultTo(false);
table.boolean("verified").defaultTo(false);
table.string("verification_token", 255);
table.timestamps(false, true);
table.index("status_page_id", "status_page_subscription_status_page_id");
table.index("verification_token", "status_page_subscription_verification_token");
table.unique(["subscriber_id", "status_page_id"]);
})
);
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists("status_page_subscription").dropTableIfExists("status_page_subscriber");
};

@ -0,0 +1,11 @@
exports.up = async function (knex) {
await knex.schema.alterTable("status_page", function (table) {
table.string("notification_email", 255);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("status_page", function (table) {
table.dropColumn("notification_email");
});
};

@ -5,6 +5,7 @@ const dayjs = require("dayjs");
const Cron = require("croner");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const apicache = require("../modules/apicache");
const { StatusPageNotification } = require("../status-page-notification");
class Maintenance extends BeanModel {
/**
@ -235,6 +236,21 @@ class Maintenance extends BeanModel {
log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now");
UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
apicache.clear();
this.notifyStatusPages("start").catch((e) => {
log.error("status-page-notification", e.message);
});
if (this.end_date) {
let endDelay = dayjs(this.end_date).diff(dayjs(this.start_date), "millisecond");
if (endDelay > 0) {
this.beanMeta.durationTimeout = setTimeout(() => {
this.notifyStatusPages("end").catch((e) => {
log.error("status-page-notification", e.message);
});
}, endDelay);
}
}
});
} else if (this.cron != null) {
let current = dayjs();
@ -253,10 +269,18 @@ class Maintenance extends BeanModel {
UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
this.notifyStatusPages("start").catch((e) => {
log.error("status-page-notification", e.message);
});
this.beanMeta.durationTimeout = setTimeout(() => {
// End of maintenance for this timeslot
this.beanMeta.status = "scheduled";
UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
this.notifyStatusPages("end").catch((e) => {
log.error("status-page-notification", e.message);
});
}, duration);
// Set last start date to current time
@ -501,6 +525,26 @@ class Maintenance extends BeanModel {
this.duration = this.calcDuration();
}
}
/**
* Notify all linked status pages about maintenance start or end
* @param {string} event "start" or "end"
* @returns {Promise<void>}
*/
async notifyStatusPages(event) {
let statusPageIds = await R.getAll(
"SELECT status_page_id FROM maintenance_status_page WHERE maintenance_id = ? ",
[this.id]
);
for (let row of statusPageIds) {
if (event === "start") {
await StatusPageNotification.sendMaintenanceNotification(row.status_page_id, this);
} else {
await StatusPageNotification.sendMaintenanceCompletedNotification(row.status_page_id, this);
}
}
}
}
module.exports = Maintenance;

@ -451,6 +451,7 @@ class StatusPage extends BeanModel {
showCertificateExpiry: !!this.show_certificate_expiry,
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat,
rssTitle: this.rss_title,
notificationEmail: this.notification_email,
};
}

@ -0,0 +1,59 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { R } = require("redbean-node");
const { nanoid } = require("nanoid");
/**
* StatusPageSubscriber model
* Represents email subscribers to status pages
*/
class StatusPageSubscriber extends BeanModel {
/**
* Generate unsubscribe token
* @returns {string} Unsubscribe token
*/
static generateUnsubscribeToken() {
return nanoid(32);
}
/**
* Return an object that ready to parse to JSON for admin
* @returns {object} Object ready to parse
*/
toJSON() {
return {
id: this.id,
email: this.email,
createdAt: this.created_at,
};
}
/**
* Return an object that ready to parse to JSON for public
* @returns {object} Object ready to parse
*/
toPublicJSON() {
return {
id: this.id,
};
}
/**
* Find status_page_subscriber by email
* @param {string} email Email address
* @returns {Promise<StatusPageSubscriber|null>} StatusPageSubscriber or null
*/
static async findByEmail(email) {
return await R.findOne("status_page_subscriber", " email = ? ", [email]);
}
/**
* Find status_page_subscriber by unsubscribe token
* @param {string} token Unsubscribe token
* @returns {Promise<StatusPageSubscriber|null>} StatusPageSubscriber or null
*/
static async findByUnsubscribeToken(token) {
return await R.findOne("status_page_subscriber", " unsubscribe_token = ? ", [token]);
}
}
module.exports = StatusPageSubscriber;

@ -0,0 +1,86 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { R } = require("redbean-node");
const { nanoid } = require("nanoid");
/**
* StatusPageSubscription model
* Links subscribers to status pages
*/
class StatusPageSubscription extends BeanModel {
/**
* Generate verification token
* @returns {string} Verification token
*/
static generateVerificationToken() {
return nanoid(32);
}
/**
* Verify the status_page_subscription
* @returns {Promise<void>}
*/
async verify() {
this.verified = true;
this.verification_token = null;
await R.store(this);
}
/**
* Find status_page_subscription by verification token
* @param {string} token Verification token
* @returns {Promise<StatusPageSubscription|null>} StatusPageSubscription or null
*/
static async findByVerificationToken(token) {
return await R.findOne("status_page_subscription", " verification_token = ? ", [token]);
}
/**
* Return an object that ready to parse to JSON
* @returns {object} Object ready to parse
*/
toJSON() {
return {
id: this.id,
subscriberId: this.subscriber_id,
statusPageId: this.status_page_id,
notifyIncidents: !!this.notify_incidents,
notifyMaintenance: !!this.notify_maintenance,
notifyStatusChanges: !!this.notify_status_changes,
verified: !!this.verified,
createdAt: this.created_at,
};
}
/**
* Get all subscriptions for a subscriber
* @param {number} subscriberId Subscriber ID
* @returns {Promise<StatusPageSubscription[]>} Array of subscriptions
*/
static async getBySubscriber(subscriberId) {
return await R.find("status_page_subscription", " subscriber_id = ? ", [subscriberId]);
}
/**
* Get all subscriptions for a status page
* @param {number} statusPageId Status Page ID
* @returns {Promise<StatusPageSubscription[]>} Array of subscriptions
*/
static async getByStatusPage(statusPageId) {
return await R.find("status_page_subscription", " status_page_id = ? ", [statusPageId]);
}
/**
* Check if status_page_subscription exists
* @param {number} subscriberId Subscriber ID
* @param {number} statusPageId Status Page ID
* @returns {Promise<StatusPageSubscription|null>} StatusPageSubscription or null
*/
static async exists(subscriberId, statusPageId) {
return await R.findOne("status_page_subscription", " subscriber_id = ? AND status_page_id = ? ", [
subscriberId,
statusPageId,
]);
}
}
module.exports = StatusPageSubscription;

@ -8,6 +8,7 @@ const apicache = require("../modules/apicache");
const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const { Settings } = require("../settings");
const { StatusPageNotification } = require("../status-page-notification");
/**
* Validates incident data
@ -61,6 +62,8 @@ module.exports.statusPageSocketHandler = (socket) => {
incidentBean.active = true;
incidentBean.status_page_id = statusPageID;
let isNewIncident = !incident.id;
if (incident.id) {
incidentBean.last_updated_date = R.isoDateTime(dayjs.utc());
} else {
@ -69,6 +72,16 @@ module.exports.statusPageSocketHandler = (socket) => {
await R.store(incidentBean);
if (isNewIncident) {
StatusPageNotification.sendIncidentNotification(statusPageID, incidentBean).catch((e) => {
log.error("status-page-notification", e.message);
});
} else {
StatusPageNotification.sendIncidentUpdateNotification(statusPageID, incidentBean).catch((e) => {
log.error("status-page-notification", e.message);
});
}
callback({
ok: true,
incident: incidentBean.toPublicJSON(),
@ -169,6 +182,10 @@ module.exports.statusPageSocketHandler = (socket) => {
await R.store(bean);
StatusPageNotification.sendIncidentUpdateNotification(statusPageID, bean).catch((e) => {
log.error("status-page-notification", e.message);
});
callback({
ok: true,
msg: "Saved.",
@ -250,6 +267,10 @@ module.exports.statusPageSocketHandler = (socket) => {
await bean.resolve();
StatusPageNotification.sendIncidentResolvedNotification(statusPageID, bean).catch((e) => {
log.error("status-page-notification", e.message);
});
callback({
ok: true,
msg: "Resolved",
@ -334,6 +355,7 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.custom_css = config.customCSS;
statusPage.show_powered_by = config.showPoweredBy;
statusPage.rss_title = config.rssTitle;
statusPage.notification_email = config.notificationEmail;
statusPage.show_only_last_heartbeat = config.showOnlyLastHeartbeat;
statusPage.show_certificate_expiry = config.showCertificateExpiry;
statusPage.modified_date = R.isoDateTime();

@ -0,0 +1,133 @@
const { R } = require("redbean-node");
const { log } = require("../src/util");
const { Notification } = require("./notification");
/**
* Send a status page notification email to a configured address.
* Uses the admin's default or first SMTP notification provider.
*/
class StatusPageNotification {
/**
* Find an SMTP notification config to use for sending.
* Prefers the default notification if it is SMTP, otherwise
* falls back to the first SMTP notification found.
* @returns {Promise<object|null>} Parsed SMTP config or null
*/
static async getSMTPConfig() {
try {
let bean = await R.findOne("notification", " is_default = ? ", [true]);
if (bean) {
let config = JSON.parse(bean.config);
if (config.type === "smtp") {
return config;
}
}
let allBeans = await R.findAll("notification");
for (let b of allBeans) {
let config = JSON.parse(b.config);
if (config.type === "smtp") {
return config;
}
}
return null;
} catch (error) {
log.error("status-page-notification", `Failed to get SMTP config: ${error.message}`);
return null;
}
}
/**
* Send a notification email to the status page's configured address.
* Does nothing if no notification_email is set or no SMTP config exists.
* @param {number} statusPageId Status page ID
* @param {string} subject Email subject line
* @param {string} body Plain text email body
* @returns {Promise<boolean>} true if sent, false otherwise
*/
static async sendNotificationEmail(statusPageId, subject, body) {
try {
let statusPage = await R.load("status_page", statusPageId);
if (!statusPage || !statusPage.notification_email) {
return false;
}
let smtpConfig = await this.getSMTPConfig();
if (!smtpConfig) {
log.warn("status-page-notification", "No SMTP notification configured. Cannot send status page email.");
return false;
}
let sendConfig = Object.assign({}, smtpConfig, {
smtpTo: statusPage.notification_email,
});
await Notification.send(sendConfig, body);
log.info("status-page-notification", `Sent notification to ${statusPage.notification_email}: ${subject}`);
return true;
} catch (error) {
log.error("status-page-notification", `Failed to send notification: ${error.message}`);
return false;
}
}
/**
* @param {number} statusPageId Status page ID
* @param {object} incident Incident bean
* @returns {Promise<boolean>} true if sent
*/
static async sendIncidentNotification(statusPageId, incident) {
let subject = `[Incident] ${incident.title}`;
let body = `Incident: ${incident.title}\nSeverity: ${incident.style}\n\n${incident.content}`;
return await this.sendNotificationEmail(statusPageId, subject, body);
}
/**
* @param {number} statusPageId Status page ID
* @param {object} incident Incident bean
* @returns {Promise<boolean>} true if sent
*/
static async sendIncidentUpdateNotification(statusPageId, incident) {
let subject = `[Incident Update] ${incident.title}`;
let body = `Incident Updated: ${incident.title}\nSeverity: ${incident.style}\n\n${incident.content}`;
return await this.sendNotificationEmail(statusPageId, subject, body);
}
/**
* @param {number} statusPageId Status page ID
* @param {object} incident Incident bean
* @returns {Promise<boolean>} true if sent
*/
static async sendIncidentResolvedNotification(statusPageId, incident) {
let subject = `[Resolved] ${incident.title}`;
let body = `Incident Resolved: ${incident.title}\n\nThis incident has been resolved.`;
return await this.sendNotificationEmail(statusPageId, subject, body);
}
/**
* @param {number} statusPageId Status page ID
* @param {object} maintenance Maintenance bean
* @returns {Promise<boolean>} true if sent
*/
static async sendMaintenanceNotification(statusPageId, maintenance) {
let subject = `[Maintenance] ${maintenance.title}`;
let body = `Scheduled Maintenance: ${maintenance.title}\n\n${maintenance.description || ""}`;
return await this.sendNotificationEmail(statusPageId, subject, body);
}
/**
* @param {number} statusPageId Status page ID
* @param {object} maintenance Maintenance bean
* @returns {Promise<boolean>} true if sent
*/
static async sendMaintenanceCompletedNotification(statusPageId, maintenance) {
let subject = `[Maintenance Complete] ${maintenance.title}`;
let body = `Maintenance Complete: ${maintenance.title}\n\nThe scheduled maintenance has ended.`;
return await this.sendNotificationEmail(statusPageId, subject, body);
}
}
module.exports = { StatusPageNotification };

@ -432,6 +432,8 @@
"Footer Text": "Footer Text",
"RSS Title": "RSS Title",
"Leave blank to use status page title": "Leave blank to use status page title",
"Notification Email": "Notification Email",
"notificationEmailDescription": "Email address or mailing list to receive notifications about incidents and maintenance for this status page. Requires an SMTP notification provider to be configured.",
"Refresh Interval": "Refresh Interval",
"Refresh Interval Description": "The status page will do a full site refresh every {0} seconds",
"Show Powered By": "Show Powered By",

@ -201,6 +201,21 @@
</div>
</div>
<!-- Notification Email -->
<div class="my-3">
<label for="notification-email" class="form-label">{{ $t("Notification Email") }}</label>
<input
id="notification-email"
v-model="config.notificationEmail"
type="email"
class="form-control"
data-testid="notification-email-input"
/>
<div class="form-text">
{{ $t("notificationEmailDescription") }}
</div>
</div>
<!-- Custom CSS -->
<div class="my-3">
<div class="mb-1">{{ $t("Custom CSS") }}</div>

@ -0,0 +1,295 @@
const { describe, test, mock, afterEach } = require("node:test");
const assert = require("node:assert");
const { R } = require("redbean-node");
const { Notification } = require("../../server/notification");
const { StatusPageNotification } = require("../../server/status-page-notification");
describe("StatusPageNotification", () => {
afterEach(() => {
mock.restoreAll();
});
describe("getSMTPConfig()", () => {
test("returns default notification if it is SMTP", async () => {
let smtpConfig = { type: "smtp", smtpHost: "mail.example.com" };
mock.method(R, "findOne", async () => ({
config: JSON.stringify(smtpConfig),
}));
let result = await StatusPageNotification.getSMTPConfig();
assert.deepStrictEqual(result, smtpConfig);
});
test("falls back to first SMTP if default is not SMTP", async () => {
let telegramConfig = { type: "telegram" };
let smtpConfig = { type: "smtp", smtpHost: "mail.example.com" };
mock.method(R, "findOne", async () => ({
config: JSON.stringify(telegramConfig),
}));
mock.method(R, "findAll", async () => [
{ config: JSON.stringify(telegramConfig) },
{ config: JSON.stringify(smtpConfig) },
]);
let result = await StatusPageNotification.getSMTPConfig();
assert.deepStrictEqual(result, smtpConfig);
});
test("falls back to first SMTP if no default notification", async () => {
let smtpConfig = { type: "smtp", smtpHost: "mail.example.com" };
mock.method(R, "findOne", async () => null);
mock.method(R, "findAll", async () => [{ config: JSON.stringify(smtpConfig) }]);
let result = await StatusPageNotification.getSMTPConfig();
assert.deepStrictEqual(result, smtpConfig);
});
test("returns null if no SMTP notification exists", async () => {
mock.method(R, "findOne", async () => null);
mock.method(R, "findAll", async () => [
{ config: JSON.stringify({ type: "telegram" }) },
{ config: JSON.stringify({ type: "discord" }) },
]);
let result = await StatusPageNotification.getSMTPConfig();
assert.strictEqual(result, null);
});
test("returns null if no notifications exist at all", async () => {
mock.method(R, "findOne", async () => null);
mock.method(R, "findAll", async () => []);
let result = await StatusPageNotification.getSMTPConfig();
assert.strictEqual(result, null);
});
test("returns null on database error", async () => {
mock.method(R, "findOne", async () => {
throw new Error("DB connection lost");
});
let result = await StatusPageNotification.getSMTPConfig();
assert.strictEqual(result, null);
});
});
describe("sendNotificationEmail()", () => {
test("returns false if status page has no notification_email", async () => {
mock.method(R, "load", async () => ({
notification_email: null,
}));
let result = await StatusPageNotification.sendNotificationEmail(1, "Subject", "Body");
assert.strictEqual(result, false);
});
test("returns false if status page has empty notification_email", async () => {
mock.method(R, "load", async () => ({
notification_email: "",
}));
let result = await StatusPageNotification.sendNotificationEmail(1, "Subject", "Body");
assert.strictEqual(result, false);
});
test("returns false if status page not found", async () => {
mock.method(R, "load", async () => null);
let result = await StatusPageNotification.sendNotificationEmail(1, "Subject", "Body");
assert.strictEqual(result, false);
});
test("returns false if no SMTP config available", async () => {
mock.method(R, "load", async () => ({
notification_email: "team@example.com",
}));
mock.method(StatusPageNotification, "getSMTPConfig", async () => null);
let result = await StatusPageNotification.sendNotificationEmail(1, "Subject", "Body");
assert.strictEqual(result, false);
});
test("sends email with overridden smtpTo and returns true", async () => {
let smtpConfig = {
type: "smtp",
smtpHost: "mail.example.com",
smtpTo: "original@example.com",
};
mock.method(R, "load", async () => ({
notification_email: "team@example.com",
}));
mock.method(StatusPageNotification, "getSMTPConfig", async () => smtpConfig);
let sentConfig = null;
mock.method(Notification, "send", async (config, msg) => {
sentConfig = config;
return "Sent Successfully.";
});
let result = await StatusPageNotification.sendNotificationEmail(1, "Test Subject", "Test Body");
assert.strictEqual(result, true);
assert.strictEqual(sentConfig.smtpTo, "team@example.com");
assert.strictEqual(sentConfig.smtpHost, "mail.example.com");
assert.strictEqual(sentConfig.type, "smtp");
});
test("does not mutate original SMTP config", async () => {
let smtpConfig = {
type: "smtp",
smtpHost: "mail.example.com",
smtpTo: "original@example.com",
};
mock.method(R, "load", async () => ({
notification_email: "team@example.com",
}));
mock.method(StatusPageNotification, "getSMTPConfig", async () => smtpConfig);
mock.method(Notification, "send", async () => "Sent Successfully.");
await StatusPageNotification.sendNotificationEmail(1, "Subject", "Body");
assert.strictEqual(smtpConfig.smtpTo, "original@example.com");
});
test("returns false on send failure", async () => {
mock.method(R, "load", async () => ({
notification_email: "team@example.com",
}));
mock.method(StatusPageNotification, "getSMTPConfig", async () => ({
type: "smtp",
smtpHost: "mail.example.com",
}));
mock.method(Notification, "send", async () => {
throw new Error("SMTP connection refused");
});
let result = await StatusPageNotification.sendNotificationEmail(1, "Subject", "Body");
assert.strictEqual(result, false);
});
});
describe("sendIncidentNotification()", () => {
test("sends with correct subject and body", async () => {
let sentSubject = null;
let sentBody = null;
mock.method(StatusPageNotification, "sendNotificationEmail", async (id, subject, body) => {
sentSubject = subject;
sentBody = body;
return true;
});
let incident = {
title: "API Outage",
content: "The API is currently unavailable.",
style: "danger",
};
let result = await StatusPageNotification.sendIncidentNotification(5, incident);
assert.strictEqual(result, true);
assert.strictEqual(sentSubject, "[Incident] API Outage");
assert.ok(sentBody.includes("API Outage"));
assert.ok(sentBody.includes("danger"));
assert.ok(sentBody.includes("The API is currently unavailable."));
});
});
describe("sendIncidentUpdateNotification()", () => {
test("sends with correct subject", async () => {
let sentSubject = null;
mock.method(StatusPageNotification, "sendNotificationEmail", async (id, subject, body) => {
sentSubject = subject;
return true;
});
let incident = {
title: "API Outage",
content: "Investigating the issue.",
style: "warning",
};
await StatusPageNotification.sendIncidentUpdateNotification(5, incident);
assert.strictEqual(sentSubject, "[Incident Update] API Outage");
});
});
describe("sendIncidentResolvedNotification()", () => {
test("sends with correct subject and resolved message", async () => {
let sentSubject = null;
let sentBody = null;
mock.method(StatusPageNotification, "sendNotificationEmail", async (id, subject, body) => {
sentSubject = subject;
sentBody = body;
return true;
});
let incident = { title: "API Outage" };
await StatusPageNotification.sendIncidentResolvedNotification(5, incident);
assert.strictEqual(sentSubject, "[Resolved] API Outage");
assert.ok(sentBody.includes("resolved"));
});
});
describe("sendMaintenanceNotification()", () => {
test("sends with correct subject and body", async () => {
let sentSubject = null;
let sentBody = null;
mock.method(StatusPageNotification, "sendNotificationEmail", async (id, subject, body) => {
sentSubject = subject;
sentBody = body;
return true;
});
let maintenance = {
title: "Database Upgrade",
description: "Upgrading to PostgreSQL 16.",
};
await StatusPageNotification.sendMaintenanceNotification(3, maintenance);
assert.strictEqual(sentSubject, "[Maintenance] Database Upgrade");
assert.ok(sentBody.includes("Upgrading to PostgreSQL 16."));
});
test("handles null description gracefully", async () => {
let sentBody = null;
mock.method(StatusPageNotification, "sendNotificationEmail", async (id, subject, body) => {
sentBody = body;
return true;
});
let maintenance = {
title: "Quick Fix",
description: null,
};
await StatusPageNotification.sendMaintenanceNotification(3, maintenance);
assert.ok(sentBody.includes("Quick Fix"));
});
});
describe("sendMaintenanceCompletedNotification()", () => {
test("sends with correct subject", async () => {
let sentSubject = null;
mock.method(StatusPageNotification, "sendNotificationEmail", async (id, subject, body) => {
sentSubject = subject;
return true;
});
let maintenance = { title: "Database Upgrade" };
await StatusPageNotification.sendMaintenanceCompletedNotification(3, maintenance);
assert.strictEqual(sentSubject, "[Maintenance Complete] Database Upgrade");
});
});
});
Loading…
Cancel
Save