summaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-11-26 09:28:28 +0100
committerDaniel Baumann <daniel@debian.org>2024-11-26 12:25:58 +0100
commita1882b67c41fe9901a0cd8059b5cc78a5beadec0 (patch)
tree2a24507c67aa99a15416707b2f7e645142230ed8 /server
parentInitial commit. (diff)
downloaduptime-kuma-a1882b67c41fe9901a0cd8059b5cc78a5beadec0.tar.xz
uptime-kuma-a1882b67c41fe9901a0cd8059b5cc78a5beadec0.zip
Adding upstream version 2.0.0~beta.0+dfsg.upstream/2.0.0_beta.0+dfsgupstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'server')
-rw-r--r--server/2fa.js18
-rw-r--r--server/auth.js179
-rw-r--r--server/check-version.js69
-rw-r--r--server/client.js252
-rw-r--r--server/config.js46
-rw-r--r--server/database.js912
-rw-r--r--server/docker.js179
-rw-r--r--server/embedded-mariadb.js176
-rw-r--r--server/google-analytics.js28
-rw-r--r--server/image-data-uri.js79
-rw-r--r--server/jobs.js58
-rw-r--r--server/jobs/clear-old-data.js65
-rw-r--r--server/jobs/incremental-vacuum.js27
-rw-r--r--server/model/api_key.js76
-rw-r--r--server/model/docker_host.js19
-rw-r--r--server/model/group.js46
-rw-r--r--server/model/heartbeat.js45
-rw-r--r--server/model/incident.js23
-rw-r--r--server/model/maintenance.js457
-rw-r--r--server/model/monitor.js1740
-rw-r--r--server/model/proxy.js25
-rw-r--r--server/model/remote_browser.js17
-rw-r--r--server/model/status_page.js491
-rw-r--r--server/model/tag.js18
-rw-r--r--server/model/user.js53
-rw-r--r--server/modules/apicache/apicache.js917
-rw-r--r--server/modules/apicache/index.js14
-rw-r--r--server/modules/apicache/memory-cache.js87
-rw-r--r--server/modules/axios-ntlm/LICENSE21
-rw-r--r--server/modules/axios-ntlm/lib/flags.js77
-rw-r--r--server/modules/axios-ntlm/lib/hash.js122
-rw-r--r--server/modules/axios-ntlm/lib/ntlm.js220
-rw-r--r--server/modules/axios-ntlm/lib/ntlmClient.js127
-rw-r--r--server/modules/dayjs/plugin/timezone.d.ts20
-rw-r--r--server/modules/dayjs/plugin/timezone.js115
-rw-r--r--server/monitor-conditions/evaluator.js71
-rw-r--r--server/monitor-conditions/expression.js111
-rw-r--r--server/monitor-conditions/operators.js318
-rw-r--r--server/monitor-conditions/variables.js31
-rw-r--r--server/monitor-types/dns.js85
-rw-r--r--server/monitor-types/mongodb.js63
-rw-r--r--server/monitor-types/monitor-type.js31
-rw-r--r--server/monitor-types/mqtt.js117
-rw-r--r--server/monitor-types/rabbitmq.js67
-rw-r--r--server/monitor-types/real-browser-monitor-type.js273
-rw-r--r--server/monitor-types/snmp.js63
-rw-r--r--server/monitor-types/tailscale-ping.js77
-rw-r--r--server/notification-providers/46elks.js35
-rw-r--r--server/notification-providers/alerta.js68
-rw-r--r--server/notification-providers/alertnow.js53
-rw-r--r--server/notification-providers/aliyun-sms.js143
-rw-r--r--server/notification-providers/apprise.js37
-rw-r--r--server/notification-providers/bitrix24.js31
-rw-r--r--server/notification-providers/call-me-bot.js23
-rw-r--r--server/notification-providers/cellsynt.js39
-rw-r--r--server/notification-providers/clicksendsms.js45
-rw-r--r--server/notification-providers/dingding.js101
-rw-r--r--server/notification-providers/discord.js120
-rw-r--r--server/notification-providers/feishu.js104
-rw-r--r--server/notification-providers/flashduty.js108
-rw-r--r--server/notification-providers/freemobile.js27
-rw-r--r--server/notification-providers/goalert.js36
-rw-r--r--server/notification-providers/google-chat.js94
-rw-r--r--server/notification-providers/gorush.js44
-rw-r--r--server/notification-providers/gotify.js31
-rw-r--r--server/notification-providers/grafana-oncall.js51
-rw-r--r--server/notification-providers/gtx-messaging.js33
-rw-r--r--server/notification-providers/heii-oncall.js52
-rw-r--r--server/notification-providers/home-assistant.js45
-rw-r--r--server/notification-providers/keep.js42
-rw-r--r--server/notification-providers/kook.js34
-rw-r--r--server/notification-providers/line.js69
-rw-r--r--server/notification-providers/linenotify.js52
-rw-r--r--server/notification-providers/lunasea.js67
-rw-r--r--server/notification-providers/matrix.js48
-rw-r--r--server/notification-providers/mattermost.js110
-rw-r--r--server/notification-providers/nostr.js122
-rw-r--r--server/notification-providers/notification-provider.js73
-rw-r--r--server/notification-providers/ntfy.js83
-rw-r--r--server/notification-providers/octopush.js76
-rw-r--r--server/notification-providers/onebot.js48
-rw-r--r--server/notification-providers/onesender.js47
-rw-r--r--server/notification-providers/opsgenie.js96
-rw-r--r--server/notification-providers/pagerduty.js114
-rw-r--r--server/notification-providers/pagertree.js93
-rw-r--r--server/notification-providers/promosms.js53
-rw-r--r--server/notification-providers/pushbullet.js56
-rw-r--r--server/notification-providers/pushdeer.js55
-rw-r--r--server/notification-providers/pushover.js58
-rw-r--r--server/notification-providers/pushy.js32
-rw-r--r--server/notification-providers/rocket-chat.js67
-rw-r--r--server/notification-providers/send-grid.js65
-rw-r--r--server/notification-providers/serverchan.js51
-rw-r--r--server/notification-providers/serwersms.js47
-rw-r--r--server/notification-providers/sevenio.js57
-rw-r--r--server/notification-providers/signal.js29
-rw-r--r--server/notification-providers/signl4.js52
-rw-r--r--server/notification-providers/slack.js173
-rw-r--r--server/notification-providers/smsc.js47
-rw-r--r--server/notification-providers/smseagle.js73
-rw-r--r--server/notification-providers/smsmanager.js29
-rw-r--r--server/notification-providers/smspartner.js46
-rw-r--r--server/notification-providers/smtp.js120
-rw-r--r--server/notification-providers/splunk.js114
-rw-r--r--server/notification-providers/squadcast.js60
-rw-r--r--server/notification-providers/stackfield.js44
-rw-r--r--server/notification-providers/teams.js240
-rw-r--r--server/notification-providers/techulus-push.js36
-rw-r--r--server/notification-providers/telegram.js36
-rw-r--r--server/notification-providers/threema.js77
-rw-r--r--server/notification-providers/twilio.js38
-rw-r--r--server/notification-providers/webhook.js66
-rw-r--r--server/notification-providers/wecom.js51
-rw-r--r--server/notification-providers/whapi.js39
-rw-r--r--server/notification-providers/wpush.js51
-rw-r--r--server/notification-providers/zoho-cliq.js101
-rw-r--r--server/notification.js284
-rw-r--r--server/password-hash.js44
-rw-r--r--server/prometheus.js123
-rw-r--r--server/proxy.js202
-rw-r--r--server/rate-limiter.js75
-rw-r--r--server/remote-browser.js74
-rw-r--r--server/routers/api-router.js631
-rw-r--r--server/routers/status-page-router.js241
-rw-r--r--server/server.js1877
-rw-r--r--server/settings.js177
-rw-r--r--server/setup-database.js271
-rw-r--r--server/socket-handlers/api-key-socket-handler.js155
-rw-r--r--server/socket-handlers/chart-socket-handler.js38
-rw-r--r--server/socket-handlers/cloudflared-socket-handler.js122
-rw-r--r--server/socket-handlers/database-socket-handler.js42
-rw-r--r--server/socket-handlers/docker-socket-handler.js82
-rw-r--r--server/socket-handlers/general-socket-handler.js127
-rw-r--r--server/socket-handlers/maintenance-socket-handler.js337
-rw-r--r--server/socket-handlers/proxy-socket-handler.js61
-rw-r--r--server/socket-handlers/remote-browser-socket-handler.js82
-rw-r--r--server/socket-handlers/status-page-socket-handler.js374
-rw-r--r--server/uptime-calculator.js865
-rw-r--r--server/uptime-kuma-server.js557
-rw-r--r--server/util-server.js1064
-rw-r--r--server/utils/array-with-key.js85
-rw-r--r--server/utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler.js22
-rw-r--r--server/utils/limit-queue.js48
-rw-r--r--server/utils/simple-migration-server.js84
144 files changed, 20826 insertions, 0 deletions
diff --git a/server/2fa.js b/server/2fa.js
new file mode 100644
index 0000000..c7076da
--- /dev/null
+++ b/server/2fa.js
@@ -0,0 +1,18 @@
+const { R } = require("redbean-node");
+
+class TwoFA {
+
+ /**
+ * Disable 2FA for specified user
+ * @param {number} userID ID of user to disable
+ * @returns {Promise<void>}
+ */
+ static async disable2FA(userID) {
+ return await R.exec("UPDATE `user` SET twofa_status = 0 WHERE id = ? ", [
+ userID,
+ ]);
+ }
+
+}
+
+module.exports = TwoFA;
diff --git a/server/auth.js b/server/auth.js
new file mode 100644
index 0000000..597cf3d
--- /dev/null
+++ b/server/auth.js
@@ -0,0 +1,179 @@
+const basicAuth = require("express-basic-auth");
+const passwordHash = require("./password-hash");
+const { R } = require("redbean-node");
+const { setting } = require("./util-server");
+const { log } = require("../src/util");
+const { loginRateLimiter, apiRateLimiter } = require("./rate-limiter");
+const { Settings } = require("./settings");
+const dayjs = require("dayjs");
+
+/**
+ * Login to web app
+ * @param {string} username Username to login with
+ * @param {string} password Password to login with
+ * @returns {Promise<(Bean|null)>} User or null if login failed
+ */
+exports.login = async function (username, password) {
+ if (typeof username !== "string" || typeof password !== "string") {
+ return null;
+ }
+
+ let user = await R.findOne("user", " username = ? AND active = 1 ", [
+ username,
+ ]);
+
+ if (user && passwordHash.verify(password, user.password)) {
+ // Upgrade the hash to bcrypt
+ if (passwordHash.needRehash(user.password)) {
+ await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
+ passwordHash.generate(password),
+ user.id,
+ ]);
+ }
+ return user;
+ }
+
+ return null;
+};
+
+/**
+ * Validate a provided API key
+ * @param {string} key API key to verify
+ * @returns {boolean} API is ok?
+ */
+async function verifyAPIKey(key) {
+ if (typeof key !== "string") {
+ return false;
+ }
+
+ // uk prefix + key ID is before _
+ let index = key.substring(2, key.indexOf("_"));
+ let clear = key.substring(key.indexOf("_") + 1, key.length);
+
+ let hash = await R.findOne("api_key", " id=? ", [ index ]);
+
+ if (hash === null) {
+ return false;
+ }
+
+ let current = dayjs();
+ let expiry = dayjs(hash.expires);
+ if (expiry.diff(current) < 0 || !hash.active) {
+ return false;
+ }
+
+ return hash && passwordHash.verify(clear, hash.key);
+}
+
+/**
+ * Callback for basic auth authorizers
+ * @callback authCallback
+ * @param {any} err Any error encountered
+ * @param {boolean} authorized Is the client authorized?
+ */
+
+/**
+ * Custom authorizer for express-basic-auth
+ * @param {string} username Username to login with
+ * @param {string} password Password to login with
+ * @param {authCallback} callback Callback to handle login result
+ * @returns {void}
+ */
+function apiAuthorizer(username, password, callback) {
+ // API Rate Limit
+ apiRateLimiter.pass(null, 0).then((pass) => {
+ if (pass) {
+ verifyAPIKey(password).then((valid) => {
+ if (!valid) {
+ log.warn("api-auth", "Failed API auth attempt: invalid API Key");
+ }
+ callback(null, valid);
+ // Only allow a set number of api requests per minute
+ // (currently set to 60)
+ apiRateLimiter.removeTokens(1);
+ });
+ } else {
+ log.warn("api-auth", "Failed API auth attempt: rate limit exceeded");
+ callback(null, false);
+ }
+ });
+}
+
+/**
+ * Custom authorizer for express-basic-auth
+ * @param {string} username Username to login with
+ * @param {string} password Password to login with
+ * @param {authCallback} callback Callback to handle login result
+ * @returns {void}
+ */
+function userAuthorizer(username, password, callback) {
+ // Login Rate Limit
+ loginRateLimiter.pass(null, 0).then((pass) => {
+ if (pass) {
+ exports.login(username, password).then((user) => {
+ callback(null, user != null);
+
+ if (user == null) {
+ log.warn("basic-auth", "Failed basic auth attempt: invalid username/password");
+ loginRateLimiter.removeTokens(1);
+ }
+ });
+ } else {
+ log.warn("basic-auth", "Failed basic auth attempt: rate limit exceeded");
+ callback(null, false);
+ }
+ });
+}
+
+/**
+ * Use basic auth if auth is not disabled
+ * @param {express.Request} req Express request object
+ * @param {express.Response} res Express response object
+ * @param {express.NextFunction} next Next handler in chain
+ * @returns {Promise<void>}
+ */
+exports.basicAuth = async function (req, res, next) {
+ const middleware = basicAuth({
+ authorizer: userAuthorizer,
+ authorizeAsync: true,
+ challenge: true,
+ });
+
+ const disabledAuth = await setting("disableAuth");
+
+ if (!disabledAuth) {
+ middleware(req, res, next);
+ } else {
+ next();
+ }
+};
+
+/**
+ * Use use API Key if API keys enabled, else use basic auth
+ * @param {express.Request} req Express request object
+ * @param {express.Response} res Express response object
+ * @param {express.NextFunction} next Next handler in chain
+ * @returns {Promise<void>}
+ */
+exports.apiAuth = async function (req, res, next) {
+ if (!await Settings.get("disableAuth")) {
+ let usingAPIKeys = await Settings.get("apiKeysEnabled");
+ let middleware;
+ if (usingAPIKeys) {
+ middleware = basicAuth({
+ authorizer: apiAuthorizer,
+ authorizeAsync: true,
+ challenge: true,
+ });
+ } else {
+ middleware = basicAuth({
+ authorizer: userAuthorizer,
+ authorizeAsync: true,
+ challenge: true,
+ });
+ }
+ middleware(req, res, next);
+ } else {
+ next();
+ }
+};
diff --git a/server/check-version.js b/server/check-version.js
new file mode 100644
index 0000000..c6d5cfb
--- /dev/null
+++ b/server/check-version.js
@@ -0,0 +1,69 @@
+const { setSetting, setting } = require("./util-server");
+const axios = require("axios");
+const compareVersions = require("compare-versions");
+const { log } = require("../src/util");
+
+exports.version = require("../package.json").version;
+exports.latestVersion = null;
+
+// How much time in ms to wait between update checks
+const UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 60 * 48;
+const UPDATE_CHECKER_LATEST_VERSION_URL = "https://uptime.kuma.pet/version";
+
+let interval;
+
+exports.startInterval = () => {
+ let check = async () => {
+ if (await setting("checkUpdate") === false) {
+ return;
+ }
+
+ log.debug("update-checker", "Retrieving latest versions");
+
+ try {
+ const res = await axios.get(UPDATE_CHECKER_LATEST_VERSION_URL);
+
+ // For debug
+ if (process.env.TEST_CHECK_VERSION === "1") {
+ res.data.slow = "1000.0.0";
+ }
+
+ let checkBeta = await setting("checkBeta");
+
+ if (checkBeta && res.data.beta) {
+ if (compareVersions.compare(res.data.beta, res.data.slow, ">")) {
+ exports.latestVersion = res.data.beta;
+ return;
+ }
+ }
+
+ if (res.data.slow) {
+ exports.latestVersion = res.data.slow;
+ }
+
+ } catch (_) {
+ log.info("update-checker", "Failed to check for new versions");
+ }
+
+ };
+
+ check();
+ interval = setInterval(check, UPDATE_CHECKER_INTERVAL_MS);
+};
+
+/**
+ * Enable the check update feature
+ * @param {boolean} value Should the check update feature be enabled?
+ * @returns {Promise<void>}
+ */
+exports.enableCheckUpdate = async (value) => {
+ await setSetting("checkUpdate", value);
+
+ clearInterval(interval);
+
+ if (value) {
+ exports.startInterval();
+ }
+};
+
+exports.socket = null;
diff --git a/server/client.js b/server/client.js
new file mode 100644
index 0000000..72f0a4e
--- /dev/null
+++ b/server/client.js
@@ -0,0 +1,252 @@
+/*
+ * For Client Socket
+ */
+const { TimeLogger } = require("../src/util");
+const { R } = require("redbean-node");
+const { UptimeKumaServer } = require("./uptime-kuma-server");
+const server = UptimeKumaServer.getInstance();
+const io = server.io;
+const { setting } = require("./util-server");
+const checkVersion = require("./check-version");
+const Database = require("./database");
+
+/**
+ * Send list of notification providers to client
+ * @param {Socket} socket Socket.io socket instance
+ * @returns {Promise<Bean[]>} List of notifications
+ */
+async function sendNotificationList(socket) {
+ const timeLogger = new TimeLogger();
+
+ let result = [];
+ let list = await R.find("notification", " user_id = ? ", [
+ socket.userID,
+ ]);
+
+ for (let bean of list) {
+ let notificationObject = bean.export();
+ notificationObject.isDefault = (notificationObject.isDefault === 1);
+ notificationObject.active = (notificationObject.active === 1);
+ result.push(notificationObject);
+ }
+
+ io.to(socket.userID).emit("notificationList", result);
+
+ timeLogger.print("Send Notification List");
+
+ return list;
+}
+
+/**
+ * Send Heartbeat History list to socket
+ * @param {Socket} socket Socket.io instance
+ * @param {number} monitorID ID of monitor to send heartbeat history
+ * @param {boolean} toUser True = send to all browsers with the same user id, False = send to the current browser only
+ * @param {boolean} overwrite Overwrite client-side's heartbeat list
+ * @returns {Promise<void>}
+ */
+async function sendHeartbeatList(socket, monitorID, toUser = false, overwrite = false) {
+ let list = await R.getAll(`
+ SELECT * FROM heartbeat
+ WHERE monitor_id = ?
+ ORDER BY time DESC
+ LIMIT 100
+ `, [
+ monitorID,
+ ]);
+
+ let result = list.reverse();
+
+ if (toUser) {
+ io.to(socket.userID).emit("heartbeatList", monitorID, result, overwrite);
+ } else {
+ socket.emit("heartbeatList", monitorID, result, overwrite);
+ }
+}
+
+/**
+ * Important Heart beat list (aka event list)
+ * @param {Socket} socket Socket.io instance
+ * @param {number} monitorID ID of monitor to send heartbeat history
+ * @param {boolean} toUser True = send to all browsers with the same user id, False = send to the current browser only
+ * @param {boolean} overwrite Overwrite client-side's heartbeat list
+ * @returns {Promise<void>}
+ */
+async function sendImportantHeartbeatList(socket, monitorID, toUser = false, overwrite = false) {
+ const timeLogger = new TimeLogger();
+
+ let list = await R.find("heartbeat", `
+ monitor_id = ?
+ AND important = 1
+ ORDER BY time DESC
+ LIMIT 500
+ `, [
+ monitorID,
+ ]);
+
+ timeLogger.print(`[Monitor: ${monitorID}] sendImportantHeartbeatList`);
+
+ if (toUser) {
+ io.to(socket.userID).emit("importantHeartbeatList", monitorID, list, overwrite);
+ } else {
+ socket.emit("importantHeartbeatList", monitorID, list, overwrite);
+ }
+
+}
+
+/**
+ * Emit proxy list to client
+ * @param {Socket} socket Socket.io socket instance
+ * @returns {Promise<Bean[]>} List of proxies
+ */
+async function sendProxyList(socket) {
+ const timeLogger = new TimeLogger();
+
+ const list = await R.find("proxy", " user_id = ? ", [ socket.userID ]);
+ io.to(socket.userID).emit("proxyList", list.map(bean => bean.export()));
+
+ timeLogger.print("Send Proxy List");
+
+ return list;
+}
+
+/**
+ * Emit API key list to client
+ * @param {Socket} socket Socket.io socket instance
+ * @returns {Promise<void>}
+ */
+async function sendAPIKeyList(socket) {
+ const timeLogger = new TimeLogger();
+
+ let result = [];
+ const list = await R.find(
+ "api_key",
+ "user_id=?",
+ [ socket.userID ],
+ );
+
+ for (let bean of list) {
+ result.push(bean.toPublicJSON());
+ }
+
+ io.to(socket.userID).emit("apiKeyList", result);
+ timeLogger.print("Sent API Key List");
+
+ return list;
+}
+
+/**
+ * Emits the version information to the client.
+ * @param {Socket} socket Socket.io socket instance
+ * @param {boolean} hideVersion Should we hide the version information in the response?
+ * @returns {Promise<void>}
+ */
+async function sendInfo(socket, hideVersion = false) {
+ let version;
+ let latestVersion;
+ let isContainer;
+ let dbType;
+
+ if (!hideVersion) {
+ version = checkVersion.version;
+ latestVersion = checkVersion.latestVersion;
+ isContainer = (process.env.UPTIME_KUMA_IS_CONTAINER === "1");
+ dbType = Database.dbConfig.type;
+ }
+
+ socket.emit("info", {
+ version,
+ latestVersion,
+ isContainer,
+ dbType,
+ primaryBaseURL: await setting("primaryBaseURL"),
+ serverTimezone: await server.getTimezone(),
+ serverTimezoneOffset: server.getTimezoneOffset(),
+ });
+}
+
+/**
+ * Send list of docker hosts to client
+ * @param {Socket} socket Socket.io socket instance
+ * @returns {Promise<Bean[]>} List of docker hosts
+ */
+async function sendDockerHostList(socket) {
+ const timeLogger = new TimeLogger();
+
+ let result = [];
+ let list = await R.find("docker_host", " user_id = ? ", [
+ socket.userID,
+ ]);
+
+ for (let bean of list) {
+ result.push(bean.toJSON());
+ }
+
+ io.to(socket.userID).emit("dockerHostList", result);
+
+ timeLogger.print("Send Docker Host List");
+
+ return list;
+}
+
+/**
+ * Send list of docker hosts to client
+ * @param {Socket} socket Socket.io socket instance
+ * @returns {Promise<Bean[]>} List of docker hosts
+ */
+async function sendRemoteBrowserList(socket) {
+ const timeLogger = new TimeLogger();
+
+ let result = [];
+ let list = await R.find("remote_browser", " user_id = ? ", [
+ socket.userID,
+ ]);
+
+ for (let bean of list) {
+ result.push(bean.toJSON());
+ }
+
+ io.to(socket.userID).emit("remoteBrowserList", result);
+
+ timeLogger.print("Send Remote Browser List");
+
+ return list;
+}
+
+/**
+ * Send list of monitor types to client
+ * @param {Socket} socket Socket.io socket instance
+ * @returns {Promise<void>}
+ */
+async function sendMonitorTypeList(socket) {
+ const result = Object.entries(UptimeKumaServer.monitorTypeList).map(([ key, type ]) => {
+ return [ key, {
+ supportsConditions: type.supportsConditions,
+ conditionVariables: type.conditionVariables.map(v => {
+ return {
+ id: v.id,
+ operators: v.operators.map(o => {
+ return {
+ id: o.id,
+ caption: o.caption,
+ };
+ }),
+ };
+ }),
+ }];
+ });
+
+ io.to(socket.userID).emit("monitorTypeList", Object.fromEntries(result));
+}
+
+module.exports = {
+ sendNotificationList,
+ sendImportantHeartbeatList,
+ sendHeartbeatList,
+ sendProxyList,
+ sendAPIKeyList,
+ sendInfo,
+ sendDockerHostList,
+ sendRemoteBrowserList,
+ sendMonitorTypeList,
+};
diff --git a/server/config.js b/server/config.js
new file mode 100644
index 0000000..515b904
--- /dev/null
+++ b/server/config.js
@@ -0,0 +1,46 @@
+const isFreeBSD = /^freebsd/.test(process.platform);
+
+// Interop with browser
+const args = (typeof process !== "undefined") ? require("args-parser")(process.argv) : {};
+
+// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise.
+// Dual-stack support for (::)
+// Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD
+let hostEnv = isFreeBSD ? null : process.env.HOST;
+const hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv;
+
+const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ]
+ .map(portValue => parseInt(portValue))
+ .find(portValue => !isNaN(portValue));
+
+const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined;
+const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined;
+const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined;
+
+const isSSL = sslKey && sslCert;
+
+/**
+ * Get the local WebSocket URL
+ * @returns {string} The local WebSocket URL
+ */
+function getLocalWebSocketURL() {
+ const protocol = isSSL ? "wss" : "ws";
+ const host = hostname || "localhost";
+ return `${protocol}://${host}:${port}`;
+}
+
+const localWebSocketURL = getLocalWebSocketURL();
+
+const demoMode = args["demo"] || false;
+
+module.exports = {
+ args,
+ hostname,
+ port,
+ sslKey,
+ sslCert,
+ sslKeyPassphrase,
+ isSSL,
+ localWebSocketURL,
+ demoMode,
+};
diff --git a/server/database.js b/server/database.js
new file mode 100644
index 0000000..3927d6d
--- /dev/null
+++ b/server/database.js
@@ -0,0 +1,912 @@
+const fs = require("fs");
+const { R } = require("redbean-node");
+const { setSetting, setting } = require("./util-server");
+const { log, sleep } = require("../src/util");
+const knex = require("knex");
+const path = require("path");
+const { EmbeddedMariaDB } = require("./embedded-mariadb");
+const mysql = require("mysql2/promise");
+const { Settings } = require("./settings");
+const { UptimeCalculator } = require("./uptime-calculator");
+const dayjs = require("dayjs");
+const { SimpleMigrationServer } = require("./utils/simple-migration-server");
+const KumaColumnCompiler = require("./utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler");
+
+/**
+ * Database & App Data Folder
+ */
+class Database {
+
+ /**
+ * Boostrap database for SQLite
+ * @type {string}
+ */
+ static templatePath = "./db/kuma.db";
+
+ /**
+ * Data Dir (Default: ./data)
+ * @type {string}
+ */
+ static dataDir;
+
+ /**
+ * User Upload Dir (Default: ./data/upload)
+ * @type {string}
+ */
+ static uploadDir;
+
+ /**
+ * Chrome Screenshot Dir (Default: ./data/screenshots)
+ * @type {string}
+ */
+ static screenshotDir;
+
+ /**
+ * SQLite file path (Default: ./data/kuma.db)
+ * @type {string}
+ */
+ static sqlitePath;
+
+ /**
+ * For storing Docker TLS certs (Default: ./data/docker-tls)
+ * @type {string}
+ */
+ static dockerTLSDir;
+
+ /**
+ * @type {boolean}
+ */
+ static patched = false;
+
+ /**
+ * SQLite only
+ * Add patch filename in key
+ * Values:
+ * true: Add it regardless of order
+ * false: Do nothing
+ * { parents: []}: Need parents before add it
+ * @deprecated
+ */
+ static patchList = {
+ "patch-setting-value-type.sql": true,
+ "patch-improve-performance.sql": true,
+ "patch-2fa.sql": true,
+ "patch-add-retry-interval-monitor.sql": true,
+ "patch-incident-table.sql": true,
+ "patch-group-table.sql": true,
+ "patch-monitor-push_token.sql": true,
+ "patch-http-monitor-method-body-and-headers.sql": true,
+ "patch-2fa-invalidate-used-token.sql": true,
+ "patch-notification_sent_history.sql": true,
+ "patch-monitor-basic-auth.sql": true,
+ "patch-add-docker-columns.sql": true,
+ "patch-status-page.sql": true,
+ "patch-proxy.sql": true,
+ "patch-monitor-expiry-notification.sql": true,
+ "patch-status-page-footer-css.sql": true,
+ "patch-added-mqtt-monitor.sql": true,
+ "patch-add-clickable-status-page-link.sql": true,
+ "patch-add-sqlserver-monitor.sql": true,
+ "patch-add-other-auth.sql": { parents: [ "patch-monitor-basic-auth.sql" ] },
+ "patch-grpc-monitor.sql": true,
+ "patch-add-radius-monitor.sql": true,
+ "patch-monitor-add-resend-interval.sql": true,
+ "patch-ping-packet-size.sql": true,
+ "patch-maintenance-table2.sql": true,
+ "patch-add-gamedig-monitor.sql": true,
+ "patch-add-google-analytics-status-page-tag.sql": true,
+ "patch-http-body-encoding.sql": true,
+ "patch-add-description-monitor.sql": true,
+ "patch-api-key-table.sql": true,
+ "patch-monitor-tls.sql": true,
+ "patch-maintenance-cron.sql": true,
+ "patch-add-parent-monitor.sql": true,
+ "patch-add-invert-keyword.sql": true,
+ "patch-added-json-query.sql": true,
+ "patch-added-kafka-producer.sql": true,
+ "patch-add-certificate-expiry-status-page.sql": true,
+ "patch-monitor-oauth-cc.sql": true,
+ "patch-add-timeout-monitor.sql": true,
+ "patch-add-gamedig-given-port.sql": true,
+ "patch-notification-config.sql": true,
+ "patch-fix-kafka-producer-booleans.sql": true,
+ "patch-timeout.sql": true,
+ "patch-monitor-tls-info-add-fk.sql": true, // The last file so far converted to a knex migration file
+ };
+
+ /**
+ * The final version should be 10 after merged tag feature
+ * @deprecated Use patchList for any new feature
+ */
+ static latestVersion = 10;
+
+ static noReject = true;
+
+ static dbConfig = {};
+
+ static knexMigrationsPath = "./db/knex_migrations";
+
+ /**
+ * Initialize the data directory
+ * @param {object} args Arguments to initialize DB with
+ * @returns {void}
+ */
+ static initDataDir(args) {
+ // Data Directory (must be end with "/")
+ Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
+
+ Database.sqlitePath = path.join(Database.dataDir, "kuma.db");
+ if (! fs.existsSync(Database.dataDir)) {
+ fs.mkdirSync(Database.dataDir, { recursive: true });
+ }
+
+ Database.uploadDir = path.join(Database.dataDir, "upload/");
+
+ if (! fs.existsSync(Database.uploadDir)) {
+ fs.mkdirSync(Database.uploadDir, { recursive: true });
+ }
+
+ // Create screenshot dir
+ Database.screenshotDir = path.join(Database.dataDir, "screenshots/");
+ if (! fs.existsSync(Database.screenshotDir)) {
+ fs.mkdirSync(Database.screenshotDir, { recursive: true });
+ }
+
+ Database.dockerTLSDir = path.join(Database.dataDir, "docker-tls/");
+ if (! fs.existsSync(Database.dockerTLSDir)) {
+ fs.mkdirSync(Database.dockerTLSDir, { recursive: true });
+ }
+
+ log.info("server", `Data Dir: ${Database.dataDir}`);
+ }
+
+ /**
+ * Read the database config
+ * @throws {Error} If the config is invalid
+ * @typedef {string|undefined} envString
+ * @returns {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} Database config
+ */
+ static readDBConfig() {
+ let dbConfig;
+
+ let dbConfigString = fs.readFileSync(path.join(Database.dataDir, "db-config.json")).toString("utf-8");
+ dbConfig = JSON.parse(dbConfigString);
+
+ if (typeof dbConfig !== "object") {
+ throw new Error("Invalid db-config.json, it must be an object");
+ }
+
+ if (typeof dbConfig.type !== "string") {
+ throw new Error("Invalid db-config.json, type must be a string");
+ }
+ return dbConfig;
+ }
+
+ /**
+ * @typedef {string|undefined} envString
+ * @param {{type: "sqlite"} | {type:envString, hostname:envString, port:envString, database:envString, username:envString, password:envString}} dbConfig the database configuration that should be written
+ * @returns {void}
+ */
+ static writeDBConfig(dbConfig) {
+ fs.writeFileSync(path.join(Database.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4));
+ }
+
+ /**
+ * Connect to the database
+ * @param {boolean} testMode Should the connection be started in test mode?
+ * @param {boolean} autoloadModels Should models be automatically loaded?
+ * @param {boolean} noLog Should logs not be output?
+ * @returns {Promise<void>}
+ */
+ static async connect(testMode = false, autoloadModels = true, noLog = false) {
+ // Patch "mysql2" knex client
+ // Workaround: Tried extending the ColumnCompiler class, but it didn't work for unknown reasons, so I override the function via prototype
+ const { getDialectByNameOrAlias } = require("knex/lib/dialects");
+ const mysql2 = getDialectByNameOrAlias("mysql2");
+ mysql2.prototype.columnCompiler = function () {
+ return new KumaColumnCompiler(this, ...arguments);
+ };
+
+ const acquireConnectionTimeout = 120 * 1000;
+ let dbConfig;
+ try {
+ dbConfig = this.readDBConfig();
+ Database.dbConfig = dbConfig;
+ } catch (err) {
+ log.warn("db", err.message);
+ dbConfig = {
+ type: "sqlite",
+ };
+ }
+
+ let config = {};
+
+ let mariadbPoolConfig = {
+ min: 0,
+ max: 10,
+ idleTimeoutMillis: 30000,
+ };
+
+ log.info("db", `Database Type: ${dbConfig.type}`);
+
+ if (dbConfig.type === "sqlite") {
+
+ if (! fs.existsSync(Database.sqlitePath)) {
+ log.info("server", "Copying Database");
+ fs.copyFileSync(Database.templatePath, Database.sqlitePath);
+ }
+
+ const Dialect = require("knex/lib/dialects/sqlite3/index.js");
+ Dialect.prototype._driver = () => require("@louislam/sqlite3");
+
+ config = {
+ client: Dialect,
+ connection: {
+ filename: Database.sqlitePath,
+ acquireConnectionTimeout: acquireConnectionTimeout,
+ },
+ useNullAsDefault: true,
+ pool: {
+ min: 1,
+ max: 1,
+ idleTimeoutMillis: 120 * 1000,
+ propagateCreateError: false,
+ acquireTimeoutMillis: acquireConnectionTimeout,
+ }
+ };
+ } else if (dbConfig.type === "mariadb") {
+ if (!/^\w+$/.test(dbConfig.dbName)) {
+ throw Error("Invalid database name. A database name can only consist of letters, numbers and underscores");
+ }
+
+ const connection = await mysql.createConnection({
+ host: dbConfig.hostname,
+ port: dbConfig.port,
+ user: dbConfig.username,
+ password: dbConfig.password,
+ });
+
+ await connection.execute("CREATE DATABASE IF NOT EXISTS " + dbConfig.dbName + " CHARACTER SET utf8mb4");
+ connection.end();
+
+ config = {
+ client: "mysql2",
+ connection: {
+ host: dbConfig.hostname,
+ port: dbConfig.port,
+ user: dbConfig.username,
+ password: dbConfig.password,
+ database: dbConfig.dbName,
+ timezone: "Z",
+ typeCast: function (field, next) {
+ if (field.type === "DATETIME") {
+ // Do not perform timezone conversion
+ return field.string();
+ }
+ return next();
+ },
+ },
+ pool: mariadbPoolConfig,
+ };
+ } else if (dbConfig.type === "embedded-mariadb") {
+ let embeddedMariaDB = EmbeddedMariaDB.getInstance();
+ await embeddedMariaDB.start();
+ log.info("mariadb", "Embedded MariaDB started");
+ config = {
+ client: "mysql2",
+ connection: {
+ socketPath: embeddedMariaDB.socketPath,
+ user: "node",
+ database: "kuma",
+ timezone: "Z",
+ typeCast: function (field, next) {
+ if (field.type === "DATETIME") {
+ // Do not perform timezone conversion
+ return field.string();
+ }
+ return next();
+ },
+ },
+ pool: mariadbPoolConfig,
+ };
+ } else {
+ throw new Error("Unknown Database type: " + dbConfig.type);
+ }
+
+ // Set to utf8mb4 for MariaDB
+ if (dbConfig.type.endsWith("mariadb")) {
+ config.pool = {
+ afterCreate(conn, done) {
+ conn.query("SET CHARACTER SET utf8mb4;", (err) => done(err, conn));
+ },
+ };
+ }
+
+ const knexInstance = knex(config);
+
+ R.setup(knexInstance);
+
+ if (process.env.SQL_LOG === "1") {
+ R.debug(true);
+ }
+
+ // Auto map the model to a bean object
+ R.freeze(true);
+
+ if (autoloadModels) {
+ await R.autoloadModels("./server/model");
+ }
+
+ if (dbConfig.type === "sqlite") {
+ await this.initSQLite(testMode, noLog);
+ } else if (dbConfig.type.endsWith("mariadb")) {
+ await this.initMariaDB();
+ }
+ }
+
+ /**
+ @param {boolean} testMode Should the connection be started in test mode?
+ @param {boolean} noLog Should logs not be output?
+ @returns {Promise<void>}
+ */
+ static async initSQLite(testMode, noLog) {
+ await R.exec("PRAGMA foreign_keys = ON");
+ if (testMode) {
+ // Change to MEMORY
+ await R.exec("PRAGMA journal_mode = MEMORY");
+ } else {
+ // Change to WAL
+ await R.exec("PRAGMA journal_mode = WAL");
+ }
+ await R.exec("PRAGMA cache_size = -12000");
+ await R.exec("PRAGMA auto_vacuum = INCREMENTAL");
+
+ // This ensures that an operating system crash or power failure will not corrupt the database.
+ // FULL synchronous is very safe, but it is also slower.
+ // Read more: https://sqlite.org/pragma.html#pragma_synchronous
+ await R.exec("PRAGMA synchronous = NORMAL");
+
+ if (!noLog) {
+ log.debug("db", "SQLite config:");
+ log.debug("db", await R.getAll("PRAGMA journal_mode"));
+ log.debug("db", await R.getAll("PRAGMA cache_size"));
+ log.debug("db", "SQLite Version: " + await R.getCell("SELECT sqlite_version()"));
+ }
+ }
+
+ /**
+ * Initialize MariaDB
+ * @returns {Promise<void>}
+ */
+ static async initMariaDB() {
+ log.debug("db", "Checking if MariaDB database exists...");
+
+ let hasTable = await R.hasTable("docker_host");
+ if (!hasTable) {
+ const { createTables } = require("../db/knex_init_db");
+ await createTables();
+ } else {
+ log.debug("db", "MariaDB database already exists");
+ }
+ }
+
+ /**
+ * Patch the database
+ * @param {number} port Start the migration server for aggregate tables on this port if provided
+ * @param {string} hostname Start the migration server for aggregate tables on this hostname if provided
+ * @returns {Promise<void>}
+ */
+ static async patch(port = undefined, hostname = undefined) {
+ // Still need to keep this for old versions of Uptime Kuma
+ if (Database.dbConfig.type === "sqlite") {
+ await this.patchSqlite();
+ }
+
+ // Using knex migrations
+ // https://knexjs.org/guide/migrations.html
+ // https://gist.github.com/NigelEarle/70db130cc040cc2868555b29a0278261
+ try {
+ // Disable foreign key check for SQLite
+ // Known issue of knex: https://github.com/drizzle-team/drizzle-orm/issues/1813
+ if (Database.dbConfig.type === "sqlite") {
+ await R.exec("PRAGMA foreign_keys = OFF");
+ }
+
+ await R.knex.migrate.latest({
+ directory: Database.knexMigrationsPath,
+ });
+
+ // Enable foreign key check for SQLite
+ if (Database.dbConfig.type === "sqlite") {
+ await R.exec("PRAGMA foreign_keys = ON");
+ }
+
+ await this.migrateAggregateTable(port, hostname);
+
+ } catch (e) {
+ // Allow missing patch files for downgrade or testing pr.
+ if (e.message.includes("the following files are missing:")) {
+ log.warn("db", e.message);
+ log.warn("db", "Database migration failed, you may be downgrading Uptime Kuma.");
+ } else {
+ log.error("db", "Database migration failed");
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * TODO
+ * @returns {Promise<void>}
+ */
+ static async rollbackLatestPatch() {
+
+ }
+
+ /**
+ * Patch the database for SQLite
+ * @returns {Promise<void>}
+ * @deprecated
+ */
+ static async patchSqlite() {
+ let version = parseInt(await setting("database_version"));
+
+ if (! version) {
+ version = 0;
+ }
+
+ if (version !== this.latestVersion) {
+ log.info("db", "Your database version: " + version);
+ log.info("db", "Latest database version: " + this.latestVersion);
+ }
+
+ if (version === this.latestVersion) {
+ log.debug("db", "Database patch not needed");
+ } else if (version > this.latestVersion) {
+ log.warn("db", "Warning: Database version is newer than expected");
+ } else {
+ log.info("db", "Database patch is needed");
+
+ // Try catch anything here
+ try {
+ for (let i = version + 1; i <= this.latestVersion; i++) {
+ const sqlFile = `./db/old_migrations/patch${i}.sql`;
+ log.info("db", `Patching ${sqlFile}`);
+ await Database.importSQLFile(sqlFile);
+ log.info("db", `Patched ${sqlFile}`);
+ await setSetting("database_version", i);
+ }
+ } catch (ex) {
+ await Database.close();
+
+ log.error("db", ex);
+ log.error("db", "Start Uptime-Kuma failed due to issue patching the database");
+ log.error("db", "Please submit a bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues");
+
+ process.exit(1);
+ }
+ }
+
+ await this.patchSqlite2();
+ await this.migrateNewStatusPage();
+ }
+
+ /**
+ * Patch DB using new process
+ * Call it from patch() only
+ * @deprecated
+ * @private
+ * @returns {Promise<void>}
+ */
+ static async patchSqlite2() {
+ log.debug("db", "Database Patch 2.0 Process");
+ let databasePatchedFiles = await setting("databasePatchedFiles");
+
+ if (! databasePatchedFiles) {
+ databasePatchedFiles = {};
+ }
+
+ log.debug("db", "Patched files:");
+ log.debug("db", databasePatchedFiles);
+
+ try {
+ for (let sqlFilename in this.patchList) {
+ await this.patch2Recursion(sqlFilename, databasePatchedFiles);
+ }
+
+ if (this.patched) {
+ log.info("db", "Database Patched Successfully");
+ }
+
+ } catch (ex) {
+ await Database.close();
+
+ log.error("db", ex);
+ log.error("db", "Start Uptime-Kuma failed due to issue patching the database");
+ log.error("db", "Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues");
+
+ process.exit(1);
+ }
+
+ await setSetting("databasePatchedFiles", databasePatchedFiles);
+ }
+
+ /**
+ * SQlite only
+ * Migrate status page value in setting to "status_page" table
+ * @returns {Promise<void>}
+ */
+ static async migrateNewStatusPage() {
+
+ // Fix 1.13.0 empty slug bug
+ await R.exec("UPDATE status_page SET slug = 'empty-slug-recover' WHERE TRIM(slug) = ''");
+
+ let title = await setting("title");
+
+ if (title) {
+ console.log("Migrating Status Page");
+
+ let statusPageCheck = await R.findOne("status_page", " slug = 'default' ");
+
+ if (statusPageCheck !== null) {
+ console.log("Migrating Status Page - Skip, default slug record is already existing");
+ return;
+ }
+
+ let statusPage = R.dispense("status_page");
+ statusPage.slug = "default";
+ statusPage.title = title;
+ statusPage.description = await setting("description");
+ statusPage.icon = await setting("icon");
+ statusPage.theme = await setting("statusPageTheme");
+ statusPage.published = !!await setting("statusPagePublished");
+ statusPage.search_engine_index = !!await setting("searchEngineIndex");
+ statusPage.show_tags = !!await setting("statusPageTags");
+ statusPage.password = null;
+
+ if (!statusPage.title) {
+ statusPage.title = "My Status Page";
+ }
+
+ if (!statusPage.icon) {
+ statusPage.icon = "";
+ }
+
+ if (!statusPage.theme) {
+ statusPage.theme = "light";
+ }
+
+ let id = await R.store(statusPage);
+
+ await R.exec("UPDATE incident SET status_page_id = ? WHERE status_page_id IS NULL", [
+ id
+ ]);
+
+ await R.exec("UPDATE [group] SET status_page_id = ? WHERE status_page_id IS NULL", [
+ id
+ ]);
+
+ await R.exec("DELETE FROM setting WHERE type = 'statusPage'");
+
+ // Migrate Entry Page if it is status page
+ let entryPage = await setting("entryPage");
+
+ if (entryPage === "statusPage") {
+ await setSetting("entryPage", "statusPage-default", "general");
+ }
+
+ console.log("Migrating Status Page - Done");
+ }
+
+ }
+
+ /**
+ * Patch database using new patching process
+ * Used it patch2() only
+ * @private
+ * @param {string} sqlFilename Name of SQL file to load
+ * @param {object} databasePatchedFiles Patch status of database files
+ * @returns {Promise<void>}
+ */
+ static async patch2Recursion(sqlFilename, databasePatchedFiles) {
+ let value = this.patchList[sqlFilename];
+
+ if (! value) {
+ log.info("db", sqlFilename + " skip");
+ return;
+ }
+
+ // Check if patched
+ if (! databasePatchedFiles[sqlFilename]) {
+ log.info("db", sqlFilename + " is not patched");
+
+ if (value.parents) {
+ log.info("db", sqlFilename + " need parents");
+ for (let parentSQLFilename of value.parents) {
+ await this.patch2Recursion(parentSQLFilename, databasePatchedFiles);
+ }
+ }
+
+ log.info("db", sqlFilename + " is patching");
+ this.patched = true;
+ await this.importSQLFile("./db/old_migrations/" + sqlFilename);
+ databasePatchedFiles[sqlFilename] = true;
+ log.info("db", sqlFilename + " was patched successfully");
+
+ } else {
+ log.debug("db", sqlFilename + " is already patched, skip");
+ }
+ }
+
+ /**
+ * Load an SQL file and execute it
+ * @param {string} filename Filename of SQL file to import
+ * @returns {Promise<void>}
+ */
+ static async importSQLFile(filename) {
+ // Sadly, multi sql statements is not supported by many sqlite libraries, I have to implement it myself
+ await R.getCell("SELECT 1");
+
+ let text = fs.readFileSync(filename).toString();
+
+ // Remove all comments (--)
+ let lines = text.split("\n");
+ lines = lines.filter((line) => {
+ return ! line.startsWith("--");
+ });
+
+ // Split statements by semicolon
+ // Filter out empty line
+ text = lines.join("\n");
+
+ let statements = text.split(";")
+ .map((statement) => {
+ return statement.trim();
+ })
+ .filter((statement) => {
+ return statement !== "";
+ });
+
+ for (let statement of statements) {
+ await R.exec(statement);
+ }
+ }
+
+ /**
+ * Special handle, because tarn.js throw a promise reject that cannot be caught
+ * @returns {Promise<void>}
+ */
+ static async close() {
+ const listener = (reason, p) => {
+ Database.noReject = false;
+ };
+ process.addListener("unhandledRejection", listener);
+
+ log.info("db", "Closing the database");
+
+ // Flush WAL to main database
+ if (Database.dbConfig.type === "sqlite") {
+ await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
+ }
+
+ while (true) {
+ Database.noReject = true;
+ await R.close();
+ await sleep(2000);
+
+ if (Database.noReject) {
+ break;
+ } else {
+ log.info("db", "Waiting to close the database");
+ }
+ }
+ log.info("db", "Database closed");
+
+ process.removeListener("unhandledRejection", listener);
+ }
+
+ /**
+ * Get the size of the database (SQLite only)
+ * @returns {number} Size of database
+ */
+ static getSize() {
+ if (Database.dbConfig.type === "sqlite") {
+ log.debug("db", "Database.getSize()");
+ let stats = fs.statSync(Database.sqlitePath);
+ log.debug("db", stats);
+ return stats.size;
+ }
+ return 0;
+ }
+
+ /**
+ * Shrink the database
+ * @returns {Promise<void>}
+ */
+ static async shrink() {
+ if (Database.dbConfig.type === "sqlite") {
+ await R.exec("VACUUM");
+ }
+ }
+
+ /**
+ * @returns {string} Get the SQL for the current time plus a number of hours
+ */
+ static sqlHourOffset() {
+ if (Database.dbConfig.type === "sqlite") {
+ return "DATETIME('now', ? || ' hours')";
+ } else {
+ return "DATE_ADD(NOW(), INTERVAL ? HOUR)";
+ }
+ }
+
+ /**
+ * Migrate the old data in the heartbeat table to the new format (stat_daily, stat_hourly, stat_minutely)
+ * It should be run once while upgrading V1 to V2
+ *
+ * Normally, it should be in transaction, but UptimeCalculator wasn't designed to be in transaction before that.
+ * I don't want to heavily modify the UptimeCalculator, so it is not in transaction.
+ * Run `npm run reset-migrate-aggregate-table-state` to reset, in case the migration is interrupted.
+ * @param {number} port Start the migration server on this port if provided
+ * @param {string} hostname Start the migration server on this hostname if provided
+ * @returns {Promise<void>}
+ */
+ static async migrateAggregateTable(port, hostname = undefined) {
+ log.debug("db", "Enter Migrate Aggregate Table function");
+
+ // Add a setting for 2.0.0-dev users to skip this migration
+ if (process.env.SET_MIGRATE_AGGREGATE_TABLE_TO_TRUE === "1") {
+ log.warn("db", "SET_MIGRATE_AGGREGATE_TABLE_TO_TRUE is set to 1, skipping aggregate table migration forever (for 2.0.0-dev users)");
+ await Settings.set("migrateAggregateTableState", "migrated");
+ }
+
+ let migrateState = await Settings.get("migrateAggregateTableState");
+
+ // Skip if already migrated
+ // If it is migrating, it possibly means the migration was interrupted, or the migration is in progress
+ if (migrateState === "migrated") {
+ log.debug("db", "Migrated aggregate table already, skip");
+ return;
+ } else if (migrateState === "migrating") {
+ log.warn("db", "Aggregate table migration is already in progress, or it was interrupted");
+ throw new Error("Aggregate table migration is already in progress");
+ }
+
+ /**
+ * Start migration server for displaying the migration status
+ * @type {SimpleMigrationServer}
+ */
+ let migrationServer;
+ let msg;
+
+ if (port) {
+ migrationServer = new SimpleMigrationServer();
+ await migrationServer.start(port, hostname);
+ }
+
+ log.info("db", "Migrating Aggregate Table");
+
+ log.info("db", "Getting list of unique monitors");
+
+ // Get a list of unique monitors from the heartbeat table, using raw sql
+ let monitors = await R.getAll(`
+ SELECT DISTINCT monitor_id
+ FROM heartbeat
+ ORDER BY monitor_id ASC
+ `);
+
+ // Stop if stat_* tables are not empty
+ for (let table of [ "stat_minutely", "stat_hourly", "stat_daily" ]) {
+ let countResult = await R.getRow(`SELECT COUNT(*) AS count FROM ${table}`);
+ let count = countResult.count;
+ if (count > 0) {
+ log.warn("db", `Aggregate table ${table} is not empty, migration will not be started (Maybe you were using 2.0.0-dev?)`);
+ await migrationServer?.stop();
+ return;
+ }
+ }
+
+ await Settings.set("migrateAggregateTableState", "migrating");
+
+ let progressPercent = 0;
+ let part = 100 / monitors.length;
+ let i = 1;
+ for (let monitor of monitors) {
+ // Get a list of unique dates from the heartbeat table, using raw sql
+ let dates = await R.getAll(`
+ SELECT DISTINCT DATE(time) AS date
+ FROM heartbeat
+ WHERE monitor_id = ?
+ ORDER BY date ASC
+ `, [
+ monitor.monitor_id
+ ]);
+
+ for (let date of dates) {
+ // New Uptime Calculator
+ let calculator = new UptimeCalculator();
+ calculator.monitorID = monitor.monitor_id;
+ calculator.setMigrationMode(true);
+
+ // Get all the heartbeats for this monitor and date
+ let heartbeats = await R.getAll(`
+ SELECT status, ping, time
+ FROM heartbeat
+ WHERE monitor_id = ?
+ AND DATE(time) = ?
+ ORDER BY time ASC
+ `, [ monitor.monitor_id, date.date ]);
+
+ if (heartbeats.length > 0) {
+ msg = `[DON'T STOP] Migrating monitor data ${monitor.monitor_id} - ${date.date} [${progressPercent.toFixed(2)}%][${i}/${monitors.length}]`;
+ log.info("db", msg);
+ migrationServer?.update(msg);
+ }
+
+ for (let heartbeat of heartbeats) {
+ await calculator.update(heartbeat.status, parseFloat(heartbeat.ping), dayjs(heartbeat.time));
+ }
+
+ progressPercent += (Math.round(part / dates.length * 100) / 100);
+
+ // Lazy to fix the floating point issue, it is acceptable since it is just a progress bar
+ if (progressPercent > 100) {
+ progressPercent = 100;
+ }
+ }
+
+ i++;
+ }
+
+ msg = "Clearing non-important heartbeats";
+ log.info("db", msg);
+ migrationServer?.update(msg);
+
+ await Database.clearHeartbeatData(true);
+ await Settings.set("migrateAggregateTableState", "migrated");
+ await migrationServer?.stop();
+
+ if (monitors.length > 0) {
+ log.info("db", "Aggregate Table Migration Completed");
+ } else {
+ log.info("db", "No data to migrate");
+ }
+ }
+
+ /**
+ * Remove all non-important heartbeats from heartbeat table, keep last 24-hour or {KEEP_LAST_ROWS} rows for each monitor
+ * @param {boolean} detailedLog Log detailed information
+ * @returns {Promise<void>}
+ */
+ static async clearHeartbeatData(detailedLog = false) {
+ let monitors = await R.getAll("SELECT id FROM monitor");
+ const sqlHourOffset = Database.sqlHourOffset();
+
+ for (let monitor of monitors) {
+ if (detailedLog) {
+ log.info("db", "Deleting non-important heartbeats for monitor " + monitor.id);
+ }
+ await R.exec(`
+ DELETE FROM heartbeat
+ WHERE monitor_id = ?
+ AND important = 0
+ AND time < ${sqlHourOffset}
+ AND id NOT IN (
+ SELECT id
+ FROM heartbeat
+ WHERE monitor_id = ?
+ ORDER BY time DESC
+ LIMIT ?
+ )
+ `, [
+ monitor.id,
+ -24,
+ monitor.id,
+ 100,
+ ]);
+ }
+ }
+
+}
+
+module.exports = Database;
diff --git a/server/docker.js b/server/docker.js
new file mode 100644
index 0000000..ee6051d
--- /dev/null
+++ b/server/docker.js
@@ -0,0 +1,179 @@
+const axios = require("axios");
+const { R } = require("redbean-node");
+const https = require("https");
+const fs = require("fs");
+const path = require("path");
+const Database = require("./database");
+const { axiosAbortSignal } = require("./util-server");
+
+class DockerHost {
+
+ static CertificateFileNameCA = "ca.pem";
+ static CertificateFileNameCert = "cert.pem";
+ static CertificateFileNameKey = "key.pem";
+
+ /**
+ * Save a docker host
+ * @param {object} dockerHost Docker host to save
+ * @param {?number} dockerHostID ID of the docker host to update
+ * @param {number} userID ID of the user who adds the docker host
+ * @returns {Promise<Bean>} Updated docker host
+ */
+ static async save(dockerHost, dockerHostID, userID) {
+ let bean;
+
+ if (dockerHostID) {
+ bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]);
+
+ if (!bean) {
+ throw new Error("docker host not found");
+ }
+
+ } else {
+ bean = R.dispense("docker_host");
+ }
+
+ bean.user_id = userID;
+ bean.docker_daemon = dockerHost.dockerDaemon;
+ bean.docker_type = dockerHost.dockerType;
+ bean.name = dockerHost.name;
+
+ await R.store(bean);
+
+ return bean;
+ }
+
+ /**
+ * Delete a Docker host
+ * @param {number} dockerHostID ID of the Docker host to delete
+ * @param {number} userID ID of the user who created the Docker host
+ * @returns {Promise<void>}
+ */
+ static async delete(dockerHostID, userID) {
+ let bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]);
+
+ if (!bean) {
+ throw new Error("docker host not found");
+ }
+
+ // Delete removed proxy from monitors if exists
+ await R.exec("UPDATE monitor SET docker_host = null WHERE docker_host = ?", [ dockerHostID ]);
+
+ await R.trash(bean);
+ }
+
+ /**
+ * Fetches the amount of containers on the Docker host
+ * @param {object} dockerHost Docker host to check for
+ * @returns {Promise<number>} Total amount of containers on the host
+ */
+ static async testDockerHost(dockerHost) {
+ const options = {
+ url: "/containers/json?all=true",
+ timeout: 5000,
+ headers: {
+ "Accept": "*/*",
+ },
+ signal: axiosAbortSignal(6000),
+ };
+
+ if (dockerHost.dockerType === "socket") {
+ options.socketPath = dockerHost.dockerDaemon;
+ } else if (dockerHost.dockerType === "tcp") {
+ options.baseURL = DockerHost.patchDockerURL(dockerHost.dockerDaemon);
+ options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
+ }
+
+ try {
+ let res = await axios.request(options);
+
+ if (Array.isArray(res.data)) {
+
+ if (res.data.length > 1) {
+
+ if ("ImageID" in res.data[0]) {
+ return res.data.length;
+ } else {
+ throw new Error("Invalid Docker response, is it Docker really a daemon?");
+ }
+
+ } else {
+ return res.data.length;
+ }
+
+ } else {
+ throw new Error("Invalid Docker response, is it Docker really a daemon?");
+ }
+ } catch (e) {
+ if (e.code === "ECONNABORTED" || e.name === "CanceledError") {
+ throw new Error("Connection to Docker daemon timed out.");
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Since axios 0.27.X, it does not accept `tcp://` protocol.
+ * Change it to `http://` on the fly in order to fix it. (https://github.com/louislam/uptime-kuma/issues/2165)
+ * @param {any} url URL to fix
+ * @returns {any} URL with tcp:// replaced by http://
+ */
+ static patchDockerURL(url) {
+ if (typeof url === "string") {
+ // Replace the first occurrence only with g
+ return url.replace(/tcp:\/\//g, "http://");
+ }
+ return url;
+ }
+
+ /**
+ * Returns HTTPS agent options with client side TLS parameters if certificate files
+ * for the given host are available under a predefined directory path.
+ *
+ * The base path where certificates are looked for can be set with the
+ * 'DOCKER_TLS_DIR_PATH' environmental variable or defaults to 'data/docker-tls/'.
+ *
+ * If a directory in this path exists with a name matching the FQDN of the docker host
+ * (e.g. the FQDN of 'https://example.com:2376' is 'example.com' so the directory
+ * 'data/docker-tls/example.com/' would be searched for certificate files),
+ * then 'ca.pem', 'key.pem' and 'cert.pem' files are included in the agent options.
+ * File names can also be overridden via 'DOCKER_TLS_FILE_NAME_(CA|KEY|CERT)'.
+ * @param {string} dockerType i.e. "tcp" or "socket"
+ * @param {string} url The docker host URL rewritten to https://
+ * @returns {object} HTTP agent options
+ */
+ static getHttpsAgentOptions(dockerType, url) {
+ let baseOptions = {
+ maxCachedSessions: 0,
+ rejectUnauthorized: true
+ };
+ let certOptions = {};
+
+ let dirName = (new URL(url)).hostname;
+
+ let caPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCA);
+ let certPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCert);
+ let keyPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameKey);
+
+ if (dockerType === "tcp" && fs.existsSync(caPath) && fs.existsSync(certPath) && fs.existsSync(keyPath)) {
+ let ca = fs.readFileSync(caPath);
+ let key = fs.readFileSync(keyPath);
+ let cert = fs.readFileSync(certPath);
+ certOptions = {
+ ca,
+ key,
+ cert
+ };
+ }
+
+ return {
+ ...baseOptions,
+ ...certOptions
+ };
+ }
+}
+
+module.exports = {
+ DockerHost,
+};
diff --git a/server/embedded-mariadb.js b/server/embedded-mariadb.js
new file mode 100644
index 0000000..8aa7134
--- /dev/null
+++ b/server/embedded-mariadb.js
@@ -0,0 +1,176 @@
+const { log } = require("../src/util");
+const childProcess = require("child_process");
+const fs = require("fs");
+const mysql = require("mysql2");
+
+/**
+ * It is only used inside the docker container
+ */
+class EmbeddedMariaDB {
+
+ static instance = null;
+
+ exec = "mariadbd";
+
+ mariadbDataDir = "/app/data/mariadb";
+
+ runDir = "/app/data/run/mariadb";
+
+ socketPath = this.runDir + "/mysqld.sock";
+
+ /**
+ * @type {ChildProcessWithoutNullStreams}
+ * @private
+ */
+ childProcess = null;
+ running = false;
+
+ started = false;
+
+ /**
+ * @returns {EmbeddedMariaDB} The singleton instance
+ */
+ static getInstance() {
+ if (!EmbeddedMariaDB.instance) {
+ EmbeddedMariaDB.instance = new EmbeddedMariaDB();
+ }
+ return EmbeddedMariaDB.instance;
+ }
+
+ /**
+ * @returns {boolean} If the singleton instance is created
+ */
+ static hasInstance() {
+ return !!EmbeddedMariaDB.instance;
+ }
+
+ /**
+ * Start the embedded MariaDB
+ * @returns {Promise<void>|void} A promise that resolves when the MariaDB is started or void if it is already started
+ */
+ start() {
+ if (this.childProcess) {
+ log.info("mariadb", "Already started");
+ return;
+ }
+
+ this.initDB();
+
+ this.running = true;
+ log.info("mariadb", "Starting Embedded MariaDB");
+ this.childProcess = childProcess.spawn(this.exec, [
+ "--user=node",
+ "--datadir=" + this.mariadbDataDir,
+ `--socket=${this.socketPath}`,
+ `--pid-file=${this.runDir}/mysqld.pid`,
+ ]);
+
+ this.childProcess.on("close", (code) => {
+ this.running = false;
+ this.childProcess = null;
+ this.started = false;
+ log.info("mariadb", "Stopped Embedded MariaDB: " + code);
+
+ if (code !== 0) {
+ log.info("mariadb", "Try to restart Embedded MariaDB as it is not stopped by user");
+ this.start();
+ }
+ });
+
+ this.childProcess.on("error", (err) => {
+ if (err.code === "ENOENT") {
+ log.error("mariadb", `Embedded MariaDB: ${this.exec} is not found`);
+ } else {
+ log.error("mariadb", err);
+ }
+ });
+
+ let handler = (data) => {
+ log.debug("mariadb", data.toString("utf-8"));
+ if (data.toString("utf-8").includes("ready for connections")) {
+ this.initDBAfterStarted();
+ }
+ };
+
+ this.childProcess.stdout.on("data", handler);
+ this.childProcess.stderr.on("data", handler);
+
+ return new Promise((resolve) => {
+ let interval = setInterval(() => {
+ if (this.started) {
+ clearInterval(interval);
+ resolve();
+ } else {
+ log.info("mariadb", "Waiting for Embedded MariaDB to start...");
+ }
+ }, 1000);
+ });
+ }
+
+ /**
+ * Stop all the child processes
+ * @returns {void}
+ */
+ stop() {
+ if (this.childProcess) {
+ this.childProcess.kill("SIGINT");
+ this.childProcess = null;
+ }
+ }
+
+ /**
+ * Install MariaDB if it is not installed and make sure the `runDir` directory exists
+ * @returns {void}
+ */
+ initDB() {
+ if (!fs.existsSync(this.mariadbDataDir)) {
+ log.info("mariadb", `Embedded MariaDB: ${this.mariadbDataDir} is not found, create one now.`);
+ fs.mkdirSync(this.mariadbDataDir, {
+ recursive: true,
+ });
+
+ let result = childProcess.spawnSync("mysql_install_db", [
+ "--user=node",
+ "--ldata=" + this.mariadbDataDir,
+ ]);
+
+ if (result.status !== 0) {
+ let error = result.stderr.toString("utf-8");
+ log.error("mariadb", error);
+ return;
+ } else {
+ log.info("mariadb", "Embedded MariaDB: mysql_install_db done:" + result.stdout.toString("utf-8"));
+ }
+ }
+
+ if (!fs.existsSync(this.runDir)) {
+ log.info("mariadb", `Embedded MariaDB: ${this.runDir} is not found, create one now.`);
+ fs.mkdirSync(this.runDir, {
+ recursive: true,
+ });
+ }
+
+ }
+
+ /**
+ * Initialise the "kuma" database in mariadb if it does not exist
+ * @returns {Promise<void>}
+ */
+ async initDBAfterStarted() {
+ const connection = mysql.createConnection({
+ socketPath: this.socketPath,
+ user: "node",
+ });
+
+ let result = await connection.execute("CREATE DATABASE IF NOT EXISTS `kuma`");
+ log.debug("mariadb", "CREATE DATABASE: " + JSON.stringify(result));
+
+ log.info("mariadb", "Embedded MariaDB is ready for connections");
+ this.started = true;
+ }
+
+}
+
+module.exports = {
+ EmbeddedMariaDB,
+};
diff --git a/server/google-analytics.js b/server/google-analytics.js
new file mode 100644
index 0000000..57ae7b7
--- /dev/null
+++ b/server/google-analytics.js
@@ -0,0 +1,28 @@
+const jsesc = require("jsesc");
+const { escape } = require("html-escaper");
+
+/**
+ * Returns a string that represents the javascript that is required to insert the Google Analytics scripts
+ * into a webpage.
+ * @param {string} tagId Google UA/G/AW/DC Property ID to use with the Google Analytics script.
+ * @returns {string} HTML script tags to inject into page
+ */
+function getGoogleAnalyticsScript(tagId) {
+ let escapedTagIdJS = jsesc(tagId, { isScriptContext: true });
+
+ if (escapedTagIdJS) {
+ escapedTagIdJS = escapedTagIdJS.trim();
+ }
+
+ // Escape the tag ID for use in an HTML attribute.
+ let escapedTagIdHTMLAttribute = escape(tagId);
+
+ return `
+ <script async src="https://www.googletagmanager.com/gtag/js?id=${escapedTagIdHTMLAttribute}"></script>
+ <script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date());gtag('config', '${escapedTagIdJS}'); </script>
+ `;
+}
+
+module.exports = {
+ getGoogleAnalyticsScript,
+};
diff --git a/server/image-data-uri.js b/server/image-data-uri.js
new file mode 100644
index 0000000..77dd233
--- /dev/null
+++ b/server/image-data-uri.js
@@ -0,0 +1,79 @@
+/*
+ From https://github.com/DiegoZoracKy/image-data-uri/blob/master/lib/image-data-uri.js
+ Modified with 0 dependencies
+ */
+let fs = require("fs");
+const { log } = require("../src/util");
+
+let ImageDataURI = (() => {
+
+ /**
+ * Decode the data:image/ URI
+ * @param {string} dataURI data:image/ URI to decode
+ * @returns {?object} An object with properties "imageType" and "dataBase64".
+ * The former is the image type, e.g., "png", and the latter is a base64
+ * encoded string of the image's binary data. If it fails to parse, returns
+ * null instead of an object.
+ */
+ function decode(dataURI) {
+ if (!/data:image\//.test(dataURI)) {
+ log.error("image-data-uri", "It seems that it is not an Image Data URI. Couldn't match \"data:image/\"");
+ return null;
+ }
+
+ let regExMatches = dataURI.match("data:(image/.*);base64,(.*)");
+ return {
+ imageType: regExMatches[1],
+ dataBase64: regExMatches[2],
+ dataBuffer: new Buffer(regExMatches[2], "base64")
+ };
+ }
+
+ /**
+ * Endcode an image into data:image/ URI
+ * @param {(Buffer|string)} data Data to encode
+ * @param {string} mediaType Media type of data
+ * @returns {(string|null)} A string representing the base64-encoded
+ * version of the given Buffer object or null if an error occurred.
+ */
+ function encode(data, mediaType) {
+ if (!data || !mediaType) {
+ log.error("image-data-uri", "Missing some of the required params: data, mediaType");
+ return null;
+ }
+
+ mediaType = (/\//.test(mediaType)) ? mediaType : "image/" + mediaType;
+ let dataBase64 = (Buffer.isBuffer(data)) ? data.toString("base64") : new Buffer(data).toString("base64");
+ let dataImgBase64 = "data:" + mediaType + ";base64," + dataBase64;
+
+ return dataImgBase64;
+ }
+
+ /**
+ * Write data URI to file
+ * @param {string} dataURI data:image/ URI
+ * @param {string} filePath Path to write file to
+ * @returns {Promise<string|void>} Write file error
+ */
+ function outputFile(dataURI, filePath) {
+ filePath = filePath || "./";
+ return new Promise((resolve, reject) => {
+ let imageDecoded = decode(dataURI);
+
+ fs.writeFile(filePath, imageDecoded.dataBuffer, err => {
+ if (err) {
+ return reject("ImageDataURI :: Error :: " + JSON.stringify(err, null, 4));
+ }
+ resolve(filePath);
+ });
+ });
+ }
+
+ return {
+ decode: decode,
+ encode: encode,
+ outputFile: outputFile,
+ };
+})();
+
+module.exports = ImageDataURI;
diff --git a/server/jobs.js b/server/jobs.js
new file mode 100644
index 0000000..0838731
--- /dev/null
+++ b/server/jobs.js
@@ -0,0 +1,58 @@
+const { UptimeKumaServer } = require("./uptime-kuma-server");
+const { clearOldData } = require("./jobs/clear-old-data");
+const { incrementalVacuum } = require("./jobs/incremental-vacuum");
+const Cron = require("croner");
+
+const jobs = [
+ {
+ name: "clear-old-data",
+ interval: "14 03 * * *",
+ jobFunc: clearOldData,
+ croner: null,
+ },
+ {
+ name: "incremental-vacuum",
+ interval: "*/5 * * * *",
+ jobFunc: incrementalVacuum,
+ croner: null,
+ }
+];
+
+/**
+ * Initialize background jobs
+ * @returns {Promise<void>}
+ */
+const initBackgroundJobs = async function () {
+ const timezone = await UptimeKumaServer.getInstance().getTimezone();
+
+ for (const job of jobs) {
+ const cornerJob = new Cron(
+ job.interval,
+ {
+ name: job.name,
+ timezone,
+ },
+ job.jobFunc,
+ );
+ job.croner = cornerJob;
+ }
+
+};
+
+/**
+ * Stop all background jobs if running
+ * @returns {void}
+ */
+const stopBackgroundJobs = function () {
+ for (const job of jobs) {
+ if (job.croner) {
+ job.croner.stop();
+ job.croner = null;
+ }
+ }
+};
+
+module.exports = {
+ initBackgroundJobs,
+ stopBackgroundJobs
+};
diff --git a/server/jobs/clear-old-data.js b/server/jobs/clear-old-data.js
new file mode 100644
index 0000000..cfd65a8
--- /dev/null
+++ b/server/jobs/clear-old-data.js
@@ -0,0 +1,65 @@
+const { R } = require("redbean-node");
+const { log } = require("../../src/util");
+const Database = require("../database");
+const { Settings } = require("../settings");
+const dayjs = require("dayjs");
+
+const DEFAULT_KEEP_PERIOD = 365;
+
+/**
+ * Clears old data from the heartbeat table and the stat_daily of the database.
+ * @returns {Promise<void>} A promise that resolves when the data has been cleared.
+ */
+const clearOldData = async () => {
+ await Database.clearHeartbeatData();
+ let period = await Settings.get("keepDataPeriodDays");
+
+ // Set Default Period
+ if (period == null) {
+ await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
+ period = DEFAULT_KEEP_PERIOD;
+ }
+
+ // Try parse setting
+ let parsedPeriod;
+ try {
+ parsedPeriod = parseInt(period);
+ } catch (_) {
+ log.warn("clearOldData", "Failed to parse setting, resetting to default..");
+ await Settings.set("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
+ parsedPeriod = DEFAULT_KEEP_PERIOD;
+ }
+
+ if (parsedPeriod < 1) {
+ log.info("clearOldData", `Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`);
+ } else {
+ log.debug("clearOldData", `Clearing Data older than ${parsedPeriod} days...`);
+ const sqlHourOffset = Database.sqlHourOffset();
+
+ try {
+ // Heartbeat
+ await R.exec("DELETE FROM heartbeat WHERE time < " + sqlHourOffset, [
+ parsedPeriod * -24,
+ ]);
+
+ let timestamp = dayjs().subtract(parsedPeriod, "day").utc().startOf("day").unix();
+
+ // stat_daily
+ await R.exec("DELETE FROM stat_daily WHERE timestamp < ? ", [
+ timestamp,
+ ]);
+
+ if (Database.dbConfig.type === "sqlite") {
+ await R.exec("PRAGMA optimize;");
+ }
+ } catch (e) {
+ log.error("clearOldData", `Failed to clear old data: ${e.message}`);
+ }
+ }
+
+ log.debug("clearOldData", "Data cleared.");
+};
+
+module.exports = {
+ clearOldData,
+};
diff --git a/server/jobs/incremental-vacuum.js b/server/jobs/incremental-vacuum.js
new file mode 100644
index 0000000..f0fa78a
--- /dev/null
+++ b/server/jobs/incremental-vacuum.js
@@ -0,0 +1,27 @@
+const { R } = require("redbean-node");
+const { log } = require("../../src/util");
+const Database = require("../database");
+
+/**
+ * Run incremental_vacuum and checkpoint the WAL.
+ * @returns {Promise<void>} A promise that resolves when the process is finished.
+ */
+
+const incrementalVacuum = async () => {
+ try {
+ if (Database.dbConfig.type !== "sqlite") {
+ log.debug("incrementalVacuum", "Skipping incremental_vacuum, not using SQLite.");
+ return;
+ }
+
+ log.debug("incrementalVacuum", "Running incremental_vacuum and wal_checkpoint(PASSIVE)...");
+ await R.exec("PRAGMA incremental_vacuum(200)");
+ await R.exec("PRAGMA wal_checkpoint(PASSIVE)");
+ } catch (e) {
+ log.error("incrementalVacuum", `Failed: ${e.message}`);
+ }
+};
+
+module.exports = {
+ incrementalVacuum,
+};
diff --git a/server/model/api_key.js b/server/model/api_key.js
new file mode 100644
index 0000000..96de984
--- /dev/null
+++ b/server/model/api_key.js
@@ -0,0 +1,76 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+const { R } = require("redbean-node");
+const dayjs = require("dayjs");
+
+class APIKey extends BeanModel {
+ /**
+ * Get the current status of this API key
+ * @returns {string} active, inactive or expired
+ */
+ getStatus() {
+ let current = dayjs();
+ let expiry = dayjs(this.expires);
+ if (expiry.diff(current) < 0) {
+ return "expired";
+ }
+
+ return this.active ? "active" : "inactive";
+ }
+
+ /**
+ * Returns an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ toJSON() {
+ return {
+ id: this.id,
+ key: this.key,
+ name: this.name,
+ userID: this.user_id,
+ createdDate: this.created_date,
+ active: this.active,
+ expires: this.expires,
+ status: this.getStatus(),
+ };
+ }
+
+ /**
+ * Returns an object that ready to parse to JSON with sensitive fields
+ * removed
+ * @returns {object} Object ready to parse
+ */
+ toPublicJSON() {
+ return {
+ id: this.id,
+ name: this.name,
+ userID: this.user_id,
+ createdDate: this.created_date,
+ active: this.active,
+ expires: this.expires,
+ status: this.getStatus(),
+ };
+ }
+
+ /**
+ * Create a new API Key and store it in the database
+ * @param {object} key Object sent by client
+ * @param {int} userID ID of socket user
+ * @returns {Promise<bean>} API key
+ */
+ static async save(key, userID) {
+ let bean;
+ bean = R.dispense("api_key");
+
+ bean.key = key.key;
+ bean.name = key.name;
+ bean.user_id = userID;
+ bean.active = key.active;
+ bean.expires = key.expires;
+
+ await R.store(bean);
+
+ return bean;
+ }
+}
+
+module.exports = APIKey;
diff --git a/server/model/docker_host.js b/server/model/docker_host.js
new file mode 100644
index 0000000..ceb8f4a
--- /dev/null
+++ b/server/model/docker_host.js
@@ -0,0 +1,19 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+
+class DockerHost extends BeanModel {
+ /**
+ * Returns an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ toJSON() {
+ return {
+ id: this.id,
+ userID: this.user_id,
+ dockerDaemon: this.docker_daemon,
+ dockerType: this.docker_type,
+ name: this.name,
+ };
+ }
+}
+
+module.exports = DockerHost;
diff --git a/server/model/group.js b/server/model/group.js
new file mode 100644
index 0000000..bd2c301
--- /dev/null
+++ b/server/model/group.js
@@ -0,0 +1,46 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+const { R } = require("redbean-node");
+
+class Group extends BeanModel {
+
+ /**
+ * Return an object that ready to parse to JSON for public Only show
+ * necessary data to public
+ * @param {boolean} showTags Should the JSON include monitor tags
+ * @param {boolean} certExpiry Should JSON include info about
+ * certificate expiry?
+ * @returns {Promise<object>} Object ready to parse
+ */
+ async toPublicJSON(showTags = false, certExpiry = false) {
+ let monitorBeanList = await this.getMonitorList();
+ let monitorList = [];
+
+ for (let bean of monitorBeanList) {
+ monitorList.push(await bean.toPublicJSON(showTags, certExpiry));
+ }
+
+ return {
+ id: this.id,
+ name: this.name,
+ weight: this.weight,
+ monitorList,
+ };
+ }
+
+ /**
+ * Get all monitors
+ * @returns {Promise<Bean[]>} List of monitors
+ */
+ async getMonitorList() {
+ return R.convertToBeans("monitor", await R.getAll(`
+ SELECT monitor.*, monitor_group.send_url FROM monitor, monitor_group
+ WHERE monitor.id = monitor_group.monitor_id
+ AND group_id = ?
+ ORDER BY monitor_group.weight
+ `, [
+ this.id,
+ ]));
+ }
+}
+
+module.exports = Group;
diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js
new file mode 100644
index 0000000..9e972a3
--- /dev/null
+++ b/server/model/heartbeat.js
@@ -0,0 +1,45 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+
+/**
+ * status:
+ * 0 = DOWN
+ * 1 = UP
+ * 2 = PENDING
+ * 3 = MAINTENANCE
+ */
+class Heartbeat extends BeanModel {
+
+ /**
+ * Return an object that ready to parse to JSON for public
+ * Only show necessary data to public
+ * @returns {object} Object ready to parse
+ */
+ toPublicJSON() {
+ return {
+ status: this.status,
+ time: this.time,
+ msg: "", // Hide for public
+ ping: this.ping,
+ };
+ }
+
+ /**
+ * Return an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ toJSON() {
+ return {
+ monitorID: this._monitorId,
+ status: this._status,
+ time: this._time,
+ msg: this._msg,
+ ping: this._ping,
+ important: this._important,
+ duration: this._duration,
+ retries: this._retries,
+ };
+ }
+
+}
+
+module.exports = Heartbeat;
diff --git a/server/model/incident.js b/server/model/incident.js
new file mode 100644
index 0000000..c47dabb
--- /dev/null
+++ b/server/model/incident.js
@@ -0,0 +1,23 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+
+class Incident extends BeanModel {
+
+ /**
+ * Return an object that ready to parse to JSON for public
+ * Only show necessary data to public
+ * @returns {object} Object ready to parse
+ */
+ toPublicJSON() {
+ return {
+ id: this.id,
+ style: this.style,
+ title: this.title,
+ content: this.content,
+ pin: this.pin,
+ createdDate: this.createdDate,
+ lastUpdatedDate: this.lastUpdatedDate,
+ };
+ }
+}
+
+module.exports = Incident;
diff --git a/server/model/maintenance.js b/server/model/maintenance.js
new file mode 100644
index 0000000..7111a18
--- /dev/null
+++ b/server/model/maintenance.js
@@ -0,0 +1,457 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+const { parseTimeObject, parseTimeFromTimeObject, log } = require("../../src/util");
+const { R } = require("redbean-node");
+const dayjs = require("dayjs");
+const Cron = require("croner");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const apicache = require("../modules/apicache");
+
+class Maintenance extends BeanModel {
+
+ /**
+ * Return an object that ready to parse to JSON for public
+ * Only show necessary data to public
+ * @returns {Promise<object>} Object ready to parse
+ */
+ async toPublicJSON() {
+
+ let dateRange = [];
+ if (this.start_date) {
+ dateRange.push(this.start_date);
+ } else {
+ dateRange.push(null);
+ }
+
+ if (this.end_date) {
+ dateRange.push(this.end_date);
+ }
+
+ let timeRange = [];
+ let startTime = parseTimeObject(this.start_time);
+ timeRange.push(startTime);
+ let endTime = parseTimeObject(this.end_time);
+ timeRange.push(endTime);
+
+ let obj = {
+ id: this.id,
+ title: this.title,
+ description: this.description,
+ strategy: this.strategy,
+ intervalDay: this.interval_day,
+ active: !!this.active,
+ dateRange: dateRange,
+ timeRange: timeRange,
+ weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [],
+ daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [],
+ timeslotList: [],
+ cron: this.cron,
+ duration: this.duration,
+ durationMinutes: parseInt(this.duration / 60),
+ timezone: await this.getTimezone(), // Only valid timezone
+ timezoneOption: this.timezone, // Mainly for dropdown menu, because there is a option "SAME_AS_SERVER"
+ timezoneOffset: await this.getTimezoneOffset(),
+ status: await this.getStatus(),
+ };
+
+ if (this.strategy === "manual") {
+ // Do nothing, no timeslots
+ } else if (this.strategy === "single") {
+ obj.timeslotList.push({
+ startDate: this.start_date,
+ endDate: this.end_date,
+ });
+ } else {
+ // Should be cron or recurring here
+ if (this.beanMeta.job) {
+ let runningTimeslot = this.getRunningTimeslot();
+
+ if (runningTimeslot) {
+ obj.timeslotList.push(runningTimeslot);
+ }
+
+ let nextRunDate = this.beanMeta.job.nextRun();
+ if (nextRunDate) {
+ let startDateDayjs = dayjs(nextRunDate);
+
+ let startDate = startDateDayjs.toISOString();
+ let endDate = startDateDayjs.add(this.duration, "second").toISOString();
+
+ obj.timeslotList.push({
+ startDate,
+ endDate,
+ });
+ }
+ }
+ }
+
+ if (!Array.isArray(obj.weekdays)) {
+ obj.weekdays = [];
+ }
+
+ if (!Array.isArray(obj.daysOfMonth)) {
+ obj.daysOfMonth = [];
+ }
+
+ return obj;
+ }
+
+ /**
+ * Return an object that ready to parse to JSON
+ * @param {string} timezone If not specified, the timeRange will be in UTC
+ * @returns {Promise<object>} Object ready to parse
+ */
+ async toJSON(timezone = null) {
+ return this.toPublicJSON(timezone);
+ }
+
+ /**
+ * Get a list of weekdays that the maintenance is active for
+ * Monday=1, Tuesday=2 etc.
+ * @returns {number[]} Array of active weekdays
+ */
+ getDayOfWeekList() {
+ log.debug("timeslot", "List: " + this.weekdays);
+ return JSON.parse(this.weekdays).sort(function (a, b) {
+ return a - b;
+ });
+ }
+
+ /**
+ * Get a list of days in month that maintenance is active for
+ * @returns {number[]|string[]} Array of active days in month
+ */
+ getDayOfMonthList() {
+ return JSON.parse(this.days_of_month).sort(function (a, b) {
+ return a - b;
+ });
+ }
+
+ /**
+ * Get the duration of maintenance in seconds
+ * @returns {number} Duration of maintenance
+ */
+ calcDuration() {
+ let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second");
+ // Add 24hours if it is across day
+ if (duration < 0) {
+ duration += 24 * 3600;
+ }
+ return duration;
+ }
+
+ /**
+ * Convert data from socket to bean
+ * @param {Bean} bean Bean to fill in
+ * @param {object} obj Data to fill bean with
+ * @returns {Promise<Bean>} Filled bean
+ */
+ static async jsonToBean(bean, obj) {
+ if (obj.id) {
+ bean.id = obj.id;
+ }
+
+ bean.title = obj.title;
+ bean.description = obj.description;
+ bean.strategy = obj.strategy;
+ bean.interval_day = obj.intervalDay;
+ bean.timezone = obj.timezoneOption;
+ bean.active = obj.active;
+
+ if (obj.dateRange[0]) {
+ bean.start_date = obj.dateRange[0];
+ } else {
+ bean.start_date = null;
+ }
+
+ if (obj.dateRange[1]) {
+ bean.end_date = obj.dateRange[1];
+ } else {
+ bean.end_date = null;
+ }
+
+ if (bean.strategy === "cron") {
+ bean.duration = obj.durationMinutes * 60;
+ bean.cron = obj.cron;
+ this.validateCron(bean.cron);
+ }
+
+ if (bean.strategy.startsWith("recurring-")) {
+ bean.start_time = parseTimeFromTimeObject(obj.timeRange[0]);
+ bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]);
+ bean.weekdays = JSON.stringify(obj.weekdays);
+ bean.days_of_month = JSON.stringify(obj.daysOfMonth);
+ await bean.generateCron();
+ this.validateCron(bean.cron);
+ }
+ return bean;
+ }
+
+ /**
+ * Throw error if cron is invalid
+ * @param {string|Date} cron Pattern or date
+ * @returns {void}
+ */
+ static validateCron(cron) {
+ let job = new Cron(cron, () => {});
+ job.stop();
+ }
+
+ /**
+ * Run the cron
+ * @param {boolean} throwError Should an error be thrown on failure
+ * @returns {Promise<void>}
+ */
+ async run(throwError = false) {
+ if (this.beanMeta.job) {
+ log.debug("maintenance", "Maintenance is already running, stop it first. id: " + this.id);
+ this.stop();
+ }
+
+ log.debug("maintenance", "Run maintenance id: " + this.id);
+
+ // 1.21.2 migration
+ if (!this.cron) {
+ await this.generateCron();
+ if (!this.timezone) {
+ this.timezone = "UTC";
+ }
+ if (this.cron) {
+ await R.store(this);
+ }
+ }
+
+ if (this.strategy === "manual") {
+ // Do nothing, because it is controlled by the user
+ } else if (this.strategy === "single") {
+ this.beanMeta.job = new Cron(this.start_date, { timezone: await this.getTimezone() }, () => {
+ log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now");
+ UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
+ apicache.clear();
+ });
+ } else if (this.cron != null) {
+ // Here should be cron or recurring
+ try {
+ this.beanMeta.status = "scheduled";
+
+ let startEvent = (customDuration = 0) => {
+ log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now");
+
+ this.beanMeta.status = "under-maintenance";
+ clearTimeout(this.beanMeta.durationTimeout);
+
+ let duration = this.inferDuration(customDuration);
+
+ UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
+
+ this.beanMeta.durationTimeout = setTimeout(() => {
+ // End of maintenance for this timeslot
+ this.beanMeta.status = "scheduled";
+ UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
+ }, duration);
+ };
+
+ // Create Cron
+ if (this.strategy === "recurring-interval") {
+ // For recurring-interval, Croner needs to have interval and startAt
+ const startDate = dayjs(this.startDate);
+ const [ hour, minute ] = this.startTime.split(":");
+ const startDateTime = startDate.hour(hour).minute(minute);
+ this.beanMeta.job = new Cron(this.cron, {
+ timezone: await this.getTimezone(),
+ interval: this.interval_day * 24 * 60 * 60,
+ startAt: startDateTime.toISOString(),
+ }, startEvent);
+ } else {
+ this.beanMeta.job = new Cron(this.cron, {
+ timezone: await this.getTimezone(),
+ }, startEvent);
+ }
+
+ // Continue if the maintenance is still in the window
+ let runningTimeslot = this.getRunningTimeslot();
+ let current = dayjs();
+
+ if (runningTimeslot) {
+ let duration = dayjs(runningTimeslot.endDate).diff(current, "second") * 1000;
+ log.debug("maintenance", "Maintenance id: " + this.id + " Remaining duration: " + duration + "ms");
+ startEvent(duration);
+ }
+
+ } catch (e) {
+ log.error("maintenance", "Error in maintenance id: " + this.id);
+ log.error("maintenance", "Cron: " + this.cron);
+ log.error("maintenance", e);
+
+ if (throwError) {
+ throw e;
+ }
+ }
+
+ } else {
+ log.error("maintenance", "Maintenance id: " + this.id + " has no cron");
+ }
+ }
+
+ /**
+ * Get timeslots where maintenance is running
+ * @returns {object|null} Maintenance time slot
+ */
+ getRunningTimeslot() {
+ let start = dayjs(this.beanMeta.job.nextRun(dayjs().add(-this.duration, "second").toDate()));
+ let end = start.add(this.duration, "second");
+ let current = dayjs();
+
+ if (current.isAfter(start) && current.isBefore(end)) {
+ return {
+ startDate: start.toISOString(),
+ endDate: end.toISOString(),
+ };
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Calculate the maintenance duration
+ * @param {number} customDuration - The custom duration in milliseconds.
+ * @returns {number} The inferred duration in milliseconds.
+ */
+ inferDuration(customDuration) {
+ // Check if duration is still in the window. If not, use the duration from the current time to the end of the window
+ if (customDuration > 0) {
+ return customDuration;
+ } else if (this.end_date) {
+ let d = dayjs(this.end_date).diff(dayjs(), "second");
+ if (d < this.duration) {
+ return d * 1000;
+ }
+ }
+ return this.duration * 1000;
+ }
+
+ /**
+ * Stop the maintenance
+ * @returns {void}
+ */
+ stop() {
+ if (this.beanMeta.job) {
+ this.beanMeta.job.stop();
+ delete this.beanMeta.job;
+ }
+ }
+
+ /**
+ * Is this maintenance currently active
+ * @returns {Promise<boolean>} The maintenance is active?
+ */
+ async isUnderMaintenance() {
+ return (await this.getStatus()) === "under-maintenance";
+ }
+
+ /**
+ * Get the timezone of the maintenance
+ * @returns {Promise<string>} timezone
+ */
+ async getTimezone() {
+ if (!this.timezone || this.timezone === "SAME_AS_SERVER") {
+ return await UptimeKumaServer.getInstance().getTimezone();
+ }
+ return this.timezone;
+ }
+
+ /**
+ * Get offset for timezone
+ * @returns {Promise<string>} offset
+ */
+ async getTimezoneOffset() {
+ return dayjs.tz(dayjs(), await this.getTimezone()).format("Z");
+ }
+
+ /**
+ * Get the current status of the maintenance
+ * @returns {Promise<string>} Current status
+ */
+ async getStatus() {
+ if (!this.active) {
+ return "inactive";
+ }
+
+ if (this.strategy === "manual") {
+ return "under-maintenance";
+ }
+
+ // Check if the maintenance is started
+ if (this.start_date && dayjs().isBefore(dayjs.tz(this.start_date, await this.getTimezone()))) {
+ return "scheduled";
+ }
+
+ // Check if the maintenance is ended
+ if (this.end_date && dayjs().isAfter(dayjs.tz(this.end_date, await this.getTimezone()))) {
+ return "ended";
+ }
+
+ if (this.strategy === "single") {
+ return "under-maintenance";
+ }
+
+ if (!this.beanMeta.status) {
+ return "unknown";
+ }
+
+ return this.beanMeta.status;
+ }
+
+ /**
+ * Generate Cron for recurring maintenance
+ * @returns {Promise<void>}
+ */
+ async generateCron() {
+ log.info("maintenance", "Generate cron for maintenance id: " + this.id);
+
+ if (this.strategy === "cron") {
+ // Do nothing for cron
+ } else if (!this.strategy.startsWith("recurring-")) {
+ this.cron = "";
+ } else if (this.strategy === "recurring-interval") {
+ // For intervals, the pattern is calculated in the run function as the interval-option is set
+ this.cron = "* * * * *";
+ this.duration = this.calcDuration();
+ log.debug("maintenance", "Cron: " + this.cron);
+ log.debug("maintenance", "Duration: " + this.duration);
+ } else if (this.strategy === "recurring-weekday") {
+ let list = this.getDayOfWeekList();
+ let array = this.start_time.split(":");
+ let hour = parseInt(array[0]);
+ let minute = parseInt(array[1]);
+ this.cron = minute + " " + hour + " * * " + list.join(",");
+ this.duration = this.calcDuration();
+ } else if (this.strategy === "recurring-day-of-month") {
+ let list = this.getDayOfMonthList();
+ let array = this.start_time.split(":");
+ let hour = parseInt(array[0]);
+ let minute = parseInt(array[1]);
+
+ let dayList = [];
+
+ for (let day of list) {
+ if (typeof day === "string" && day.startsWith("lastDay")) {
+ if (day === "lastDay1") {
+ dayList.push("L");
+ }
+ // Unfortunately, lastDay2-4 is not supported by cron
+ } else {
+ dayList.push(day);
+ }
+ }
+
+ // Remove duplicate
+ dayList = [ ...new Set(dayList) ];
+
+ this.cron = minute + " " + hour + " " + dayList.join(",") + " * *";
+ this.duration = this.calcDuration();
+ }
+
+ }
+}
+
+module.exports = Maintenance;
diff --git a/server/model/monitor.js b/server/model/monitor.js
new file mode 100644
index 0000000..9a30a66
--- /dev/null
+++ b/server/model/monitor.js
@@ -0,0 +1,1740 @@
+const dayjs = require("dayjs");
+const axios = require("axios");
+const { Prometheus } = require("../prometheus");
+const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
+ SQL_DATETIME_FORMAT, evaluateJsonQuery
+} = require("../../src/util");
+const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
+ redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
+} = require("../util-server");
+const { R } = require("redbean-node");
+const { BeanModel } = require("redbean-node/dist/bean-model");
+const { Notification } = require("../notification");
+const { Proxy } = require("../proxy");
+const { demoMode } = require("../config");
+const version = require("../../package.json").version;
+const apicache = require("../modules/apicache");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const { DockerHost } = require("../docker");
+const Gamedig = require("gamedig");
+const jwt = require("jsonwebtoken");
+const crypto = require("crypto");
+const { UptimeCalculator } = require("../uptime-calculator");
+const { CookieJar } = require("tough-cookie");
+const { HttpsCookieAgent } = require("http-cookie-agent/http");
+const https = require("https");
+const http = require("http");
+
+const rootCertificates = rootCertificatesFingerprints();
+
+/**
+ * status:
+ * 0 = DOWN
+ * 1 = UP
+ * 2 = PENDING
+ * 3 = MAINTENANCE
+ */
+class Monitor extends BeanModel {
+
+ /**
+ * Return an object that ready to parse to JSON for public Only show
+ * necessary data to public
+ * @param {boolean} showTags Include tags in JSON
+ * @param {boolean} certExpiry Include certificate expiry info in
+ * JSON
+ * @returns {Promise<object>} Object ready to parse
+ */
+ async toPublicJSON(showTags = false, certExpiry = false) {
+ let obj = {
+ id: this.id,
+ name: this.name,
+ sendUrl: this.sendUrl,
+ type: this.type,
+ };
+
+ if (this.sendUrl) {
+ obj.url = this.url;
+ }
+
+ if (showTags) {
+ obj.tags = await this.getTags();
+ }
+
+ if (certExpiry && (this.type === "http" || this.type === "keyword" || this.type === "json-query") && this.getURLProtocol() === "https:") {
+ const { certExpiryDaysRemaining, validCert } = await this.getCertExpiry(this.id);
+ obj.certExpiryDaysRemaining = certExpiryDaysRemaining;
+ obj.validCert = validCert;
+ }
+
+ return obj;
+ }
+
+ /**
+ * Return an object that ready to parse to JSON
+ * @param {object} preloadData to prevent n+1 problems, we query the data in a batch outside of this function
+ * @param {boolean} includeSensitiveData Include sensitive data in
+ * JSON
+ * @returns {object} Object ready to parse
+ */
+ toJSON(preloadData = {}, includeSensitiveData = true) {
+
+ let screenshot = null;
+
+ if (this.type === "real-browser") {
+ screenshot = "/screenshots/" + jwt.sign(this.id, UptimeKumaServer.getInstance().jwtSecret) + ".png";
+ }
+
+ const path = preloadData.paths.get(this.id) || [];
+ const pathName = path.join(" / ");
+
+ let data = {
+ id: this.id,
+ name: this.name,
+ description: this.description,
+ path,
+ pathName,
+ parent: this.parent,
+ childrenIDs: preloadData.childrenIDs.get(this.id) || [],
+ url: this.url,
+ method: this.method,
+ hostname: this.hostname,
+ port: this.port,
+ maxretries: this.maxretries,
+ weight: this.weight,
+ active: preloadData.activeStatus.get(this.id),
+ forceInactive: preloadData.forceInactive.get(this.id),
+ type: this.type,
+ timeout: this.timeout,
+ interval: this.interval,
+ retryInterval: this.retryInterval,
+ resendInterval: this.resendInterval,
+ keyword: this.keyword,
+ invertKeyword: this.isInvertKeyword(),
+ expiryNotification: this.isEnabledExpiryNotification(),
+ ignoreTls: this.getIgnoreTls(),
+ upsideDown: this.isUpsideDown(),
+ packetSize: this.packetSize,
+ maxredirects: this.maxredirects,
+ accepted_statuscodes: this.getAcceptedStatuscodes(),
+ dns_resolve_type: this.dns_resolve_type,
+ dns_resolve_server: this.dns_resolve_server,
+ dns_last_result: this.dns_last_result,
+ docker_container: this.docker_container,
+ docker_host: this.docker_host,
+ proxyId: this.proxy_id,
+ notificationIDList: preloadData.notifications.get(this.id) || {},
+ tags: preloadData.tags.get(this.id) || [],
+ maintenance: preloadData.maintenanceStatus.get(this.id),
+ mqttTopic: this.mqttTopic,
+ mqttSuccessMessage: this.mqttSuccessMessage,
+ mqttCheckType: this.mqttCheckType,
+ databaseQuery: this.databaseQuery,
+ authMethod: this.authMethod,
+ grpcUrl: this.grpcUrl,
+ grpcProtobuf: this.grpcProtobuf,
+ grpcMethod: this.grpcMethod,
+ grpcServiceName: this.grpcServiceName,
+ grpcEnableTls: this.getGrpcEnableTls(),
+ radiusCalledStationId: this.radiusCalledStationId,
+ radiusCallingStationId: this.radiusCallingStationId,
+ game: this.game,
+ gamedigGivenPortOnly: this.getGameDigGivenPortOnly(),
+ httpBodyEncoding: this.httpBodyEncoding,
+ jsonPath: this.jsonPath,
+ expectedValue: this.expectedValue,
+ kafkaProducerTopic: this.kafkaProducerTopic,
+ kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers),
+ kafkaProducerSsl: this.getKafkaProducerSsl(),
+ kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(),
+ kafkaProducerMessage: this.kafkaProducerMessage,
+ screenshot,
+ cacheBust: this.getCacheBust(),
+ remote_browser: this.remote_browser,
+ snmpOid: this.snmpOid,
+ jsonPathOperator: this.jsonPathOperator,
+ snmpVersion: this.snmpVersion,
+ rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
+ conditions: JSON.parse(this.conditions),
+ };
+
+ if (includeSensitiveData) {
+ data = {
+ ...data,
+ headers: this.headers,
+ body: this.body,
+ grpcBody: this.grpcBody,
+ grpcMetadata: this.grpcMetadata,
+ basic_auth_user: this.basic_auth_user,
+ basic_auth_pass: this.basic_auth_pass,
+ oauth_client_id: this.oauth_client_id,
+ oauth_client_secret: this.oauth_client_secret,
+ oauth_token_url: this.oauth_token_url,
+ oauth_scopes: this.oauth_scopes,
+ oauth_auth_method: this.oauth_auth_method,
+ pushToken: this.pushToken,
+ databaseConnectionString: this.databaseConnectionString,
+ radiusUsername: this.radiusUsername,
+ radiusPassword: this.radiusPassword,
+ radiusSecret: this.radiusSecret,
+ mqttUsername: this.mqttUsername,
+ mqttPassword: this.mqttPassword,
+ authWorkstation: this.authWorkstation,
+ authDomain: this.authDomain,
+ tlsCa: this.tlsCa,
+ tlsCert: this.tlsCert,
+ tlsKey: this.tlsKey,
+ kafkaProducerSaslOptions: JSON.parse(this.kafkaProducerSaslOptions),
+ rabbitmqUsername: this.rabbitmqUsername,
+ rabbitmqPassword: this.rabbitmqPassword,
+ };
+ }
+
+ data.includeSensitiveData = includeSensitiveData;
+ return data;
+ }
+
+ /**
+ * Get all tags applied to this monitor
+ * @returns {Promise<LooseObject<any>[]>} List of tags on the
+ * monitor
+ */
+ async getTags() {
+ return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ? ORDER BY tag.name", [ this.id ]);
+ }
+
+ /**
+ * Gets certificate expiry for this monitor
+ * @param {number} monitorID ID of monitor to send
+ * @returns {Promise<LooseObject<any>>} Certificate expiry info for
+ * monitor
+ */
+ async getCertExpiry(monitorID) {
+ let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
+ monitorID,
+ ]);
+ let tlsInfo;
+ if (tlsInfoBean) {
+ tlsInfo = JSON.parse(tlsInfoBean?.info_json);
+ if (tlsInfo?.valid && tlsInfo?.certInfo?.daysRemaining) {
+ return {
+ certExpiryDaysRemaining: tlsInfo.certInfo.daysRemaining,
+ validCert: true
+ };
+ }
+ }
+ return {
+ certExpiryDaysRemaining: "",
+ validCert: false
+ };
+ }
+
+ /**
+ * Encode user and password to Base64 encoding
+ * for HTTP "basic" auth, as per RFC-7617
+ * @param {string|null} user - The username (nullable if not changed by a user)
+ * @param {string|null} pass - The password (nullable if not changed by a user)
+ * @returns {string} Encoded Base64 string
+ */
+ encodeBase64(user, pass) {
+ return Buffer.from(`${user || ""}:${pass || ""}`).toString("base64");
+ }
+
+ /**
+ * Is the TLS expiry notification enabled?
+ * @returns {boolean} Enabled?
+ */
+ isEnabledExpiryNotification() {
+ return Boolean(this.expiryNotification);
+ }
+
+ /**
+ * Parse to boolean
+ * @returns {boolean} Should TLS errors be ignored?
+ */
+ getIgnoreTls() {
+ return Boolean(this.ignoreTls);
+ }
+
+ /**
+ * Parse to boolean
+ * @returns {boolean} Is the monitor in upside down mode?
+ */
+ isUpsideDown() {
+ return Boolean(this.upsideDown);
+ }
+
+ /**
+ * Parse to boolean
+ * @returns {boolean} Invert keyword match?
+ */
+ isInvertKeyword() {
+ return Boolean(this.invertKeyword);
+ }
+
+ /**
+ * Parse to boolean
+ * @returns {boolean} Enable TLS for gRPC?
+ */
+ getGrpcEnableTls() {
+ return Boolean(this.grpcEnableTls);
+ }
+
+ /**
+ * Parse to boolean
+ * @returns {boolean} if cachebusting is enabled
+ */
+ getCacheBust() {
+ return Boolean(this.cacheBust);
+ }
+
+ /**
+ * Get accepted status codes
+ * @returns {object} Accepted status codes
+ */
+ getAcceptedStatuscodes() {
+ return JSON.parse(this.accepted_statuscodes_json);
+ }
+
+ /**
+ * Get if game dig should only use the port which was provided
+ * @returns {boolean} gamedig should only use the provided port
+ */
+ getGameDigGivenPortOnly() {
+ return Boolean(this.gamedigGivenPortOnly);
+ }
+
+ /**
+ * Parse to boolean
+ * @returns {boolean} Kafka Producer Ssl enabled?
+ */
+ getKafkaProducerSsl() {
+ return Boolean(this.kafkaProducerSsl);
+ }
+
+ /**
+ * Parse to boolean
+ * @returns {boolean} Kafka Producer Allow Auto Topic Creation Enabled?
+ */
+ getKafkaProducerAllowAutoTopicCreation() {
+ return Boolean(this.kafkaProducerAllowAutoTopicCreation);
+ }
+
+ /**
+ * Start monitor
+ * @param {Server} io Socket server instance
+ * @returns {Promise<void>}
+ */
+ async start(io) {
+ let previousBeat = null;
+ let retries = 0;
+
+ this.prometheus = new Prometheus(this);
+
+ const beat = async () => {
+
+ let beatInterval = this.interval;
+
+ if (! beatInterval) {
+ beatInterval = 1;
+ }
+
+ if (demoMode) {
+ if (beatInterval < 20) {
+ console.log("beat interval too low, reset to 20s");
+ beatInterval = 20;
+ }
+ }
+
+ // Expose here for prometheus update
+ // undefined if not https
+ let tlsInfo = undefined;
+
+ if (!previousBeat || this.type === "push") {
+ previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [
+ this.id,
+ ]);
+ if (previousBeat) {
+ retries = previousBeat.retries;
+ }
+ }
+
+ const isFirstBeat = !previousBeat;
+
+ let bean = R.dispense("heartbeat");
+ bean.monitor_id = this.id;
+ bean.time = R.isoDateTimeMillis(dayjs.utc());
+ bean.status = DOWN;
+ bean.downCount = previousBeat?.downCount || 0;
+
+ if (this.isUpsideDown()) {
+ bean.status = flipStatus(bean.status);
+ }
+
+ // Runtime patch timeout if it is 0
+ // See https://github.com/louislam/uptime-kuma/pull/3961#issuecomment-1804149144
+ if (!this.timeout || this.timeout <= 0) {
+ this.timeout = this.interval * 1000 * 0.8;
+ }
+
+ try {
+ if (await Monitor.isUnderMaintenance(this.id)) {
+ bean.msg = "Monitor under maintenance";
+ bean.status = MAINTENANCE;
+ } else if (this.type === "group") {
+ const children = await Monitor.getChildren(this.id);
+
+ if (children.length > 0) {
+ bean.status = UP;
+ bean.msg = "All children up and running";
+ for (const child of children) {
+ if (!child.active) {
+ // Ignore inactive childs
+ continue;
+ }
+ const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
+
+ // Only change state if the monitor is in worse conditions then the ones before
+ // lastBeat.status could be null
+ if (!lastBeat) {
+ bean.status = PENDING;
+ } else if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
+ bean.status = lastBeat.status;
+ } else if (bean.status === PENDING && lastBeat.status === DOWN) {
+ bean.status = lastBeat.status;
+ }
+ }
+
+ if (bean.status !== UP) {
+ bean.msg = "Child inaccessible";
+ }
+ } else {
+ // Set status pending if group is empty
+ bean.status = PENDING;
+ bean.msg = "Group empty";
+ }
+
+ } else if (this.type === "http" || this.type === "keyword" || this.type === "json-query") {
+ // Do not do any queries/high loading things before the "bean.ping"
+ let startTime = dayjs().valueOf();
+
+ // HTTP basic auth
+ let basicAuthHeader = {};
+ if (this.auth_method === "basic") {
+ basicAuthHeader = {
+ "Authorization": "Basic " + this.encodeBase64(this.basic_auth_user, this.basic_auth_pass),
+ };
+ }
+
+ // OIDC: Basic client credential flow.
+ // Additional grants might be implemented in the future
+ let oauth2AuthHeader = {};
+ if (this.auth_method === "oauth2-cc") {
+ try {
+ if (this.oauthAccessToken === undefined || new Date(this.oauthAccessToken.expires_at * 1000) <= new Date()) {
+ this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest();
+ }
+ oauth2AuthHeader = {
+ "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token,
+ };
+ } catch (e) {
+ throw new Error("The oauth config is invalid. " + e.message);
+ }
+ }
+
+ const httpsAgentOptions = {
+ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
+ rejectUnauthorized: !this.getIgnoreTls(),
+ secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
+ };
+
+ log.debug("monitor", `[${this.name}] Prepare Options for axios`);
+
+ let contentType = null;
+ let bodyValue = null;
+
+ if (this.body && (typeof this.body === "string" && this.body.trim().length > 0)) {
+ if (!this.httpBodyEncoding || this.httpBodyEncoding === "json") {
+ try {
+ bodyValue = JSON.parse(this.body);
+ contentType = "application/json";
+ } catch (e) {
+ throw new Error("Your JSON body is invalid. " + e.message);
+ }
+ } else if (this.httpBodyEncoding === "form") {
+ bodyValue = this.body;
+ contentType = "application/x-www-form-urlencoded";
+ } else if (this.httpBodyEncoding === "xml") {
+ bodyValue = this.body;
+ contentType = "text/xml; charset=utf-8";
+ }
+ }
+
+ // Axios Options
+ const options = {
+ url: this.url,
+ method: (this.method || "get").toLowerCase(),
+ timeout: this.timeout * 1000,
+ headers: {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
+ ...(contentType ? { "Content-Type": contentType } : {}),
+ ...(basicAuthHeader),
+ ...(oauth2AuthHeader),
+ ...(this.headers ? JSON.parse(this.headers) : {})
+ },
+ maxRedirects: this.maxredirects,
+ validateStatus: (status) => {
+ return checkStatusCode(status, this.getAcceptedStatuscodes());
+ },
+ signal: axiosAbortSignal((this.timeout + 10) * 1000),
+ };
+
+ if (bodyValue) {
+ options.data = bodyValue;
+ }
+
+ if (this.cacheBust) {
+ const randomFloatString = Math.random().toString(36);
+ const cacheBust = randomFloatString.substring(2);
+ options.params = {
+ uptime_kuma_cachebuster: cacheBust,
+ };
+ }
+
+ if (this.proxy_id) {
+ const proxy = await R.load("proxy", this.proxy_id);
+
+ if (proxy && proxy.active) {
+ const { httpAgent, httpsAgent } = Proxy.createAgents(proxy, {
+ httpsAgentOptions: httpsAgentOptions,
+ });
+
+ options.proxy = false;
+ options.httpAgent = httpAgent;
+ options.httpsAgent = httpsAgent;
+ }
+ }
+
+ if (!options.httpsAgent) {
+ let jar = new CookieJar();
+ let httpsCookieAgentOptions = {
+ ...httpsAgentOptions,
+ cookies: { jar }
+ };
+ options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions);
+ }
+
+ if (this.auth_method === "mtls") {
+ if (this.tlsCert !== null && this.tlsCert !== "") {
+ options.httpsAgent.options.cert = Buffer.from(this.tlsCert);
+ }
+ if (this.tlsCa !== null && this.tlsCa !== "") {
+ options.httpsAgent.options.ca = Buffer.from(this.tlsCa);
+ }
+ if (this.tlsKey !== null && this.tlsKey !== "") {
+ options.httpsAgent.options.key = Buffer.from(this.tlsKey);
+ }
+ }
+
+ let tlsInfo = {};
+ // Store tlsInfo when secureConnect event is emitted
+ // The keylog event listener is a workaround to access the tlsSocket
+ options.httpsAgent.once("keylog", async (line, tlsSocket) => {
+ tlsSocket.once("secureConnect", async () => {
+ tlsInfo = checkCertificate(tlsSocket);
+ tlsInfo.valid = tlsSocket.authorized || false;
+
+ await this.handleTlsInfo(tlsInfo);
+ });
+ });
+
+ log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`);
+ log.debug("monitor", `[${this.name}] Axios Request`);
+
+ // Make Request
+ let res = await this.makeAxiosRequest(options);
+
+ bean.msg = `${res.status} - ${res.statusText}`;
+ bean.ping = dayjs().valueOf() - startTime;
+
+ // fallback for if kelog event is not emitted, but we may still have tlsInfo,
+ // e.g. if the connection is made through a proxy
+ if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) {
+ const tlsSocket = res.request.res.socket;
+
+ if (tlsSocket) {
+ tlsInfo = checkCertificate(tlsSocket);
+ tlsInfo.valid = tlsSocket.authorized || false;
+
+ await this.handleTlsInfo(tlsInfo);
+ }
+ }
+
+ if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID === this.id) {
+ log.info("monitor", res.data);
+ }
+
+ if (this.type === "http") {
+ bean.status = UP;
+ } else if (this.type === "keyword") {
+
+ let data = res.data;
+
+ // Convert to string for object/array
+ if (typeof data !== "string") {
+ data = JSON.stringify(data);
+ }
+
+ let keywordFound = data.includes(this.keyword);
+ if (keywordFound === !this.isInvertKeyword()) {
+ bean.msg += ", keyword " + (keywordFound ? "is" : "not") + " found";
+ bean.status = UP;
+ } else {
+ data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim();
+ if (data.length > 50) {
+ data = data.substring(0, 47) + "...";
+ }
+ throw new Error(bean.msg + ", but keyword is " +
+ (keywordFound ? "present" : "not") + " in [" + data + "]");
+ }
+
+ } else if (this.type === "json-query") {
+ let data = res.data;
+
+ const { status, response } = await evaluateJsonQuery(data, this.jsonPath, this.jsonPathOperator, this.expectedValue);
+
+ if (status) {
+ bean.status = UP;
+ bean.msg = `JSON query passes (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`;
+ } else {
+ throw new Error(`JSON query does not pass (comparing ${response} ${this.jsonPathOperator} ${this.expectedValue})`);
+ }
+
+ }
+
+ } else if (this.type === "port") {
+ bean.ping = await tcping(this.hostname, this.port);
+ bean.msg = "";
+ bean.status = UP;
+
+ } else if (this.type === "ping") {
+ bean.ping = await ping(this.hostname, this.packetSize);
+ bean.msg = "";
+ bean.status = UP;
+ } else if (this.type === "push") { // Type: Push
+ log.debug("monitor", `[${this.name}] Checking monitor at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
+ const bufferTime = 1000; // 1s buffer to accommodate clock differences
+
+ if (previousBeat) {
+ const msSinceLastBeat = dayjs.utc().valueOf() - dayjs.utc(previousBeat.time).valueOf();
+
+ log.debug("monitor", `[${this.name}] msSinceLastBeat = ${msSinceLastBeat}`);
+
+ // If the previous beat was down or pending we use the regular
+ // beatInterval/retryInterval in the setTimeout further below
+ if (previousBeat.status !== (this.isUpsideDown() ? DOWN : UP) || msSinceLastBeat > beatInterval * 1000 + bufferTime) {
+ bean.duration = Math.round(msSinceLastBeat / 1000);
+ throw new Error("No heartbeat in the time window");
+ } else {
+ let timeout = beatInterval * 1000 - msSinceLastBeat;
+ if (timeout < 0) {
+ timeout = bufferTime;
+ } else {
+ timeout += bufferTime;
+ }
+ // No need to insert successful heartbeat for push type, so end here
+ retries = 0;
+ log.debug("monitor", `[${this.name}] timeout = ${timeout}`);
+ this.heartbeatInterval = setTimeout(safeBeat, timeout);
+ return;
+ }
+ } else {
+ bean.duration = beatInterval;
+ throw new Error("No heartbeat in the time window");
+ }
+
+ } else if (this.type === "steam") {
+ const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/";
+ const steamAPIKey = await setting("steamAPIKey");
+ const filter = `addr\\${this.hostname}:${this.port}`;
+
+ if (!steamAPIKey) {
+ throw new Error("Steam API Key not found");
+ }
+
+ let res = await axios.get(steamApiUrl, {
+ timeout: this.timeout * 1000,
+ headers: {
+ "Accept": "*/*",
+ },
+ httpsAgent: new https.Agent({
+ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
+ rejectUnauthorized: !this.getIgnoreTls(),
+ secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
+ }),
+ httpAgent: new http.Agent({
+ maxCachedSessions: 0,
+ }),
+ maxRedirects: this.maxredirects,
+ validateStatus: (status) => {
+ return checkStatusCode(status, this.getAcceptedStatuscodes());
+ },
+ params: {
+ filter: filter,
+ key: steamAPIKey,
+ }
+ });
+
+ if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) {
+ bean.status = UP;
+ bean.msg = res.data.response.servers[0].name;
+
+ try {
+ bean.ping = await ping(this.hostname, this.packetSize);
+ } catch (_) { }
+ } else {
+ throw new Error("Server not found on Steam");
+ }
+ } else if (this.type === "gamedig") {
+ try {
+ const state = await Gamedig.query({
+ type: this.game,
+ host: this.hostname,
+ port: this.port,
+ givenPortOnly: this.getGameDigGivenPortOnly(),
+ });
+
+ bean.msg = state.name;
+ bean.status = UP;
+ bean.ping = state.ping;
+ } catch (e) {
+ throw new Error(e.message);
+ }
+ } else if (this.type === "docker") {
+ log.debug("monitor", `[${this.name}] Prepare Options for Axios`);
+
+ const options = {
+ url: `/containers/${this.docker_container}/json`,
+ timeout: this.interval * 1000 * 0.8,
+ headers: {
+ "Accept": "*/*",
+ },
+ httpsAgent: new https.Agent({
+ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
+ rejectUnauthorized: !this.getIgnoreTls(),
+ secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
+ }),
+ httpAgent: new http.Agent({
+ maxCachedSessions: 0,
+ }),
+ };
+
+ const dockerHost = await R.load("docker_host", this.docker_host);
+
+ if (!dockerHost) {
+ throw new Error("Failed to load docker host config");
+ }
+
+ if (dockerHost._dockerType === "socket") {
+ options.socketPath = dockerHost._dockerDaemon;
+ } else if (dockerHost._dockerType === "tcp") {
+ options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
+ options.httpsAgent = new https.Agent(
+ DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL)
+ );
+ }
+
+ log.debug("monitor", `[${this.name}] Axios Request`);
+ let res = await axios.request(options);
+
+ if (res.data.State.Running) {
+ if (res.data.State.Health && res.data.State.Health.Status !== "healthy") {
+ bean.status = PENDING;
+ bean.msg = res.data.State.Health.Status;
+ } else {
+ bean.status = UP;
+ bean.msg = res.data.State.Health ? res.data.State.Health.Status : res.data.State.Status;
+ }
+ } else {
+ throw Error("Container State is " + res.data.State.Status);
+ }
+ } else if (this.type === "sqlserver") {
+ let startTime = dayjs().valueOf();
+
+ await mssqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1");
+
+ bean.msg = "";
+ bean.status = UP;
+ bean.ping = dayjs().valueOf() - startTime;
+ } else if (this.type === "grpc-keyword") {
+ let startTime = dayjs().valueOf();
+ const options = {
+ grpcUrl: this.grpcUrl,
+ grpcProtobufData: this.grpcProtobuf,
+ grpcServiceName: this.grpcServiceName,
+ grpcEnableTls: this.grpcEnableTls,
+ grpcMethod: this.grpcMethod,
+ grpcBody: this.grpcBody,
+ };
+ const response = await grpcQuery(options);
+ bean.ping = dayjs().valueOf() - startTime;
+ log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
+ let responseData = response.data;
+ if (responseData.length > 50) {
+ responseData = responseData.toString().substring(0, 47) + "...";
+ }
+ if (response.code !== 1) {
+ bean.status = DOWN;
+ bean.msg = `Error in send gRPC ${response.code} ${response.errorMessage}`;
+ } else {
+ let keywordFound = response.data.toString().includes(this.keyword);
+ if (keywordFound === !this.isInvertKeyword()) {
+ bean.status = UP;
+ bean.msg = `${responseData}, keyword [${this.keyword}] ${keywordFound ? "is" : "not"} found`;
+ } else {
+ log.debug("monitor:", `GRPC response [${response.data}] + ", but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${response.data} + "]"`);
+ bean.status = DOWN;
+ bean.msg = `, but keyword [${this.keyword}] is ${keywordFound ? "present" : "not"} in [" + ${responseData} + "]`;
+ }
+ }
+ } else if (this.type === "postgres") {
+ let startTime = dayjs().valueOf();
+
+ await postgresQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1");
+
+ bean.msg = "";
+ bean.status = UP;
+ bean.ping = dayjs().valueOf() - startTime;
+ } else if (this.type === "mysql") {
+ let startTime = dayjs().valueOf();
+
+ // Use `radius_password` as `password` field, since there are too many unnecessary fields
+ // TODO: rename `radius_password` to `password` later for general use
+ let mysqlPassword = this.radiusPassword;
+
+ bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1", mysqlPassword);
+ bean.status = UP;
+ bean.ping = dayjs().valueOf() - startTime;
+ } else if (this.type === "radius") {
+ let startTime = dayjs().valueOf();
+
+ // Handle monitors that were created before the
+ // update and as such don't have a value for
+ // this.port.
+ let port;
+ if (this.port == null) {
+ port = 1812;
+ } else {
+ port = this.port;
+ }
+
+ const resp = await radius(
+ this.hostname,
+ this.radiusUsername,
+ this.radiusPassword,
+ this.radiusCalledStationId,
+ this.radiusCallingStationId,
+ this.radiusSecret,
+ port,
+ this.interval * 1000 * 0.4,
+ );
+
+ bean.msg = resp.code;
+ bean.status = UP;
+ bean.ping = dayjs().valueOf() - startTime;
+ } else if (this.type === "redis") {
+ let startTime = dayjs().valueOf();
+
+ bean.msg = await redisPingAsync(this.databaseConnectionString, !this.ignoreTls);
+ bean.status = UP;
+ bean.ping = dayjs().valueOf() - startTime;
+
+ } else if (this.type in UptimeKumaServer.monitorTypeList) {
+ let startTime = dayjs().valueOf();
+ const monitorType = UptimeKumaServer.monitorTypeList[this.type];
+ await monitorType.check(this, bean, UptimeKumaServer.getInstance());
+ if (!bean.ping) {
+ bean.ping = dayjs().valueOf() - startTime;
+ }
+
+ } else if (this.type === "kafka-producer") {
+ let startTime = dayjs().valueOf();
+
+ bean.msg = await kafkaProducerAsync(
+ JSON.parse(this.kafkaProducerBrokers),
+ this.kafkaProducerTopic,
+ this.kafkaProducerMessage,
+ {
+ allowAutoTopicCreation: this.kafkaProducerAllowAutoTopicCreation,
+ ssl: this.kafkaProducerSsl,
+ clientId: `Uptime-Kuma/${version}`,
+ interval: this.interval,
+ },
+ JSON.parse(this.kafkaProducerSaslOptions),
+ );
+ bean.status = UP;
+ bean.ping = dayjs().valueOf() - startTime;
+
+ } else {
+ throw new Error("Unknown Monitor Type");
+ }
+
+ if (this.isUpsideDown()) {
+ bean.status = flipStatus(bean.status);
+
+ if (bean.status === DOWN) {
+ throw new Error("Flip UP to DOWN");
+ }
+ }
+
+ retries = 0;
+
+ } catch (error) {
+
+ if (error?.name === "CanceledError") {
+ bean.msg = `timeout by AbortSignal (${this.timeout}s)`;
+ } else {
+ bean.msg = error.message;
+ }
+
+ // If UP come in here, it must be upside down mode
+ // Just reset the retries
+ if (this.isUpsideDown() && bean.status === UP) {
+ retries = 0;
+
+ } else if ((this.maxretries > 0) && (retries < this.maxretries)) {
+ retries++;
+ bean.status = PENDING;
+ } else {
+ // Continue counting retries during DOWN
+ retries++;
+ }
+ }
+
+ bean.retries = retries;
+
+ log.debug("monitor", `[${this.name}] Check isImportant`);
+ let isImportant = Monitor.isImportantBeat(isFirstBeat, previousBeat?.status, bean.status);
+
+ // Mark as important if status changed, ignore pending pings,
+ // Don't notify if disrupted changes to up
+ if (isImportant) {
+ bean.important = true;
+
+ if (Monitor.isImportantForNotification(isFirstBeat, previousBeat?.status, bean.status)) {
+ log.debug("monitor", `[${this.name}] sendNotification`);
+ await Monitor.sendNotification(isFirstBeat, this, bean);
+ } else {
+ log.debug("monitor", `[${this.name}] will not sendNotification because it is (or was) under maintenance`);
+ }
+
+ // Reset down count
+ bean.downCount = 0;
+
+ // Clear Status Page Cache
+ log.debug("monitor", `[${this.name}] apicache clear`);
+ apicache.clear();
+
+ await UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
+
+ } else {
+ bean.important = false;
+
+ if (bean.status === DOWN && this.resendInterval > 0) {
+ ++bean.downCount;
+ if (bean.downCount >= this.resendInterval) {
+ // Send notification again, because we are still DOWN
+ log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
+ await Monitor.sendNotification(isFirstBeat, this, bean);
+
+ // Reset down count
+ bean.downCount = 0;
+ }
+ }
+ }
+
+ if (bean.status === UP) {
+ log.debug("monitor", `Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${beatInterval} seconds | Type: ${this.type}`);
+ } else if (bean.status === PENDING) {
+ if (this.retryInterval > 0) {
+ beatInterval = this.retryInterval;
+ }
+ log.warn("monitor", `Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`);
+ } else if (bean.status === MAINTENANCE) {
+ log.warn("monitor", `Monitor #${this.id} '${this.name}': Under Maintenance | Type: ${this.type}`);
+ } else {
+ log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
+ }
+
+ // Calculate uptime
+ let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(this.id);
+ let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
+ bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
+
+ // Send to frontend
+ log.debug("monitor", `[${this.name}] Send to socket`);
+ io.to(this.user_id).emit("heartbeat", bean.toJSON());
+ Monitor.sendStats(io, this.id, this.user_id);
+
+ // Store to database
+ log.debug("monitor", `[${this.name}] Store`);
+ await R.store(bean);
+
+ log.debug("monitor", `[${this.name}] prometheus.update`);
+ this.prometheus?.update(bean, tlsInfo);
+
+ previousBeat = bean;
+
+ if (! this.isStop) {
+ log.debug("monitor", `[${this.name}] SetTimeout for next check.`);
+
+ let intervalRemainingMs = Math.max(
+ 1,
+ beatInterval * 1000 - dayjs().diff(dayjs.utc(bean.time))
+ );
+
+ log.debug("monitor", `[${this.name}] Next heartbeat in: ${intervalRemainingMs}ms`);
+
+ this.heartbeatInterval = setTimeout(safeBeat, intervalRemainingMs);
+ } else {
+ log.info("monitor", `[${this.name}] isStop = true, no next check.`);
+ }
+
+ };
+
+ /**
+ * Get a heartbeat and handle errors7
+ * @returns {void}
+ */
+ const safeBeat = async () => {
+ try {
+ await beat();
+ } catch (e) {
+ console.trace(e);
+ UptimeKumaServer.errorLog(e, false);
+ log.error("monitor", "Please report to https://github.com/louislam/uptime-kuma/issues");
+
+ if (! this.isStop) {
+ log.info("monitor", "Try to restart the monitor");
+ this.heartbeatInterval = setTimeout(safeBeat, this.interval * 1000);
+ }
+ }
+ };
+
+ // Delay Push Type
+ if (this.type === "push") {
+ setTimeout(() => {
+ safeBeat();
+ }, this.interval * 1000);
+ } else {
+ safeBeat();
+ }
+ }
+
+ /**
+ * Make a request using axios
+ * @param {object} options Options for Axios
+ * @param {boolean} finalCall Should this be the final call i.e
+ * don't retry on failure
+ * @returns {object} Axios response
+ */
+ async makeAxiosRequest(options, finalCall = false) {
+ try {
+ let res;
+ if (this.auth_method === "ntlm") {
+ options.httpsAgent.keepAlive = true;
+
+ res = await httpNtlm(options, {
+ username: this.basic_auth_user,
+ password: this.basic_auth_pass,
+ domain: this.authDomain,
+ workstation: this.authWorkstation ? this.authWorkstation : undefined
+ });
+ } else {
+ res = await axios.request(options);
+ }
+
+ return res;
+ } catch (error) {
+
+ /**
+ * Make a single attempt to obtain an new access token in the event that
+ * the recent api request failed for authentication purposes
+ */
+ if (this.auth_method === "oauth2-cc" && error.response.status === 401 && !finalCall) {
+ this.oauthAccessToken = await this.makeOidcTokenClientCredentialsRequest();
+ let oauth2AuthHeader = {
+ "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token,
+ };
+ options.headers = { ...(options.headers),
+ ...(oauth2AuthHeader)
+ };
+
+ return this.makeAxiosRequest(options, true);
+ }
+
+ // Fix #2253
+ // Read more: https://stackoverflow.com/questions/1759956/curl-error-18-transfer-closed-with-outstanding-read-data-remaining
+ if (!finalCall && typeof error.message === "string" && error.message.includes("maxContentLength size of -1 exceeded")) {
+ log.debug("monitor", "makeAxiosRequest with gzip");
+ options.headers["Accept-Encoding"] = "gzip, deflate";
+ return this.makeAxiosRequest(options, true);
+ } else {
+ if (typeof error.message === "string" && error.message.includes("maxContentLength size of -1 exceeded")) {
+ error.message = "response timeout: incomplete response within a interval";
+ }
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * Stop monitor
+ * @returns {Promise<void>}
+ */
+ async stop() {
+ clearTimeout(this.heartbeatInterval);
+ this.isStop = true;
+
+ this.prometheus?.remove();
+ }
+
+ /**
+ * Get prometheus instance
+ * @returns {Prometheus|undefined} Current prometheus instance
+ */
+ getPrometheus() {
+ return this.prometheus;
+ }
+
+ /**
+ * Helper Method:
+ * returns URL object for further usage
+ * returns null if url is invalid
+ * @returns {(null|URL)} Monitor URL
+ */
+ getUrl() {
+ try {
+ return new URL(this.url);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ /**
+ * Example: http: or https:
+ * @returns {(null|string)} URL's protocol
+ */
+ getURLProtocol() {
+ const url = this.getUrl();
+ if (url) {
+ return this.getUrl().protocol;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Store TLS info to database
+ * @param {object} checkCertificateResult Certificate to update
+ * @returns {Promise<object>} Updated certificate
+ */
+ async updateTlsInfo(checkCertificateResult) {
+ let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
+ this.id,
+ ]);
+
+ if (tlsInfoBean == null) {
+ tlsInfoBean = R.dispense("monitor_tls_info");
+ tlsInfoBean.monitor_id = this.id;
+ } else {
+
+ // Clear sent history if the cert changed.
+ try {
+ let oldCertInfo = JSON.parse(tlsInfoBean.info_json);
+
+ let isValidObjects = oldCertInfo && oldCertInfo.certInfo && checkCertificateResult && checkCertificateResult.certInfo;
+
+ if (isValidObjects) {
+ if (oldCertInfo.certInfo.fingerprint256 !== checkCertificateResult.certInfo.fingerprint256) {
+ log.debug("monitor", "Resetting sent_history");
+ await R.exec("DELETE FROM notification_sent_history WHERE type = 'certificate' AND monitor_id = ?", [
+ this.id
+ ]);
+ } else {
+ log.debug("monitor", "No need to reset sent_history");
+ log.debug("monitor", oldCertInfo.certInfo.fingerprint256);
+ log.debug("monitor", checkCertificateResult.certInfo.fingerprint256);
+ }
+ } else {
+ log.debug("monitor", "Not valid object");
+ }
+ } catch (e) { }
+
+ }
+
+ tlsInfoBean.info_json = JSON.stringify(checkCertificateResult);
+ await R.store(tlsInfoBean);
+
+ return checkCertificateResult;
+ }
+
+ /**
+ * Checks if the monitor is active based on itself and its parents
+ * @param {number} monitorID ID of monitor to send
+ * @param {boolean} active is active
+ * @returns {Promise<boolean>} Is the monitor active?
+ */
+ static async isActive(monitorID, active) {
+ const parentActive = await Monitor.isParentActive(monitorID);
+
+ return (active === 1) && parentActive;
+ }
+
+ /**
+ * Send statistics to clients
+ * @param {Server} io Socket server instance
+ * @param {number} monitorID ID of monitor to send
+ * @param {number} userID ID of user to send to
+ * @returns {void}
+ */
+ static async sendStats(io, monitorID, userID) {
+ const hasClients = getTotalClientInRoom(io, userID) > 0;
+ let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
+
+ if (hasClients) {
+ // Send 24 hour average ping
+ let data24h = await uptimeCalculator.get24Hour();
+ io.to(userID).emit("avgPing", monitorID, (data24h.avgPing) ? Number(data24h.avgPing.toFixed(2)) : null);
+
+ // Send 24 hour uptime
+ io.to(userID).emit("uptime", monitorID, 24, data24h.uptime);
+
+ // Send 30 day uptime
+ let data30d = await uptimeCalculator.get30Day();
+ io.to(userID).emit("uptime", monitorID, 720, data30d.uptime);
+
+ // Send 1-year uptime
+ let data1y = await uptimeCalculator.get1Year();
+ io.to(userID).emit("uptime", monitorID, "1y", data1y.uptime);
+
+ // Send Cert Info
+ await Monitor.sendCertInfo(io, monitorID, userID);
+ } else {
+ log.debug("monitor", "No clients in the room, no need to send stats");
+ }
+ }
+
+ /**
+ * Send certificate information to client
+ * @param {Server} io Socket server instance
+ * @param {number} monitorID ID of monitor to send
+ * @param {number} userID ID of user to send to
+ * @returns {void}
+ */
+ static async sendCertInfo(io, monitorID, userID) {
+ let tlsInfo = await R.findOne("monitor_tls_info", "monitor_id = ?", [
+ monitorID,
+ ]);
+ if (tlsInfo != null) {
+ io.to(userID).emit("certInfo", monitorID, tlsInfo.info_json);
+ }
+ }
+
+ /**
+ * Has status of monitor changed since last beat?
+ * @param {boolean} isFirstBeat Is this the first beat of this monitor?
+ * @param {const} previousBeatStatus Status of the previous beat
+ * @param {const} currentBeatStatus Status of the current beat
+ * @returns {boolean} True if is an important beat else false
+ */
+ static isImportantBeat(isFirstBeat, previousBeatStatus, currentBeatStatus) {
+ // * ? -> ANY STATUS = important [isFirstBeat]
+ // UP -> PENDING = not important
+ // * UP -> DOWN = important
+ // UP -> UP = not important
+ // PENDING -> PENDING = not important
+ // * PENDING -> DOWN = important
+ // PENDING -> UP = not important
+ // DOWN -> PENDING = this case not exists
+ // DOWN -> DOWN = not important
+ // * DOWN -> UP = important
+ // MAINTENANCE -> MAINTENANCE = not important
+ // * MAINTENANCE -> UP = important
+ // * MAINTENANCE -> DOWN = important
+ // * DOWN -> MAINTENANCE = important
+ // * UP -> MAINTENANCE = important
+ return isFirstBeat ||
+ (previousBeatStatus === DOWN && currentBeatStatus === MAINTENANCE) ||
+ (previousBeatStatus === UP && currentBeatStatus === MAINTENANCE) ||
+ (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) ||
+ (previousBeatStatus === MAINTENANCE && currentBeatStatus === UP) ||
+ (previousBeatStatus === UP && currentBeatStatus === DOWN) ||
+ (previousBeatStatus === DOWN && currentBeatStatus === UP) ||
+ (previousBeatStatus === PENDING && currentBeatStatus === DOWN);
+ }
+
+ /**
+ * Is this beat important for notifications?
+ * @param {boolean} isFirstBeat Is this the first beat of this monitor?
+ * @param {const} previousBeatStatus Status of the previous beat
+ * @param {const} currentBeatStatus Status of the current beat
+ * @returns {boolean} True if is an important beat else false
+ */
+ static isImportantForNotification(isFirstBeat, previousBeatStatus, currentBeatStatus) {
+ // * ? -> ANY STATUS = important [isFirstBeat]
+ // UP -> PENDING = not important
+ // * UP -> DOWN = important
+ // UP -> UP = not important
+ // PENDING -> PENDING = not important
+ // * PENDING -> DOWN = important
+ // PENDING -> UP = not important
+ // DOWN -> PENDING = this case not exists
+ // DOWN -> DOWN = not important
+ // * DOWN -> UP = important
+ // MAINTENANCE -> MAINTENANCE = not important
+ // MAINTENANCE -> UP = not important
+ // * MAINTENANCE -> DOWN = important
+ // DOWN -> MAINTENANCE = not important
+ // UP -> MAINTENANCE = not important
+ return isFirstBeat ||
+ (previousBeatStatus === MAINTENANCE && currentBeatStatus === DOWN) ||
+ (previousBeatStatus === UP && currentBeatStatus === DOWN) ||
+ (previousBeatStatus === DOWN && currentBeatStatus === UP) ||
+ (previousBeatStatus === PENDING && currentBeatStatus === DOWN);
+ }
+
+ /**
+ * Send a notification about a monitor
+ * @param {boolean} isFirstBeat Is this beat the first of this monitor?
+ * @param {Monitor} monitor The monitor to send a notificaton about
+ * @param {Bean} bean Status information about monitor
+ * @returns {void}
+ */
+ static async sendNotification(isFirstBeat, monitor, bean) {
+ if (!isFirstBeat || bean.status === DOWN) {
+ const notificationList = await Monitor.getNotificationList(monitor);
+
+ let text;
+ if (bean.status === UP) {
+ text = "✅ Up";
+ } else {
+ text = "🔴 Down";
+ }
+
+ let msg = `[${monitor.name}] [${text}] ${bean.msg}`;
+
+ for (let notification of notificationList) {
+ try {
+ const heartbeatJSON = bean.toJSON();
+ const monitorData = [{ id: monitor.id,
+ active: monitor.active
+ }];
+ const preloadData = await Monitor.preparePreloadData(monitorData);
+ // Prevent if the msg is undefined, notifications such as Discord cannot send out.
+ if (!heartbeatJSON["msg"]) {
+ heartbeatJSON["msg"] = "N/A";
+ }
+
+ // Also provide the time in server timezone
+ heartbeatJSON["timezone"] = await UptimeKumaServer.getInstance().getTimezone();
+ heartbeatJSON["timezoneOffset"] = UptimeKumaServer.getInstance().getTimezoneOffset();
+ heartbeatJSON["localDateTime"] = dayjs.utc(heartbeatJSON["time"]).tz(heartbeatJSON["timezone"]).format(SQL_DATETIME_FORMAT);
+
+ await Notification.send(JSON.parse(notification.config), msg, monitor.toJSON(preloadData, false), heartbeatJSON);
+ } catch (e) {
+ log.error("monitor", "Cannot send notification to " + notification.name);
+ log.error("monitor", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Get list of notification providers for a given monitor
+ * @param {Monitor} monitor Monitor to get notification providers for
+ * @returns {Promise<LooseObject<any>[]>} List of notifications
+ */
+ static async getNotificationList(monitor) {
+ let notificationList = await R.getAll("SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id ", [
+ monitor.id,
+ ]);
+ return notificationList;
+ }
+
+ /**
+ * checks certificate chain for expiring certificates
+ * @param {object} tlsInfoObject Information about certificate
+ * @returns {void}
+ */
+ async checkCertExpiryNotifications(tlsInfoObject) {
+ if (tlsInfoObject && tlsInfoObject.certInfo && tlsInfoObject.certInfo.daysRemaining) {
+ const notificationList = await Monitor.getNotificationList(this);
+
+ if (! notificationList.length > 0) {
+ // fail fast. If no notification is set, all the following checks can be skipped.
+ log.debug("monitor", "No notification, no need to send cert notification");
+ return;
+ }
+
+ let notifyDays = await setting("tlsExpiryNotifyDays");
+ if (notifyDays == null || !Array.isArray(notifyDays)) {
+ // Reset Default
+ await setSetting("tlsExpiryNotifyDays", [ 7, 14, 21 ], "general");
+ notifyDays = [ 7, 14, 21 ];
+ }
+
+ if (Array.isArray(notifyDays)) {
+ for (const targetDays of notifyDays) {
+ let certInfo = tlsInfoObject.certInfo;
+ while (certInfo) {
+ let subjectCN = certInfo.subject["CN"];
+ if (rootCertificates.has(certInfo.fingerprint256)) {
+ log.debug("monitor", `Known root cert: ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`);
+ break;
+ } else if (certInfo.daysRemaining > targetDays) {
+ log.debug("monitor", `No need to send cert notification for ${certInfo.certType} certificate "${subjectCN}" (${certInfo.daysRemaining} days valid) on ${targetDays} deadline.`);
+ } else {
+ log.debug("monitor", `call sendCertNotificationByTargetDays for ${targetDays} deadline on certificate ${subjectCN}.`);
+ await this.sendCertNotificationByTargetDays(subjectCN, certInfo.certType, certInfo.daysRemaining, targetDays, notificationList);
+ }
+ certInfo = certInfo.issuerCertificate;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Send a certificate notification when certificate expires in less
+ * than target days
+ * @param {string} certCN Common Name attribute from the certificate subject
+ * @param {string} certType certificate type
+ * @param {number} daysRemaining Number of days remaining on certificate
+ * @param {number} targetDays Number of days to alert after
+ * @param {LooseObject<any>[]} notificationList List of notification providers
+ * @returns {Promise<void>}
+ */
+ async sendCertNotificationByTargetDays(certCN, certType, daysRemaining, targetDays, notificationList) {
+
+ let row = await R.getRow("SELECT * FROM notification_sent_history WHERE type = ? AND monitor_id = ? AND days <= ?", [
+ "certificate",
+ this.id,
+ targetDays,
+ ]);
+
+ // Sent already, no need to send again
+ if (row) {
+ log.debug("monitor", "Sent already, no need to send again");
+ return;
+ }
+
+ let sent = false;
+ log.debug("monitor", "Send certificate notification");
+
+ for (let notification of notificationList) {
+ try {
+ log.debug("monitor", "Sending to " + notification.name);
+ await Notification.send(JSON.parse(notification.config), `[${this.name}][${this.url}] ${certType} certificate ${certCN} will be expired in ${daysRemaining} days`);
+ sent = true;
+ } catch (e) {
+ log.error("monitor", "Cannot send cert notification to " + notification.name);
+ log.error("monitor", e);
+ }
+ }
+
+ if (sent) {
+ await R.exec("INSERT INTO notification_sent_history (type, monitor_id, days) VALUES(?, ?, ?)", [
+ "certificate",
+ this.id,
+ targetDays,
+ ]);
+ }
+ }
+
+ /**
+ * Get the status of the previous heartbeat
+ * @param {number} monitorID ID of monitor to check
+ * @returns {Promise<LooseObject<any>>} Previous heartbeat
+ */
+ static async getPreviousHeartbeat(monitorID) {
+ return await R.findOne("heartbeat", " id = (select MAX(id) from heartbeat where monitor_id = ?)", [
+ monitorID
+ ]);
+ }
+
+ /**
+ * Check if monitor is under maintenance
+ * @param {number} monitorID ID of monitor to check
+ * @returns {Promise<boolean>} Is the monitor under maintenance
+ */
+ static async isUnderMaintenance(monitorID) {
+ const maintenanceIDList = await R.getCol(`
+ SELECT maintenance_id FROM monitor_maintenance
+ WHERE monitor_id = ?
+ `, [ monitorID ]);
+
+ for (const maintenanceID of maintenanceIDList) {
+ const maintenance = await UptimeKumaServer.getInstance().getMaintenance(maintenanceID);
+ if (maintenance && await maintenance.isUnderMaintenance()) {
+ return true;
+ }
+ }
+
+ const parent = await Monitor.getParent(monitorID);
+ if (parent != null) {
+ return await Monitor.isUnderMaintenance(parent.id);
+ }
+
+ return false;
+ }
+
+ /**
+ * Make sure monitor interval is between bounds
+ * @returns {void}
+ * @throws Interval is outside of range
+ */
+ validate() {
+ if (this.interval > MAX_INTERVAL_SECOND) {
+ throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`);
+ }
+ if (this.interval < MIN_INTERVAL_SECOND) {
+ throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
+ }
+ }
+
+ /**
+ * Gets monitor notification of multiple monitor
+ * @param {Array} monitorIDs IDs of monitor to get
+ * @returns {Promise<LooseObject<any>>} object
+ */
+ static async getMonitorNotification(monitorIDs) {
+ return await R.getAll(`
+ SELECT monitor_notification.monitor_id, monitor_notification.notification_id
+ FROM monitor_notification
+ WHERE monitor_notification.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
+ `, monitorIDs);
+ }
+
+ /**
+ * Gets monitor tags of multiple monitor
+ * @param {Array} monitorIDs IDs of monitor to get
+ * @returns {Promise<LooseObject<any>>} object
+ */
+ static async getMonitorTag(monitorIDs) {
+ return await R.getAll(`
+ SELECT monitor_tag.monitor_id, monitor_tag.tag_id, tag.name, tag.color
+ FROM monitor_tag
+ JOIN tag ON monitor_tag.tag_id = tag.id
+ WHERE monitor_tag.monitor_id IN (${monitorIDs.map((_) => "?").join(",")})
+ `, monitorIDs);
+ }
+
+ /**
+ * prepare preloaded data for efficient access
+ * @param {Array} monitorData IDs & active field of monitor to get
+ * @returns {Promise<LooseObject<any>>} object
+ */
+ static async preparePreloadData(monitorData) {
+
+ const notificationsMap = new Map();
+ const tagsMap = new Map();
+ const maintenanceStatusMap = new Map();
+ const childrenIDsMap = new Map();
+ const activeStatusMap = new Map();
+ const forceInactiveMap = new Map();
+ const pathsMap = new Map();
+
+ if (monitorData.length > 0) {
+ const monitorIDs = monitorData.map(monitor => monitor.id);
+ const notifications = await Monitor.getMonitorNotification(monitorIDs);
+ const tags = await Monitor.getMonitorTag(monitorIDs);
+ const maintenanceStatuses = await Promise.all(monitorData.map(monitor => Monitor.isUnderMaintenance(monitor.id)));
+ const childrenIDs = await Promise.all(monitorData.map(monitor => Monitor.getAllChildrenIDs(monitor.id)));
+ const activeStatuses = await Promise.all(monitorData.map(monitor => Monitor.isActive(monitor.id, monitor.active)));
+ const forceInactiveStatuses = await Promise.all(monitorData.map(monitor => Monitor.isParentActive(monitor.id)));
+ const paths = await Promise.all(monitorData.map(monitor => Monitor.getAllPath(monitor.id, monitor.name)));
+
+ notifications.forEach(row => {
+ if (!notificationsMap.has(row.monitor_id)) {
+ notificationsMap.set(row.monitor_id, {});
+ }
+ notificationsMap.get(row.monitor_id)[row.notification_id] = true;
+ });
+
+ tags.forEach(row => {
+ if (!tagsMap.has(row.monitor_id)) {
+ tagsMap.set(row.monitor_id, []);
+ }
+ tagsMap.get(row.monitor_id).push({
+ tag_id: row.tag_id,
+ name: row.name,
+ color: row.color
+ });
+ });
+
+ monitorData.forEach((monitor, index) => {
+ maintenanceStatusMap.set(monitor.id, maintenanceStatuses[index]);
+ });
+
+ monitorData.forEach((monitor, index) => {
+ childrenIDsMap.set(monitor.id, childrenIDs[index]);
+ });
+
+ monitorData.forEach((monitor, index) => {
+ activeStatusMap.set(monitor.id, activeStatuses[index]);
+ });
+
+ monitorData.forEach((monitor, index) => {
+ forceInactiveMap.set(monitor.id, !forceInactiveStatuses[index]);
+ });
+
+ monitorData.forEach((monitor, index) => {
+ pathsMap.set(monitor.id, paths[index]);
+ });
+ }
+
+ return {
+ notifications: notificationsMap,
+ tags: tagsMap,
+ maintenanceStatus: maintenanceStatusMap,
+ childrenIDs: childrenIDsMap,
+ activeStatus: activeStatusMap,
+ forceInactive: forceInactiveMap,
+ paths: pathsMap,
+ };
+ }
+
+ /**
+ * Gets Parent of the monitor
+ * @param {number} monitorID ID of monitor to get
+ * @returns {Promise<LooseObject<any>>} Parent
+ */
+ static async getParent(monitorID) {
+ return await R.getRow(`
+ SELECT parent.* FROM monitor parent
+ LEFT JOIN monitor child
+ ON child.parent = parent.id
+ WHERE child.id = ?
+ `, [
+ monitorID,
+ ]);
+ }
+
+ /**
+ * Gets all Children of the monitor
+ * @param {number} monitorID ID of monitor to get
+ * @returns {Promise<LooseObject<any>>} Children
+ */
+ static async getChildren(monitorID) {
+ return await R.getAll(`
+ SELECT * FROM monitor
+ WHERE parent = ?
+ `, [
+ monitorID,
+ ]);
+ }
+
+ /**
+ * Gets the full path
+ * @param {number} monitorID ID of the monitor to get
+ * @param {string} name of the monitor to get
+ * @returns {Promise<string[]>} Full path (includes groups and the name) of the monitor
+ */
+ static async getAllPath(monitorID, name) {
+ const path = [ name ];
+
+ if (this.parent === null) {
+ return path;
+ }
+
+ let parent = await Monitor.getParent(monitorID);
+ while (parent !== null) {
+ path.unshift(parent.name);
+ parent = await Monitor.getParent(parent.id);
+ }
+
+ return path;
+ }
+
+ /**
+ * Gets recursive all child ids
+ * @param {number} monitorID ID of the monitor to get
+ * @returns {Promise<Array>} IDs of all children
+ */
+ static async getAllChildrenIDs(monitorID) {
+ const childs = await Monitor.getChildren(monitorID);
+
+ if (childs === null) {
+ return [];
+ }
+
+ let childrenIDs = [];
+
+ for (const child of childs) {
+ childrenIDs.push(child.id);
+ childrenIDs = childrenIDs.concat(await Monitor.getAllChildrenIDs(child.id));
+ }
+
+ return childrenIDs;
+ }
+
+ /**
+ * Unlinks all children of the group monitor
+ * @param {number} groupID ID of group to remove children of
+ * @returns {Promise<void>}
+ */
+ static async unlinkAllChildren(groupID) {
+ return await R.exec("UPDATE `monitor` SET parent = ? WHERE parent = ? ", [
+ null, groupID
+ ]);
+ }
+
+ /**
+ * Checks recursive if parent (ancestors) are active
+ * @param {number} monitorID ID of the monitor to get
+ * @returns {Promise<boolean>} Is the parent monitor active?
+ */
+ static async isParentActive(monitorID) {
+ const parent = await Monitor.getParent(monitorID);
+
+ if (parent === null) {
+ return true;
+ }
+
+ const parentActive = await Monitor.isParentActive(parent.id);
+ return parent.active && parentActive;
+ }
+
+ /**
+ * Obtains a new Oidc Token
+ * @returns {Promise<object>} OAuthProvider client
+ */
+ async makeOidcTokenClientCredentialsRequest() {
+ log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new token`);
+ const oAuthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method);
+ if (this.oauthAccessToken?.expires_at) {
+ log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken?.expires_at * 1000)}`);
+ } else {
+ log.debug("monitor", `[${this.name}] Obtained oauth access-token. Time until expiry was not provided`);
+ }
+
+ return oAuthAccessToken;
+ }
+
+ /**
+ * Store TLS certificate information and check for expiry
+ * @param {object} tlsInfo Information about the TLS connection
+ * @returns {Promise<void>}
+ */
+ async handleTlsInfo(tlsInfo) {
+ await this.updateTlsInfo(tlsInfo);
+ this.prometheus?.update(null, tlsInfo);
+
+ if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) {
+ log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`);
+ await this.checkCertExpiryNotifications(tlsInfo);
+ }
+ }
+}
+
+module.exports = Monitor;
diff --git a/server/model/proxy.js b/server/model/proxy.js
new file mode 100644
index 0000000..ec78403
--- /dev/null
+++ b/server/model/proxy.js
@@ -0,0 +1,25 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+
+class Proxy extends BeanModel {
+ /**
+ * Return an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ toJSON() {
+ return {
+ id: this._id,
+ userId: this._user_id,
+ protocol: this._protocol,
+ host: this._host,
+ port: this._port,
+ auth: !!this._auth,
+ username: this._username,
+ password: this._password,
+ active: !!this._active,
+ default: !!this._default,
+ createdDate: this._created_date,
+ };
+ }
+}
+
+module.exports = Proxy;
diff --git a/server/model/remote_browser.js b/server/model/remote_browser.js
new file mode 100644
index 0000000..49299ad
--- /dev/null
+++ b/server/model/remote_browser.js
@@ -0,0 +1,17 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+
+class RemoteBrowser extends BeanModel {
+ /**
+ * Returns an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ toJSON() {
+ return {
+ id: this.id,
+ url: this.url,
+ name: this.name,
+ };
+ }
+}
+
+module.exports = RemoteBrowser;
diff --git a/server/model/status_page.js b/server/model/status_page.js
new file mode 100644
index 0000000..38f548e
--- /dev/null
+++ b/server/model/status_page.js
@@ -0,0 +1,491 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+const { R } = require("redbean-node");
+const cheerio = require("cheerio");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const jsesc = require("jsesc");
+const googleAnalytics = require("../google-analytics");
+const { marked } = require("marked");
+const { Feed } = require("feed");
+const config = require("../config");
+
+const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util");
+
+class StatusPage extends BeanModel {
+
+ /**
+ * Like this: { "test-uptime.kuma.pet": "default" }
+ * @type {{}}
+ */
+ static domainMappingList = { };
+
+ /**
+ * Handle responses to RSS pages
+ * @param {Response} response Response object
+ * @param {string} slug Status page slug
+ * @returns {Promise<void>}
+ */
+ static async handleStatusPageRSSResponse(response, slug) {
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (statusPage) {
+ response.send(await StatusPage.renderRSS(statusPage, slug));
+ } else {
+ response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
+ }
+ }
+
+ /**
+ * Handle responses to status page
+ * @param {Response} response Response object
+ * @param {string} indexHTML HTML to render
+ * @param {string} slug Status page slug
+ * @returns {Promise<void>}
+ */
+ static async handleStatusPageResponse(response, indexHTML, slug) {
+ // Handle url with trailing slash (http://localhost:3001/status/)
+ // The slug comes from the route "/status/:slug". If the slug is empty, express converts it to "index.html"
+ if (slug === "index.html") {
+ slug = "default";
+ }
+
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (statusPage) {
+ response.send(await StatusPage.renderHTML(indexHTML, statusPage));
+ } else {
+ response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
+ }
+ }
+
+ /**
+ * SSR for RSS feed
+ * @param {statusPage} statusPage object
+ * @param {slug} slug from router
+ * @returns {Promise<string>} the rendered html
+ */
+ static async renderRSS(statusPage, slug) {
+ const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage);
+
+ let proto = config.isSSL ? "https" : "http";
+ let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`;
+
+ const feed = new Feed({
+ title: "uptime kuma rss feed",
+ description: `current status: ${statusDescription}`,
+ link: host,
+ language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
+ updated: new Date(), // optional, default = today
+ });
+
+ heartbeats.forEach(heartbeat => {
+ feed.addItem({
+ title: `${heartbeat.name} is down`,
+ description: `${heartbeat.name} has been down since ${heartbeat.time}`,
+ id: heartbeat.monitorID,
+ date: new Date(heartbeat.time),
+ });
+ });
+
+ return feed.rss2();
+ }
+
+ /**
+ * SSR for status pages
+ * @param {string} indexHTML HTML page to render
+ * @param {StatusPage} statusPage Status page populate HTML with
+ * @returns {Promise<string>} the rendered html
+ */
+ static async renderHTML(indexHTML, statusPage) {
+ const $ = cheerio.load(indexHTML);
+
+ const description155 = marked(statusPage.description ?? "")
+ .replace(/<[^>]+>/gm, "")
+ .trim()
+ .substring(0, 155);
+
+ $("title").text(statusPage.title);
+ $("meta[name=description]").attr("content", description155);
+
+ if (statusPage.icon) {
+ $("link[rel=icon]")
+ .attr("href", statusPage.icon)
+ .removeAttr("type");
+
+ $("link[rel=apple-touch-icon]").remove();
+ }
+
+ const head = $("head");
+
+ if (statusPage.googleAnalyticsTagId) {
+ let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId);
+ head.append($(escapedGoogleAnalyticsScript));
+ }
+
+ // OG Meta Tags
+ let ogTitle = $("<meta property=\"og:title\" content=\"\" />").attr("content", statusPage.title);
+ head.append(ogTitle);
+
+ let ogDescription = $("<meta property=\"og:description\" content=\"\" />").attr("content", description155);
+ head.append(ogDescription);
+
+ // Preload data
+ // Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186
+ const escapedJSONObject = jsesc(await StatusPage.getStatusPageData(statusPage), {
+ "isScriptContext": true
+ });
+
+ const script = $(`
+ <script id="preload-data" data-json="{}">
+ window.preloadData = ${escapedJSONObject};
+ </script>
+ `);
+
+ head.append(script);
+
+ // manifest.json
+ $("link[rel=manifest]").attr("href", `/api/status-page/${statusPage.slug}/manifest.json`);
+
+ return $.root().html();
+ }
+
+ /**
+ * @param {heartbeats} heartbeats from getRSSPageData
+ * @returns {number} status_page constant from util.ts
+ */
+ static overallStatus(heartbeats) {
+ if (heartbeats.length === 0) {
+ return -1;
+ }
+
+ let status = STATUS_PAGE_ALL_UP;
+ let hasUp = false;
+
+ for (let beat of heartbeats) {
+ if (beat.status === MAINTENANCE) {
+ return STATUS_PAGE_MAINTENANCE;
+ } else if (beat.status === UP) {
+ hasUp = true;
+ } else {
+ status = STATUS_PAGE_PARTIAL_DOWN;
+ }
+ }
+
+ if (! hasUp) {
+ status = STATUS_PAGE_ALL_DOWN;
+ }
+
+ return status;
+ }
+
+ /**
+ * @param {number} status from overallStatus
+ * @returns {string} description
+ */
+ static getStatusDescription(status) {
+ if (status === -1) {
+ return "No Services";
+ }
+
+ if (status === STATUS_PAGE_ALL_UP) {
+ return "All Systems Operational";
+ }
+
+ if (status === STATUS_PAGE_PARTIAL_DOWN) {
+ return "Partially Degraded Service";
+ }
+
+ if (status === STATUS_PAGE_ALL_DOWN) {
+ return "Degraded Service";
+ }
+
+ // TODO: show the real maintenance information: title, description, time
+ if (status === MAINTENANCE) {
+ return "Under maintenance";
+ }
+
+ return "?";
+ }
+
+ /**
+ * Get all data required for RSS
+ * @param {StatusPage} statusPage Status page to get data for
+ * @returns {object} Status page data
+ */
+ static async getRSSPageData(statusPage) {
+ // get all heartbeats that correspond to this statusPage
+ const config = await statusPage.toPublicJSON();
+
+ // Public Group List
+ const showTags = !!statusPage.show_tags;
+
+ const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [
+ statusPage.id
+ ]);
+
+ let heartbeats = [];
+
+ for (let groupBean of list) {
+ let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
+ for (const monitor of monitorGroup.monitorList) {
+ const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]);
+ if (heartbeat) {
+ heartbeats.push({
+ ...monitor,
+ status: heartbeat.status,
+ time: heartbeat.time
+ });
+ }
+ }
+ }
+
+ // calculate RSS feed description
+ let status = StatusPage.overallStatus(heartbeats);
+ let statusDescription = StatusPage.getStatusDescription(status);
+
+ // keep only DOWN heartbeats in the RSS feed
+ heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN);
+
+ return {
+ heartbeats,
+ statusDescription
+ };
+ }
+
+ /**
+ * Get all status page data in one call
+ * @param {StatusPage} statusPage Status page to get data for
+ * @returns {object} Status page data
+ */
+ static async getStatusPageData(statusPage) {
+ const config = await statusPage.toPublicJSON();
+
+ // Incident
+ let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [
+ statusPage.id,
+ ]);
+
+ if (incident) {
+ incident = incident.toPublicJSON();
+ }
+
+ let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
+
+ // Public Group List
+ const publicGroupList = [];
+ const showTags = !!statusPage.show_tags;
+
+ const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [
+ statusPage.id
+ ]);
+
+ for (let groupBean of list) {
+ let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
+ publicGroupList.push(monitorGroup);
+ }
+
+ // Response
+ return {
+ config,
+ incident,
+ publicGroupList,
+ maintenanceList,
+ };
+ }
+
+ /**
+ * Loads domain mapping from DB
+ * Return object like this: { "test-uptime.kuma.pet": "default" }
+ * @returns {Promise<void>}
+ */
+ static async loadDomainMappingList() {
+ StatusPage.domainMappingList = await R.getAssoc(`
+ SELECT domain, slug
+ FROM status_page, status_page_cname
+ WHERE status_page.id = status_page_cname.status_page_id
+ `);
+ }
+
+ /**
+ * Send status page list to client
+ * @param {Server} io io Socket server instance
+ * @param {Socket} socket Socket.io instance
+ * @returns {Promise<Bean[]>} Status page list
+ */
+ static async sendStatusPageList(io, socket) {
+ let result = {};
+
+ let list = await R.findAll("status_page", " ORDER BY title ");
+
+ for (let item of list) {
+ result[item.id] = await item.toJSON();
+ }
+
+ io.to(socket.userID).emit("statusPageList", result);
+ return list;
+ }
+
+ /**
+ * Update list of domain names
+ * @param {string[]} domainNameList List of status page domains
+ * @returns {Promise<void>}
+ */
+ async updateDomainNameList(domainNameList) {
+
+ if (!Array.isArray(domainNameList)) {
+ throw new Error("Invalid array");
+ }
+
+ let trx = await R.begin();
+
+ await trx.exec("DELETE FROM status_page_cname WHERE status_page_id = ?", [
+ this.id,
+ ]);
+
+ try {
+ for (let domain of domainNameList) {
+ if (typeof domain !== "string") {
+ throw new Error("Invalid domain");
+ }
+
+ if (domain.trim() === "") {
+ continue;
+ }
+
+ // If the domain name is used in another status page, delete it
+ await trx.exec("DELETE FROM status_page_cname WHERE domain = ?", [
+ domain,
+ ]);
+
+ let mapping = trx.dispense("status_page_cname");
+ mapping.status_page_id = this.id;
+ mapping.domain = domain;
+ await trx.store(mapping);
+ }
+ await trx.commit();
+ } catch (error) {
+ await trx.rollback();
+ throw error;
+ }
+ }
+
+ /**
+ * Get list of domain names
+ * @returns {object[]} List of status page domains
+ */
+ getDomainNameList() {
+ let domainList = [];
+ for (let domain in StatusPage.domainMappingList) {
+ let s = StatusPage.domainMappingList[domain];
+
+ if (this.slug === s) {
+ domainList.push(domain);
+ }
+ }
+ return domainList;
+ }
+
+ /**
+ * Return an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ async toJSON() {
+ return {
+ id: this.id,
+ slug: this.slug,
+ title: this.title,
+ description: this.description,
+ icon: this.getIcon(),
+ theme: this.theme,
+ autoRefreshInterval: this.autoRefreshInterval,
+ published: !!this.published,
+ showTags: !!this.show_tags,
+ domainNameList: this.getDomainNameList(),
+ customCSS: this.custom_css,
+ footerText: this.footer_text,
+ showPoweredBy: !!this.show_powered_by,
+ googleAnalyticsId: this.google_analytics_tag_id,
+ showCertificateExpiry: !!this.show_certificate_expiry,
+ };
+ }
+
+ /**
+ * Return an object that ready to parse to JSON for public
+ * Only show necessary data to public
+ * @returns {object} Object ready to parse
+ */
+ async toPublicJSON() {
+ return {
+ slug: this.slug,
+ title: this.title,
+ description: this.description,
+ icon: this.getIcon(),
+ autoRefreshInterval: this.autoRefreshInterval,
+ theme: this.theme,
+ published: !!this.published,
+ showTags: !!this.show_tags,
+ customCSS: this.custom_css,
+ footerText: this.footer_text,
+ showPoweredBy: !!this.show_powered_by,
+ googleAnalyticsId: this.google_analytics_tag_id,
+ showCertificateExpiry: !!this.show_certificate_expiry,
+ };
+ }
+
+ /**
+ * Convert slug to status page ID
+ * @param {string} slug Status page slug
+ * @returns {Promise<number>} ID of status page
+ */
+ static async slugToID(slug) {
+ return await R.getCell("SELECT id FROM status_page WHERE slug = ? ", [
+ slug
+ ]);
+ }
+
+ /**
+ * Get path to the icon for the page
+ * @returns {string} Path
+ */
+ getIcon() {
+ if (!this.icon) {
+ return "/icon.svg";
+ } else {
+ return this.icon;
+ }
+ }
+
+ /**
+ * Get list of maintenances
+ * @param {number} statusPageId ID of status page to get maintenance for
+ * @returns {object} Object representing maintenances sanitized for public
+ */
+ static async getMaintenanceList(statusPageId) {
+ try {
+ const publicMaintenanceList = [];
+
+ let maintenanceIDList = await R.getCol(`
+ SELECT DISTINCT maintenance_id
+ FROM maintenance_status_page
+ WHERE status_page_id = ?
+ `, [ statusPageId ]);
+
+ for (const maintenanceID of maintenanceIDList) {
+ let maintenance = UptimeKumaServer.getInstance().getMaintenance(maintenanceID);
+ if (maintenance && await maintenance.isUnderMaintenance()) {
+ publicMaintenanceList.push(await maintenance.toPublicJSON());
+ }
+ }
+
+ return publicMaintenanceList;
+
+ } catch (error) {
+ return [];
+ }
+ }
+}
+
+module.exports = StatusPage;
diff --git a/server/model/tag.js b/server/model/tag.js
new file mode 100644
index 0000000..bc8a4db
--- /dev/null
+++ b/server/model/tag.js
@@ -0,0 +1,18 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+
+class Tag extends BeanModel {
+
+ /**
+ * Return an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ toJSON() {
+ return {
+ id: this._id,
+ name: this._name,
+ color: this._color,
+ };
+ }
+}
+
+module.exports = Tag;
diff --git a/server/model/user.js b/server/model/user.js
new file mode 100644
index 0000000..329402f
--- /dev/null
+++ b/server/model/user.js
@@ -0,0 +1,53 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+const passwordHash = require("../password-hash");
+const { R } = require("redbean-node");
+const jwt = require("jsonwebtoken");
+const { shake256, SHAKE256_LENGTH } = require("../util-server");
+
+class User extends BeanModel {
+ /**
+ * Reset user password
+ * Fix #1510, as in the context reset-password.js, there is no auto model mapping. Call this static function instead.
+ * @param {number} userID ID of user to update
+ * @param {string} newPassword Users new password
+ * @returns {Promise<void>}
+ */
+ static async resetPassword(userID, newPassword) {
+ await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
+ passwordHash.generate(newPassword),
+ userID
+ ]);
+ }
+
+ /**
+ * Reset this users password
+ * @param {string} newPassword Users new password
+ * @returns {Promise<void>}
+ */
+ async resetPassword(newPassword) {
+ const hashedPassword = passwordHash.generate(newPassword);
+
+ await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
+ hashedPassword,
+ this.id
+ ]);
+
+ this.password = hashedPassword;
+ }
+
+ /**
+ * Create a new JWT for a user
+ * @param {User} user The User to create a JsonWebToken for
+ * @param {string} jwtSecret The key used to sign the JsonWebToken
+ * @returns {string} the JsonWebToken as a string
+ */
+ static createJWT(user, jwtSecret) {
+ return jwt.sign({
+ username: user.username,
+ h: shake256(user.password, SHAKE256_LENGTH),
+ }, jwtSecret);
+ }
+
+}
+
+module.exports = User;
diff --git a/server/modules/apicache/apicache.js b/server/modules/apicache/apicache.js
new file mode 100644
index 0000000..41930b2
--- /dev/null
+++ b/server/modules/apicache/apicache.js
@@ -0,0 +1,917 @@
+let url = require("url");
+let MemoryCache = require("./memory-cache");
+
+let t = {
+ ms: 1,
+ second: 1000,
+ minute: 60000,
+ hour: 3600000,
+ day: 3600000 * 24,
+ week: 3600000 * 24 * 7,
+ month: 3600000 * 24 * 30,
+};
+
+let instances = [];
+
+/**
+ * Does a === b
+ * @param {any} a
+ * @returns {function(any): boolean}
+ */
+let matches = function (a) {
+ return function (b) {
+ return a === b;
+ };
+};
+
+/**
+ * Does a!==b
+ * @param {any} a
+ * @returns {function(any): boolean}
+ */
+let doesntMatch = function (a) {
+ return function (b) {
+ return !matches(a)(b);
+ };
+};
+
+/**
+ * Get log duration
+ * @param {number} d Time in ms
+ * @param {string} prefix Prefix for log
+ * @returns {string} Coloured log string
+ */
+let logDuration = function (d, prefix) {
+ let str = d > 1000 ? (d / 1000).toFixed(2) + "sec" : d + "ms";
+ return "\x1b[33m- " + (prefix ? prefix + " " : "") + str + "\x1b[0m";
+};
+
+/**
+ * Get safe headers
+ * @param {Object} res Express response object
+ * @returns {Object}
+ */
+function getSafeHeaders(res) {
+ return res.getHeaders ? res.getHeaders() : res._headers;
+}
+
+/** Constructor for ApiCache instance */
+function ApiCache() {
+ let memCache = new MemoryCache();
+
+ let globalOptions = {
+ debug: false,
+ defaultDuration: 3600000,
+ enabled: true,
+ appendKey: [],
+ jsonp: false,
+ redisClient: false,
+ headerBlacklist: [],
+ statusCodes: {
+ include: [],
+ exclude: [],
+ },
+ events: {
+ expire: undefined,
+ },
+ headers: {
+ // 'cache-control': 'no-cache' // example of header overwrite
+ },
+ trackPerformance: false,
+ respectCacheControl: false,
+ };
+
+ let middlewareOptions = [];
+ let instance = this;
+ let index = null;
+ let timers = {};
+ let performanceArray = []; // for tracking cache hit rate
+
+ instances.push(this);
+ this.id = instances.length;
+
+ /**
+ * Logs a message to the console if the `DEBUG` environment variable is set.
+ * @param {string} a The first argument to log.
+ * @param {string} b The second argument to log.
+ * @param {string} c The third argument to log.
+ * @param {string} d The fourth argument to log, and so on... (optional)
+ *
+ * Generated by Trelent
+ */
+ function debug(a, b, c, d) {
+ let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) {
+ return arg !== undefined;
+ });
+ let debugEnv = process.env.DEBUG && process.env.DEBUG.split(",").indexOf("apicache") !== -1;
+
+ return (globalOptions.debug || debugEnv) && console.log.apply(null, arr);
+ }
+
+ /**
+ * Returns true if the given request and response should be logged.
+ * @param {Object} request The HTTP request object.
+ * @param {Object} response The HTTP response object.
+ * @param {function(Object, Object):boolean} toggle
+ * @returns {boolean}
+ */
+ function shouldCacheResponse(request, response, toggle) {
+ let opt = globalOptions;
+ let codes = opt.statusCodes;
+
+ if (!response) {
+ return false;
+ }
+
+ if (toggle && !toggle(request, response)) {
+ return false;
+ }
+
+ if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) {
+ return false;
+ }
+ if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Add key to index array
+ * @param {string} key Key to add
+ * @param {Object} req Express request object
+ */
+ function addIndexEntries(key, req) {
+ let groupName = req.apicacheGroup;
+
+ if (groupName) {
+ debug("group detected \"" + groupName + "\"");
+ let group = (index.groups[groupName] = index.groups[groupName] || []);
+ group.unshift(key);
+ }
+
+ index.all.unshift(key);
+ }
+
+ /**
+ * Returns a new object containing only the whitelisted headers.
+ * @param {Object} headers The original object of header names and
+ * values.
+ * @param {string[]} globalOptions.headerWhitelist An array of
+ * strings representing the whitelisted header names to keep in the
+ * output object.
+ *
+ * Generated by Trelent
+ */
+ function filterBlacklistedHeaders(headers) {
+ return Object.keys(headers)
+ .filter(function (key) {
+ return globalOptions.headerBlacklist.indexOf(key) === -1;
+ })
+ .reduce(function (acc, header) {
+ acc[header] = headers[header];
+ return acc;
+ }, {});
+ }
+
+ /**
+ * Create a cache object
+ * @param {Object} headers The response headers to filter.
+ * @returns {Object} A new object containing only the whitelisted
+ * response headers.
+ *
+ * Generated by Trelent
+ */
+ function createCacheObject(status, headers, data, encoding) {
+ return {
+ status: status,
+ headers: filterBlacklistedHeaders(headers),
+ data: data,
+ encoding: encoding,
+ timestamp: new Date().getTime() / 1000, // seconds since epoch. This is used to properly decrement max-age headers in cached responses.
+ };
+ }
+
+ /**
+ * Sets a cache value for the given key.
+ * @param {string} key The cache key to set.
+ * @param {any} value The cache value to set.
+ * @param {number} duration How long in milliseconds the cached
+ * response should be valid for (defaults to 1 hour).
+ *
+ * Generated by Trelent
+ */
+ function cacheResponse(key, value, duration) {
+ let redis = globalOptions.redisClient;
+ let expireCallback = globalOptions.events.expire;
+
+ if (redis && redis.connected) {
+ try {
+ redis.hset(key, "response", JSON.stringify(value));
+ redis.hset(key, "duration", duration);
+ redis.expire(key, duration / 1000, expireCallback || function () {});
+ } catch (err) {
+ debug("[apicache] error in redis.hset()");
+ }
+ } else {
+ memCache.add(key, value, duration, expireCallback);
+ }
+
+ // add automatic cache clearing from duration, includes max limit on setTimeout
+ timers[key] = setTimeout(function () {
+ instance.clear(key, true);
+ }, Math.min(duration, 2147483647));
+ }
+
+ /**
+ * Appends content to the response.
+ * @param {Object} res Express response object
+ * @param {(string|Buffer)} content The content to append.
+ *
+ * Generated by Trelent
+ */
+ function accumulateContent(res, content) {
+ if (content) {
+ if (typeof content == "string") {
+ res._apicache.content = (res._apicache.content || "") + content;
+ } else if (Buffer.isBuffer(content)) {
+ let oldContent = res._apicache.content;
+
+ if (typeof oldContent === "string") {
+ oldContent = !Buffer.from ? new Buffer(oldContent) : Buffer.from(oldContent);
+ }
+
+ if (!oldContent) {
+ oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0);
+ }
+
+ res._apicache.content = Buffer.concat(
+ [oldContent, content],
+ oldContent.length + content.length
+ );
+ } else {
+ res._apicache.content = content;
+ }
+ }
+ }
+
+ /**
+ * Monkeypatches the response object to add cache control headers
+ * and create a cache object.
+ * @param {Object} req Express request object
+ * @param {Object} res Express response object
+ * @param {function} next Function to call next
+ * @param {string} key Key to add response as
+ * @param {number} duration Time to cache response for
+ * @param {string} strDuration Duration in string form
+ * @param {function(Object, Object):boolean} toggle
+ */
+ function makeResponseCacheable(req, res, next, key, duration, strDuration, toggle) {
+ // monkeypatch res.end to create cache object
+ res._apicache = {
+ write: res.write,
+ writeHead: res.writeHead,
+ end: res.end,
+ cacheable: true,
+ content: undefined,
+ };
+
+ // append header overwrites if applicable
+ Object.keys(globalOptions.headers).forEach(function (name) {
+ res.setHeader(name, globalOptions.headers[name]);
+ });
+
+ res.writeHead = function () {
+ // add cache control headers
+ if (!globalOptions.headers["cache-control"]) {
+ if (shouldCacheResponse(req, res, toggle)) {
+ res.setHeader("cache-control", "max-age=" + (duration / 1000).toFixed(0));
+ } else {
+ res.setHeader("cache-control", "no-cache, no-store, must-revalidate");
+ }
+ }
+
+ res._apicache.headers = Object.assign({}, getSafeHeaders(res));
+ return res._apicache.writeHead.apply(this, arguments);
+ };
+
+ // patch res.write
+ res.write = function (content) {
+ accumulateContent(res, content);
+ return res._apicache.write.apply(this, arguments);
+ };
+
+ // patch res.end
+ res.end = function (content, encoding) {
+ if (shouldCacheResponse(req, res, toggle)) {
+ accumulateContent(res, content);
+
+ if (res._apicache.cacheable && res._apicache.content) {
+ addIndexEntries(key, req);
+ let headers = res._apicache.headers || getSafeHeaders(res);
+ let cacheObject = createCacheObject(
+ res.statusCode,
+ headers,
+ res._apicache.content,
+ encoding
+ );
+ cacheResponse(key, cacheObject, duration);
+
+ // display log entry
+ let elapsed = new Date() - req.apicacheTimer;
+ debug("adding cache entry for \"" + key + "\" @ " + strDuration, logDuration(elapsed));
+ debug("_apicache.headers: ", res._apicache.headers);
+ debug("res.getHeaders(): ", getSafeHeaders(res));
+ debug("cacheObject: ", cacheObject);
+ }
+ }
+
+ return res._apicache.end.apply(this, arguments);
+ };
+
+ next();
+ }
+
+ /**
+ * Send a cached response to client
+ * @param {Request} request Express request object
+ * @param {Response} response Express response object
+ * @param {object} cacheObject Cache object to send
+ * @param {function(Object, Object):boolean} toggle
+ * @param {function} next Function to call next
+ * @param {number} duration Not used
+ * @returns {boolean|undefined} true if the request should be
+ * cached, false otherwise. If undefined, defaults to true.
+ */
+ function sendCachedResponse(request, response, cacheObject, toggle, next, duration) {
+ if (toggle && !toggle(request, response)) {
+ return next();
+ }
+
+ let headers = getSafeHeaders(response);
+
+ // Modified by @louislam, removed Cache-control, since I don't need client side cache!
+ // Original Source: https://github.com/kwhitley/apicache/blob/0d5686cc21fad353c6dddee646288c2fca3e4f50/src/apicache.js#L254
+ Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}));
+
+ // only embed apicache headers when not in production environment
+ if (process.env.NODE_ENV !== "production") {
+ Object.assign(headers, {
+ "apicache-store": globalOptions.redisClient ? "redis" : "memory",
+ "apicache-version": "1.6.2-modified",
+ });
+ }
+
+ // unstringify buffers
+ let data = cacheObject.data;
+ if (data && data.type === "Buffer") {
+ data =
+ typeof data.data === "number" ? new Buffer.alloc(data.data) : new Buffer.from(data.data);
+ }
+
+ // test Etag against If-None-Match for 304
+ let cachedEtag = cacheObject.headers.etag;
+ let requestEtag = request.headers["if-none-match"];
+
+ if (requestEtag && cachedEtag === requestEtag) {
+ response.writeHead(304, headers);
+ return response.end();
+ }
+
+ response.writeHead(cacheObject.status || 200, headers);
+
+ return response.end(data, cacheObject.encoding);
+ }
+
+ /** Sync caching options */
+ function syncOptions() {
+ for (let i in middlewareOptions) {
+ Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions);
+ }
+ }
+
+ /**
+ * Clear key from cache
+ * @param {string} target Key to clear
+ * @param {boolean} isAutomatic Is the key being cleared automatically
+ * @returns {number}
+ */
+ this.clear = function (target, isAutomatic) {
+ let group = index.groups[target];
+ let redis = globalOptions.redisClient;
+
+ if (group) {
+ debug("clearing group \"" + target + "\"");
+
+ group.forEach(function (key) {
+ debug("clearing cached entry for \"" + key + "\"");
+ clearTimeout(timers[key]);
+ delete timers[key];
+ if (!globalOptions.redisClient) {
+ memCache.delete(key);
+ } else {
+ try {
+ redis.del(key);
+ } catch (err) {
+ console.log("[apicache] error in redis.del(\"" + key + "\")");
+ }
+ }
+ index.all = index.all.filter(doesntMatch(key));
+ });
+
+ delete index.groups[target];
+ } else if (target) {
+ debug("clearing " + (isAutomatic ? "expired" : "cached") + " entry for \"" + target + "\"");
+ clearTimeout(timers[target]);
+ delete timers[target];
+ // clear actual cached entry
+ if (!redis) {
+ memCache.delete(target);
+ } else {
+ try {
+ redis.del(target);
+ } catch (err) {
+ console.log("[apicache] error in redis.del(\"" + target + "\")");
+ }
+ }
+
+ // remove from global index
+ index.all = index.all.filter(doesntMatch(target));
+
+ // remove target from each group that it may exist in
+ Object.keys(index.groups).forEach(function (groupName) {
+ index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target));
+
+ // delete group if now empty
+ if (!index.groups[groupName].length) {
+ delete index.groups[groupName];
+ }
+ });
+ } else {
+ debug("clearing entire index");
+
+ if (!redis) {
+ memCache.clear();
+ } else {
+ // clear redis keys one by one from internal index to prevent clearing non-apicache entries
+ index.all.forEach(function (key) {
+ clearTimeout(timers[key]);
+ delete timers[key];
+ try {
+ redis.del(key);
+ } catch (err) {
+ console.log("[apicache] error in redis.del(\"" + key + "\")");
+ }
+ });
+ }
+ this.resetIndex();
+ }
+
+ return this.getIndex();
+ };
+
+ /**
+ * Converts a duration string to an integer number of milliseconds.
+ * @param {(string|number)} duration The string to convert.
+ * @param {number} defaultDuration The default duration to return if
+ * can't parse duration
+ * @returns {number} The converted value in milliseconds, or the
+ * defaultDuration if it can't be parsed.
+ */
+ function parseDuration(duration, defaultDuration) {
+ if (typeof duration === "number") {
+ return duration;
+ }
+
+ if (typeof duration === "string") {
+ let split = duration.match(/^([\d\.,]+)\s?(\w+)$/);
+
+ if (split.length === 3) {
+ let len = parseFloat(split[1]);
+ let unit = split[2].replace(/s$/i, "").toLowerCase();
+ if (unit === "m") {
+ unit = "ms";
+ }
+
+ return (len || 1) * (t[unit] || 0);
+ }
+ }
+
+ return defaultDuration;
+ }
+
+ /**
+ * Parse duration
+ * @param {(number|string)} duration
+ * @returns {number} Duration parsed to a number
+ */
+ this.getDuration = function (duration) {
+ return parseDuration(duration, globalOptions.defaultDuration);
+ };
+
+ /**
+ * Return cache performance statistics (hit rate). Suitable for
+ * putting into a route:
+ * <code>
+ * app.get('/api/cache/performance', (req, res) => {
+ * res.json(apicache.getPerformance())
+ * })
+ * </code>
+ * @returns {any[]}
+ */
+ this.getPerformance = function () {
+ return performanceArray.map(function (p) {
+ return p.report();
+ });
+ };
+
+ /**
+ * Get index of a group
+ * @param {string} group
+ * @returns {number}
+ */
+ this.getIndex = function (group) {
+ if (group) {
+ return index.groups[group];
+ } else {
+ return index;
+ }
+ };
+
+ /**
+ * Express middleware
+ * @param {(string|number)} strDuration Duration to cache responses
+ * for.
+ * @param {function(Object, Object):boolean} middlewareToggle
+ * @param {Object} localOptions Options for APICache
+ * @returns
+ */
+ this.middleware = function cache(strDuration, middlewareToggle, localOptions) {
+ let duration = instance.getDuration(strDuration);
+ let opt = {};
+
+ middlewareOptions.push({
+ options: opt,
+ });
+
+ let options = function (localOptions) {
+ if (localOptions) {
+ middlewareOptions.find(function (middleware) {
+ return middleware.options === opt;
+ }).localOptions = localOptions;
+ }
+
+ syncOptions();
+
+ return opt;
+ };
+
+ options(localOptions);
+
+ /**
+ * A Function for non tracking performance
+ */
+ function NOOPCachePerformance() {
+ this.report = this.hit = this.miss = function () {}; // noop;
+ }
+
+ /**
+ * A function for tracking and reporting hit rate. These
+ * statistics are returned by the getPerformance() call above.
+ */
+ function CachePerformance() {
+ /**
+ * Tracks the hit rate for the last 100 requests. If there
+ * have been fewer than 100 requests, the hit rate just
+ * considers the requests that have happened.
+ */
+ this.hitsLast100 = new Uint8Array(100 / 4); // each hit is 2 bits
+
+ /**
+ * Tracks the hit rate for the last 1000 requests. If there
+ * have been fewer than 1000 requests, the hit rate just
+ * considers the requests that have happened.
+ */
+ this.hitsLast1000 = new Uint8Array(1000 / 4); // each hit is 2 bits
+
+ /**
+ * Tracks the hit rate for the last 10000 requests. If there
+ * have been fewer than 10000 requests, the hit rate just
+ * considers the requests that have happened.
+ */
+ this.hitsLast10000 = new Uint8Array(10000 / 4); // each hit is 2 bits
+
+ /**
+ * Tracks the hit rate for the last 100000 requests. If
+ * there have been fewer than 100000 requests, the hit rate
+ * just considers the requests that have happened.
+ */
+ this.hitsLast100000 = new Uint8Array(100000 / 4); // each hit is 2 bits
+
+ /**
+ * The number of calls that have passed through the
+ * middleware since the server started.
+ */
+ this.callCount = 0;
+
+ /**
+ * The total number of hits since the server started
+ */
+ this.hitCount = 0;
+
+ /**
+ * The key from the last cache hit. This is useful in
+ * identifying which route these statistics apply to.
+ */
+ this.lastCacheHit = null;
+
+ /**
+ * The key from the last cache miss. This is useful in
+ * identifying which route these statistics apply to.
+ */
+ this.lastCacheMiss = null;
+
+ /**
+ * Return performance statistics
+ * @returns {Object}
+ */
+ this.report = function () {
+ return {
+ lastCacheHit: this.lastCacheHit,
+ lastCacheMiss: this.lastCacheMiss,
+ callCount: this.callCount,
+ hitCount: this.hitCount,
+ missCount: this.callCount - this.hitCount,
+ hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount,
+ hitRateLast100: this.hitRate(this.hitsLast100),
+ hitRateLast1000: this.hitRate(this.hitsLast1000),
+ hitRateLast10000: this.hitRate(this.hitsLast10000),
+ hitRateLast100000: this.hitRate(this.hitsLast100000),
+ };
+ };
+
+ /**
+ * Computes a cache hit rate from an array of hits and
+ * misses.
+ * @param {Uint8Array} array An array representing hits and
+ * misses.
+ * @returns {?number} a number between 0 and 1, or null if
+ * the array has no hits or misses
+ */
+ this.hitRate = function (array) {
+ let hits = 0;
+ let misses = 0;
+ for (let i = 0; i < array.length; i++) {
+ let n8 = array[i];
+ for (let j = 0; j < 4; j++) {
+ switch (n8 & 3) {
+ case 1:
+ hits++;
+ break;
+ case 2:
+ misses++;
+ break;
+ }
+ n8 >>= 2;
+ }
+ }
+ let total = hits + misses;
+ if (total == 0) {
+ return null;
+ }
+ return hits / total;
+ };
+
+ /**
+ * Record a hit or miss in the given array. It will be
+ * recorded at a position determined by the current value of
+ * the callCount variable.
+ * @param {Uint8Array} array An array representing hits and
+ * misses.
+ * @param {boolean} hit true for a hit, false for a miss
+ * Each element in the array is 8 bits, and encodes 4
+ * hit/miss records. Each hit or miss is encoded as to bits
+ * as follows: 00 means no hit or miss has been recorded in
+ * these bits 01 encodes a hit 10 encodes a miss
+ */
+ this.recordHitInArray = function (array, hit) {
+ let arrayIndex = ~~(this.callCount / 4) % array.length;
+ let bitOffset = (this.callCount % 4) * 2; // 2 bits per record, 4 records per uint8 array element
+ let clearMask = ~(3 << bitOffset);
+ let record = (hit ? 1 : 2) << bitOffset;
+ array[arrayIndex] = (array[arrayIndex] & clearMask) | record;
+ };
+
+ /**
+ * Records the hit or miss in the tracking arrays and
+ * increments the call count.
+ * @param {boolean} hit true records a hit, false records a
+ * miss
+ */
+ this.recordHit = function (hit) {
+ this.recordHitInArray(this.hitsLast100, hit);
+ this.recordHitInArray(this.hitsLast1000, hit);
+ this.recordHitInArray(this.hitsLast10000, hit);
+ this.recordHitInArray(this.hitsLast100000, hit);
+ if (hit) {
+ this.hitCount++;
+ }
+ this.callCount++;
+ };
+
+ /**
+ * Records a hit event, setting lastCacheMiss to the given key
+ * @param {string} key The key that had the cache hit
+ */
+ this.hit = function (key) {
+ this.recordHit(true);
+ this.lastCacheHit = key;
+ };
+
+ /**
+ * Records a miss event, setting lastCacheMiss to the given key
+ * @param {string} key The key that had the cache miss
+ */
+ this.miss = function (key) {
+ this.recordHit(false);
+ this.lastCacheMiss = key;
+ };
+ }
+
+ let perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance();
+
+ performanceArray.push(perf);
+
+ /**
+ * Cache a request
+ * @param {Object} req Express request object
+ * @param {Object} res Express response object
+ * @param {function} next Function to call next
+ * @returns {any}
+ */
+ let cache = function (req, res, next) {
+ function bypass() {
+ debug("bypass detected, skipping cache.");
+ return next();
+ }
+
+ // initial bypass chances
+ if (!opt.enabled) {
+ return bypass();
+ }
+ if (
+ req.headers["x-apicache-bypass"] ||
+ req.headers["x-apicache-force-fetch"] ||
+ (opt.respectCacheControl && req.headers["cache-control"] == "no-cache")
+ ) {
+ return bypass();
+ }
+
+ // REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER
+ // if (typeof middlewareToggle === 'function') {
+ // if (!middlewareToggle(req, res)) return bypass()
+ // } else if (middlewareToggle !== undefined && !middlewareToggle) {
+ // return bypass()
+ // }
+
+ // embed timer
+ req.apicacheTimer = new Date();
+
+ // In Express 4.x the url is ambigious based on where a router is mounted. originalUrl will give the full Url
+ let key = req.originalUrl || req.url;
+
+ // Remove querystring from key if jsonp option is enabled
+ if (opt.jsonp) {
+ key = url.parse(key).pathname;
+ }
+
+ // add appendKey (either custom function or response path)
+ if (typeof opt.appendKey === "function") {
+ key += "$$appendKey=" + opt.appendKey(req, res);
+ } else if (opt.appendKey.length > 0) {
+ let appendKey = req;
+
+ for (let i = 0; i < opt.appendKey.length; i++) {
+ appendKey = appendKey[opt.appendKey[i]];
+ }
+ key += "$$appendKey=" + appendKey;
+ }
+
+ // attempt cache hit
+ let redis = opt.redisClient;
+ let cached = !redis ? memCache.getValue(key) : null;
+
+ // send if cache hit from memory-cache
+ if (cached) {
+ let elapsed = new Date() - req.apicacheTimer;
+ debug("sending cached (memory-cache) version of", key, logDuration(elapsed));
+
+ perf.hit(key);
+ return sendCachedResponse(req, res, cached, middlewareToggle, next, duration);
+ }
+
+ // send if cache hit from redis
+ if (redis && redis.connected) {
+ try {
+ redis.hgetall(key, function (err, obj) {
+ if (!err && obj && obj.response) {
+ let elapsed = new Date() - req.apicacheTimer;
+ debug("sending cached (redis) version of", key, logDuration(elapsed));
+
+ perf.hit(key);
+ return sendCachedResponse(
+ req,
+ res,
+ JSON.parse(obj.response),
+ middlewareToggle,
+ next,
+ duration
+ );
+ } else {
+ perf.miss(key);
+ return makeResponseCacheable(
+ req,
+ res,
+ next,
+ key,
+ duration,
+ strDuration,
+ middlewareToggle
+ );
+ }
+ });
+ } catch (err) {
+ // bypass redis on error
+ perf.miss(key);
+ return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
+ }
+ } else {
+ perf.miss(key);
+ return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
+ }
+ };
+
+ cache.options = options;
+
+ return cache;
+ };
+
+ /**
+ * Process options
+ * @param {Object} options
+ * @returns {Object}
+ */
+ this.options = function (options) {
+ if (options) {
+ Object.assign(globalOptions, options);
+ syncOptions();
+
+ if ("defaultDuration" in options) {
+ // Convert the default duration to a number in milliseconds (if needed)
+ globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000);
+ }
+
+ if (globalOptions.trackPerformance) {
+ debug("WARNING: using trackPerformance flag can cause high memory usage!");
+ }
+
+ return this;
+ } else {
+ return globalOptions;
+ }
+ };
+
+ /** Reset the index */
+ this.resetIndex = function () {
+ index = {
+ all: [],
+ groups: {},
+ };
+ };
+
+ /**
+ * Create a new instance of ApiCache
+ * @param {Object} config Config to pass
+ * @returns {ApiCache}
+ */
+ this.newInstance = function (config) {
+ let instance = new ApiCache();
+
+ if (config) {
+ instance.options(config);
+ }
+
+ return instance;
+ };
+
+ /** Clone this instance */
+ this.clone = function () {
+ return this.newInstance(this.options());
+ };
+
+ // initialize index
+ this.resetIndex();
+}
+
+module.exports = new ApiCache();
diff --git a/server/modules/apicache/index.js b/server/modules/apicache/index.js
new file mode 100644
index 0000000..b8bb9b3
--- /dev/null
+++ b/server/modules/apicache/index.js
@@ -0,0 +1,14 @@
+const apicache = require("./apicache");
+
+apicache.options({
+ headerBlacklist: [
+ "cache-control"
+ ],
+ headers: {
+ // Disable client side cache, only server side cache.
+ // BUG! Not working for the second request
+ "cache-control": "no-cache",
+ },
+});
+
+module.exports = apicache;
diff --git a/server/modules/apicache/memory-cache.js b/server/modules/apicache/memory-cache.js
new file mode 100644
index 0000000..a91eee3
--- /dev/null
+++ b/server/modules/apicache/memory-cache.js
@@ -0,0 +1,87 @@
+function MemoryCache() {
+ this.cache = {};
+ this.size = 0;
+}
+
+/**
+ *
+ * @param {string} key Key to store cache as
+ * @param {any} value Value to store
+ * @param {number} time Time to store for
+ * @param {function(any, string)} timeoutCallback Callback to call in
+ * case of timeout
+ * @returns {Object}
+ */
+MemoryCache.prototype.add = function (key, value, time, timeoutCallback) {
+ let old = this.cache[key];
+ let instance = this;
+
+ let entry = {
+ value: value,
+ expire: time + Date.now(),
+ timeout: setTimeout(function () {
+ instance.delete(key);
+ return timeoutCallback && typeof timeoutCallback === "function" && timeoutCallback(value, key);
+ }, time)
+ };
+
+ this.cache[key] = entry;
+ this.size = Object.keys(this.cache).length;
+
+ return entry;
+};
+
+/**
+ * Delete a cache entry
+ * @param {string} key Key to delete
+ * @returns {null}
+ */
+MemoryCache.prototype.delete = function (key) {
+ let entry = this.cache[key];
+
+ if (entry) {
+ clearTimeout(entry.timeout);
+ }
+
+ delete this.cache[key];
+
+ this.size = Object.keys(this.cache).length;
+
+ return null;
+};
+
+/**
+ * Get value of key
+ * @param {string} key
+ * @returns {Object}
+ */
+MemoryCache.prototype.get = function (key) {
+ let entry = this.cache[key];
+
+ return entry;
+};
+
+/**
+ * Get value of cache entry
+ * @param {string} key
+ * @returns {any}
+ */
+MemoryCache.prototype.getValue = function (key) {
+ let entry = this.get(key);
+
+ return entry && entry.value;
+};
+
+/**
+ * Clear cache
+ * @returns {boolean}
+ */
+MemoryCache.prototype.clear = function () {
+ Object.keys(this.cache).forEach(function (key) {
+ this.delete(key);
+ }, this);
+
+ return true;
+};
+
+module.exports = MemoryCache;
diff --git a/server/modules/axios-ntlm/LICENSE b/server/modules/axios-ntlm/LICENSE
new file mode 100644
index 0000000..1744ee4
--- /dev/null
+++ b/server/modules/axios-ntlm/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 CatButtes
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/server/modules/axios-ntlm/lib/flags.js b/server/modules/axios-ntlm/lib/flags.js
new file mode 100644
index 0000000..c16028c
--- /dev/null
+++ b/server/modules/axios-ntlm/lib/flags.js
@@ -0,0 +1,77 @@
+'use strict';
+// Original file https://raw.githubusercontent.com/elasticio/node-ntlm-client/master/lib/flags.js
+module.exports.NTLMFLAG_NEGOTIATE_UNICODE = 1 << 0;
+/* Indicates that Unicode strings are supported for use in security buffer
+ data. */
+module.exports.NTLMFLAG_NEGOTIATE_OEM = 1 << 1;
+/* Indicates that OEM strings are supported for use in security buffer data. */
+module.exports.NTLMFLAG_REQUEST_TARGET = 1 << 2;
+/* Requests that the server's authentication realm be included in the Type 2
+ message. */
+/* unknown (1<<3) */
+module.exports.NTLMFLAG_NEGOTIATE_SIGN = 1 << 4;
+/* Specifies that authenticated communication between the client and server
+ should carry a digital signature (message integrity). */
+module.exports.NTLMFLAG_NEGOTIATE_SEAL = 1 << 5;
+/* Specifies that authenticated communication between the client and server
+ should be encrypted (message confidentiality). */
+module.exports.NTLMFLAG_NEGOTIATE_DATAGRAM_STYLE = 1 << 6;
+/* Indicates that datagram authentication is being used. */
+module.exports.NTLMFLAG_NEGOTIATE_LM_KEY = 1 << 7;
+/* Indicates that the LAN Manager session key should be used for signing and
+ sealing authenticated communications. */
+module.exports.NTLMFLAG_NEGOTIATE_NETWARE = 1 << 8;
+/* unknown purpose */
+module.exports.NTLMFLAG_NEGOTIATE_NTLM_KEY = 1 << 9;
+/* Indicates that NTLM authentication is being used. */
+/* unknown (1<<10) */
+module.exports.NTLMFLAG_NEGOTIATE_ANONYMOUS = 1 << 11;
+/* Sent by the client in the Type 3 message to indicate that an anonymous
+ context has been established. This also affects the response fields. */
+module.exports.NTLMFLAG_NEGOTIATE_DOMAIN_SUPPLIED = 1 << 12;
+/* Sent by the client in the Type 1 message to indicate that a desired
+ authentication realm is included in the message. */
+module.exports.NTLMFLAG_NEGOTIATE_WORKSTATION_SUPPLIED = 1 << 13;
+/* Sent by the client in the Type 1 message to indicate that the client
+ workstation's name is included in the message. */
+module.exports.NTLMFLAG_NEGOTIATE_LOCAL_CALL = 1 << 14;
+/* Sent by the server to indicate that the server and client are on the same
+ machine. Implies that the client may use a pre-established local security
+ context rather than responding to the challenge. */
+module.exports.NTLMFLAG_NEGOTIATE_ALWAYS_SIGN = 1 << 15;
+/* Indicates that authenticated communication between the client and server
+ should be signed with a "dummy" signature. */
+module.exports.NTLMFLAG_TARGET_TYPE_DOMAIN = 1 << 16;
+/* Sent by the server in the Type 2 message to indicate that the target
+ authentication realm is a domain. */
+module.exports.NTLMFLAG_TARGET_TYPE_SERVER = 1 << 17;
+/* Sent by the server in the Type 2 message to indicate that the target
+ authentication realm is a server. */
+module.exports.NTLMFLAG_TARGET_TYPE_SHARE = 1 << 18;
+/* Sent by the server in the Type 2 message to indicate that the target
+ authentication realm is a share. Presumably, this is for share-level
+ authentication. Usage is unclear. */
+module.exports.NTLMFLAG_NEGOTIATE_NTLM2_KEY = 1 << 19;
+/* Indicates that the NTLM2 signing and sealing scheme should be used for
+ protecting authenticated communications. */
+module.exports.NTLMFLAG_REQUEST_INIT_RESPONSE = 1 << 20;
+/* unknown purpose */
+module.exports.NTLMFLAG_REQUEST_ACCEPT_RESPONSE = 1 << 21;
+/* unknown purpose */
+module.exports.NTLMFLAG_REQUEST_NONNT_SESSION_KEY = 1 << 22;
+/* unknown purpose */
+module.exports.NTLMFLAG_NEGOTIATE_TARGET_INFO = 1 << 23;
+/* Sent by the server in the Type 2 message to indicate that it is including a
+ Target Information block in the message. */
+/* unknown (1<24) */
+/* unknown (1<25) */
+/* unknown (1<26) */
+/* unknown (1<27) */
+/* unknown (1<28) */
+module.exports.NTLMFLAG_NEGOTIATE_128 = 1 << 29;
+/* Indicates that 128-bit encryption is supported. */
+module.exports.NTLMFLAG_NEGOTIATE_KEY_EXCHANGE = 1 << 30;
+/* Indicates that the client will provide an encrypted master key in
+ the "Session Key" field of the Type 3 message. */
+module.exports.NTLMFLAG_NEGOTIATE_56 = 1 << 31;
+//# sourceMappingURL=flags.js.map \ No newline at end of file
diff --git a/server/modules/axios-ntlm/lib/hash.js b/server/modules/axios-ntlm/lib/hash.js
new file mode 100644
index 0000000..4e5aa26
--- /dev/null
+++ b/server/modules/axios-ntlm/lib/hash.js
@@ -0,0 +1,122 @@
+'use strict';
+// Original source at https://github.com/elasticio/node-ntlm-client/blob/master/lib/hash.js
+var crypto = require('crypto');
+function createLMResponse(challenge, lmhash) {
+ var buf = new Buffer.alloc(24), pwBuffer = new Buffer.alloc(21).fill(0);
+ lmhash.copy(pwBuffer);
+ calculateDES(pwBuffer.slice(0, 7), challenge).copy(buf);
+ calculateDES(pwBuffer.slice(7, 14), challenge).copy(buf, 8);
+ calculateDES(pwBuffer.slice(14), challenge).copy(buf, 16);
+ return buf;
+}
+function createLMHash(password) {
+ var buf = new Buffer.alloc(16), pwBuffer = new Buffer.alloc(14), magicKey = new Buffer.from('KGS!@#$%', 'ascii');
+ if (password.length > 14) {
+ buf.fill(0);
+ return buf;
+ }
+ pwBuffer.fill(0);
+ pwBuffer.write(password.toUpperCase(), 0, 'ascii');
+ return Buffer.concat([
+ calculateDES(pwBuffer.slice(0, 7), magicKey),
+ calculateDES(pwBuffer.slice(7), magicKey)
+ ]);
+}
+function calculateDES(key, message) {
+ var desKey = new Buffer.alloc(8);
+ desKey[0] = key[0] & 0xFE;
+ desKey[1] = ((key[0] << 7) & 0xFF) | (key[1] >> 1);
+ desKey[2] = ((key[1] << 6) & 0xFF) | (key[2] >> 2);
+ desKey[3] = ((key[2] << 5) & 0xFF) | (key[3] >> 3);
+ desKey[4] = ((key[3] << 4) & 0xFF) | (key[4] >> 4);
+ desKey[5] = ((key[4] << 3) & 0xFF) | (key[5] >> 5);
+ desKey[6] = ((key[5] << 2) & 0xFF) | (key[6] >> 6);
+ desKey[7] = (key[6] << 1) & 0xFF;
+ for (var i = 0; i < 8; i++) {
+ var parity = 0;
+ for (var j = 1; j < 8; j++) {
+ parity += (desKey[i] >> j) % 2;
+ }
+ desKey[i] |= (parity % 2) === 0 ? 1 : 0;
+ }
+ var des = crypto.createCipheriv('DES-ECB', desKey, '');
+ return des.update(message);
+}
+function createNTLMResponse(challenge, ntlmhash) {
+ var buf = new Buffer.alloc(24), ntlmBuffer = new Buffer.alloc(21).fill(0);
+ ntlmhash.copy(ntlmBuffer);
+ calculateDES(ntlmBuffer.slice(0, 7), challenge).copy(buf);
+ calculateDES(ntlmBuffer.slice(7, 14), challenge).copy(buf, 8);
+ calculateDES(ntlmBuffer.slice(14), challenge).copy(buf, 16);
+ return buf;
+}
+function createNTLMHash(password) {
+ var md4sum = crypto.createHash('md4');
+ md4sum.update(new Buffer.from(password, 'ucs2'));
+ return md4sum.digest();
+}
+function createNTLMv2Hash(ntlmhash, username, authTargetName) {
+ var hmac = crypto.createHmac('md5', ntlmhash);
+ hmac.update(new Buffer.from(username.toUpperCase() + authTargetName, 'ucs2'));
+ return hmac.digest();
+}
+function createLMv2Response(type2message, username, ntlmhash, nonce, targetName) {
+ var buf = new Buffer.alloc(24), ntlm2hash = createNTLMv2Hash(ntlmhash, username, targetName), hmac = crypto.createHmac('md5', ntlm2hash);
+ //server challenge
+ type2message.challenge.copy(buf, 8);
+ //client nonce
+ buf.write(nonce || createPseudoRandomValue(16), 16, 'hex');
+ //create hash
+ hmac.update(buf.slice(8));
+ var hashedBuffer = hmac.digest();
+ hashedBuffer.copy(buf);
+ return buf;
+}
+function createNTLMv2Response(type2message, username, ntlmhash, nonce, targetName) {
+ var buf = new Buffer.alloc(48 + type2message.targetInfo.buffer.length), ntlm2hash = createNTLMv2Hash(ntlmhash, username, targetName), hmac = crypto.createHmac('md5', ntlm2hash);
+ //the first 8 bytes are spare to store the hashed value before the blob
+ //server challenge
+ type2message.challenge.copy(buf, 8);
+ //blob signature
+ buf.writeUInt32BE(0x01010000, 16);
+ //reserved
+ buf.writeUInt32LE(0, 20);
+ //timestamp
+ //TODO: we are loosing precision here since js is not able to handle those large integers
+ // maybe think about a different solution here
+ // 11644473600000 = diff between 1970 and 1601
+ var timestamp = ((Date.now() + 11644473600000) * 10000).toString(16);
+ var timestampLow = Number('0x' + timestamp.substring(Math.max(0, timestamp.length - 8)));
+ var timestampHigh = Number('0x' + timestamp.substring(0, Math.max(0, timestamp.length - 8)));
+ buf.writeUInt32LE(timestampLow, 24, false);
+ buf.writeUInt32LE(timestampHigh, 28, false);
+ //random client nonce
+ buf.write(nonce || createPseudoRandomValue(16), 32, 'hex');
+ //zero
+ buf.writeUInt32LE(0, 40);
+ //complete target information block from type 2 message
+ type2message.targetInfo.buffer.copy(buf, 44);
+ //zero
+ buf.writeUInt32LE(0, 44 + type2message.targetInfo.buffer.length);
+ hmac.update(buf.slice(8));
+ var hashedBuffer = hmac.digest();
+ hashedBuffer.copy(buf);
+ return buf;
+}
+function createPseudoRandomValue(length) {
+ var str = '';
+ while (str.length < length) {
+ str += Math.floor(Math.random() * 16).toString(16);
+ }
+ return str;
+}
+module.exports = {
+ createLMHash: createLMHash,
+ createNTLMHash: createNTLMHash,
+ createLMResponse: createLMResponse,
+ createNTLMResponse: createNTLMResponse,
+ createLMv2Response: createLMv2Response,
+ createNTLMv2Response: createNTLMv2Response,
+ createPseudoRandomValue: createPseudoRandomValue
+};
+//# sourceMappingURL=hash.js.map \ No newline at end of file
diff --git a/server/modules/axios-ntlm/lib/ntlm.js b/server/modules/axios-ntlm/lib/ntlm.js
new file mode 100644
index 0000000..54490c0
--- /dev/null
+++ b/server/modules/axios-ntlm/lib/ntlm.js
@@ -0,0 +1,220 @@
+'use strict';
+// Original file https://raw.githubusercontent.com/elasticio/node-ntlm-client/master/lib/ntlm.js
+var os = require('os'), flags = require('./flags'), hash = require('./hash');
+var NTLMSIGNATURE = "NTLMSSP\0";
+function createType1Message(workstation, target) {
+ var dataPos = 32, pos = 0, buf = new Buffer.alloc(1024);
+ workstation = workstation === undefined ? os.hostname() : workstation;
+ target = target === undefined ? '' : target;
+ //signature
+ buf.write(NTLMSIGNATURE, pos, NTLMSIGNATURE.length, 'ascii');
+ pos += NTLMSIGNATURE.length;
+ //message type
+ buf.writeUInt32LE(1, pos);
+ pos += 4;
+ //flags
+ buf.writeUInt32LE(flags.NTLMFLAG_NEGOTIATE_OEM |
+ flags.NTLMFLAG_REQUEST_TARGET |
+ flags.NTLMFLAG_NEGOTIATE_NTLM_KEY |
+ flags.NTLMFLAG_NEGOTIATE_NTLM2_KEY |
+ flags.NTLMFLAG_NEGOTIATE_ALWAYS_SIGN, pos);
+ pos += 4;
+ //domain security buffer
+ buf.writeUInt16LE(target.length, pos);
+ pos += 2;
+ buf.writeUInt16LE(target.length, pos);
+ pos += 2;
+ buf.writeUInt32LE(target.length === 0 ? 0 : dataPos, pos);
+ pos += 4;
+ if (target.length > 0) {
+ dataPos += buf.write(target, dataPos, 'ascii');
+ }
+ //workstation security buffer
+ buf.writeUInt16LE(workstation.length, pos);
+ pos += 2;
+ buf.writeUInt16LE(workstation.length, pos);
+ pos += 2;
+ buf.writeUInt32LE(workstation.length === 0 ? 0 : dataPos, pos);
+ pos += 4;
+ if (workstation.length > 0) {
+ dataPos += buf.write(workstation, dataPos, 'ascii');
+ }
+ return 'NTLM ' + buf.toString('base64', 0, dataPos);
+}
+function decodeType2Message(str) {
+ if (str === undefined) {
+ throw new Error('Invalid argument');
+ }
+ //convenience
+ if (Object.prototype.toString.call(str) !== '[object String]') {
+ if (str.hasOwnProperty('headers') && str.headers.hasOwnProperty('www-authenticate')) {
+ str = str.headers['www-authenticate'];
+ }
+ else {
+ throw new Error('Invalid argument');
+ }
+ }
+ var ntlmMatch = /^NTLM ([^,\s]+)/.exec(str);
+ if (ntlmMatch) {
+ str = ntlmMatch[1];
+ }
+ var buf = new Buffer.from(str, 'base64'), obj = {};
+ //check signature
+ if (buf.toString('ascii', 0, NTLMSIGNATURE.length) !== NTLMSIGNATURE) {
+ throw new Error('Invalid message signature: ' + str);
+ }
+ //check message type
+ if (buf.readUInt32LE(NTLMSIGNATURE.length) !== 2) {
+ throw new Error('Invalid message type (no type 2)');
+ }
+ //read flags
+ obj.flags = buf.readUInt32LE(20);
+ obj.encoding = (obj.flags & flags.NTLMFLAG_NEGOTIATE_OEM) ? 'ascii' : 'ucs2';
+ obj.version = (obj.flags & flags.NTLMFLAG_NEGOTIATE_NTLM2_KEY) ? 2 : 1;
+ obj.challenge = buf.slice(24, 32);
+ //read target name
+ obj.targetName = (function () {
+ var length = buf.readUInt16LE(12);
+ //skipping allocated space
+ var offset = buf.readUInt32LE(16);
+ if (length === 0) {
+ return '';
+ }
+ if ((offset + length) > buf.length || offset < 32) {
+ throw new Error('Bad type 2 message');
+ }
+ return buf.toString(obj.encoding, offset, offset + length);
+ })();
+ //read target info
+ if (obj.flags & flags.NTLMFLAG_NEGOTIATE_TARGET_INFO) {
+ obj.targetInfo = (function () {
+ var info = {};
+ var length = buf.readUInt16LE(40);
+ //skipping allocated space
+ var offset = buf.readUInt32LE(44);
+ var targetInfoBuffer = new Buffer.alloc(length);
+ buf.copy(targetInfoBuffer, 0, offset, offset + length);
+ if (length === 0) {
+ return info;
+ }
+ if ((offset + length) > buf.length || offset < 32) {
+ throw new Error('Bad type 2 message');
+ }
+ var pos = offset;
+ while (pos < (offset + length)) {
+ var blockType = buf.readUInt16LE(pos);
+ pos += 2;
+ var blockLength = buf.readUInt16LE(pos);
+ pos += 2;
+ if (blockType === 0) {
+ //reached the terminator subblock
+ break;
+ }
+ var blockTypeStr = void 0;
+ switch (blockType) {
+ case 1:
+ blockTypeStr = 'SERVER';
+ break;
+ case 2:
+ blockTypeStr = 'DOMAIN';
+ break;
+ case 3:
+ blockTypeStr = 'FQDN';
+ break;
+ case 4:
+ blockTypeStr = 'DNS';
+ break;
+ case 5:
+ blockTypeStr = 'PARENT_DNS';
+ break;
+ default:
+ blockTypeStr = '';
+ break;
+ }
+ if (blockTypeStr) {
+ info[blockTypeStr] = buf.toString('ucs2', pos, pos + blockLength);
+ }
+ pos += blockLength;
+ }
+ return {
+ parsed: info,
+ buffer: targetInfoBuffer
+ };
+ })();
+ }
+ return obj;
+}
+function createType3Message(type2Message, username, password, workstation, target) {
+ var dataPos = 52, buf = new Buffer.alloc(1024);
+ if (workstation === undefined) {
+ workstation = os.hostname();
+ }
+ if (target === undefined) {
+ target = type2Message.targetName;
+ }
+ //signature
+ buf.write(NTLMSIGNATURE, 0, NTLMSIGNATURE.length, 'ascii');
+ //message type
+ buf.writeUInt32LE(3, 8);
+ if (type2Message.version === 2) {
+ dataPos = 64;
+ var ntlmHash = hash.createNTLMHash(password), nonce = hash.createPseudoRandomValue(16), lmv2 = hash.createLMv2Response(type2Message, username, ntlmHash, nonce, target), ntlmv2 = hash.createNTLMv2Response(type2Message, username, ntlmHash, nonce, target);
+ //lmv2 security buffer
+ buf.writeUInt16LE(lmv2.length, 12);
+ buf.writeUInt16LE(lmv2.length, 14);
+ buf.writeUInt32LE(dataPos, 16);
+ lmv2.copy(buf, dataPos);
+ dataPos += lmv2.length;
+ //ntlmv2 security buffer
+ buf.writeUInt16LE(ntlmv2.length, 20);
+ buf.writeUInt16LE(ntlmv2.length, 22);
+ buf.writeUInt32LE(dataPos, 24);
+ ntlmv2.copy(buf, dataPos);
+ dataPos += ntlmv2.length;
+ }
+ else {
+ var lmHash = hash.createLMHash(password), ntlmHash = hash.createNTLMHash(password), lm = hash.createLMResponse(type2Message.challenge, lmHash), ntlm = hash.createNTLMResponse(type2Message.challenge, ntlmHash);
+ //lm security buffer
+ buf.writeUInt16LE(lm.length, 12);
+ buf.writeUInt16LE(lm.length, 14);
+ buf.writeUInt32LE(dataPos, 16);
+ lm.copy(buf, dataPos);
+ dataPos += lm.length;
+ //ntlm security buffer
+ buf.writeUInt16LE(ntlm.length, 20);
+ buf.writeUInt16LE(ntlm.length, 22);
+ buf.writeUInt32LE(dataPos, 24);
+ ntlm.copy(buf, dataPos);
+ dataPos += ntlm.length;
+ }
+ //target name security buffer
+ buf.writeUInt16LE(type2Message.encoding === 'ascii' ? target.length : target.length * 2, 28);
+ buf.writeUInt16LE(type2Message.encoding === 'ascii' ? target.length : target.length * 2, 30);
+ buf.writeUInt32LE(dataPos, 32);
+ dataPos += buf.write(target, dataPos, type2Message.encoding);
+ //user name security buffer
+ buf.writeUInt16LE(type2Message.encoding === 'ascii' ? username.length : username.length * 2, 36);
+ buf.writeUInt16LE(type2Message.encoding === 'ascii' ? username.length : username.length * 2, 38);
+ buf.writeUInt32LE(dataPos, 40);
+ dataPos += buf.write(username, dataPos, type2Message.encoding);
+ //workstation name security buffer
+ buf.writeUInt16LE(type2Message.encoding === 'ascii' ? workstation.length : workstation.length * 2, 44);
+ buf.writeUInt16LE(type2Message.encoding === 'ascii' ? workstation.length : workstation.length * 2, 46);
+ buf.writeUInt32LE(dataPos, 48);
+ dataPos += buf.write(workstation, dataPos, type2Message.encoding);
+ if (type2Message.version === 2) {
+ //session key security buffer
+ buf.writeUInt16LE(0, 52);
+ buf.writeUInt16LE(0, 54);
+ buf.writeUInt32LE(0, 56);
+ //flags
+ buf.writeUInt32LE(type2Message.flags, 60);
+ }
+ return 'NTLM ' + buf.toString('base64', 0, dataPos);
+}
+module.exports = {
+ createType1Message: createType1Message,
+ decodeType2Message: decodeType2Message,
+ createType3Message: createType3Message
+};
+//# sourceMappingURL=ntlm.js.map \ No newline at end of file
diff --git a/server/modules/axios-ntlm/lib/ntlmClient.js b/server/modules/axios-ntlm/lib/ntlmClient.js
new file mode 100644
index 0000000..682de5f
--- /dev/null
+++ b/server/modules/axios-ntlm/lib/ntlmClient.js
@@ -0,0 +1,127 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
+ __setModuleDefault(result, mod);
+ return result;
+};
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+var __generator = (this && this.__generator) || function (thisArg, body) {
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
+ function verb(n) { return function (v) { return step([n, v]); }; }
+ function step(op) {
+ if (f) throw new TypeError("Generator is already executing.");
+ while (_) try {
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
+ if (y = 0, t) op = [op[0] & 2, t.value];
+ switch (op[0]) {
+ case 0: case 1: t = op; break;
+ case 4: _.label++; return { value: op[1], done: false };
+ case 5: _.label++; y = op[1]; op = [0]; continue;
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
+ default:
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
+ if (t[2]) _.ops.pop();
+ _.trys.pop(); continue;
+ }
+ op = body.call(thisArg, _);
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
+ }
+};
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.NtlmClient = void 0;
+var axios_1 = __importDefault(require("axios"));
+var ntlm = __importStar(require("./ntlm"));
+var https = __importStar(require("https"));
+var http = __importStar(require("http"));
+var dev_null_1 = __importDefault(require("dev-null"));
+/**
+* @param credentials An NtlmCredentials object containing the username and password
+* @param AxiosConfig The Axios config for the instance you wish to create
+*
+* @returns This function returns an axios instance configured to use the provided credentials
+*/
+function NtlmClient(credentials, AxiosConfig) {
+ var _this = this;
+ var config = AxiosConfig !== null && AxiosConfig !== void 0 ? AxiosConfig : {};
+ if (!config.httpAgent) {
+ config.httpAgent = new http.Agent({ keepAlive: true });
+ }
+ if (!config.httpsAgent) {
+ config.httpsAgent = new https.Agent({ keepAlive: true });
+ }
+ var client = axios_1.default.create(config);
+ client.interceptors.response.use(function (response) {
+ return response;
+ }, function (err) { return __awaiter(_this, void 0, void 0, function () {
+ var error, t1Msg, t2Msg, t3Msg, stream_1;
+ var _a;
+ return __generator(this, function (_b) {
+ switch (_b.label) {
+ case 0:
+ error = err.response;
+ if (!(error && error.status === 401
+ && error.headers['www-authenticate']
+ && error.headers['www-authenticate'].includes('NTLM'))) return [3 /*break*/, 3];
+ // This length check is a hack because SharePoint is awkward and will
+ // include the Negotiate option when responding with the T2 message
+ // There is nore we could do to ensure we are processing correctly,
+ // but this is the easiest option for now
+ if (error.headers['www-authenticate'].length < 50) {
+ t1Msg = ntlm.createType1Message(credentials.workstation, credentials.domain);
+ error.config.headers["Authorization"] = t1Msg;
+ }
+ else {
+ t2Msg = ntlm.decodeType2Message((error.headers['www-authenticate'].match(/^NTLM\s+(.+?)(,|\s+|$)/) || [])[1]);
+ t3Msg = ntlm.createType3Message(t2Msg, credentials.username, credentials.password, credentials.workstation, credentials.domain);
+ error.config.headers["X-retry"] = "false";
+ error.config.headers["Authorization"] = t3Msg;
+ }
+ if (!(error.config.responseType === "stream")) return [3 /*break*/, 2];
+ stream_1 = (_a = err.response) === null || _a === void 0 ? void 0 : _a.data;
+ if (!(stream_1 && !stream_1.readableEnded)) return [3 /*break*/, 2];
+ return [4 /*yield*/, new Promise(function (resolve) {
+ stream_1.pipe((0, dev_null_1.default)());
+ stream_1.once('close', resolve);
+ })];
+ case 1:
+ _b.sent();
+ _b.label = 2;
+ case 2: return [2 /*return*/, client(error.config)];
+ case 3: throw err;
+ }
+ });
+ }); });
+ return client;
+}
+exports.NtlmClient = NtlmClient;
+//# sourceMappingURL=ntlmClient.js.map \ No newline at end of file
diff --git a/server/modules/dayjs/plugin/timezone.d.ts b/server/modules/dayjs/plugin/timezone.d.ts
new file mode 100644
index 0000000..d504f69
--- /dev/null
+++ b/server/modules/dayjs/plugin/timezone.d.ts
@@ -0,0 +1,20 @@
+import { PluginFunc, ConfigType } from 'dayjs'
+
+declare const plugin: PluginFunc
+export = plugin
+
+declare module 'dayjs' {
+ interface Dayjs {
+ tz(timezone?: string, keepLocalTime?: boolean): Dayjs
+ offsetName(type?: 'short' | 'long'): string | undefined
+ }
+
+ interface DayjsTimezone {
+ (date: ConfigType, timezone?: string): Dayjs
+ (date: ConfigType, format: string, timezone?: string): Dayjs
+ guess(): string
+ setDefault(timezone?: string): void
+ }
+
+ const tz: DayjsTimezone
+}
diff --git a/server/modules/dayjs/plugin/timezone.js b/server/modules/dayjs/plugin/timezone.js
new file mode 100644
index 0000000..de709ae
--- /dev/null
+++ b/server/modules/dayjs/plugin/timezone.js
@@ -0,0 +1,115 @@
+/**
+ * Copy from node_modules/dayjs/plugin/timezone.js
+ * Try to fix https://github.com/louislam/uptime-kuma/issues/2318
+ * Source: https://github.com/iamkun/dayjs/tree/dev/src/plugin/utc
+ * License: MIT
+ */
+!function (t, e) {
+ // eslint-disable-next-line no-undef
+ typeof exports == "object" && typeof module != "undefined" ? module.exports = e() : typeof define == "function" && define.amd ? define(e) : (t = typeof globalThis != "undefined" ? globalThis : t || self).dayjs_plugin_timezone = e();
+}(this, (function () {
+ "use strict";
+ let t = {
+ year: 0,
+ month: 1,
+ day: 2,
+ hour: 3,
+ minute: 4,
+ second: 5
+ };
+ let e = {};
+ return function (n, i, o) {
+ let r;
+ let a = function (t, n, i) {
+ void 0 === i && (i = {});
+ let o = new Date(t);
+ let r = function (t, n) {
+ void 0 === n && (n = {});
+ let i = n.timeZoneName || "short";
+ let o = t + "|" + i;
+ let r = e[o];
+ return r || (r = new Intl.DateTimeFormat("en-US", {
+ hour12: !1,
+ timeZone: t,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZoneName: i
+ }), e[o] = r), r;
+ }(n, i);
+ return r.formatToParts(o);
+ };
+ let u = function (e, n) {
+ let i = a(e, n);
+ let r = [];
+ let u = 0;
+ for (; u < i.length; u += 1) {
+ let f = i[u];
+ let s = f.type;
+ let m = f.value;
+ let c = t[s];
+ c >= 0 && (r[c] = parseInt(m, 10));
+ }
+ let d = r[3];
+ let l = d === 24 ? 0 : d;
+ let v = r[0] + "-" + r[1] + "-" + r[2] + " " + l + ":" + r[4] + ":" + r[5] + ":000";
+ let h = +e;
+ return (o.utc(v).valueOf() - (h -= h % 1e3)) / 6e4;
+ };
+ let f = i.prototype;
+ f.tz = function (t, e) {
+ void 0 === t && (t = r);
+ let n = this.utcOffset();
+ let i = this.toDate();
+ let a = i.toLocaleString("en-US", { timeZone: t }).replace("\u202f", " ");
+ let u = Math.round((i - new Date(a)) / 1e3 / 60);
+ let f = o(a).$set("millisecond", this.$ms).utcOffset(15 * -Math.round(i.getTimezoneOffset() / 15) - u, !0);
+ if (e) {
+ let s = f.utcOffset();
+ f = f.add(n - s, "minute");
+ }
+ return f.$x.$timezone = t, f;
+ }, f.offsetName = function (t) {
+ let e = this.$x.$timezone || o.tz.guess();
+ let n = a(this.valueOf(), e, { timeZoneName: t }).find((function (t) {
+ return t.type.toLowerCase() === "timezonename";
+ }));
+ return n && n.value;
+ };
+ let s = f.startOf;
+ f.startOf = function (t, e) {
+ if (!this.$x || !this.$x.$timezone) {
+ return s.call(this, t, e);
+ }
+ let n = o(this.format("YYYY-MM-DD HH:mm:ss:SSS"));
+ return s.call(n, t, e).tz(this.$x.$timezone, !0);
+ }, o.tz = function (t, e, n) {
+ let i = n && e;
+ let a = n || e || r;
+ let f = u(+o(), a);
+ if (typeof t != "string") {
+ return o(t).tz(a);
+ }
+ let s = function (t, e, n) {
+ let i = t - 60 * e * 1e3;
+ let o = u(i, n);
+ if (e === o) {
+ return [ i, e ];
+ }
+ let r = u(i -= 60 * (o - e) * 1e3, n);
+ return o === r ? [ i, o ] : [ t - 60 * Math.min(o, r) * 1e3, Math.max(o, r) ];
+ }(o.utc(t, i).valueOf(), f, a);
+ let m = s[0];
+ let c = s[1];
+ let d = o(m).utcOffset(c);
+ return d.$x.$timezone = a, d;
+ }, o.tz.guess = function () {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+ }, o.tz.setDefault = function (t) {
+ r = t;
+ };
+ };
+}));
diff --git a/server/monitor-conditions/evaluator.js b/server/monitor-conditions/evaluator.js
new file mode 100644
index 0000000..3860a33
--- /dev/null
+++ b/server/monitor-conditions/evaluator.js
@@ -0,0 +1,71 @@
+const { ConditionExpressionGroup, ConditionExpression, LOGICAL } = require("./expression");
+const { operatorMap } = require("./operators");
+
+/**
+ * @param {ConditionExpression} expression Expression to evaluate
+ * @param {object} context Context to evaluate against; These are values for variables in the expression
+ * @returns {boolean} Whether the expression evaluates true or false
+ * @throws {Error}
+ */
+function evaluateExpression(expression, context) {
+ /**
+ * @type {import("./operators").ConditionOperator|null}
+ */
+ const operator = operatorMap.get(expression.operator) || null;
+ if (operator === null) {
+ throw new Error("Unexpected expression operator ID '" + expression.operator + "'. Expected one of [" + operatorMap.keys().join(",") + "]");
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(context, expression.variable)) {
+ throw new Error("Variable missing in context: " + expression.variable);
+ }
+
+ return operator.test(context[expression.variable], expression.value);
+}
+
+/**
+ * @param {ConditionExpressionGroup} group Group of expressions to evaluate
+ * @param {object} context Context to evaluate against; These are values for variables in the expression
+ * @returns {boolean} Whether the group evaluates true or false
+ * @throws {Error}
+ */
+function evaluateExpressionGroup(group, context) {
+ if (!group.children.length) {
+ throw new Error("ConditionExpressionGroup must contain at least one child.");
+ }
+
+ let result = null;
+
+ for (const child of group.children) {
+ let childResult;
+
+ if (child instanceof ConditionExpression) {
+ childResult = evaluateExpression(child, context);
+ } else if (child instanceof ConditionExpressionGroup) {
+ childResult = evaluateExpressionGroup(child, context);
+ } else {
+ throw new Error("Invalid child type in ConditionExpressionGroup. Expected ConditionExpression or ConditionExpressionGroup");
+ }
+
+ if (result === null) {
+ result = childResult; // Initialize result with the first child's result
+ } else if (child.andOr === LOGICAL.OR) {
+ result = result || childResult;
+ } else if (child.andOr === LOGICAL.AND) {
+ result = result && childResult;
+ } else {
+ throw new Error("Invalid logical operator in child of ConditionExpressionGroup. Expected 'and' or 'or'. Got '" + group.andOr + "'");
+ }
+ }
+
+ if (result === null) {
+ throw new Error("ConditionExpressionGroup did not result in a boolean.");
+ }
+
+ return result;
+}
+
+module.exports = {
+ evaluateExpression,
+ evaluateExpressionGroup,
+};
diff --git a/server/monitor-conditions/expression.js b/server/monitor-conditions/expression.js
new file mode 100644
index 0000000..1e70369
--- /dev/null
+++ b/server/monitor-conditions/expression.js
@@ -0,0 +1,111 @@
+/**
+ * @readonly
+ * @enum {string}
+ */
+const LOGICAL = {
+ AND: "and",
+ OR: "or",
+};
+
+/**
+ * Recursively processes an array of raw condition objects and populates the given parent group with
+ * corresponding ConditionExpression or ConditionExpressionGroup instances.
+ * @param {Array} conditions Array of raw condition objects, where each object represents either a group or an expression.
+ * @param {ConditionExpressionGroup} parentGroup The parent group to which the instantiated ConditionExpression or ConditionExpressionGroup objects will be added.
+ * @returns {void}
+ */
+function processMonitorConditions(conditions, parentGroup) {
+ conditions.forEach(condition => {
+ const andOr = condition.andOr === LOGICAL.OR ? LOGICAL.OR : LOGICAL.AND;
+
+ if (condition.type === "group") {
+ const group = new ConditionExpressionGroup([], andOr);
+
+ // Recursively process the group's children
+ processMonitorConditions(condition.children, group);
+
+ parentGroup.children.push(group);
+ } else if (condition.type === "expression") {
+ const expression = new ConditionExpression(condition.variable, condition.operator, condition.value, andOr);
+ parentGroup.children.push(expression);
+ }
+ });
+}
+
+class ConditionExpressionGroup {
+ /**
+ * @type {ConditionExpressionGroup[]|ConditionExpression[]} Groups and/or expressions to test
+ */
+ children = [];
+
+ /**
+ * @type {LOGICAL} Connects group result with previous group/expression results
+ */
+ andOr;
+
+ /**
+ * @param {ConditionExpressionGroup[]|ConditionExpression[]} children Groups and/or expressions to test
+ * @param {LOGICAL} andOr Connects group result with previous group/expression results
+ */
+ constructor(children = [], andOr = LOGICAL.AND) {
+ this.children = children;
+ this.andOr = andOr;
+ }
+
+ /**
+ * @param {Monitor} monitor Monitor instance
+ * @returns {ConditionExpressionGroup|null} A ConditionExpressionGroup with the Monitor's conditions
+ */
+ static fromMonitor(monitor) {
+ const conditions = JSON.parse(monitor.conditions);
+ if (conditions.length === 0) {
+ return null;
+ }
+
+ const root = new ConditionExpressionGroup();
+ processMonitorConditions(conditions, root);
+
+ return root;
+ }
+}
+
+class ConditionExpression {
+ /**
+ * @type {string} ID of variable
+ */
+ variable;
+
+ /**
+ * @type {string} ID of operator
+ */
+ operator;
+
+ /**
+ * @type {string} Value to test with the operator
+ */
+ value;
+
+ /**
+ * @type {LOGICAL} Connects expression result with previous group/expression results
+ */
+ andOr;
+
+ /**
+ * @param {string} variable ID of variable to test against
+ * @param {string} operator ID of operator to test the variable with
+ * @param {string} value Value to test with the operator
+ * @param {LOGICAL} andOr Connects expression result with previous group/expression results
+ */
+ constructor(variable, operator, value, andOr = LOGICAL.AND) {
+ this.variable = variable;
+ this.operator = operator;
+ this.value = value;
+ this.andOr = andOr;
+ }
+}
+
+module.exports = {
+ LOGICAL,
+ ConditionExpressionGroup,
+ ConditionExpression,
+};
diff --git a/server/monitor-conditions/operators.js b/server/monitor-conditions/operators.js
new file mode 100644
index 0000000..d900dff
--- /dev/null
+++ b/server/monitor-conditions/operators.js
@@ -0,0 +1,318 @@
+class ConditionOperator {
+ id = undefined;
+ caption = undefined;
+
+ /**
+ * @type {mixed} variable
+ * @type {mixed} value
+ */
+ test(variable, value) {
+ throw new Error("You need to override test()");
+ }
+}
+
+const OP_STR_EQUALS = "equals";
+
+const OP_STR_NOT_EQUALS = "not_equals";
+
+const OP_CONTAINS = "contains";
+
+const OP_NOT_CONTAINS = "not_contains";
+
+const OP_STARTS_WITH = "starts_with";
+
+const OP_NOT_STARTS_WITH = "not_starts_with";
+
+const OP_ENDS_WITH = "ends_with";
+
+const OP_NOT_ENDS_WITH = "not_ends_with";
+
+const OP_NUM_EQUALS = "num_equals";
+
+const OP_NUM_NOT_EQUALS = "num_not_equals";
+
+const OP_LT = "lt";
+
+const OP_GT = "gt";
+
+const OP_LTE = "lte";
+
+const OP_GTE = "gte";
+
+/**
+ * Asserts a variable is equal to a value.
+ */
+class StringEqualsOperator extends ConditionOperator {
+ id = OP_STR_EQUALS;
+ caption = "equals";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable === value;
+ }
+}
+
+/**
+ * Asserts a variable is not equal to a value.
+ */
+class StringNotEqualsOperator extends ConditionOperator {
+ id = OP_STR_NOT_EQUALS;
+ caption = "not equals";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable !== value;
+ }
+}
+
+/**
+ * Asserts a variable contains a value.
+ * Handles both Array and String variable types.
+ */
+class ContainsOperator extends ConditionOperator {
+ id = OP_CONTAINS;
+ caption = "contains";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ if (Array.isArray(variable)) {
+ return variable.includes(value);
+ }
+
+ return variable.indexOf(value) !== -1;
+ }
+}
+
+/**
+ * Asserts a variable does not contain a value.
+ * Handles both Array and String variable types.
+ */
+class NotContainsOperator extends ConditionOperator {
+ id = OP_NOT_CONTAINS;
+ caption = "not contains";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ if (Array.isArray(variable)) {
+ return !variable.includes(value);
+ }
+
+ return variable.indexOf(value) === -1;
+ }
+}
+
+/**
+ * Asserts a variable starts with a value.
+ */
+class StartsWithOperator extends ConditionOperator {
+ id = OP_STARTS_WITH;
+ caption = "starts with";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable.startsWith(value);
+ }
+}
+
+/**
+ * Asserts a variable does not start with a value.
+ */
+class NotStartsWithOperator extends ConditionOperator {
+ id = OP_NOT_STARTS_WITH;
+ caption = "not starts with";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return !variable.startsWith(value);
+ }
+}
+
+/**
+ * Asserts a variable ends with a value.
+ */
+class EndsWithOperator extends ConditionOperator {
+ id = OP_ENDS_WITH;
+ caption = "ends with";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable.endsWith(value);
+ }
+}
+
+/**
+ * Asserts a variable does not end with a value.
+ */
+class NotEndsWithOperator extends ConditionOperator {
+ id = OP_NOT_ENDS_WITH;
+ caption = "not ends with";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return !variable.endsWith(value);
+ }
+}
+
+/**
+ * Asserts a numeric variable is equal to a value.
+ */
+class NumberEqualsOperator extends ConditionOperator {
+ id = OP_NUM_EQUALS;
+ caption = "equals";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable === Number(value);
+ }
+}
+
+/**
+ * Asserts a numeric variable is not equal to a value.
+ */
+class NumberNotEqualsOperator extends ConditionOperator {
+ id = OP_NUM_NOT_EQUALS;
+ caption = "not equals";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable !== Number(value);
+ }
+}
+
+/**
+ * Asserts a variable is less than a value.
+ */
+class LessThanOperator extends ConditionOperator {
+ id = OP_LT;
+ caption = "less than";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable < Number(value);
+ }
+}
+
+/**
+ * Asserts a variable is greater than a value.
+ */
+class GreaterThanOperator extends ConditionOperator {
+ id = OP_GT;
+ caption = "greater than";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable > Number(value);
+ }
+}
+
+/**
+ * Asserts a variable is less than or equal to a value.
+ */
+class LessThanOrEqualToOperator extends ConditionOperator {
+ id = OP_LTE;
+ caption = "less than or equal to";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable <= Number(value);
+ }
+}
+
+/**
+ * Asserts a variable is greater than or equal to a value.
+ */
+class GreaterThanOrEqualToOperator extends ConditionOperator {
+ id = OP_GTE;
+ caption = "greater than or equal to";
+
+ /**
+ * @inheritdoc
+ */
+ test(variable, value) {
+ return variable >= Number(value);
+ }
+}
+
+const operatorMap = new Map([
+ [ OP_STR_EQUALS, new StringEqualsOperator ],
+ [ OP_STR_NOT_EQUALS, new StringNotEqualsOperator ],
+ [ OP_CONTAINS, new ContainsOperator ],
+ [ OP_NOT_CONTAINS, new NotContainsOperator ],
+ [ OP_STARTS_WITH, new StartsWithOperator ],
+ [ OP_NOT_STARTS_WITH, new NotStartsWithOperator ],
+ [ OP_ENDS_WITH, new EndsWithOperator ],
+ [ OP_NOT_ENDS_WITH, new NotEndsWithOperator ],
+ [ OP_NUM_EQUALS, new NumberEqualsOperator ],
+ [ OP_NUM_NOT_EQUALS, new NumberNotEqualsOperator ],
+ [ OP_LT, new LessThanOperator ],
+ [ OP_GT, new GreaterThanOperator ],
+ [ OP_LTE, new LessThanOrEqualToOperator ],
+ [ OP_GTE, new GreaterThanOrEqualToOperator ],
+]);
+
+const defaultStringOperators = [
+ operatorMap.get(OP_STR_EQUALS),
+ operatorMap.get(OP_STR_NOT_EQUALS),
+ operatorMap.get(OP_CONTAINS),
+ operatorMap.get(OP_NOT_CONTAINS),
+ operatorMap.get(OP_STARTS_WITH),
+ operatorMap.get(OP_NOT_STARTS_WITH),
+ operatorMap.get(OP_ENDS_WITH),
+ operatorMap.get(OP_NOT_ENDS_WITH)
+];
+
+const defaultNumberOperators = [
+ operatorMap.get(OP_NUM_EQUALS),
+ operatorMap.get(OP_NUM_NOT_EQUALS),
+ operatorMap.get(OP_LT),
+ operatorMap.get(OP_GT),
+ operatorMap.get(OP_LTE),
+ operatorMap.get(OP_GTE)
+];
+
+module.exports = {
+ OP_STR_EQUALS,
+ OP_STR_NOT_EQUALS,
+ OP_CONTAINS,
+ OP_NOT_CONTAINS,
+ OP_STARTS_WITH,
+ OP_NOT_STARTS_WITH,
+ OP_ENDS_WITH,
+ OP_NOT_ENDS_WITH,
+ OP_NUM_EQUALS,
+ OP_NUM_NOT_EQUALS,
+ OP_LT,
+ OP_GT,
+ OP_LTE,
+ OP_GTE,
+ operatorMap,
+ defaultStringOperators,
+ defaultNumberOperators,
+ ConditionOperator,
+};
diff --git a/server/monitor-conditions/variables.js b/server/monitor-conditions/variables.js
new file mode 100644
index 0000000..af98d2f
--- /dev/null
+++ b/server/monitor-conditions/variables.js
@@ -0,0 +1,31 @@
+/**
+ * Represents a variable used in a condition and the set of operators that can be applied to this variable.
+ *
+ * A `ConditionVariable` holds the ID of the variable and a list of operators that define how this variable can be evaluated
+ * in conditions. For example, if the variable is a request body or a specific field in a request, the operators can include
+ * operations such as equality checks, comparisons, or other custom evaluations.
+ */
+class ConditionVariable {
+ /**
+ * @type {string}
+ */
+ id;
+
+ /**
+ * @type {import("./operators").ConditionOperator[]}
+ */
+ operators = {};
+
+ /**
+ * @param {string} id ID of variable
+ * @param {import("./operators").ConditionOperator[]} operators Operators the condition supports
+ */
+ constructor(id, operators = []) {
+ this.id = id;
+ this.operators = operators;
+ }
+}
+
+module.exports = {
+ ConditionVariable,
+};
diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js
new file mode 100644
index 0000000..8b87932
--- /dev/null
+++ b/server/monitor-types/dns.js
@@ -0,0 +1,85 @@
+const { MonitorType } = require("./monitor-type");
+const { UP, DOWN } = require("../../src/util");
+const dayjs = require("dayjs");
+const { dnsResolve } = require("../util-server");
+const { R } = require("redbean-node");
+const { ConditionVariable } = require("../monitor-conditions/variables");
+const { defaultStringOperators } = require("../monitor-conditions/operators");
+const { ConditionExpressionGroup } = require("../monitor-conditions/expression");
+const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
+
+class DnsMonitorType extends MonitorType {
+ name = "dns";
+
+ supportsConditions = true;
+
+ conditionVariables = [
+ new ConditionVariable("record", defaultStringOperators ),
+ ];
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ let startTime = dayjs().valueOf();
+ let dnsMessage = "";
+
+ let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type);
+ heartbeat.ping = dayjs().valueOf() - startTime;
+
+ const conditions = ConditionExpressionGroup.fromMonitor(monitor);
+ let conditionsResult = true;
+ const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true;
+
+ switch (monitor.dns_resolve_type) {
+ case "A":
+ case "AAAA":
+ case "TXT":
+ case "PTR":
+ dnsMessage = `Records: ${dnsRes.join(" | ")}`;
+ conditionsResult = dnsRes.some(record => handleConditions({ record }));
+ break;
+
+ case "CNAME":
+ dnsMessage = dnsRes[0];
+ conditionsResult = handleConditions({ record: dnsRes[0] });
+ break;
+
+ case "CAA":
+ dnsMessage = dnsRes[0].issue;
+ conditionsResult = handleConditions({ record: dnsRes[0].issue });
+ break;
+
+ case "MX":
+ dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | ");
+ conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange }));
+ break;
+
+ case "NS":
+ dnsMessage = `Servers: ${dnsRes.join(" | ")}`;
+ conditionsResult = dnsRes.some(record => handleConditions({ record }));
+ break;
+
+ case "SOA":
+ dnsMessage = `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`;
+ conditionsResult = handleConditions({ record: dnsRes.nsname });
+ break;
+
+ case "SRV":
+ dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | ");
+ conditionsResult = dnsRes.some(record => handleConditions({ record: record.name }));
+ break;
+ }
+
+ if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) {
+ await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]);
+ }
+
+ heartbeat.msg = dnsMessage;
+ heartbeat.status = conditionsResult ? UP : DOWN;
+ }
+}
+
+module.exports = {
+ DnsMonitorType,
+};
diff --git a/server/monitor-types/mongodb.js b/server/monitor-types/mongodb.js
new file mode 100644
index 0000000..73747db
--- /dev/null
+++ b/server/monitor-types/mongodb.js
@@ -0,0 +1,63 @@
+const { MonitorType } = require("./monitor-type");
+const { UP } = require("../../src/util");
+const { MongoClient } = require("mongodb");
+const jsonata = require("jsonata");
+
+class MongodbMonitorType extends MonitorType {
+ name = "mongodb";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ let command = { "ping": 1 };
+ if (monitor.databaseQuery) {
+ command = JSON.parse(monitor.databaseQuery);
+ }
+
+ let result = await this.runMongodbCommand(monitor.databaseConnectionString, command);
+
+ if (result["ok"] !== 1) {
+ throw new Error("MongoDB command failed");
+ } else {
+ heartbeat.msg = "Command executed successfully";
+ }
+
+ if (monitor.jsonPath) {
+ let expression = jsonata(monitor.jsonPath);
+ result = await expression.evaluate(result);
+ if (result) {
+ heartbeat.msg = "Command executed successfully and the jsonata expression produces a result.";
+ } else {
+ throw new Error("Queried value not found.");
+ }
+ }
+
+ if (monitor.expectedValue) {
+ if (result.toString() === monitor.expectedValue) {
+ heartbeat.msg = "Command executed successfully and expected value was found";
+ } else {
+ throw new Error("Query executed, but value is not equal to expected value, value was: [" + JSON.stringify(result) + "]");
+ }
+ }
+
+ heartbeat.status = UP;
+ }
+
+ /**
+ * Connect to and run MongoDB command on a MongoDB database
+ * @param {string} connectionString The database connection string
+ * @param {object} command MongoDB command to run on the database
+ * @returns {Promise<(string[] | object[] | object)>} Response from server
+ */
+ async runMongodbCommand(connectionString, command) {
+ let client = await MongoClient.connect(connectionString);
+ let result = await client.db().command(command);
+ await client.close();
+ return result;
+ }
+}
+
+module.exports = {
+ MongodbMonitorType,
+};
diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js
new file mode 100644
index 0000000..8f3cbca
--- /dev/null
+++ b/server/monitor-types/monitor-type.js
@@ -0,0 +1,31 @@
+class MonitorType {
+ name = undefined;
+
+ /**
+ * Whether or not this type supports monitor conditions. Controls UI visibility in monitor form.
+ * @type {boolean}
+ */
+ supportsConditions = false;
+
+ /**
+ * Variables supported by this type. e.g. an HTTP type could have a "response_code" variable to test against.
+ * This property controls the choices displayed in the monitor edit form.
+ * @type {import("../monitor-conditions/variables").ConditionVariable[]}
+ */
+ conditionVariables = [];
+
+ /**
+ * Run the monitoring check on the given monitor
+ * @param {Monitor} monitor Monitor to check
+ * @param {Heartbeat} heartbeat Monitor heartbeat to update
+ * @param {UptimeKumaServer} server Uptime Kuma server
+ * @returns {Promise<void>}
+ */
+ async check(monitor, heartbeat, server) {
+ throw new Error("You need to override check()");
+ }
+}
+
+module.exports = {
+ MonitorType,
+};
diff --git a/server/monitor-types/mqtt.js b/server/monitor-types/mqtt.js
new file mode 100644
index 0000000..ad734ce
--- /dev/null
+++ b/server/monitor-types/mqtt.js
@@ -0,0 +1,117 @@
+const { MonitorType } = require("./monitor-type");
+const { log, UP } = require("../../src/util");
+const mqtt = require("mqtt");
+const jsonata = require("jsonata");
+
+class MqttMonitorType extends MonitorType {
+ name = "mqtt";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, server) {
+ const receivedMessage = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, {
+ port: monitor.port,
+ username: monitor.mqttUsername,
+ password: monitor.mqttPassword,
+ interval: monitor.interval,
+ });
+
+ if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") {
+ // use old default
+ monitor.mqttCheckType = "keyword";
+ }
+
+ if (monitor.mqttCheckType === "keyword") {
+ if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) {
+ heartbeat.msg = `Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`;
+ heartbeat.status = UP;
+ } else {
+ throw Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`);
+ }
+ } else if (monitor.mqttCheckType === "json-query") {
+ const parsedMessage = JSON.parse(receivedMessage);
+
+ let expression = jsonata(monitor.jsonPath);
+
+ let result = await expression.evaluate(parsedMessage);
+
+ if (result?.toString() === monitor.expectedValue) {
+ heartbeat.msg = "Message received, expected value is found";
+ heartbeat.status = UP;
+ } else {
+ throw new Error("Message received but value is not equal to expected value, value was: [" + result + "]");
+ }
+ } else {
+ throw Error("Unknown MQTT Check Type");
+ }
+ }
+
+ /**
+ * Connect to MQTT Broker, subscribe to topic and receive message as String
+ * @param {string} hostname Hostname / address of machine to test
+ * @param {string} topic MQTT topic
+ * @param {object} options MQTT options. Contains port, username,
+ * password and interval (interval defaults to 20)
+ * @returns {Promise<string>} Received MQTT message
+ */
+ mqttAsync(hostname, topic, options = {}) {
+ return new Promise((resolve, reject) => {
+ const { port, username, password, interval = 20 } = options;
+
+ // Adds MQTT protocol to the hostname if not already present
+ if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
+ hostname = "mqtt://" + hostname;
+ }
+
+ const timeoutID = setTimeout(() => {
+ log.debug("mqtt", "MQTT timeout triggered");
+ client.end();
+ reject(new Error("Timeout, Message not received"));
+ }, interval * 1000 * 0.8);
+
+ const mqttUrl = `${hostname}:${port}`;
+
+ log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);
+
+ let client = mqtt.connect(mqttUrl, {
+ username,
+ password,
+ clientId: "uptime-kuma_" + Math.random().toString(16).substr(2, 8)
+ });
+
+ client.on("connect", () => {
+ log.debug("mqtt", "MQTT connected");
+
+ try {
+ client.subscribe(topic, () => {
+ log.debug("mqtt", "MQTT subscribed to topic");
+ });
+ } catch (e) {
+ client.end();
+ clearTimeout(timeoutID);
+ reject(new Error("Cannot subscribe topic"));
+ }
+ });
+
+ client.on("error", (error) => {
+ client.end();
+ clearTimeout(timeoutID);
+ reject(error);
+ });
+
+ client.on("message", (messageTopic, message) => {
+ if (messageTopic === topic) {
+ client.end();
+ clearTimeout(timeoutID);
+ resolve(message.toString("utf8"));
+ }
+ });
+
+ });
+ }
+}
+
+module.exports = {
+ MqttMonitorType,
+};
diff --git a/server/monitor-types/rabbitmq.js b/server/monitor-types/rabbitmq.js
new file mode 100644
index 0000000..165a0ed
--- /dev/null
+++ b/server/monitor-types/rabbitmq.js
@@ -0,0 +1,67 @@
+const { MonitorType } = require("./monitor-type");
+const { log, UP, DOWN } = require("../../src/util");
+const { axiosAbortSignal } = require("../util-server");
+const axios = require("axios");
+
+class RabbitMqMonitorType extends MonitorType {
+ name = "rabbitmq";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, server) {
+ let baseUrls = [];
+ try {
+ baseUrls = JSON.parse(monitor.rabbitmqNodes);
+ } catch (error) {
+ throw new Error("Invalid RabbitMQ Nodes");
+ }
+
+ heartbeat.status = DOWN;
+ for (let baseUrl of baseUrls) {
+ try {
+ // Without a trailing slash, path in baseUrl will be removed. https://example.com/api -> https://example.com
+ if ( !baseUrl.endsWith("/") ) {
+ baseUrl += "/";
+ }
+ const options = {
+ // Do not start with slash, it will strip the trailing slash from baseUrl
+ url: new URL("api/health/checks/alarms/", baseUrl).href,
+ method: "get",
+ timeout: monitor.timeout * 1000,
+ headers: {
+ "Accept": "application/json",
+ "Authorization": "Basic " + Buffer.from(`${monitor.rabbitmqUsername || ""}:${monitor.rabbitmqPassword || ""}`).toString("base64"),
+ },
+ signal: axiosAbortSignal((monitor.timeout + 10) * 1000),
+ // Capture reason for 503 status
+ validateStatus: (status) => status === 200 || status === 503,
+ };
+ log.debug("monitor", `[${monitor.name}] Axios Request: ${JSON.stringify(options)}`);
+ const res = await axios.request(options);
+ log.debug("monitor", `[${monitor.name}] Axios Response: status=${res.status} body=${JSON.stringify(res.data)}`);
+ if (res.status === 200) {
+ heartbeat.status = UP;
+ heartbeat.msg = "OK";
+ break;
+ } else if (res.status === 503) {
+ heartbeat.msg = res.data.reason;
+ } else {
+ heartbeat.msg = `${res.status} - ${res.statusText}`;
+ }
+ } catch (error) {
+ if (axios.isCancel(error)) {
+ heartbeat.msg = "Request timed out";
+ log.debug("monitor", `[${monitor.name}] Request timed out`);
+ } else {
+ log.debug("monitor", `[${monitor.name}] Axios Error: ${JSON.stringify(error.message)}`);
+ heartbeat.msg = error.message;
+ }
+ }
+ }
+ }
+}
+
+module.exports = {
+ RabbitMqMonitorType,
+};
diff --git a/server/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js
new file mode 100644
index 0000000..f1219af
--- /dev/null
+++ b/server/monitor-types/real-browser-monitor-type.js
@@ -0,0 +1,273 @@
+const { MonitorType } = require("./monitor-type");
+const { chromium } = require("playwright-core");
+const { UP, log } = require("../../src/util");
+const { Settings } = require("../settings");
+const commandExistsSync = require("command-exists").sync;
+const childProcess = require("child_process");
+const path = require("path");
+const Database = require("../database");
+const jwt = require("jsonwebtoken");
+const config = require("../config");
+const { RemoteBrowser } = require("../remote-browser");
+
+/**
+ * Cached instance of a browser
+ * @type {import ("playwright-core").Browser}
+ */
+let browser = null;
+
+let allowedList = [];
+let lastAutoDetectChromeExecutable = null;
+
+if (process.platform === "win32") {
+ allowedList.push(process.env.LOCALAPPDATA + "\\Google\\Chrome\\Application\\chrome.exe");
+ allowedList.push(process.env.PROGRAMFILES + "\\Google\\Chrome\\Application\\chrome.exe");
+ allowedList.push(process.env["ProgramFiles(x86)"] + "\\Google\\Chrome\\Application\\chrome.exe");
+
+ // Allow Chromium too
+ allowedList.push(process.env.LOCALAPPDATA + "\\Chromium\\Application\\chrome.exe");
+ allowedList.push(process.env.PROGRAMFILES + "\\Chromium\\Application\\chrome.exe");
+ allowedList.push(process.env["ProgramFiles(x86)"] + "\\Chromium\\Application\\chrome.exe");
+
+ // Allow MS Edge
+ allowedList.push(process.env["ProgramFiles(x86)"] + "\\Microsoft\\Edge\\Application\\msedge.exe");
+
+ // For Loop A to Z
+ for (let i = 65; i <= 90; i++) {
+ let drive = String.fromCharCode(i);
+ allowedList.push(drive + ":\\Program Files\\Google\\Chrome\\Application\\chrome.exe");
+ allowedList.push(drive + ":\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe");
+ }
+
+} else if (process.platform === "linux") {
+ allowedList = [
+ "chromium",
+ "chromium-browser",
+ "google-chrome",
+
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/usr/bin/google-chrome",
+ "/snap/bin/chromium", // Ubuntu
+ ];
+} else if (process.platform === "darwin") {
+ allowedList = [
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ];
+}
+
+/**
+ * Is the executable path allowed?
+ * @param {string} executablePath Path to executable
+ * @returns {Promise<boolean>} The executable is allowed?
+ */
+async function isAllowedChromeExecutable(executablePath) {
+ console.log(config.args);
+ if (config.args["allow-all-chrome-exec"] || process.env.UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC === "1") {
+ return true;
+ }
+
+ // Check if the executablePath is in the list of allowed executables
+ return allowedList.includes(executablePath);
+}
+
+/**
+ * Get the current instance of the browser. If there isn't one, create
+ * it.
+ * @returns {Promise<import ("playwright-core").Browser>} The browser
+ */
+async function getBrowser() {
+ if (browser && browser.isConnected()) {
+ return browser;
+ } else {
+ let executablePath = await Settings.get("chromeExecutable");
+
+ executablePath = await prepareChromeExecutable(executablePath);
+
+ browser = await chromium.launch({
+ //headless: false,
+ executablePath,
+ });
+
+ return browser;
+ }
+}
+
+/**
+ * Get the current instance of the browser. If there isn't one, create it
+ * @param {integer} remoteBrowserID Path to executable
+ * @param {integer} userId User ID
+ * @returns {Promise<Browser>} The browser
+ */
+async function getRemoteBrowser(remoteBrowserID, userId) {
+ let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId);
+ log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`);
+ browser = await chromium.connect(remoteBrowser.url);
+ return browser;
+}
+
+/**
+ * Prepare the chrome executable path
+ * @param {string} executablePath Path to chrome executable
+ * @returns {Promise<string>} Executable path
+ */
+async function prepareChromeExecutable(executablePath) {
+ // Special code for using the playwright_chromium
+ if (typeof executablePath === "string" && executablePath.toLocaleLowerCase() === "#playwright_chromium") {
+ // Set to undefined = use playwright_chromium
+ executablePath = undefined;
+ } else if (!executablePath) {
+ if (process.env.UPTIME_KUMA_IS_CONTAINER) {
+ executablePath = "/usr/bin/chromium";
+
+ // Install chromium in container via apt install
+ if ( !commandExistsSync(executablePath)) {
+ await new Promise((resolve, reject) => {
+ log.info("Chromium", "Installing Chromium...");
+ let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk");
+
+ // On exit
+ child.on("exit", (code) => {
+ log.info("Chromium", "apt install chromium exited with code " + code);
+
+ if (code === 0) {
+ log.info("Chromium", "Installed Chromium");
+ let version = childProcess.execSync(executablePath + " --version").toString("utf8");
+ log.info("Chromium", "Chromium version: " + version);
+ resolve();
+ } else if (code === 100) {
+ reject(new Error("Installing Chromium, please wait..."));
+ } else {
+ reject(new Error("apt install chromium failed with code " + code));
+ }
+ });
+ });
+ }
+
+ } else {
+ executablePath = findChrome(allowedList);
+ }
+ } else {
+ // User specified a path
+ // Check if the executablePath is in the list of allowed
+ if (!await isAllowedChromeExecutable(executablePath)) {
+ throw new Error("This Chromium executable path is not allowed by default. If you are sure this is safe, please add an environment variable UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC=1 to allow it.");
+ }
+ }
+ return executablePath;
+}
+
+/**
+ * Find the chrome executable
+ * @param {any[]} executables Executables to search through
+ * @returns {any} Executable
+ * @throws Could not find executable
+ */
+function findChrome(executables) {
+ // Use the last working executable, so we don't have to search for it again
+ if (lastAutoDetectChromeExecutable) {
+ if (commandExistsSync(lastAutoDetectChromeExecutable)) {
+ return lastAutoDetectChromeExecutable;
+ }
+ }
+
+ for (let executable of executables) {
+ if (commandExistsSync(executable)) {
+ lastAutoDetectChromeExecutable = executable;
+ return executable;
+ }
+ }
+ throw new Error("Chromium not found, please specify Chromium executable path in the settings page.");
+}
+
+/**
+ * Reset chrome
+ * @returns {Promise<void>}
+ */
+async function resetChrome() {
+ if (browser) {
+ await browser.close();
+ browser = null;
+ }
+}
+
+/**
+ * Test if the chrome executable is valid and return the version
+ * @param {string} executablePath Path to executable
+ * @returns {Promise<string>} Chrome version
+ */
+async function testChrome(executablePath) {
+ try {
+ executablePath = await prepareChromeExecutable(executablePath);
+
+ log.info("Chromium", "Testing Chromium executable: " + executablePath);
+
+ const browser = await chromium.launch({
+ executablePath,
+ });
+ const version = browser.version();
+ await browser.close();
+ return version;
+ } catch (e) {
+ throw new Error(e.message);
+ }
+}
+// test remote browser
+/**
+ * @param {string} remoteBrowserURL Remote Browser URL
+ * @returns {Promise<boolean>} Returns if connection worked
+ */
+async function testRemoteBrowser(remoteBrowserURL) {
+ try {
+ const browser = await chromium.connect(remoteBrowserURL);
+ browser.version();
+ await browser.close();
+ return true;
+ } catch (e) {
+ throw new Error(e.message);
+ }
+}
+class RealBrowserMonitorType extends MonitorType {
+
+ name = "real-browser";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, server) {
+ const browser = monitor.remote_browser ? await getRemoteBrowser(monitor.remote_browser, monitor.user_id) : await getBrowser();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ const res = await page.goto(monitor.url, {
+ waitUntil: "networkidle",
+ timeout: monitor.interval * 1000 * 0.8,
+ });
+
+ let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
+
+ await page.screenshot({
+ path: path.join(Database.screenshotDir, filename),
+ });
+
+ await context.close();
+
+ if (res.status() >= 200 && res.status() < 400) {
+ heartbeat.status = UP;
+ heartbeat.msg = res.status();
+
+ const timing = res.request().timing();
+ heartbeat.ping = timing.responseEnd;
+ } else {
+ throw new Error(res.status() + "");
+ }
+ }
+}
+
+module.exports = {
+ RealBrowserMonitorType,
+ testChrome,
+ resetChrome,
+ testRemoteBrowser,
+};
diff --git a/server/monitor-types/snmp.js b/server/monitor-types/snmp.js
new file mode 100644
index 0000000..a1760fa
--- /dev/null
+++ b/server/monitor-types/snmp.js
@@ -0,0 +1,63 @@
+const { MonitorType } = require("./monitor-type");
+const { UP, log, evaluateJsonQuery } = require("../../src/util");
+const snmp = require("net-snmp");
+
+class SNMPMonitorType extends MonitorType {
+ name = "snmp";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ let session;
+ try {
+ const sessionOptions = {
+ port: monitor.port || "161",
+ retries: monitor.maxretries,
+ timeout: monitor.timeout * 1000,
+ version: snmp.Version[monitor.snmpVersion],
+ };
+ session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
+
+ // Handle errors during session creation
+ session.on("error", (error) => {
+ throw new Error(`Error creating SNMP session: ${error.message}`);
+ });
+
+ const varbinds = await new Promise((resolve, reject) => {
+ session.get([ monitor.snmpOid ], (error, varbinds) => {
+ error ? reject(error) : resolve(varbinds);
+ });
+ });
+ log.debug("monitor", `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value})`);
+
+ if (varbinds.length === 0) {
+ throw new Error(`No varbinds returned from SNMP session (OID: ${monitor.snmpOid})`);
+ }
+
+ if (varbinds[0].type === snmp.ObjectType.NoSuchInstance) {
+ throw new Error(`The SNMP query returned that no instance exists for OID ${monitor.snmpOid}`);
+ }
+
+ // We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in.
+ const value = varbinds[0].value;
+
+ const { status, response } = await evaluateJsonQuery(value, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue);
+
+ if (status) {
+ heartbeat.status = UP;
+ heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`;
+ } else {
+ throw new Error(`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`);
+ }
+ } finally {
+ if (session) {
+ session.close();
+ }
+ }
+ }
+}
+
+module.exports = {
+ SNMPMonitorType,
+};
diff --git a/server/monitor-types/tailscale-ping.js b/server/monitor-types/tailscale-ping.js
new file mode 100644
index 0000000..8537651
--- /dev/null
+++ b/server/monitor-types/tailscale-ping.js
@@ -0,0 +1,77 @@
+const { MonitorType } = require("./monitor-type");
+const { UP } = require("../../src/util");
+const childProcessAsync = require("promisify-child-process");
+
+class TailscalePing extends MonitorType {
+ name = "tailscale-ping";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ try {
+ let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval);
+ this.parseTailscaleOutput(tailscaleOutput, heartbeat);
+ } catch (err) {
+ // trigger log function somewhere to display a notification or alert to the user (but how?)
+ throw new Error(`Error checking Tailscale ping: ${err}`);
+ }
+ }
+
+ /**
+ * Runs the Tailscale ping command to the given URL.
+ * @param {string} hostname The hostname to ping.
+ * @param {number} interval Interval to send ping
+ * @returns {Promise<string>} A Promise that resolves to the output of the Tailscale ping command
+ * @throws Will throw an error if the command execution encounters any error.
+ */
+ async runTailscalePing(hostname, interval) {
+ let timeout = interval * 1000 * 0.8;
+ let res = await childProcessAsync.spawn("tailscale", [ "ping", "--c", "1", hostname ], {
+ timeout: timeout,
+ encoding: "utf8",
+ });
+ if (res.stderr && res.stderr.toString()) {
+ throw new Error(`Error in output: ${res.stderr.toString()}`);
+ }
+ if (res.stdout && res.stdout.toString()) {
+ return res.stdout.toString();
+ } else {
+ throw new Error("No output from Tailscale ping");
+ }
+ }
+
+ /**
+ * Parses the output of the Tailscale ping command to update the heartbeat.
+ * @param {string} tailscaleOutput The output of the Tailscale ping command.
+ * @param {object} heartbeat The heartbeat object to update.
+ * @returns {void}
+ * @throws Will throw an eror if the output contains any unexpected string.
+ */
+ parseTailscaleOutput(tailscaleOutput, heartbeat) {
+ let lines = tailscaleOutput.split("\n");
+
+ for (let line of lines) {
+ if (line.includes("pong from")) {
+ heartbeat.status = UP;
+ let time = line.split(" in ")[1].split(" ")[0];
+ heartbeat.ping = parseInt(time);
+ heartbeat.msg = "OK";
+ break;
+ } else if (line.includes("timed out")) {
+ throw new Error(`Ping timed out: "${line}"`);
+ // Immediately throws upon "timed out" message, the server is expected to re-call the check function
+ } else if (line.includes("no matching peer")) {
+ throw new Error(`Nonexistant or inaccessible due to ACLs: "${line}"`);
+ } else if (line.includes("is local Tailscale IP")) {
+ throw new Error(`Tailscale only works if used on other machines: "${line}"`);
+ } else if (line !== "") {
+ throw new Error(`Unexpected output: "${line}"`);
+ }
+ }
+ }
+}
+
+module.exports = {
+ TailscalePing,
+};
diff --git a/server/notification-providers/46elks.js b/server/notification-providers/46elks.js
new file mode 100644
index 0000000..4b15e9f
--- /dev/null
+++ b/server/notification-providers/46elks.js
@@ -0,0 +1,35 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Elks extends NotificationProvider {
+ name = "Elks";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://api.46elks.com/a1/sms";
+
+ try {
+ let data = new URLSearchParams();
+ data.append("from", notification.elksFromNumber);
+ data.append("to", notification.elksToNumber );
+ data.append("message", msg);
+
+ const config = {
+ headers: {
+ "Authorization": "Basic " + Buffer.from(`${notification.elksUsername}:${notification.elksAuthToken}`).toString("base64")
+ }
+ };
+
+ await axios.post(url, data, config);
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Elks;
diff --git a/server/notification-providers/alerta.js b/server/notification-providers/alerta.js
new file mode 100644
index 0000000..f9a273b
--- /dev/null
+++ b/server/notification-providers/alerta.js
@@ -0,0 +1,68 @@
+const NotificationProvider = require("./notification-provider");
+const { DOWN, UP } = require("../../src/util");
+const axios = require("axios");
+
+class Alerta extends NotificationProvider {
+ name = "alerta";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json;charset=UTF-8",
+ "Authorization": "Key " + notification.alertaApiKey,
+ }
+ };
+ let data = {
+ environment: notification.alertaEnvironment,
+ severity: "critical",
+ correlate: [],
+ service: [ "UptimeKuma" ],
+ value: "Timeout",
+ tags: [ "uptimekuma" ],
+ attributes: {},
+ origin: "uptimekuma",
+ type: "exceptionAlert",
+ };
+
+ if (heartbeatJSON == null) {
+ let postData = Object.assign({
+ event: "msg",
+ text: msg,
+ group: "uptimekuma-msg",
+ resource: "Message",
+ }, data);
+
+ await axios.post(notification.alertaApiEndpoint, postData, config);
+ } else {
+ let datadup = Object.assign( {
+ correlate: [ "service_up", "service_down" ],
+ event: monitorJSON["type"],
+ group: "uptimekuma-" + monitorJSON["type"],
+ resource: monitorJSON["name"],
+ }, data );
+
+ if (heartbeatJSON["status"] === DOWN) {
+ datadup.severity = notification.alertaAlertState; // critical
+ datadup.text = "Service " + monitorJSON["type"] + " is down.";
+ await axios.post(notification.alertaApiEndpoint, datadup, config);
+ } else if (heartbeatJSON["status"] === UP) {
+ datadup.severity = notification.alertaRecoverState; // cleaned
+ datadup.text = "Service " + monitorJSON["type"] + " is up.";
+ await axios.post(notification.alertaApiEndpoint, datadup, config);
+ }
+ }
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = Alerta;
diff --git a/server/notification-providers/alertnow.js b/server/notification-providers/alertnow.js
new file mode 100644
index 0000000..4257ca9
--- /dev/null
+++ b/server/notification-providers/alertnow.js
@@ -0,0 +1,53 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { setting } = require("../util-server");
+const { getMonitorRelativeURL, UP, DOWN } = require("../../src/util");
+
+class AlertNow extends NotificationProvider {
+ name = "AlertNow";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let textMsg = "";
+ let status = "open";
+ let eventType = "ERROR";
+ let eventId = new Date().toISOString().slice(0, 10).replace(/-/g, "");
+
+ if (heartbeatJSON && heartbeatJSON.status === UP) {
+ textMsg = `[${heartbeatJSON.name}] ✅ Application is back online`;
+ status = "close";
+ eventType = "INFO";
+ eventId += `_${heartbeatJSON.name.replace(/\s/g, "")}`;
+ } else if (heartbeatJSON && heartbeatJSON.status === DOWN) {
+ textMsg = `[${heartbeatJSON.name}] 🔴 Application went down`;
+ }
+
+ textMsg += ` - ${msg}`;
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorJSON) {
+ textMsg += ` >> ${baseURL + getMonitorRelativeURL(monitorJSON.id)}`;
+ }
+
+ const data = {
+ "summary": textMsg,
+ "status": status,
+ "event_type": eventType,
+ "event_id": eventId,
+ };
+
+ await axios.post(notification.alertNowWebhookURL, data);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = AlertNow;
diff --git a/server/notification-providers/aliyun-sms.js b/server/notification-providers/aliyun-sms.js
new file mode 100644
index 0000000..ff38bd0
--- /dev/null
+++ b/server/notification-providers/aliyun-sms.js
@@ -0,0 +1,143 @@
+const NotificationProvider = require("./notification-provider");
+const { DOWN, UP } = require("../../src/util");
+const { default: axios } = require("axios");
+const Crypto = require("crypto");
+const qs = require("qs");
+
+class AliyunSMS extends NotificationProvider {
+ name = "AliyunSMS";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ if (heartbeatJSON != null) {
+ let msgBody = JSON.stringify({
+ name: monitorJSON["name"],
+ time: heartbeatJSON["time"],
+ status: this.statusToString(heartbeatJSON["status"]),
+ msg: heartbeatJSON["msg"],
+ });
+ if (await this.sendSms(notification, msgBody)) {
+ return okMsg;
+ }
+ } else {
+ let msgBody = JSON.stringify({
+ name: "",
+ time: "",
+ status: "",
+ msg: msg,
+ });
+ if (await this.sendSms(notification, msgBody)) {
+ return okMsg;
+ }
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Send the SMS notification
+ * @param {BeanModel} notification Notification details
+ * @param {string} msgbody Message template
+ * @returns {Promise<boolean>} True if successful else false
+ */
+ async sendSms(notification, msgbody) {
+ let params = {
+ PhoneNumbers: notification.phonenumber,
+ TemplateCode: notification.templateCode,
+ SignName: notification.signName,
+ TemplateParam: msgbody,
+ AccessKeyId: notification.accessKeyId,
+ Format: "JSON",
+ SignatureMethod: "HMAC-SHA1",
+ SignatureVersion: "1.0",
+ SignatureNonce: Math.random().toString(),
+ Timestamp: new Date().toISOString(),
+ Action: "SendSms",
+ Version: "2017-05-25",
+ };
+
+ params.Signature = this.sign(params, notification.secretAccessKey);
+ let config = {
+ method: "POST",
+ url: "http://dysmsapi.aliyuncs.com/",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ data: qs.stringify(params),
+ };
+
+ let result = await axios(config);
+ if (result.data.Message === "OK") {
+ return true;
+ }
+
+ throw new Error(result.data.Message);
+ }
+
+ /**
+ * Aliyun request sign
+ * @param {object} param Parameters object to sign
+ * @param {string} AccessKeySecret Secret key to sign parameters with
+ * @returns {string} Base64 encoded request
+ */
+ sign(param, AccessKeySecret) {
+ let param2 = {};
+ let data = [];
+
+ let oa = Object.keys(param).sort();
+
+ for (let i = 0; i < oa.length; i++) {
+ let key = oa[i];
+ param2[key] = param[key];
+ }
+
+ // Escape more characters than encodeURIComponent does.
+ // For generating Aliyun signature, all characters except A-Za-z0-9~-._ are encoded.
+ // See https://help.aliyun.com/document_detail/315526.html
+ // This encoding methods as known as RFC 3986 (https://tools.ietf.org/html/rfc3986)
+ let moreEscapesTable = function (m) {
+ return {
+ "!": "%21",
+ "*": "%2A",
+ "'": "%27",
+ "(": "%28",
+ ")": "%29"
+ }[m];
+ };
+
+ for (let key in param2) {
+ let value = encodeURIComponent(param2[key]).replace(/[!*'()]/g, moreEscapesTable);
+ data.push(`${encodeURIComponent(key)}=${value}`);
+ }
+
+ let StringToSign = `POST&${encodeURIComponent("/")}&${encodeURIComponent(data.join("&"))}`;
+ return Crypto
+ .createHmac("sha1", `${AccessKeySecret}&`)
+ .update(Buffer.from(StringToSign))
+ .digest("base64");
+ }
+
+ /**
+ * Convert status constant to string
+ * @param {const} status The status constant
+ * @returns {string} Status
+ */
+ statusToString(status) {
+ switch (status) {
+ case DOWN:
+ return "DOWN";
+ case UP:
+ return "UP";
+ default:
+ return status;
+ }
+ }
+}
+
+module.exports = AliyunSMS;
diff --git a/server/notification-providers/apprise.js b/server/notification-providers/apprise.js
new file mode 100644
index 0000000..0f69821
--- /dev/null
+++ b/server/notification-providers/apprise.js
@@ -0,0 +1,37 @@
+const NotificationProvider = require("./notification-provider");
+const childProcessAsync = require("promisify-child-process");
+
+class Apprise extends NotificationProvider {
+ name = "apprise";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ const args = [ "-vv", "-b", msg, notification.appriseURL ];
+ if (notification.title) {
+ args.push("-t");
+ args.push(notification.title);
+ }
+ const s = await childProcessAsync.spawn("apprise", args, {
+ encoding: "utf8",
+ });
+
+ const output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found";
+
+ if (output) {
+
+ if (! output.includes("ERROR")) {
+ return okMsg;
+ }
+
+ throw new Error(output);
+ } else {
+ return "No output from apprise";
+ }
+ }
+}
+
+module.exports = Apprise;
diff --git a/server/notification-providers/bitrix24.js b/server/notification-providers/bitrix24.js
new file mode 100644
index 0000000..ba12126
--- /dev/null
+++ b/server/notification-providers/bitrix24.js
@@ -0,0 +1,31 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP } = require("../../src/util");
+
+class Bitrix24 extends NotificationProvider {
+ name = "Bitrix24";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ const params = {
+ user_id: notification.bitrix24UserID,
+ message: "[B]Uptime Kuma[/B]",
+ "ATTACH[COLOR]": (heartbeatJSON ?? {})["status"] === UP ? "#b73419" : "#67b518",
+ "ATTACH[BLOCKS][0][MESSAGE]": msg
+ };
+
+ await axios.get(`${notification.bitrix24WebhookURL}/im.notify.system.add.json`, { params });
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Bitrix24;
diff --git a/server/notification-providers/call-me-bot.js b/server/notification-providers/call-me-bot.js
new file mode 100644
index 0000000..daa9ccd
--- /dev/null
+++ b/server/notification-providers/call-me-bot.js
@@ -0,0 +1,23 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class CallMeBot extends NotificationProvider {
+ name = "CallMeBot";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ try {
+ const url = new URL(notification.callMeBotEndpoint);
+ url.searchParams.set("text", msg);
+ await axios.get(url.toString());
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = CallMeBot;
diff --git a/server/notification-providers/cellsynt.js b/server/notification-providers/cellsynt.js
new file mode 100644
index 0000000..e842237
--- /dev/null
+++ b/server/notification-providers/cellsynt.js
@@ -0,0 +1,39 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Cellsynt extends NotificationProvider {
+ name = "Cellsynt";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const data = {
+ // docs at https://www.cellsynt.com/en/sms/api-integration
+ params: {
+ "username": notification.cellsyntLogin,
+ "password": notification.cellsyntPassword,
+ "destination": notification.cellsyntDestination,
+ "text": msg.replace(/[^\x00-\x7F]/g, ""),
+ "originatortype": notification.cellsyntOriginatortype,
+ "originator": notification.cellsyntOriginator,
+ "allowconcat": notification.cellsyntAllowLongSMS ? 6 : 1
+ }
+ };
+ try {
+ const resp = await axios.post("https://se-1.cellsynt.net/sms.php", null, data);
+ if (resp.data == null ) {
+ throw new Error("Could not connect to Cellsynt, please try again.");
+ } else if (resp.data.includes("Error:")) {
+ resp.data = resp.data.replaceAll("Error:", "");
+ throw new Error(resp.data);
+ }
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Cellsynt;
diff --git a/server/notification-providers/clicksendsms.js b/server/notification-providers/clicksendsms.js
new file mode 100644
index 0000000..c090b7f
--- /dev/null
+++ b/server/notification-providers/clicksendsms.js
@@ -0,0 +1,45 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class ClickSendSMS extends NotificationProvider {
+ name = "clicksendsms";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://rest.clicksend.com/v3/sms/send";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Basic " + Buffer.from(notification.clicksendsmsLogin + ":" + notification.clicksendsmsPassword).toString("base64"),
+ "Accept": "text/json",
+ }
+ };
+ let data = {
+ messages: [
+ {
+ "body": msg.replace(/[^\x00-\x7F]/g, ""),
+ "to": notification.clicksendsmsToNumber,
+ "source": "uptime-kuma",
+ "from": notification.clicksendsmsSenderName,
+ }
+ ]
+ };
+ let resp = await axios.post(url, data, config);
+ if (resp.data.data.messages[0].status !== "SUCCESS") {
+ let error = "Something gone wrong. Api returned " + resp.data.data.messages[0].status + ".";
+ this.throwGeneralAxiosError(error);
+ }
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = ClickSendSMS;
diff --git a/server/notification-providers/dingding.js b/server/notification-providers/dingding.js
new file mode 100644
index 0000000..c66f270
--- /dev/null
+++ b/server/notification-providers/dingding.js
@@ -0,0 +1,101 @@
+const NotificationProvider = require("./notification-provider");
+const { DOWN, UP } = require("../../src/util");
+const { default: axios } = require("axios");
+const Crypto = require("crypto");
+
+class DingDing extends NotificationProvider {
+ name = "DingDing";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ if (heartbeatJSON != null) {
+ let params = {
+ msgtype: "markdown",
+ markdown: {
+ title: `[${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]}`,
+ text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n> ${heartbeatJSON["msg"]}\n> Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
+ },
+ "at": {
+ "isAtAll": notification.mentioning === "everyone"
+ }
+ };
+ if (await this.sendToDingDing(notification, params)) {
+ return okMsg;
+ }
+ } else {
+ let params = {
+ msgtype: "text",
+ text: {
+ content: msg
+ }
+ };
+ if (await this.sendToDingDing(notification, params)) {
+ return okMsg;
+ }
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Send message to DingDing
+ * @param {BeanModel} notification Notification to send
+ * @param {object} params Parameters of message
+ * @returns {Promise<boolean>} True if successful else false
+ */
+ async sendToDingDing(notification, params) {
+ let timestamp = Date.now();
+
+ let config = {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ url: `${notification.webHookUrl}&timestamp=${timestamp}&sign=${encodeURIComponent(this.sign(timestamp, notification.secretKey))}`,
+ data: JSON.stringify(params),
+ };
+
+ let result = await axios(config);
+ if (result.data.errmsg === "ok") {
+ return true;
+ }
+ throw new Error(result.data.errmsg);
+ }
+
+ /**
+ * DingDing sign
+ * @param {Date} timestamp Timestamp of message
+ * @param {string} secretKey Secret key to sign data with
+ * @returns {string} Base64 encoded signature
+ */
+ sign(timestamp, secretKey) {
+ return Crypto
+ .createHmac("sha256", Buffer.from(secretKey, "utf8"))
+ .update(Buffer.from(`${timestamp}\n${secretKey}`, "utf8"))
+ .digest("base64");
+ }
+
+ /**
+ * Convert status constant to string
+ * @param {const} status The status constant
+ * @returns {string} Status
+ */
+ statusToString(status) {
+ switch (status) {
+ case DOWN:
+ return "DOWN";
+ case UP:
+ return "UP";
+ default:
+ return status;
+ }
+ }
+}
+
+module.exports = DingDing;
diff --git a/server/notification-providers/discord.js b/server/notification-providers/discord.js
new file mode 100644
index 0000000..6a52f8f
--- /dev/null
+++ b/server/notification-providers/discord.js
@@ -0,0 +1,120 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class Discord extends NotificationProvider {
+ name = "discord";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ const discordDisplayName = notification.discordUsername || "Uptime Kuma";
+ const webhookUrl = new URL(notification.discordWebhookUrl);
+ if (notification.discordChannelType === "postToThread") {
+ webhookUrl.searchParams.append("thread_id", notification.threadId);
+ }
+
+ // If heartbeatJSON is null, assume we're testing.
+ if (heartbeatJSON == null) {
+ let discordtestdata = {
+ username: discordDisplayName,
+ content: msg,
+ };
+
+ if (notification.discordChannelType === "createNewForumPost") {
+ discordtestdata.thread_name = notification.postName;
+ }
+
+ await axios.post(webhookUrl.toString(), discordtestdata);
+ return okMsg;
+ }
+
+ // If heartbeatJSON is not null, we go into the normal alerting loop.
+ if (heartbeatJSON["status"] === DOWN) {
+ let discorddowndata = {
+ username: discordDisplayName,
+ embeds: [{
+ title: "❌ Your service " + monitorJSON["name"] + " went down. ❌",
+ color: 16711680,
+ timestamp: heartbeatJSON["time"],
+ fields: [
+ {
+ name: "Service Name",
+ value: monitorJSON["name"],
+ },
+ {
+ name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
+ value: this.extractAddress(monitorJSON),
+ },
+ {
+ name: `Time (${heartbeatJSON["timezone"]})`,
+ value: heartbeatJSON["localDateTime"],
+ },
+ {
+ name: "Error",
+ value: heartbeatJSON["msg"] == null ? "N/A" : heartbeatJSON["msg"],
+ },
+ ],
+ }],
+ };
+ if (notification.discordChannelType === "createNewForumPost") {
+ discorddowndata.thread_name = notification.postName;
+ }
+ if (notification.discordPrefixMessage) {
+ discorddowndata.content = notification.discordPrefixMessage;
+ }
+
+ await axios.post(webhookUrl.toString(), discorddowndata);
+ return okMsg;
+
+ } else if (heartbeatJSON["status"] === UP) {
+ let discordupdata = {
+ username: discordDisplayName,
+ embeds: [{
+ title: "✅ Your service " + monitorJSON["name"] + " is up! ✅",
+ color: 65280,
+ timestamp: heartbeatJSON["time"],
+ fields: [
+ {
+ name: "Service Name",
+ value: monitorJSON["name"],
+ },
+ {
+ name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
+ value: this.extractAddress(monitorJSON),
+ },
+ {
+ name: `Time (${heartbeatJSON["timezone"]})`,
+ value: heartbeatJSON["localDateTime"],
+ },
+ {
+ name: "Ping",
+ value: heartbeatJSON["ping"] == null ? "N/A" : heartbeatJSON["ping"] + " ms",
+ },
+ ],
+ }],
+ };
+
+ if (notification.discordChannelType === "createNewForumPost") {
+ discordupdata.thread_name = notification.postName;
+ }
+
+ if (notification.discordPrefixMessage) {
+ discordupdata.content = notification.discordPrefixMessage;
+ }
+
+ await axios.post(webhookUrl.toString(), discordupdata);
+ return okMsg;
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+}
+
+module.exports = Discord;
diff --git a/server/notification-providers/feishu.js b/server/notification-providers/feishu.js
new file mode 100644
index 0000000..cd5331d
--- /dev/null
+++ b/server/notification-providers/feishu.js
@@ -0,0 +1,104 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class Feishu extends NotificationProvider {
+ name = "Feishu";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ if (heartbeatJSON == null) {
+ let testdata = {
+ msg_type: "text",
+ content: {
+ text: msg,
+ },
+ };
+ await axios.post(notification.feishuWebHookUrl, testdata);
+ return okMsg;
+ }
+
+ if (heartbeatJSON["status"] === DOWN) {
+ let downdata = {
+ msg_type: "interactive",
+ card: {
+ config: {
+ update_multi: false,
+ wide_screen_mode: true,
+ },
+ header: {
+ title: {
+ tag: "plain_text",
+ content: "UptimeKuma Alert: [Down] " + monitorJSON["name"],
+ },
+ template: "red",
+ },
+ elements: [
+ {
+ tag: "div",
+ text: {
+ tag: "lark_md",
+ content: getContent(heartbeatJSON),
+ },
+ }
+ ]
+ }
+ };
+ await axios.post(notification.feishuWebHookUrl, downdata);
+ return okMsg;
+ }
+
+ if (heartbeatJSON["status"] === UP) {
+ let updata = {
+ msg_type: "interactive",
+ card: {
+ config: {
+ update_multi: false,
+ wide_screen_mode: true,
+ },
+ header: {
+ title: {
+ tag: "plain_text",
+ content: "UptimeKuma Alert: [UP] " + monitorJSON["name"],
+ },
+ template: "green",
+ },
+ elements: [
+ {
+ tag: "div",
+ text: {
+ tag: "lark_md",
+ content: getContent(heartbeatJSON),
+ },
+ },
+ ]
+ }
+ };
+ await axios.post(notification.feishuWebHookUrl, updata);
+ return okMsg;
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+/**
+ * Get content
+ * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @returns {string} Return Successful Message
+ */
+function getContent(heartbeatJSON) {
+ return [
+ "**Message**: " + heartbeatJSON["msg"],
+ "**Ping**: " + (heartbeatJSON["ping"] == null ? "N/A" : heartbeatJSON["ping"] + " ms"),
+ `**Time (${heartbeatJSON["timezone"]})**: ${heartbeatJSON["localDateTime"]}`
+ ].join("\n");
+}
+
+module.exports = Feishu;
diff --git a/server/notification-providers/flashduty.js b/server/notification-providers/flashduty.js
new file mode 100644
index 0000000..c340ed0
--- /dev/null
+++ b/server/notification-providers/flashduty.js
@@ -0,0 +1,108 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
+const { setting } = require("../util-server");
+const successMessage = "Sent Successfully.";
+
+class FlashDuty extends NotificationProvider {
+ name = "FlashDuty";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ try {
+ if (heartbeatJSON == null) {
+ const title = "Uptime Kuma Alert";
+ const monitor = {
+ type: "ping",
+ url: msg,
+ name: "https://flashcat.cloud"
+ };
+ return this.postNotification(notification, title, msg, monitor);
+ }
+
+ if (heartbeatJSON.status === UP) {
+ const title = "Uptime Kuma Monitor ✅ Up";
+
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "Ok");
+ }
+
+ if (heartbeatJSON.status === DOWN) {
+ const title = "Uptime Kuma Monitor 🔴 Down";
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, notification.flashdutySeverity);
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Generate a monitor url from the monitors infomation
+ * @param {object} monitorInfo Monitor details
+ * @returns {string|undefined} Monitor URL
+ */
+ genMonitorUrl(monitorInfo) {
+ if (monitorInfo.type === "port" && monitorInfo.port) {
+ return monitorInfo.hostname + ":" + monitorInfo.port;
+ }
+ if (monitorInfo.hostname != null) {
+ return monitorInfo.hostname;
+ }
+ return monitorInfo.url;
+ }
+
+ /**
+ * Send the message
+ * @param {BeanModel} notification Message title
+ * @param {string} title Message
+ * @param {string} body Message
+ * @param {object} monitorInfo Monitor details
+ * @param {string} eventStatus Monitor status (Info, Warning, Critical, Ok)
+ * @returns {string} Success message
+ */
+ async postNotification(notification, title, body, monitorInfo, eventStatus) {
+ let labels = {
+ resource: this.genMonitorUrl(monitorInfo),
+ check: monitorInfo.name,
+ };
+ if (monitorInfo.tags && monitorInfo.tags.length > 0) {
+ for (let tag of monitorInfo.tags) {
+ labels[tag.name] = tag.value;
+ }
+ }
+ const options = {
+ method: "POST",
+ url: "https://api.flashcat.cloud/event/push/alert/standard?integration_key=" + notification.flashdutyIntegrationKey,
+ headers: { "Content-Type": "application/json" },
+ data: {
+ description: `[${title}] [${monitorInfo.name}] ${body}`,
+ title,
+ event_status: eventStatus || "Info",
+ alert_key: String(monitorInfo.id) || Math.random().toString(36).substring(7),
+ labels,
+ }
+ };
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorInfo) {
+ options.client = "Uptime Kuma";
+ options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id);
+ }
+
+ let result = await axios.request(options);
+ if (result.status == null) {
+ throw new Error("FlashDuty notification failed with invalid response!");
+ }
+ if (result.status < 200 || result.status >= 300) {
+ throw new Error("FlashDuty notification failed with status code " + result.status);
+ }
+ if (result.statusText != null) {
+ return "FlashDuty notification succeed: " + result.statusText;
+ }
+
+ return successMessage;
+ }
+}
+
+module.exports = FlashDuty;
diff --git a/server/notification-providers/freemobile.js b/server/notification-providers/freemobile.js
new file mode 100644
index 0000000..4de45ac
--- /dev/null
+++ b/server/notification-providers/freemobile.js
@@ -0,0 +1,27 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class FreeMobile extends NotificationProvider {
+ name = "FreeMobile";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ await axios.post(`https://smsapi.free-mobile.fr/sendmsg?msg=${encodeURIComponent(msg.replace("🔴", "⛔️"))}`, {
+ "user": notification.freemobileUser,
+ "pass": notification.freemobilePass,
+ });
+
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = FreeMobile;
diff --git a/server/notification-providers/goalert.js b/server/notification-providers/goalert.js
new file mode 100644
index 0000000..847c6a0
--- /dev/null
+++ b/server/notification-providers/goalert.js
@@ -0,0 +1,36 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP } = require("../../src/util");
+
+class GoAlert extends NotificationProvider {
+ name = "GoAlert";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let data = {
+ summary: msg,
+ };
+ if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {
+ data["action"] = "close";
+ }
+ let headers = {
+ "Content-Type": "multipart/form-data",
+ };
+ let config = {
+ headers: headers
+ };
+ await axios.post(`${notification.goAlertBaseURL}/api/v2/generic/incoming?token=${notification.goAlertToken}`, data, config);
+ return okMsg;
+ } catch (error) {
+ let msg = (error.response.data) ? error.response.data : "Error without response";
+ throw new Error(msg);
+ }
+ }
+}
+
+module.exports = GoAlert;
diff --git a/server/notification-providers/google-chat.js b/server/notification-providers/google-chat.js
new file mode 100644
index 0000000..0b72fea
--- /dev/null
+++ b/server/notification-providers/google-chat.js
@@ -0,0 +1,94 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { setting } = require("../util-server");
+const { getMonitorRelativeURL, UP } = require("../../src/util");
+
+class GoogleChat extends NotificationProvider {
+ name = "GoogleChat";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ // Google Chat message formatting: https://developers.google.com/chat/api/guides/message-formats/basic
+
+ let chatHeader = {
+ title: "Uptime Kuma Alert",
+ };
+
+ if (monitorJSON && heartbeatJSON) {
+ chatHeader["title"] =
+ heartbeatJSON["status"] === UP
+ ? `✅ ${monitorJSON["name"]} is back online`
+ : `🔴 ${monitorJSON["name"]} went down`;
+ }
+
+ // always show msg
+ let sectionWidgets = [
+ {
+ textParagraph: {
+ text: `<b>Message:</b>\n${msg}`,
+ },
+ },
+ ];
+
+ // add time if available
+ if (heartbeatJSON) {
+ sectionWidgets.push({
+ textParagraph: {
+ text: `<b>Time (${heartbeatJSON["timezone"]}):</b>\n${heartbeatJSON["localDateTime"]}`,
+ },
+ });
+ }
+
+ // add button for monitor link if available
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL) {
+ const urlPath = monitorJSON ? getMonitorRelativeURL(monitorJSON.id) : "/";
+ sectionWidgets.push({
+ buttonList: {
+ buttons: [
+ {
+ text: "Visit Uptime Kuma",
+ onClick: {
+ openLink: {
+ url: baseURL + urlPath,
+ },
+ },
+ },
+ ],
+ },
+ });
+ }
+
+ let chatSections = [
+ {
+ widgets: sectionWidgets,
+ },
+ ];
+
+ // construct json data
+ let data = {
+ cardsV2: [
+ {
+ card: {
+ header: chatHeader,
+ sections: chatSections,
+ },
+ },
+ ],
+ };
+
+ await axios.post(notification.googleChatWebhookURL, data);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = GoogleChat;
diff --git a/server/notification-providers/gorush.js b/server/notification-providers/gorush.js
new file mode 100644
index 0000000..ba9d470
--- /dev/null
+++ b/server/notification-providers/gorush.js
@@ -0,0 +1,44 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Gorush extends NotificationProvider {
+ name = "gorush";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ let platformMapping = {
+ "ios": 1,
+ "android": 2,
+ "huawei": 3,
+ };
+
+ try {
+ let data = {
+ "notifications": [
+ {
+ "tokens": [ notification.gorushDeviceToken ],
+ "platform": platformMapping[notification.gorushPlatform],
+ "message": msg,
+ // Optional
+ "title": notification.gorushTitle,
+ "priority": notification.gorushPriority,
+ "retry": parseInt(notification.gorushRetry) || 0,
+ "topic": notification.gorushTopic,
+ }
+ ]
+ };
+ let config = {};
+
+ await axios.post(`${notification.gorushServerURL}/api/push`, data, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Gorush;
diff --git a/server/notification-providers/gotify.js b/server/notification-providers/gotify.js
new file mode 100644
index 0000000..a52ef51
--- /dev/null
+++ b/server/notification-providers/gotify.js
@@ -0,0 +1,31 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Gotify extends NotificationProvider {
+ name = "gotify";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ if (notification.gotifyserverurl && notification.gotifyserverurl.endsWith("/")) {
+ notification.gotifyserverurl = notification.gotifyserverurl.slice(0, -1);
+ }
+ await axios.post(`${notification.gotifyserverurl}/message?token=${notification.gotifyapplicationToken}`, {
+ "message": msg,
+ "priority": notification.gotifyPriority || 8,
+ "title": "Uptime-Kuma",
+ });
+
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Gotify;
diff --git a/server/notification-providers/grafana-oncall.js b/server/notification-providers/grafana-oncall.js
new file mode 100644
index 0000000..e93c77c
--- /dev/null
+++ b/server/notification-providers/grafana-oncall.js
@@ -0,0 +1,51 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class GrafanaOncall extends NotificationProvider {
+ name = "GrafanaOncall";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ if (!notification.GrafanaOncallURL) {
+ throw new Error("GrafanaOncallURL cannot be empty");
+ }
+
+ try {
+ if (heartbeatJSON === null) {
+ let grafanaupdata = {
+ title: "General notification",
+ message: msg,
+ state: "alerting",
+ };
+ await axios.post(notification.GrafanaOncallURL, grafanaupdata);
+ return okMsg;
+ } else if (heartbeatJSON["status"] === DOWN) {
+ let grafanadowndata = {
+ title: monitorJSON["name"] + " is down",
+ message: heartbeatJSON["msg"],
+ state: "alerting",
+ };
+ await axios.post(notification.GrafanaOncallURL, grafanadowndata);
+ return okMsg;
+ } else if (heartbeatJSON["status"] === UP) {
+ let grafanaupdata = {
+ title: monitorJSON["name"] + " is up",
+ message: heartbeatJSON["msg"],
+ state: "ok",
+ };
+ await axios.post(notification.GrafanaOncallURL, grafanaupdata);
+ return okMsg;
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = GrafanaOncall;
diff --git a/server/notification-providers/gtx-messaging.js b/server/notification-providers/gtx-messaging.js
new file mode 100644
index 0000000..1ff97d1
--- /dev/null
+++ b/server/notification-providers/gtx-messaging.js
@@ -0,0 +1,33 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class GtxMessaging extends NotificationProvider {
+ name = "gtxmessaging";
+
+ /**
+ * @inheritDoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ // The UP/DOWN symbols will be replaced with `???` by gtx-messaging
+ const text = msg.replaceAll("🔴 ", "").replaceAll("✅ ", "");
+
+ try {
+ const data = new URLSearchParams();
+ data.append("from", notification.gtxMessagingFrom.trim());
+ data.append("to", notification.gtxMessagingTo.trim());
+ data.append("text", text);
+
+ const url = `https://rest.gtx-messaging.net/smsc/sendsms/${notification.gtxMessagingApiKey}/json`;
+
+ await axios.post(url, data);
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = GtxMessaging;
diff --git a/server/notification-providers/heii-oncall.js b/server/notification-providers/heii-oncall.js
new file mode 100644
index 0000000..20b53e6
--- /dev/null
+++ b/server/notification-providers/heii-oncall.js
@@ -0,0 +1,52 @@
+const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
+const { setting } = require("../util-server");
+
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+class HeiiOnCall extends NotificationProvider {
+ name = "HeiiOnCall";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const payload = heartbeatJSON || {};
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorJSON) {
+ payload["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id);
+ }
+
+ const config = {
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + notification.heiiOnCallApiKey,
+ },
+ };
+ const heiiUrl = `https://heiioncall.com/triggers/${notification.heiiOnCallTriggerId}/`;
+ // docs https://heiioncall.com/docs#manual-triggers
+ try {
+ if (!heartbeatJSON) {
+ // Testing or general notification like certificate expiry
+ payload["msg"] = msg;
+ await axios.post(heiiUrl + "alert", payload, config);
+ return okMsg;
+ }
+
+ if (heartbeatJSON.status === DOWN) {
+ await axios.post(heiiUrl + "alert", payload, config);
+ return okMsg;
+ }
+ if (heartbeatJSON.status === UP) {
+ await axios.post(heiiUrl + "resolve", payload, config);
+ return okMsg;
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = HeiiOnCall;
diff --git a/server/notification-providers/home-assistant.js b/server/notification-providers/home-assistant.js
new file mode 100644
index 0000000..4536b2a
--- /dev/null
+++ b/server/notification-providers/home-assistant.js
@@ -0,0 +1,45 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+const defaultNotificationService = "notify";
+
+class HomeAssistant extends NotificationProvider {
+ name = "HomeAssistant";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ const notificationService = notification?.notificationService || defaultNotificationService;
+
+ try {
+ await axios.post(
+ `${notification.homeAssistantUrl.trim().replace(/\/*$/, "")}/api/services/notify/${notificationService}`,
+ {
+ title: "Uptime Kuma",
+ message: msg,
+ ...(notificationService !== "persistent_notification" && { data: {
+ name: monitorJSON?.name,
+ status: heartbeatJSON?.status,
+ channel: "Uptime Kuma",
+ icon_url: "https://github.com/louislam/uptime-kuma/blob/master/public/icon.png?raw=true",
+ } }),
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${notification.longLivedAccessToken}`,
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = HomeAssistant;
diff --git a/server/notification-providers/keep.js b/server/notification-providers/keep.js
new file mode 100644
index 0000000..aa65a86
--- /dev/null
+++ b/server/notification-providers/keep.js
@@ -0,0 +1,42 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Keep extends NotificationProvider {
+ name = "Keep";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let data = {
+ heartbeat: heartbeatJSON,
+ monitor: monitorJSON,
+ msg,
+ };
+ let config = {
+ headers: {
+ "x-api-key": notification.webhookAPIKey,
+ "content-type": "application/json",
+ },
+ };
+
+ let url = notification.webhookURL;
+
+ if (url.endsWith("/")) {
+ url = url.slice(0, -1);
+ }
+
+ let webhookURL = url + "/alerts/event/uptimekuma";
+
+ await axios.post(webhookURL, data, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Keep;
diff --git a/server/notification-providers/kook.js b/server/notification-providers/kook.js
new file mode 100644
index 0000000..dab1951
--- /dev/null
+++ b/server/notification-providers/kook.js
@@ -0,0 +1,34 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Kook extends NotificationProvider {
+ name = "Kook";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://www.kookapp.cn/api/v3/message/create";
+
+ let data = {
+ target_id: notification.kookGuildID,
+ content: msg,
+ };
+ let config = {
+ headers: {
+ "Authorization": "Bot " + notification.kookBotToken,
+ "Content-Type": "application/json",
+ },
+ };
+ try {
+ await axios.post(url, data, config);
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Kook;
diff --git a/server/notification-providers/line.js b/server/notification-providers/line.js
new file mode 100644
index 0000000..57dc87e
--- /dev/null
+++ b/server/notification-providers/line.js
@@ -0,0 +1,69 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class Line extends NotificationProvider {
+ name = "line";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://api.line.me/v2/bot/message/push";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + notification.lineChannelAccessToken
+ }
+ };
+ if (heartbeatJSON == null) {
+ let testMessage = {
+ "to": notification.lineUserID,
+ "messages": [
+ {
+ "type": "text",
+ "text": "Test Successful!"
+ }
+ ]
+ };
+ await axios.post(url, testMessage, config);
+ } else if (heartbeatJSON["status"] === DOWN) {
+ let downMessage = {
+ "to": notification.lineUserID,
+ "messages": [
+ {
+ "type": "text",
+ "text": "UptimeKuma Alert: [🔴 Down]\n" +
+ "Name: " + monitorJSON["name"] + " \n" +
+ heartbeatJSON["msg"] +
+ `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
+ }
+ ]
+ };
+ await axios.post(url, downMessage, config);
+ } else if (heartbeatJSON["status"] === UP) {
+ let upMessage = {
+ "to": notification.lineUserID,
+ "messages": [
+ {
+ "type": "text",
+ "text": "UptimeKuma Alert: [✅ Up]\n" +
+ "Name: " + monitorJSON["name"] + " \n" +
+ heartbeatJSON["msg"] +
+ `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
+ }
+ ]
+ };
+ await axios.post(url, upMessage, config);
+ }
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Line;
diff --git a/server/notification-providers/linenotify.js b/server/notification-providers/linenotify.js
new file mode 100644
index 0000000..2622e3f
--- /dev/null
+++ b/server/notification-providers/linenotify.js
@@ -0,0 +1,52 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const qs = require("qs");
+const { DOWN, UP } = require("../../src/util");
+
+class LineNotify extends NotificationProvider {
+ name = "LineNotify";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://notify-api.line.me/api/notify";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Authorization": "Bearer " + notification.lineNotifyAccessToken
+ }
+ };
+ if (heartbeatJSON == null) {
+ let testMessage = {
+ "message": msg,
+ };
+ await axios.post(url, qs.stringify(testMessage), config);
+ } else if (heartbeatJSON["status"] === DOWN) {
+ let downMessage = {
+ "message": "\n[🔴 Down]\n" +
+ "Name: " + monitorJSON["name"] + " \n" +
+ heartbeatJSON["msg"] + "\n" +
+ `Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
+ };
+ await axios.post(url, qs.stringify(downMessage), config);
+ } else if (heartbeatJSON["status"] === UP) {
+ let upMessage = {
+ "message": "\n[✅ Up]\n" +
+ "Name: " + monitorJSON["name"] + " \n" +
+ heartbeatJSON["msg"] + "\n" +
+ `Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
+ };
+ await axios.post(url, qs.stringify(upMessage), config);
+ }
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = LineNotify;
diff --git a/server/notification-providers/lunasea.js b/server/notification-providers/lunasea.js
new file mode 100644
index 0000000..787a704
--- /dev/null
+++ b/server/notification-providers/lunasea.js
@@ -0,0 +1,67 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class LunaSea extends NotificationProvider {
+ name = "lunasea";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://notify.lunasea.app/v1";
+
+ try {
+ const target = this.getTarget(notification);
+ if (heartbeatJSON == null) {
+ let testdata = {
+ "title": "Uptime Kuma Alert",
+ "body": msg,
+ };
+ await axios.post(`${url}/custom/${target}`, testdata);
+ return okMsg;
+ }
+
+ if (heartbeatJSON["status"] === DOWN) {
+ let downdata = {
+ "title": "UptimeKuma Alert: " + monitorJSON["name"],
+ "body": "[🔴 Down] " +
+ heartbeatJSON["msg"] +
+ `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
+ };
+ await axios.post(`${url}/custom/${target}`, downdata);
+ return okMsg;
+ }
+
+ if (heartbeatJSON["status"] === UP) {
+ let updata = {
+ "title": "UptimeKuma Alert: " + monitorJSON["name"],
+ "body": "[✅ Up] " +
+ heartbeatJSON["msg"] +
+ `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
+ };
+ await axios.post(`${url}/custom/${target}`, updata);
+ return okMsg;
+ }
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Generates the lunasea target to send the notification to
+ * @param {BeanModel} notification Notification details
+ * @returns {string} The target to send the notification to
+ */
+ getTarget(notification) {
+ if (notification.lunaseaTarget === "user") {
+ return "user/" + notification.lunaseaUserID;
+ }
+ return "device/" + notification.lunaseaDevice;
+
+ }
+}
+
+module.exports = LunaSea;
diff --git a/server/notification-providers/matrix.js b/server/notification-providers/matrix.js
new file mode 100644
index 0000000..805a494
--- /dev/null
+++ b/server/notification-providers/matrix.js
@@ -0,0 +1,48 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const Crypto = require("crypto");
+const { log } = require("../../src/util");
+
+class Matrix extends NotificationProvider {
+ name = "matrix";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ const size = 20;
+ const randomString = encodeURIComponent(
+ Crypto
+ .randomBytes(size)
+ .toString("base64")
+ .slice(0, size)
+ );
+
+ log.debug("notification", "Random String: " + randomString);
+
+ const roomId = encodeURIComponent(notification.internalRoomId);
+
+ log.debug("notification", "Matrix Room ID: " + roomId);
+
+ try {
+ let config = {
+ headers: {
+ "Authorization": `Bearer ${notification.accessToken}`,
+ }
+ };
+ let data = {
+ "msgtype": "m.text",
+ "body": msg
+ };
+
+ await axios.put(`${notification.homeserverUrl}/_matrix/client/r0/rooms/${roomId}/send/m.room.message/${randomString}`, data, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Matrix;
diff --git a/server/notification-providers/mattermost.js b/server/notification-providers/mattermost.js
new file mode 100644
index 0000000..9946d02
--- /dev/null
+++ b/server/notification-providers/mattermost.js
@@ -0,0 +1,110 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class Mattermost extends NotificationProvider {
+ name = "mattermost";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ const mattermostUserName = notification.mattermostusername || "Uptime Kuma";
+ // If heartbeatJSON is null, assume non monitoring notification (Certificate warning) or testing.
+ if (heartbeatJSON == null) {
+ let mattermostTestData = {
+ username: mattermostUserName,
+ text: msg,
+ };
+ await axios.post(notification.mattermostWebhookUrl, mattermostTestData);
+ return okMsg;
+ }
+
+ let mattermostChannel;
+
+ if (typeof notification.mattermostchannel === "string") {
+ mattermostChannel = notification.mattermostchannel.toLowerCase();
+ }
+
+ const mattermostIconEmoji = notification.mattermosticonemo;
+ let mattermostIconEmojiOnline = "";
+ let mattermostIconEmojiOffline = "";
+
+ if (mattermostIconEmoji && typeof mattermostIconEmoji === "string") {
+ const emojiArray = mattermostIconEmoji.split(" ");
+ if (emojiArray.length >= 2) {
+ mattermostIconEmojiOnline = emojiArray[0];
+ mattermostIconEmojiOffline = emojiArray[1];
+ }
+ }
+ const mattermostIconUrl = notification.mattermosticonurl;
+ let iconEmoji = mattermostIconEmoji;
+ let statusField = {
+ short: false,
+ title: "Error",
+ value: heartbeatJSON.msg,
+ };
+ let statusText = "unknown";
+ let color = "#000000";
+ if (heartbeatJSON.status === DOWN) {
+ iconEmoji = mattermostIconEmojiOffline || mattermostIconEmoji;
+ statusField = {
+ short: false,
+ title: "Error",
+ value: heartbeatJSON.msg,
+ };
+ statusText = "down.";
+ color = "#FF0000";
+ } else if (heartbeatJSON.status === UP) {
+ iconEmoji = mattermostIconEmojiOnline || mattermostIconEmoji;
+ statusField = {
+ short: false,
+ title: "Ping",
+ value: heartbeatJSON.ping + "ms",
+ };
+ statusText = "up!";
+ color = "#32CD32";
+ }
+
+ let mattermostdata = {
+ username: monitorJSON.name + " " + mattermostUserName,
+ channel: mattermostChannel,
+ icon_emoji: iconEmoji,
+ icon_url: mattermostIconUrl,
+ attachments: [
+ {
+ fallback:
+ "Your " +
+ monitorJSON.pathName +
+ " service went " +
+ statusText,
+ color: color,
+ title:
+ monitorJSON.pathName +
+ " service went " +
+ statusText,
+ title_link: monitorJSON.url,
+ fields: [
+ statusField,
+ {
+ short: true,
+ title: `Time (${heartbeatJSON["timezone"]})`,
+ value: heartbeatJSON.localDateTime,
+ },
+ ],
+ },
+ ],
+ };
+ await axios.post(notification.mattermostWebhookUrl, mattermostdata);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = Mattermost;
diff --git a/server/notification-providers/nostr.js b/server/notification-providers/nostr.js
new file mode 100644
index 0000000..8784738
--- /dev/null
+++ b/server/notification-providers/nostr.js
@@ -0,0 +1,122 @@
+const NotificationProvider = require("./notification-provider");
+const {
+ relayInit,
+ getPublicKey,
+ getEventHash,
+ getSignature,
+ nip04,
+ nip19
+} = require("nostr-tools");
+
+// polyfills for node versions
+const semver = require("semver");
+const nodeVersion = process.version;
+if (semver.lt(nodeVersion, "20.0.0")) {
+ // polyfills for node 18
+ global.crypto = require("crypto");
+ global.WebSocket = require("isomorphic-ws");
+} else {
+ // polyfills for node 20
+ global.WebSocket = require("isomorphic-ws");
+}
+
+class Nostr extends NotificationProvider {
+ name = "nostr";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ // All DMs should have same timestamp
+ const createdAt = Math.floor(Date.now() / 1000);
+
+ const senderPrivateKey = await this.getPrivateKey(notification.sender);
+ const senderPublicKey = getPublicKey(senderPrivateKey);
+ const recipientsPublicKeys = await this.getPublicKeys(notification.recipients);
+
+ // Create NIP-04 encrypted direct message event for each recipient
+ const events = [];
+ for (const recipientPublicKey of recipientsPublicKeys) {
+ const ciphertext = await nip04.encrypt(senderPrivateKey, recipientPublicKey, msg);
+ let event = {
+ kind: 4,
+ pubkey: senderPublicKey,
+ created_at: createdAt,
+ tags: [[ "p", recipientPublicKey ]],
+ content: ciphertext,
+ };
+ event.id = getEventHash(event);
+ event.sig = getSignature(event, senderPrivateKey);
+ events.push(event);
+ }
+
+ // Publish events to each relay
+ const relays = notification.relays.split("\n");
+ let successfulRelays = 0;
+
+ // Connect to each relay
+ for (const relayUrl of relays) {
+ const relay = relayInit(relayUrl);
+ try {
+ await relay.connect();
+ successfulRelays++;
+
+ // Publish events
+ for (const event of events) {
+ relay.publish(event);
+ }
+ } catch (error) {
+ continue;
+ } finally {
+ relay.close();
+ }
+ }
+
+ // Report success or failure
+ if (successfulRelays === 0) {
+ throw Error("Failed to connect to any relays.");
+ }
+ return `${successfulRelays}/${relays.length} relays connected.`;
+ }
+
+ /**
+ * Get the private key for the sender
+ * @param {string} sender Sender to retrieve key for
+ * @returns {nip19.DecodeResult} Private key
+ */
+ async getPrivateKey(sender) {
+ try {
+ const senderDecodeResult = await nip19.decode(sender);
+ const { data } = senderDecodeResult;
+ return data;
+ } catch (error) {
+ throw new Error(`Failed to get private key: ${error.message}`);
+ }
+ }
+
+ /**
+ * Get public keys for recipients
+ * @param {string} recipients Newline delimited list of recipients
+ * @returns {Promise<nip19.DecodeResult[]>} Public keys
+ */
+ async getPublicKeys(recipients) {
+ const recipientsList = recipients.split("\n");
+ const publicKeys = [];
+ for (const recipient of recipientsList) {
+ try {
+ const recipientDecodeResult = await nip19.decode(recipient);
+ const { type, data } = recipientDecodeResult;
+ if (type === "npub") {
+ publicKeys.push(data);
+ } else {
+ throw new Error("not an npub");
+ }
+ } catch (error) {
+ throw new Error(`Error decoding recipient: ${error}`);
+ }
+ }
+ return publicKeys;
+ }
+}
+
+module.exports = Nostr;
diff --git a/server/notification-providers/notification-provider.js b/server/notification-providers/notification-provider.js
new file mode 100644
index 0000000..b9fb3d8
--- /dev/null
+++ b/server/notification-providers/notification-provider.js
@@ -0,0 +1,73 @@
+class NotificationProvider {
+
+ /**
+ * Notification Provider Name
+ * @type {string}
+ */
+ name = undefined;
+
+ /**
+ * Send a notification
+ * @param {BeanModel} notification Notification to send
+ * @param {string} msg General Message
+ * @param {?object} monitorJSON Monitor details (For Up/Down only)
+ * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @returns {Promise<string>} Return Successful Message
+ * @throws Error with fail msg
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ throw new Error("Have to override Notification.send(...)");
+ }
+
+ /**
+ * Extracts the address from a monitor JSON object based on its type.
+ * @param {?object} monitorJSON Monitor details (For Up/Down only)
+ * @returns {string} The extracted address based on the monitor type.
+ */
+ extractAddress(monitorJSON) {
+ if (!monitorJSON) {
+ return "";
+ }
+ switch (monitorJSON["type"]) {
+ case "push":
+ return "Heartbeat";
+ case "ping":
+ return monitorJSON["hostname"];
+ case "port":
+ case "dns":
+ case "gamedig":
+ case "steam":
+ if (monitorJSON["port"]) {
+ return monitorJSON["hostname"] + ":" + monitorJSON["port"];
+ }
+ return monitorJSON["hostname"];
+ default:
+ if (![ "https://", "http://", "" ].includes(monitorJSON["url"])) {
+ return monitorJSON["url"];
+ }
+ return "";
+ }
+ }
+
+ /**
+ * Throws an error
+ * @param {any} error The error to throw
+ * @returns {void}
+ * @throws {any} The error specified
+ */
+ throwGeneralAxiosError(error) {
+ let msg = "Error: " + error + " ";
+
+ if (error.response && error.response.data) {
+ if (typeof error.response.data === "string") {
+ msg += error.response.data;
+ } else {
+ msg += JSON.stringify(error.response.data);
+ }
+ }
+
+ throw new Error(msg);
+ }
+}
+
+module.exports = NotificationProvider;
diff --git a/server/notification-providers/ntfy.js b/server/notification-providers/ntfy.js
new file mode 100644
index 0000000..ad1d39f
--- /dev/null
+++ b/server/notification-providers/ntfy.js
@@ -0,0 +1,83 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class Ntfy extends NotificationProvider {
+ name = "ntfy";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let headers = {};
+ if (notification.ntfyAuthenticationMethod === "usernamePassword") {
+ headers = {
+ "Authorization": "Basic " + Buffer.from(notification.ntfyusername + ":" + notification.ntfypassword).toString("base64"),
+ };
+ } else if (notification.ntfyAuthenticationMethod === "accessToken") {
+ headers = {
+ "Authorization": "Bearer " + notification.ntfyaccesstoken,
+ };
+ }
+ // If heartbeatJSON is null, assume non monitoring notification (Certificate warning) or testing.
+ if (heartbeatJSON == null) {
+ let ntfyTestData = {
+ "topic": notification.ntfytopic,
+ "title": (monitorJSON?.name || notification.ntfytopic) + " [Uptime-Kuma]",
+ "message": msg,
+ "priority": notification.ntfyPriority,
+ "tags": [ "test_tube" ],
+ };
+ await axios.post(notification.ntfyserverurl, ntfyTestData, { headers: headers });
+ return okMsg;
+ }
+ let tags = [];
+ let status = "unknown";
+ let priority = notification.ntfyPriority || 4;
+ if ("status" in heartbeatJSON) {
+ if (heartbeatJSON.status === DOWN) {
+ tags = [ "red_circle" ];
+ status = "Down";
+ // if priority is not 5, increase priority for down alerts
+ priority = priority === 5 ? priority : priority + 1;
+ } else if (heartbeatJSON["status"] === UP) {
+ tags = [ "green_circle" ];
+ status = "Up";
+ }
+ }
+ let data = {
+ "topic": notification.ntfytopic,
+ "message": heartbeatJSON.msg,
+ "priority": priority,
+ "title": monitorJSON.name + " " + status + " [Uptime-Kuma]",
+ "tags": tags,
+ };
+
+ if (monitorJSON.url && monitorJSON.url !== "https://") {
+ data.actions = [
+ {
+ "action": "view",
+ "label": "Open " + monitorJSON.name,
+ "url": monitorJSON.url,
+ },
+ ];
+ }
+
+ if (notification.ntfyIcon) {
+ data.icon = notification.ntfyIcon;
+ }
+
+ await axios.post(notification.ntfyserverurl, data, { headers: headers });
+
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Ntfy;
diff --git a/server/notification-providers/octopush.js b/server/notification-providers/octopush.js
new file mode 100644
index 0000000..7576e0a
--- /dev/null
+++ b/server/notification-providers/octopush.js
@@ -0,0 +1,76 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Octopush extends NotificationProvider {
+ name = "octopush";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const urlV2 = "https://api.octopush.com/v1/public/sms-campaign/send";
+ const urlV1 = "https://www.octopush-dm.com/api/sms/json";
+
+ try {
+ // Default - V2
+ if (notification.octopushVersion === "2" || !notification.octopushVersion) {
+ let config = {
+ headers: {
+ "api-key": notification.octopushAPIKey,
+ "api-login": notification.octopushLogin,
+ "cache-control": "no-cache"
+ }
+ };
+ let data = {
+ "recipients": [
+ {
+ "phone_number": notification.octopushPhoneNumber
+ }
+ ],
+ //octopush not supporting non ascii char
+ "text": msg.replace(/[^\x00-\x7F]/g, ""),
+ "type": notification.octopushSMSType,
+ "purpose": "alert",
+ "sender": notification.octopushSenderName
+ };
+ await axios.post(urlV2, data, config);
+ } else if (notification.octopushVersion === "1") {
+ let data = {
+ "user_login": notification.octopushDMLogin,
+ "api_key": notification.octopushDMAPIKey,
+ "sms_recipients": notification.octopushDMPhoneNumber,
+ "sms_sender": notification.octopushDMSenderName,
+ "sms_type": (notification.octopushDMSMSType === "sms_premium") ? "FR" : "XXX",
+ "transactional": "1",
+ //octopush not supporting non ascii char
+ "sms_text": msg.replace(/[^\x00-\x7F]/g, ""),
+ };
+
+ let config = {
+ headers: {
+ "cache-control": "no-cache"
+ },
+ params: data
+ };
+
+ // V1 API returns 200 even on error so we must check
+ // response data
+ let response = await axios.post(urlV1, {}, config);
+ if ("error_code" in response.data) {
+ if (response.data.error_code !== "000") {
+ this.throwGeneralAxiosError(`Octopush error ${JSON.stringify(response.data)}`);
+ }
+ }
+ } else {
+ throw new Error("Unknown Octopush version!");
+ }
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Octopush;
diff --git a/server/notification-providers/onebot.js b/server/notification-providers/onebot.js
new file mode 100644
index 0000000..b04794d
--- /dev/null
+++ b/server/notification-providers/onebot.js
@@ -0,0 +1,48 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class OneBot extends NotificationProvider {
+ name = "OneBot";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let url = notification.httpAddr;
+ if (!url.startsWith("http")) {
+ url = "http://" + url;
+ }
+ if (!url.endsWith("/")) {
+ url += "/";
+ }
+ url += "send_msg";
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + notification.accessToken,
+ }
+ };
+ let pushText = "UptimeKuma Alert: " + msg;
+ let data = {
+ "auto_escape": true,
+ "message": pushText,
+ };
+ if (notification.msgType === "group") {
+ data["message_type"] = "group";
+ data["group_id"] = notification.recieverId;
+ } else {
+ data["message_type"] = "private";
+ data["user_id"] = notification.recieverId;
+ }
+ await axios.post(url, data, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = OneBot;
diff --git a/server/notification-providers/onesender.js b/server/notification-providers/onesender.js
new file mode 100644
index 0000000..4a33931
--- /dev/null
+++ b/server/notification-providers/onesender.js
@@ -0,0 +1,47 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Onesender extends NotificationProvider {
+ name = "Onesender";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let data = {
+ heartbeat: heartbeatJSON,
+ monitor: monitorJSON,
+ msg,
+ to: notification.onesenderReceiver,
+ type: "text",
+ recipient_type: "individual",
+ text: {
+ body: msg
+ }
+ };
+ if (notification.onesenderTypeReceiver === "private") {
+ data.to = notification.onesenderReceiver + "@s.whatsapp.net";
+ } else {
+ data.recipient_type = "group";
+ data.to = notification.onesenderReceiver + "@g.us";
+ }
+ let config = {
+ headers: {
+ "Authorization": "Bearer " + notification.onesenderToken,
+ }
+ };
+ await axios.post(notification.onesenderURL, data, config);
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+
+}
+
+module.exports = Onesender;
diff --git a/server/notification-providers/opsgenie.js b/server/notification-providers/opsgenie.js
new file mode 100644
index 0000000..59a7970
--- /dev/null
+++ b/server/notification-providers/opsgenie.js
@@ -0,0 +1,96 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP, DOWN } = require("../../src/util");
+
+const opsgenieAlertsUrlEU = "https://api.eu.opsgenie.com/v2/alerts";
+const opsgenieAlertsUrlUS = "https://api.opsgenie.com/v2/alerts";
+const okMsg = "Sent Successfully.";
+
+class Opsgenie extends NotificationProvider {
+ name = "Opsgenie";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ let opsgenieAlertsUrl;
+ let priority = (!notification.opsgeniePriority) ? 3 : notification.opsgeniePriority;
+ const textMsg = "Uptime Kuma Alert";
+
+ try {
+ switch (notification.opsgenieRegion) {
+ case "us":
+ opsgenieAlertsUrl = opsgenieAlertsUrlUS;
+ break;
+ case "eu":
+ opsgenieAlertsUrl = opsgenieAlertsUrlEU;
+ break;
+ default:
+ opsgenieAlertsUrl = opsgenieAlertsUrlUS;
+ }
+
+ if (heartbeatJSON == null) {
+ let notificationTestAlias = "uptime-kuma-notification-test";
+ let data = {
+ "message": msg,
+ "alias": notificationTestAlias,
+ "source": "Uptime Kuma",
+ "priority": "P5"
+ };
+
+ return this.post(notification, opsgenieAlertsUrl, data);
+ }
+
+ if (heartbeatJSON.status === DOWN) {
+ let data = {
+ "message": monitorJSON ? textMsg + `: ${monitorJSON.name}` : textMsg,
+ "alias": monitorJSON.name,
+ "description": msg,
+ "source": "Uptime Kuma",
+ "priority": `P${priority}`
+ };
+
+ return this.post(notification, opsgenieAlertsUrl, data);
+ }
+
+ if (heartbeatJSON.status === UP) {
+ let opsgenieAlertsCloseUrl = `${opsgenieAlertsUrl}/${encodeURIComponent(monitorJSON.name)}/close?identifierType=alias`;
+ let data = {
+ "source": "Uptime Kuma",
+ };
+
+ return this.post(notification, opsgenieAlertsCloseUrl, data);
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Make POST request to Opsgenie
+ * @param {BeanModel} notification Notification to send
+ * @param {string} url Request url
+ * @param {object} data Request body
+ * @returns {Promise<string>} Success message
+ */
+ async post(notification, url, data) {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `GenieKey ${notification.opsgenieApiKey}`,
+ }
+ };
+
+ let res = await axios.post(url, data, config);
+ if (res.status == null) {
+ return "Opsgenie notification failed with invalid response!";
+ }
+ if (res.status < 200 || res.status >= 300) {
+ return `Opsgenie notification failed with status code ${res.status}`;
+ }
+
+ return okMsg;
+ }
+}
+
+module.exports = Opsgenie;
diff --git a/server/notification-providers/pagerduty.js b/server/notification-providers/pagerduty.js
new file mode 100644
index 0000000..c60d782
--- /dev/null
+++ b/server/notification-providers/pagerduty.js
@@ -0,0 +1,114 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
+const { setting } = require("../util-server");
+let successMessage = "Sent Successfully.";
+
+class PagerDuty extends NotificationProvider {
+ name = "PagerDuty";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ try {
+ if (heartbeatJSON == null) {
+ const title = "Uptime Kuma Alert";
+ const monitor = {
+ type: "ping",
+ url: "Uptime Kuma Test Button",
+ };
+ return this.postNotification(notification, title, msg, monitor);
+ }
+
+ if (heartbeatJSON.status === UP) {
+ const title = "Uptime Kuma Monitor ✅ Up";
+ const eventAction = notification.pagerdutyAutoResolve || null;
+
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, eventAction);
+ }
+
+ if (heartbeatJSON.status === DOWN) {
+ const title = "Uptime Kuma Monitor 🔴 Down";
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "trigger");
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Check if result is successful, result code should be in range 2xx
+ * @param {object} result Axios response object
+ * @returns {void}
+ * @throws {Error} The status code is not in range 2xx
+ */
+ checkResult(result) {
+ if (result.status == null) {
+ throw new Error("PagerDuty notification failed with invalid response!");
+ }
+ if (result.status < 200 || result.status >= 300) {
+ throw new Error("PagerDuty notification failed with status code " + result.status);
+ }
+ }
+
+ /**
+ * Send the message
+ * @param {BeanModel} notification Message title
+ * @param {string} title Message title
+ * @param {string} body Message
+ * @param {object} monitorInfo Monitor details (For Up/Down only)
+ * @param {?string} eventAction Action event for PagerDuty (trigger, acknowledge, resolve)
+ * @returns {Promise<string>} Success message
+ */
+ async postNotification(notification, title, body, monitorInfo, eventAction = "trigger") {
+
+ if (eventAction == null) {
+ return "No action required";
+ }
+
+ let monitorUrl;
+ if (monitorInfo.type === "port") {
+ monitorUrl = monitorInfo.hostname;
+ if (monitorInfo.port) {
+ monitorUrl += ":" + monitorInfo.port;
+ }
+ } else if (monitorInfo.hostname != null) {
+ monitorUrl = monitorInfo.hostname;
+ } else {
+ monitorUrl = monitorInfo.url;
+ }
+
+ const options = {
+ method: "POST",
+ url: notification.pagerdutyIntegrationUrl,
+ headers: { "Content-Type": "application/json" },
+ data: {
+ payload: {
+ summary: `[${title}] [${monitorInfo.name}] ${body}`,
+ severity: notification.pagerdutyPriority || "warning",
+ source: monitorUrl,
+ },
+ routing_key: notification.pagerdutyIntegrationKey,
+ event_action: eventAction,
+ dedup_key: "Uptime Kuma/" + monitorInfo.id,
+ }
+ };
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorInfo) {
+ options.client = "Uptime Kuma";
+ options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id);
+ }
+
+ let result = await axios.request(options);
+ this.checkResult(result);
+ if (result.statusText != null) {
+ return "PagerDuty notification succeed: " + result.statusText;
+ }
+
+ return successMessage;
+ }
+}
+
+module.exports = PagerDuty;
diff --git a/server/notification-providers/pagertree.js b/server/notification-providers/pagertree.js
new file mode 100644
index 0000000..c7a5338
--- /dev/null
+++ b/server/notification-providers/pagertree.js
@@ -0,0 +1,93 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
+const { setting } = require("../util-server");
+let successMessage = "Sent Successfully.";
+
+class PagerTree extends NotificationProvider {
+ name = "PagerTree";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ try {
+ if (heartbeatJSON == null) {
+ // general messages
+ return this.postNotification(notification, msg, monitorJSON, heartbeatJSON);
+ }
+
+ if (heartbeatJSON.status === UP && notification.pagertreeAutoResolve === "resolve") {
+ return this.postNotification(notification, null, monitorJSON, heartbeatJSON, notification.pagertreeAutoResolve);
+ }
+
+ if (heartbeatJSON.status === DOWN) {
+ const title = `Uptime Kuma Monitor "${monitorJSON.name}" is DOWN`;
+ return this.postNotification(notification, title, monitorJSON, heartbeatJSON);
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Check if result is successful, result code should be in range 2xx
+ * @param {object} result Axios response object
+ * @returns {void}
+ * @throws {Error} The status code is not in range 2xx
+ */
+ checkResult(result) {
+ if (result.status == null) {
+ throw new Error("PagerTree notification failed with invalid response!");
+ }
+ if (result.status < 200 || result.status >= 300) {
+ throw new Error("PagerTree notification failed with status code " + result.status);
+ }
+ }
+
+ /**
+ * Send the message
+ * @param {BeanModel} notification Message title
+ * @param {string} title Message title
+ * @param {object} monitorJSON Monitor details (For Up/Down only)
+ * @param {object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @param {?string} eventAction Action event for PagerTree (create, resolve)
+ * @returns {Promise<string>} Success state
+ */
+ async postNotification(notification, title, monitorJSON, heartbeatJSON, eventAction = "create") {
+
+ if (eventAction == null) {
+ return "No action required";
+ }
+
+ const options = {
+ method: "POST",
+ url: notification.pagertreeIntegrationUrl,
+ headers: { "Content-Type": "application/json" },
+ data: {
+ event_type: eventAction,
+ id: heartbeatJSON?.monitorID || "uptime-kuma",
+ title: title,
+ urgency: notification.pagertreeUrgency,
+ heartbeat: heartbeatJSON,
+ monitor: monitorJSON
+ }
+ };
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorJSON) {
+ options.client = "Uptime Kuma";
+ options.client_url = baseURL + getMonitorRelativeURL(monitorJSON.id);
+ }
+
+ let result = await axios.request(options);
+ this.checkResult(result);
+ if (result.statusText != null) {
+ return "PagerTree notification succeed: " + result.statusText;
+ }
+
+ return successMessage;
+ }
+}
+
+module.exports = PagerTree;
diff --git a/server/notification-providers/promosms.js b/server/notification-providers/promosms.js
new file mode 100644
index 0000000..05334e9
--- /dev/null
+++ b/server/notification-providers/promosms.js
@@ -0,0 +1,53 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class PromoSMS extends NotificationProvider {
+ name = "promosms";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://promosms.com/api/rest/v3_2/sms";
+
+ if (notification.promosmsAllowLongSMS === undefined) {
+ notification.promosmsAllowLongSMS = false;
+ }
+
+ //TODO: Add option for enabling special characters. It will decrese message max length from 160 to 70 chars.
+ //Lets remove non ascii char
+ let cleanMsg = msg.replace(/[^\x00-\x7F]/g, "");
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Basic " + Buffer.from(notification.promosmsLogin + ":" + notification.promosmsPassword).toString("base64"),
+ "Accept": "text/json",
+ }
+ };
+ let data = {
+ "recipients": [ notification.promosmsPhoneNumber ],
+ //Trim message to maximum length of 1 SMS or 4 if we allowed long messages
+ "text": notification.promosmsAllowLongSMS ? cleanMsg.substring(0, 639) : cleanMsg.substring(0, 159),
+ "long-sms": notification.promosmsAllowLongSMS,
+ "type": Number(notification.promosmsSMSType),
+ "sender": notification.promosmsSenderName
+ };
+
+ let resp = await axios.post(url, data, config);
+
+ if (resp.data.response.status !== 0) {
+ let error = "Something gone wrong. Api returned " + resp.data.response.status + ".";
+ this.throwGeneralAxiosError(error);
+ }
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = PromoSMS;
diff --git a/server/notification-providers/pushbullet.js b/server/notification-providers/pushbullet.js
new file mode 100644
index 0000000..0b73031
--- /dev/null
+++ b/server/notification-providers/pushbullet.js
@@ -0,0 +1,56 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+const { DOWN, UP } = require("../../src/util");
+
+class Pushbullet extends NotificationProvider {
+ name = "pushbullet";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://api.pushbullet.com/v2/pushes";
+
+ try {
+ let config = {
+ headers: {
+ "Access-Token": notification.pushbulletAccessToken,
+ "Content-Type": "application/json"
+ }
+ };
+ if (heartbeatJSON == null) {
+ let data = {
+ "type": "note",
+ "title": "Uptime Kuma Alert",
+ "body": msg,
+ };
+ await axios.post(url, data, config);
+ } else if (heartbeatJSON["status"] === DOWN) {
+ let downData = {
+ "type": "note",
+ "title": "UptimeKuma Alert: " + monitorJSON["name"],
+ "body": "[🔴 Down] " +
+ heartbeatJSON["msg"] +
+ `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
+ };
+ await axios.post(url, downData, config);
+ } else if (heartbeatJSON["status"] === UP) {
+ let upData = {
+ "type": "note",
+ "title": "UptimeKuma Alert: " + monitorJSON["name"],
+ "body": "[✅ Up] " +
+ heartbeatJSON["msg"] +
+ `\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
+ };
+ await axios.post(url, upData, config);
+ }
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Pushbullet;
diff --git a/server/notification-providers/pushdeer.js b/server/notification-providers/pushdeer.js
new file mode 100644
index 0000000..276c2f4
--- /dev/null
+++ b/server/notification-providers/pushdeer.js
@@ -0,0 +1,55 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class PushDeer extends NotificationProvider {
+ name = "PushDeer";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const serverUrl = notification.pushdeerServer || "https://api2.pushdeer.com";
+ const url = `${serverUrl.trim().replace(/\/*$/, "")}/message/push`;
+
+ let valid = msg != null && monitorJSON != null && heartbeatJSON != null;
+
+ let title;
+ if (valid && heartbeatJSON.status === UP) {
+ title = "## Uptime Kuma: " + monitorJSON.name + " up";
+ } else if (valid && heartbeatJSON.status === DOWN) {
+ title = "## Uptime Kuma: " + monitorJSON.name + " down";
+ } else {
+ title = "## Uptime Kuma Message";
+ }
+
+ let data = {
+ "pushkey": notification.pushdeerKey,
+ "text": title,
+ "desp": msg.replace(/\n/g, "\n\n"),
+ "type": "markdown",
+ };
+
+ try {
+ let res = await axios.post(url, data);
+
+ if ("error" in res.data) {
+ let error = res.data.error;
+ this.throwGeneralAxiosError(error);
+ }
+ if (res.data.content.result.length === 0) {
+ let error = "Invalid PushDeer key";
+ this.throwGeneralAxiosError(error);
+ } else if (JSON.parse(res.data.content.result[0]).success !== "ok") {
+ let error = "Unknown error";
+ this.throwGeneralAxiosError(error);
+ }
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = PushDeer;
diff --git a/server/notification-providers/pushover.js b/server/notification-providers/pushover.js
new file mode 100644
index 0000000..8422b64
--- /dev/null
+++ b/server/notification-providers/pushover.js
@@ -0,0 +1,58 @@
+const { getMonitorRelativeURL } = require("../../src/util");
+const { setting } = require("../util-server");
+
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Pushover extends NotificationProvider {
+ name = "pushover";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://api.pushover.net/1/messages.json";
+
+ let data = {
+ "message": msg,
+ "user": notification.pushoveruserkey,
+ "token": notification.pushoverapptoken,
+ "sound": notification.pushoversounds,
+ "priority": notification.pushoverpriority,
+ "title": notification.pushovertitle,
+ "retry": "30",
+ "expire": "3600",
+ "html": 1,
+ };
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorJSON) {
+ data["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id);
+ data["url_title"] = "Link to Monitor";
+ }
+
+ if (notification.pushoverdevice) {
+ data.device = notification.pushoverdevice;
+ }
+ if (notification.pushoverttl) {
+ data.ttl = notification.pushoverttl;
+ }
+
+ try {
+ if (heartbeatJSON == null) {
+ await axios.post(url, data);
+ return okMsg;
+ } else {
+ data.message += `\n<b>Time (${heartbeatJSON["timezone"]})</b>:${heartbeatJSON["localDateTime"]}`;
+ await axios.post(url, data);
+ return okMsg;
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = Pushover;
diff --git a/server/notification-providers/pushy.js b/server/notification-providers/pushy.js
new file mode 100644
index 0000000..cb70022
--- /dev/null
+++ b/server/notification-providers/pushy.js
@@ -0,0 +1,32 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Pushy extends NotificationProvider {
+ name = "pushy";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ await axios.post(`https://api.pushy.me/push?api_key=${notification.pushyAPIKey}`, {
+ "to": notification.pushyToken,
+ "data": {
+ "message": "Uptime-Kuma"
+ },
+ "notification": {
+ "body": msg,
+ "badge": 1,
+ "sound": "ping.aiff"
+ }
+ });
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Pushy;
diff --git a/server/notification-providers/rocket-chat.js b/server/notification-providers/rocket-chat.js
new file mode 100644
index 0000000..690e33a
--- /dev/null
+++ b/server/notification-providers/rocket-chat.js
@@ -0,0 +1,67 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const Slack = require("./slack");
+const { setting } = require("../util-server");
+const { getMonitorRelativeURL, DOWN } = require("../../src/util");
+
+class RocketChat extends NotificationProvider {
+ name = "rocket.chat";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ if (heartbeatJSON == null) {
+ let data = {
+ "text": msg,
+ "channel": notification.rocketchannel,
+ "username": notification.rocketusername,
+ "icon_emoji": notification.rocketiconemo,
+ };
+ await axios.post(notification.rocketwebhookURL, data);
+ return okMsg;
+ }
+
+ let data = {
+ "text": "Uptime Kuma Alert",
+ "channel": notification.rocketchannel,
+ "username": notification.rocketusername,
+ "icon_emoji": notification.rocketiconemo,
+ "attachments": [
+ {
+ "title": `Uptime Kuma Alert *Time (${heartbeatJSON["timezone"]})*\n${heartbeatJSON["localDateTime"]}`,
+ "text": "*Message*\n" + msg,
+ }
+ ]
+ };
+
+ // Color
+ if (heartbeatJSON.status === DOWN) {
+ data.attachments[0].color = "#ff0000";
+ } else {
+ data.attachments[0].color = "#32cd32";
+ }
+
+ if (notification.rocketbutton) {
+ await Slack.deprecateURL(notification.rocketbutton);
+ }
+
+ const baseURL = await setting("primaryBaseURL");
+
+ if (baseURL) {
+ data.attachments[0].title_link = baseURL + getMonitorRelativeURL(monitorJSON.id);
+ }
+
+ await axios.post(notification.rocketwebhookURL, data);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = RocketChat;
diff --git a/server/notification-providers/send-grid.js b/server/notification-providers/send-grid.js
new file mode 100644
index 0000000..3489f63
--- /dev/null
+++ b/server/notification-providers/send-grid.js
@@ -0,0 +1,65 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class SendGrid extends NotificationProvider {
+ name = "SendGrid";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${notification.sendgridApiKey}`,
+ },
+ };
+
+ let personalizations = {
+ to: [{ email: notification.sendgridToEmail }],
+ };
+
+ // Add CC recipients if provided
+ if (notification.sendgridCcEmail) {
+ personalizations.cc = notification.sendgridCcEmail
+ .split(",")
+ .map((email) => ({ email: email.trim() }));
+ }
+
+ // Add BCC recipients if provided
+ if (notification.sendgridBccEmail) {
+ personalizations.bcc = notification.sendgridBccEmail
+ .split(",")
+ .map((email) => ({ email: email.trim() }));
+ }
+
+ let data = {
+ personalizations: [ personalizations ],
+ from: { email: notification.sendgridFromEmail.trim() },
+ subject:
+ notification.sendgridSubject ||
+ "Notification from Your Uptime Kuma",
+ content: [
+ {
+ type: "text/plain",
+ value: msg,
+ },
+ ],
+ };
+
+ await axios.post(
+ "https://api.sendgrid.com/v3/mail/send",
+ data,
+ config
+ );
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = SendGrid;
diff --git a/server/notification-providers/serverchan.js b/server/notification-providers/serverchan.js
new file mode 100644
index 0000000..aee22f8
--- /dev/null
+++ b/server/notification-providers/serverchan.js
@@ -0,0 +1,51 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class ServerChan extends NotificationProvider {
+ name = "ServerChan";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ // serverchan3 requires sending via ft07.com
+ const matchResult = String(notification.serverChanSendKey).match(/^sctp(\d+)t/i);
+ const url = matchResult && matchResult[1]
+ ? `https://${matchResult[1]}.push.ft07.com/send/${notification.serverChanSendKey}.send`
+ : `https://sctapi.ftqq.com/${notification.serverChanSendKey}.send`;
+
+ try {
+ await axios.post(url, {
+ "title": this.checkStatus(heartbeatJSON, monitorJSON),
+ "desp": msg,
+ });
+
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Get the formatted title for message
+ * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @param {?object} monitorJSON Monitor details (For Up/Down only)
+ * @returns {string} Formatted title
+ */
+ checkStatus(heartbeatJSON, monitorJSON) {
+ let title = "UptimeKuma Message";
+ if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {
+ title = "UptimeKuma Monitor Up " + monitorJSON["name"];
+ }
+ if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
+ title = "UptimeKuma Monitor Down " + monitorJSON["name"];
+ }
+ return title;
+ }
+}
+
+module.exports = ServerChan;
diff --git a/server/notification-providers/serwersms.js b/server/notification-providers/serwersms.js
new file mode 100644
index 0000000..f7c8644
--- /dev/null
+++ b/server/notification-providers/serwersms.js
@@ -0,0 +1,47 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class SerwerSMS extends NotificationProvider {
+ name = "serwersms";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://api2.serwersms.pl/messages/send_sms";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ }
+ };
+ let data = {
+ "username": notification.serwersmsUsername,
+ "password": notification.serwersmsPassword,
+ "phone": notification.serwersmsPhoneNumber,
+ "text": msg.replace(/[^\x00-\x7F]/g, ""),
+ "sender": notification.serwersmsSenderName,
+ };
+
+ let resp = await axios.post(url, data, config);
+
+ if (!resp.data.success) {
+ if (resp.data.error) {
+ let error = `SerwerSMS.pl API returned error code ${resp.data.error.code} (${resp.data.error.type}) with error message: ${resp.data.error.message}`;
+ this.throwGeneralAxiosError(error);
+ } else {
+ let error = "SerwerSMS.pl API returned an unexpected response";
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = SerwerSMS;
diff --git a/server/notification-providers/sevenio.js b/server/notification-providers/sevenio.js
new file mode 100644
index 0000000..eac38a2
--- /dev/null
+++ b/server/notification-providers/sevenio.js
@@ -0,0 +1,57 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class SevenIO extends NotificationProvider {
+ name = "SevenIO";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ const data = {
+ to: notification.sevenioTo,
+ from: notification.sevenioSender || "Uptime Kuma",
+ text: msg,
+ };
+
+ const config = {
+ baseURL: "https://gateway.seven.io/api/",
+ headers: {
+ "Content-Type": "application/json",
+ "X-API-Key": notification.sevenioApiKey,
+ },
+ };
+
+ try {
+ // testing or certificate expiry notification
+ if (heartbeatJSON == null) {
+ await axios.post("sms", data, config);
+ return okMsg;
+ }
+
+ let address = this.extractAddress(monitorJSON);
+ if (address !== "") {
+ address = `(${address}) `;
+ }
+
+ // If heartbeatJSON is not null, we go into the normal alerting loop.
+ if (heartbeatJSON["status"] === DOWN) {
+ data.text = `Your service ${monitorJSON["name"]} ${address}went down at ${heartbeatJSON["localDateTime"]} ` +
+ `(${heartbeatJSON["timezone"]}). Error: ${heartbeatJSON["msg"]}`;
+ } else if (heartbeatJSON["status"] === UP) {
+ data.text = `Your service ${monitorJSON["name"]} ${address}went back up at ${heartbeatJSON["localDateTime"]} ` +
+ `(${heartbeatJSON["timezone"]}).`;
+ }
+ await axios.post("sms", data, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+}
+
+module.exports = SevenIO;
diff --git a/server/notification-providers/signal.js b/server/notification-providers/signal.js
new file mode 100644
index 0000000..9702d06
--- /dev/null
+++ b/server/notification-providers/signal.js
@@ -0,0 +1,29 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Signal extends NotificationProvider {
+ name = "signal";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let data = {
+ "message": msg,
+ "number": notification.signalNumber,
+ "recipients": notification.signalRecipients.replace(/\s/g, "").split(","),
+ };
+ let config = {};
+
+ await axios.post(notification.signalURL, data, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Signal;
diff --git a/server/notification-providers/signl4.js b/server/notification-providers/signl4.js
new file mode 100644
index 0000000..8261a73
--- /dev/null
+++ b/server/notification-providers/signl4.js
@@ -0,0 +1,52 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP, DOWN } = require("../../src/util");
+
+class SIGNL4 extends NotificationProvider {
+ name = "SIGNL4";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let data = {
+ heartbeat: heartbeatJSON,
+ monitor: monitorJSON,
+ msg,
+ // Source system
+ "X-S4-SourceSystem": "UptimeKuma",
+ monitorUrl: this.extractAddress(monitorJSON),
+ };
+
+ const config = {
+ headers: {
+ "Content-Type": "application/json"
+ }
+ };
+
+ if (heartbeatJSON == null) {
+ // Test alert
+ data.title = "Uptime Kuma Alert";
+ data.message = msg;
+ } else if (heartbeatJSON.status === UP) {
+ data.title = "Uptime Kuma Monitor ✅ Up";
+ data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID;
+ data["X-S4-Status"] = "resolved";
+ } else if (heartbeatJSON.status === DOWN) {
+ data.title = "Uptime Kuma Monitor 🔴 Down";
+ data["X-S4-ExternalID"] = "UptimeKuma-" + monitorJSON.monitorID;
+ data["X-S4-Status"] = "new";
+ }
+
+ await axios.post(notification.webhookURL, data, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = SIGNL4;
diff --git a/server/notification-providers/slack.js b/server/notification-providers/slack.js
new file mode 100644
index 0000000..209c7c0
--- /dev/null
+++ b/server/notification-providers/slack.js
@@ -0,0 +1,173 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { setSettings, setting } = require("../util-server");
+const { getMonitorRelativeURL, UP } = require("../../src/util");
+
+class Slack extends NotificationProvider {
+ name = "slack";
+
+ /**
+ * Deprecated property notification.slackbutton
+ * Set it as primary base url if this is not yet set.
+ * @deprecated
+ * @param {string} url The primary base URL to use
+ * @returns {Promise<void>}
+ */
+ static async deprecateURL(url) {
+ let currentPrimaryBaseURL = await setting("primaryBaseURL");
+
+ if (!currentPrimaryBaseURL) {
+ console.log("Move the url to be the primary base URL");
+ await setSettings("general", {
+ primaryBaseURL: url,
+ });
+ } else {
+ console.log("Already there, no need to move the primary base URL");
+ }
+ }
+
+ /**
+ * Builds the actions available in the slack message
+ * @param {string} baseURL Uptime Kuma base URL
+ * @param {object} monitorJSON The monitor config
+ * @returns {Array} The relevant action objects
+ */
+ buildActions(baseURL, monitorJSON) {
+ const actions = [];
+
+ if (baseURL) {
+ actions.push({
+ "type": "button",
+ "text": {
+ "type": "plain_text",
+ "text": "Visit Uptime Kuma",
+ },
+ "value": "Uptime-Kuma",
+ "url": baseURL + getMonitorRelativeURL(monitorJSON.id),
+ });
+
+ }
+
+ const address = this.extractAddress(monitorJSON);
+ if (address) {
+ actions.push({
+ "type": "button",
+ "text": {
+ "type": "plain_text",
+ "text": "Visit site",
+ },
+ "value": "Site",
+ "url": address,
+ });
+ }
+
+ return actions;
+ }
+
+ /**
+ * Builds the different blocks the Slack message consists of.
+ * @param {string} baseURL Uptime Kuma base URL
+ * @param {object} monitorJSON The monitor object
+ * @param {object} heartbeatJSON The heartbeat object
+ * @param {string} title The message title
+ * @param {string} msg The message body
+ * @returns {Array<object>} The rich content blocks for the Slack message
+ */
+ buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg) {
+
+ //create an array to dynamically add blocks
+ const blocks = [];
+
+ // the header block
+ blocks.push({
+ "type": "header",
+ "text": {
+ "type": "plain_text",
+ "text": title,
+ },
+ });
+
+ // the body block, containing the details
+ blocks.push({
+ "type": "section",
+ "fields": [
+ {
+ "type": "mrkdwn",
+ "text": "*Message*\n" + msg,
+ },
+ {
+ "type": "mrkdwn",
+ "text": `*Time (${heartbeatJSON["timezone"]})*\n${heartbeatJSON["localDateTime"]}`,
+ }
+ ],
+ });
+
+ const actions = this.buildActions(baseURL, monitorJSON);
+ if (actions.length > 0) {
+ //the actions block, containing buttons
+ blocks.push({
+ "type": "actions",
+ "elements": actions,
+ });
+ }
+
+ return blocks;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ if (notification.slackchannelnotify) {
+ msg += " <!channel>";
+ }
+
+ try {
+ if (heartbeatJSON == null) {
+ let data = {
+ "text": msg,
+ "channel": notification.slackchannel,
+ "username": notification.slackusername,
+ "icon_emoji": notification.slackiconemo,
+ };
+ await axios.post(notification.slackwebhookURL, data);
+ return okMsg;
+ }
+
+ const baseURL = await setting("primaryBaseURL");
+
+ const title = "Uptime Kuma Alert";
+ let data = {
+ "channel": notification.slackchannel,
+ "username": notification.slackusername,
+ "icon_emoji": notification.slackiconemo,
+ "attachments": [],
+ };
+
+ if (notification.slackrichmessage) {
+ data.attachments.push(
+ {
+ "color": (heartbeatJSON["status"] === UP) ? "#2eb886" : "#e01e5a",
+ "blocks": this.buildBlocks(baseURL, monitorJSON, heartbeatJSON, title, msg),
+ }
+ );
+ } else {
+ data.text = `${title}\n${msg}`;
+ }
+
+ if (notification.slackbutton) {
+ await Slack.deprecateURL(notification.slackbutton);
+ }
+
+ await axios.post(notification.slackwebhookURL, data);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = Slack;
diff --git a/server/notification-providers/smsc.js b/server/notification-providers/smsc.js
new file mode 100644
index 0000000..89f01d0
--- /dev/null
+++ b/server/notification-providers/smsc.js
@@ -0,0 +1,47 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class SMSC extends NotificationProvider {
+ name = "smsc";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://smsc.kz/sys/send.php?";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "text/json",
+ }
+ };
+
+ let getArray = [
+ "fmt=3",
+ "translit=" + notification.smscTranslit,
+ "login=" + notification.smscLogin,
+ "psw=" + notification.smscPassword,
+ "phones=" + notification.smscToNumber,
+ "mes=" + encodeURIComponent(msg.replace(/[^\x00-\x7F]/g, "")),
+ ];
+ if (notification.smscSenderName !== "") {
+ getArray.push("sender=" + notification.smscSenderName);
+ }
+
+ let resp = await axios.get(url + getArray.join("&"), config);
+ if (resp.data.id === undefined) {
+ let error = `Something gone wrong. Api returned code ${resp.data.error_code}: ${resp.data.error}`;
+ this.throwGeneralAxiosError(error);
+ }
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = SMSC;
diff --git a/server/notification-providers/smseagle.js b/server/notification-providers/smseagle.js
new file mode 100644
index 0000000..4e89700
--- /dev/null
+++ b/server/notification-providers/smseagle.js
@@ -0,0 +1,73 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class SMSEagle extends NotificationProvider {
+ name = "SMSEagle";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ }
+ };
+
+ let postData;
+ let sendMethod;
+ let recipientType;
+
+ let encoding = (notification.smseagleEncoding) ? "1" : "0";
+ let priority = (notification.smseaglePriority) ? notification.smseaglePriority : "0";
+
+ if (notification.smseagleRecipientType === "smseagle-contact") {
+ recipientType = "contactname";
+ sendMethod = "sms.send_tocontact";
+ }
+ if (notification.smseagleRecipientType === "smseagle-group") {
+ recipientType = "groupname";
+ sendMethod = "sms.send_togroup";
+ }
+ if (notification.smseagleRecipientType === "smseagle-to") {
+ recipientType = "to";
+ sendMethod = "sms.send_sms";
+ }
+
+ let params = {
+ access_token: notification.smseagleToken,
+ [recipientType]: notification.smseagleRecipient,
+ message: msg,
+ responsetype: "extended",
+ unicode: encoding,
+ highpriority: priority
+ };
+
+ postData = {
+ method: sendMethod,
+ params: params
+ };
+
+ let resp = await axios.post(notification.smseagleUrl + "/jsonrpc/sms", postData, config);
+
+ if ((JSON.stringify(resp.data)).indexOf("message_id") === -1) {
+ let error = "";
+ if (resp.data.result && resp.data.result.error_text) {
+ error = `SMSEagle API returned error: ${JSON.stringify(resp.data.result.error_text)}`;
+ } else {
+ error = "SMSEagle API returned an unexpected response";
+ }
+ throw new Error(error);
+ }
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = SMSEagle;
diff --git a/server/notification-providers/smsmanager.js b/server/notification-providers/smsmanager.js
new file mode 100644
index 0000000..d01285d
--- /dev/null
+++ b/server/notification-providers/smsmanager.js
@@ -0,0 +1,29 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class SMSManager extends NotificationProvider {
+ name = "SMSManager";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://http-api.smsmanager.cz/Send";
+
+ try {
+ let data = {
+ apikey: notification.smsmanagerApiKey,
+ message: msg.replace(/[^\x00-\x7F]/g, ""),
+ number: notification.numbers,
+ gateway: notification.messageType,
+ };
+ await axios.get(`${url}?apikey=${data.apikey}&message=${data.message}&number=${data.number}&gateway=${data.messageType}`);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = SMSManager;
diff --git a/server/notification-providers/smspartner.js b/server/notification-providers/smspartner.js
new file mode 100644
index 0000000..5595217
--- /dev/null
+++ b/server/notification-providers/smspartner.js
@@ -0,0 +1,46 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class SMSPartner extends NotificationProvider {
+ name = "SMSPartner";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://api.smspartner.fr/v1/send";
+
+ try {
+ // smspartner does not support non ascii characters and only a maximum 639 characters
+ let cleanMsg = msg.replace(/[^\x00-\x7F]/g, "").substring(0, 639);
+
+ let data = {
+ "apiKey": notification.smspartnerApikey,
+ "sender": notification.smspartnerSenderName.substring(0, 11),
+ "phoneNumbers": notification.smspartnerPhoneNumber,
+ "message": cleanMsg,
+ };
+
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ "cache-control": "no-cache",
+ "Accept": "application/json",
+ }
+ };
+
+ let resp = await axios.post(url, data, config);
+
+ if (resp.data.success !== true) {
+ throw Error(`Api returned ${resp.data.response.status}.`);
+ }
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = SMSPartner;
diff --git a/server/notification-providers/smtp.js b/server/notification-providers/smtp.js
new file mode 100644
index 0000000..9f3defa
--- /dev/null
+++ b/server/notification-providers/smtp.js
@@ -0,0 +1,120 @@
+const nodemailer = require("nodemailer");
+const NotificationProvider = require("./notification-provider");
+const { DOWN } = require("../../src/util");
+const { Liquid } = require("liquidjs");
+
+class SMTP extends NotificationProvider {
+ name = "smtp";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ const config = {
+ host: notification.smtpHost,
+ port: notification.smtpPort,
+ secure: notification.smtpSecure,
+ tls: {
+ rejectUnauthorized: !notification.smtpIgnoreTLSError || false,
+ }
+ };
+
+ // Fix #1129
+ if (notification.smtpDkimDomain) {
+ config.dkim = {
+ domainName: notification.smtpDkimDomain,
+ keySelector: notification.smtpDkimKeySelector,
+ privateKey: notification.smtpDkimPrivateKey,
+ hashAlgo: notification.smtpDkimHashAlgo,
+ headerFieldNames: notification.smtpDkimheaderFieldNames,
+ skipFields: notification.smtpDkimskipFields,
+ };
+ }
+
+ // Should fix the issue in https://github.com/louislam/uptime-kuma/issues/26#issuecomment-896373904
+ if (notification.smtpUsername || notification.smtpPassword) {
+ config.auth = {
+ user: notification.smtpUsername,
+ pass: notification.smtpPassword,
+ };
+ }
+
+ // default values in case the user does not want to template
+ let subject = msg;
+ let body = msg;
+ if (heartbeatJSON) {
+ body = `${msg}\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`;
+ }
+ // subject and body are templated
+ if ((monitorJSON && heartbeatJSON) || msg.endsWith("Testing")) {
+ // cannot end with whitespace as this often raises spam scores
+ const customSubject = notification.customSubject?.trim() || "";
+ const customBody = notification.customBody?.trim() || "";
+
+ const context = this.generateContext(msg, monitorJSON, heartbeatJSON);
+ const engine = new Liquid();
+ if (customSubject !== "") {
+ const tpl = engine.parse(customSubject);
+ subject = await engine.render(tpl, context);
+ }
+ if (customBody !== "") {
+ const tpl = engine.parse(customBody);
+ body = await engine.render(tpl, context);
+ }
+ }
+
+ // send mail with defined transport object
+ let transporter = nodemailer.createTransport(config);
+ await transporter.sendMail({
+ from: notification.smtpFrom,
+ cc: notification.smtpCC,
+ bcc: notification.smtpBCC,
+ to: notification.smtpTo,
+ subject: subject,
+ text: body,
+ });
+
+ return okMsg;
+ }
+
+ /**
+ * Generate context for LiquidJS
+ * @param {string} msg the message that will be included in the context
+ * @param {?object} monitorJSON Monitor details (For Up/Down/Cert-Expiry only)
+ * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @returns {{STATUS: string, status: string, HOSTNAME_OR_URL: string, hostnameOrUrl: string, NAME: string, name: string, monitorJSON: ?object, heartbeatJSON: ?object, msg: string}} the context
+ */
+ generateContext(msg, monitorJSON, heartbeatJSON) {
+ // Let's start with dummy values to simplify code
+ let monitorName = "Monitor Name not available";
+ let monitorHostnameOrURL = "testing.hostname";
+
+ if (monitorJSON !== null) {
+ monitorName = monitorJSON["name"];
+ monitorHostnameOrURL = this.extractAddress(monitorJSON);
+ }
+
+ let serviceStatus = "⚠️ Test";
+ if (heartbeatJSON !== null) {
+ serviceStatus = (heartbeatJSON["status"] === DOWN) ? "🔴 Down" : "✅ Up";
+ }
+ return {
+ // for v1 compatibility, to be removed in v3
+ "STATUS": serviceStatus,
+ "NAME": monitorName,
+ "HOSTNAME_OR_URL": monitorHostnameOrURL,
+
+ // variables which are officially supported
+ "status": serviceStatus,
+ "name": monitorName,
+ "hostnameOrURL": monitorHostnameOrURL,
+ monitorJSON,
+ heartbeatJSON,
+ msg,
+ };
+ }
+}
+
+module.exports = SMTP;
diff --git a/server/notification-providers/splunk.js b/server/notification-providers/splunk.js
new file mode 100644
index 0000000..e07c510
--- /dev/null
+++ b/server/notification-providers/splunk.js
@@ -0,0 +1,114 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util");
+const { setting } = require("../util-server");
+let successMessage = "Sent Successfully.";
+
+class Splunk extends NotificationProvider {
+ name = "Splunk";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ try {
+ if (heartbeatJSON == null) {
+ const title = "Uptime Kuma Alert";
+ const monitor = {
+ type: "ping",
+ url: "Uptime Kuma Test Button",
+ };
+ return this.postNotification(notification, title, msg, monitor, "trigger");
+ }
+
+ if (heartbeatJSON.status === UP) {
+ const title = "Uptime Kuma Monitor ✅ Up";
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "recovery");
+ }
+
+ if (heartbeatJSON.status === DOWN) {
+ const title = "Uptime Kuma Monitor 🔴 Down";
+ return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "trigger");
+ }
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Check if result is successful, result code should be in range 2xx
+ * @param {object} result Axios response object
+ * @returns {void}
+ * @throws {Error} The status code is not in range 2xx
+ */
+ checkResult(result) {
+ if (result.status == null) {
+ throw new Error("Splunk notification failed with invalid response!");
+ }
+ if (result.status < 200 || result.status >= 300) {
+ throw new Error("Splunk notification failed with status code " + result.status);
+ }
+ }
+
+ /**
+ * Send the message
+ * @param {BeanModel} notification Message title
+ * @param {string} title Message title
+ * @param {string} body Message
+ * @param {object} monitorInfo Monitor details (For Up/Down only)
+ * @param {?string} eventAction Action event for PagerDuty (trigger, acknowledge, resolve)
+ * @returns {Promise<string>} Success state
+ */
+ async postNotification(notification, title, body, monitorInfo, eventAction = "trigger") {
+
+ let monitorUrl;
+ if (monitorInfo.type === "port") {
+ monitorUrl = monitorInfo.hostname;
+ if (monitorInfo.port) {
+ monitorUrl += ":" + monitorInfo.port;
+ }
+ } else if (monitorInfo.hostname != null) {
+ monitorUrl = monitorInfo.hostname;
+ } else {
+ monitorUrl = monitorInfo.url;
+ }
+
+ if (eventAction === "recovery") {
+ if (notification.splunkAutoResolve === "0") {
+ return "No action required";
+ }
+ eventAction = notification.splunkAutoResolve;
+ } else {
+ eventAction = notification.splunkSeverity;
+ }
+
+ const options = {
+ method: "POST",
+ url: notification.splunkRestURL,
+ headers: { "Content-Type": "application/json" },
+ data: {
+ message_type: eventAction,
+ state_message: `[${title}] [${monitorUrl}] ${body}`,
+ entity_display_name: "Uptime Kuma Alert: " + monitorInfo.name,
+ routing_key: notification.pagerdutyIntegrationKey,
+ entity_id: "Uptime Kuma/" + monitorInfo.id,
+ }
+ };
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL && monitorInfo) {
+ options.client = "Uptime Kuma";
+ options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id);
+ }
+
+ let result = await axios.request(options);
+ this.checkResult(result);
+ if (result.statusText != null) {
+ return "Splunk notification succeed: " + result.statusText;
+ }
+
+ return successMessage;
+ }
+}
+
+module.exports = Splunk;
diff --git a/server/notification-providers/squadcast.js b/server/notification-providers/squadcast.js
new file mode 100644
index 0000000..5713783
--- /dev/null
+++ b/server/notification-providers/squadcast.js
@@ -0,0 +1,60 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN } = require("../../src/util");
+
+class Squadcast extends NotificationProvider {
+ name = "squadcast";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+
+ let config = {};
+ let data = {
+ message: msg,
+ description: "",
+ tags: {},
+ heartbeat: heartbeatJSON,
+ source: "uptime-kuma"
+ };
+
+ if (heartbeatJSON !== null) {
+ data.description = heartbeatJSON["msg"];
+ data.event_id = heartbeatJSON["monitorID"];
+
+ if (heartbeatJSON["status"] === DOWN) {
+ data.message = `${monitorJSON["name"]} is DOWN`;
+ data.status = "trigger";
+ } else {
+ data.message = `${monitorJSON["name"]} is UP`;
+ data.status = "resolve";
+ }
+
+ data.tags["AlertAddress"] = this.extractAddress(monitorJSON);
+
+ monitorJSON["tags"].forEach(tag => {
+ data.tags[tag["name"]] = {
+ value: tag["value"]
+ };
+ if (tag["color"] !== null) {
+ data.tags[tag["name"]]["color"] = tag["color"];
+ }
+ });
+ }
+
+ await axios.post(notification.squadcastWebhookURL, data, config);
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+
+}
+
+module.exports = Squadcast;
diff --git a/server/notification-providers/stackfield.js b/server/notification-providers/stackfield.js
new file mode 100644
index 0000000..65a9245
--- /dev/null
+++ b/server/notification-providers/stackfield.js
@@ -0,0 +1,44 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { setting } = require("../util-server");
+const { getMonitorRelativeURL } = require("../../src/util");
+
+class Stackfield extends NotificationProvider {
+ name = "stackfield";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ // Stackfield message formatting: https://www.stackfield.com/help/formatting-messages-2001
+
+ let textMsg = "+Uptime Kuma Alert+";
+
+ if (monitorJSON && monitorJSON.name) {
+ textMsg += `\n*${monitorJSON.name}*`;
+ }
+
+ textMsg += `\n${msg}`;
+
+ const baseURL = await setting("primaryBaseURL");
+ if (baseURL) {
+ textMsg += `\n${baseURL + getMonitorRelativeURL(monitorJSON.id)}`;
+ }
+
+ const data = {
+ "Title": textMsg,
+ };
+
+ await axios.post(notification.stackfieldwebhookURL, data);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+}
+
+module.exports = Stackfield;
diff --git a/server/notification-providers/teams.js b/server/notification-providers/teams.js
new file mode 100644
index 0000000..2793604
--- /dev/null
+++ b/server/notification-providers/teams.js
@@ -0,0 +1,240 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { setting } = require("../util-server");
+const { DOWN, UP, getMonitorRelativeURL } = require("../../src/util");
+
+class Teams extends NotificationProvider {
+ name = "teams";
+
+ /**
+ * Generate the message to send
+ * @param {const} status The status constant
+ * @param {string} monitorName Name of monitor
+ * @param {boolean} withStatusSymbol If the status should be prepended as symbol
+ * @returns {string} Status message
+ */
+ _statusMessageFactory = (status, monitorName, withStatusSymbol) => {
+ if (status === DOWN) {
+ return (withStatusSymbol ? "🔴 " : "") + `[${monitorName}] went down`;
+ } else if (status === UP) {
+ return (withStatusSymbol ? "✅ " : "") + `[${monitorName}] is back online`;
+ }
+ return "Notification";
+ };
+
+ /**
+ * Select the style to use based on status
+ * @param {const} status The status constant
+ * @returns {string} Selected style for adaptive cards
+ */
+ _getStyle = (status) => {
+ if (status === DOWN) {
+ return "attention";
+ }
+ if (status === UP) {
+ return "good";
+ }
+ return "emphasis";
+ };
+
+ /**
+ * Generate payload for notification
+ * @param {object} args Method arguments
+ * @param {object} args.heartbeatJSON Heartbeat details
+ * @param {string} args.monitorName Name of the monitor affected
+ * @param {string} args.monitorUrl URL of the monitor affected
+ * @param {string} args.dashboardUrl URL of the dashboard affected
+ * @returns {object} Notification payload
+ */
+ _notificationPayloadFactory = ({
+ heartbeatJSON,
+ monitorName,
+ monitorUrl,
+ dashboardUrl,
+ }) => {
+ const status = heartbeatJSON?.status;
+ const facts = [];
+ const actions = [];
+
+ if (dashboardUrl) {
+ actions.push({
+ "type": "Action.OpenUrl",
+ "title": "Visit Uptime Kuma",
+ "url": dashboardUrl
+ });
+ }
+
+ if (heartbeatJSON?.msg) {
+ facts.push({
+ title: "Description",
+ value: heartbeatJSON.msg,
+ });
+ }
+
+ if (monitorName) {
+ facts.push({
+ title: "Monitor",
+ value: monitorName,
+ });
+ }
+
+ if (monitorUrl && monitorUrl !== "https://") {
+ facts.push({
+ title: "URL",
+ // format URL as markdown syntax, to be clickable
+ value: `[${monitorUrl}](${monitorUrl})`,
+ });
+ actions.push({
+ "type": "Action.OpenUrl",
+ "title": "Visit Monitor URL",
+ "url": monitorUrl
+ });
+ }
+
+ if (heartbeatJSON?.localDateTime) {
+ facts.push({
+ title: "Time",
+ value: heartbeatJSON.localDateTime + (heartbeatJSON.timezone ? ` (${heartbeatJSON.timezone})` : ""),
+ });
+ }
+
+ const payload = {
+ "type": "message",
+ // message with status prefix as notification text
+ "summary": this._statusMessageFactory(status, monitorName, true),
+ "attachments": [
+ {
+ "contentType": "application/vnd.microsoft.card.adaptive",
+ "contentUrl": "",
+ "content": {
+ "type": "AdaptiveCard",
+ "body": [
+ {
+ "type": "Container",
+ "verticalContentAlignment": "Center",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "style": this._getStyle(status),
+ "columns": [
+ {
+ "type": "Column",
+ "width": "auto",
+ "verticalContentAlignment": "Center",
+ "items": [
+ {
+ "type": "Image",
+ "width": "32px",
+ "style": "Person",
+ "url": "https://raw.githubusercontent.com/louislam/uptime-kuma/master/public/icon.png",
+ "altText": "Uptime Kuma Logo"
+ }
+ ]
+ },
+ {
+ "type": "Column",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "size": "Medium",
+ "weight": "Bolder",
+ "text": `**${this._statusMessageFactory(status, monitorName, false)}**`,
+ },
+ {
+ "type": "TextBlock",
+ "size": "Small",
+ "weight": "Default",
+ "text": "Uptime Kuma Alert",
+ "isSubtle": true,
+ "spacing": "None"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "FactSet",
+ "separator": false,
+ "facts": facts
+ }
+ ],
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+ "version": "1.5"
+ }
+ }
+ ]
+ };
+
+ if (actions) {
+ payload.attachments[0].content.body.push({
+ "type": "ActionSet",
+ "actions": actions,
+ });
+ }
+
+ return payload;
+ };
+
+ /**
+ * Send the notification
+ * @param {string} webhookUrl URL to send the request to
+ * @param {object} payload Payload generated by _notificationPayloadFactory
+ * @returns {Promise<void>}
+ */
+ _sendNotification = async (webhookUrl, payload) => {
+ await axios.post(webhookUrl, payload);
+ };
+
+ /**
+ * Send a general notification
+ * @param {string} webhookUrl URL to send request to
+ * @param {string} msg Message to send
+ * @returns {Promise<void>}
+ */
+ _handleGeneralNotification = (webhookUrl, msg) => {
+ const payload = this._notificationPayloadFactory({
+ heartbeatJSON: {
+ msg: msg
+ }
+ });
+
+ return this._sendNotification(webhookUrl, payload);
+ };
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ if (heartbeatJSON == null) {
+ await this._handleGeneralNotification(notification.webhookUrl, msg);
+ return okMsg;
+ }
+
+ const baseURL = await setting("primaryBaseURL");
+ let dashboardUrl;
+ if (baseURL) {
+ dashboardUrl = baseURL + getMonitorRelativeURL(monitorJSON.id);
+ }
+
+ const payload = this._notificationPayloadFactory({
+ heartbeatJSON: heartbeatJSON,
+ monitorName: monitorJSON.name,
+ monitorUrl: this.extractAddress(monitorJSON),
+ dashboardUrl: dashboardUrl,
+ });
+
+ await this._sendNotification(notification.webhookUrl, payload);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Teams;
diff --git a/server/notification-providers/techulus-push.js b/server/notification-providers/techulus-push.js
new file mode 100644
index 0000000..bf688b1
--- /dev/null
+++ b/server/notification-providers/techulus-push.js
@@ -0,0 +1,36 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class TechulusPush extends NotificationProvider {
+ name = "PushByTechulus";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ let data = {
+ "title": notification?.pushTitle?.length ? notification.pushTitle : "Uptime-Kuma",
+ "body": msg,
+ "timeSensitive": notification.pushTimeSensitive ?? true,
+ };
+
+ if (notification.pushChannel) {
+ data.channel = notification.pushChannel;
+ }
+
+ if (notification.pushSound) {
+ data.sound = notification.pushSound;
+ }
+
+ try {
+ await axios.post(`https://push.techulus.com/api/v1/notify/${notification.pushAPIKey}`, data);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = TechulusPush;
diff --git a/server/notification-providers/telegram.js b/server/notification-providers/telegram.js
new file mode 100644
index 0000000..c5bbb19
--- /dev/null
+++ b/server/notification-providers/telegram.js
@@ -0,0 +1,36 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Telegram extends NotificationProvider {
+ name = "telegram";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+ const url = "https://api.telegram.org";
+
+ try {
+ let params = {
+ chat_id: notification.telegramChatID,
+ text: msg,
+ disable_notification: notification.telegramSendSilently ?? false,
+ protect_content: notification.telegramProtectContent ?? false,
+ };
+ if (notification.telegramMessageThreadID) {
+ params.message_thread_id = notification.telegramMessageThreadID;
+ }
+
+ await axios.get(`${url}/bot${notification.telegramBotToken}/sendMessage`, {
+ params: params,
+ });
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = Telegram;
diff --git a/server/notification-providers/threema.js b/server/notification-providers/threema.js
new file mode 100644
index 0000000..07a54ab
--- /dev/null
+++ b/server/notification-providers/threema.js
@@ -0,0 +1,77 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Threema extends NotificationProvider {
+ name = "threema";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const url = "https://msgapi.threema.ch/send_simple";
+
+ const config = {
+ headers: {
+ "Accept": "*/*",
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
+ }
+ };
+
+ const data = {
+ from: notification.threemaSenderIdentity,
+ secret: notification.threemaSecret,
+ text: msg
+ };
+
+ switch (notification.threemaRecipientType) {
+ case "identity":
+ data.to = notification.threemaRecipient;
+ break;
+ case "phone":
+ data.phone = notification.threemaRecipient;
+ break;
+ case "email":
+ data.email = notification.threemaRecipient;
+ break;
+ default:
+ throw new Error(`Unsupported recipient type: ${notification.threemaRecipientType}`);
+ }
+
+ try {
+ await axios.post(url, new URLSearchParams(data), config);
+ return "Threema notification sent successfully.";
+ } catch (error) {
+ const errorMessage = this.handleApiError(error);
+ this.throwGeneralAxiosError(errorMessage);
+ }
+ }
+
+ /**
+ * Handle Threema API errors
+ * @param {any} error The error to handle
+ * @returns {string} Additional error context
+ */
+ handleApiError(error) {
+ if (!error.response) {
+ return error.message;
+ }
+ switch (error.response.status) {
+ case 400:
+ return "Invalid recipient identity or account not set up for basic mode (400).";
+ case 401:
+ return "Incorrect API identity or secret (401).";
+ case 402:
+ return "No credits remaining (402).";
+ case 404:
+ return "Recipient not found (404).";
+ case 413:
+ return "Message is too long (413).";
+ case 500:
+ return "Temporary internal server error (500).";
+ default:
+ return error.message;
+ }
+ }
+}
+
+module.exports = Threema;
diff --git a/server/notification-providers/twilio.js b/server/notification-providers/twilio.js
new file mode 100644
index 0000000..c38a6d7
--- /dev/null
+++ b/server/notification-providers/twilio.js
@@ -0,0 +1,38 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Twilio extends NotificationProvider {
+ name = "twilio";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ let apiKey = notification.twilioApiKey ? notification.twilioApiKey : notification.twilioAccountSID;
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
+ "Authorization": "Basic " + Buffer.from(apiKey + ":" + notification.twilioAuthToken).toString("base64"),
+ }
+ };
+
+ let data = new URLSearchParams();
+ data.append("To", notification.twilioToNumber);
+ data.append("From", notification.twilioFromNumber);
+ data.append("Body", msg);
+
+ await axios.post(`https://api.twilio.com/2010-04-01/Accounts/${(notification.twilioAccountSID)}/Messages.json`, data, config);
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+}
+
+module.exports = Twilio;
diff --git a/server/notification-providers/webhook.js b/server/notification-providers/webhook.js
new file mode 100644
index 0000000..986986d
--- /dev/null
+++ b/server/notification-providers/webhook.js
@@ -0,0 +1,66 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const FormData = require("form-data");
+const { Liquid } = require("liquidjs");
+
+class Webhook extends NotificationProvider {
+ name = "webhook";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let data = {
+ heartbeat: heartbeatJSON,
+ monitor: monitorJSON,
+ msg,
+ };
+ let config = {
+ headers: {}
+ };
+
+ if (notification.webhookContentType === "form-data") {
+ const formData = new FormData();
+ formData.append("data", JSON.stringify(data));
+ config.headers = formData.getHeaders();
+ data = formData;
+ } else if (notification.webhookContentType === "custom") {
+ // Initialize LiquidJS and parse the custom Body Template
+ const engine = new Liquid();
+ const tpl = engine.parse(notification.webhookCustomBody);
+
+ // Insert templated values into Body
+ data = await engine.render(tpl,
+ {
+ msg,
+ heartbeatJSON,
+ monitorJSON
+ });
+ }
+
+ if (notification.webhookAdditionalHeaders) {
+ try {
+ config.headers = {
+ ...config.headers,
+ ...JSON.parse(notification.webhookAdditionalHeaders)
+ };
+ } catch (err) {
+ throw "Additional Headers is not a valid JSON";
+ }
+ }
+
+ await axios.post(notification.webhookURL, data, config);
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+
+ }
+
+}
+
+module.exports = Webhook;
diff --git a/server/notification-providers/wecom.js b/server/notification-providers/wecom.js
new file mode 100644
index 0000000..1eb0690
--- /dev/null
+++ b/server/notification-providers/wecom.js
@@ -0,0 +1,51 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class WeCom extends NotificationProvider {
+ name = "WeCom";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ let config = {
+ headers: {
+ "Content-Type": "application/json"
+ }
+ };
+ let body = this.composeMessage(heartbeatJSON, msg);
+ await axios.post(`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${notification.weComBotKey}`, body, config);
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Generate the message to send
+ * @param {object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @param {string} msg General message
+ * @returns {object} Message
+ */
+ composeMessage(heartbeatJSON, msg) {
+ let title = "UptimeKuma Message";
+ if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === UP) {
+ title = "UptimeKuma Monitor Up";
+ }
+ if (msg != null && heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
+ title = "UptimeKuma Monitor Down";
+ }
+ return {
+ msgtype: "text",
+ text: {
+ content: title + "\n" + msg
+ }
+ };
+ }
+}
+
+module.exports = WeCom;
diff --git a/server/notification-providers/whapi.js b/server/notification-providers/whapi.js
new file mode 100644
index 0000000..70e0fbb
--- /dev/null
+++ b/server/notification-providers/whapi.js
@@ -0,0 +1,39 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+
+class Whapi extends NotificationProvider {
+ name = "whapi";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ const config = {
+ headers: {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + notification.whapiAuthToken,
+ }
+ };
+
+ let data = {
+ "to": notification.whapiRecipient,
+ "body": msg,
+ };
+
+ let url = (notification.whapiApiUrl || "https://gate.whapi.cloud/").replace(/\/+$/, "") + "/messages/text";
+
+ await axios.post(url, data, config);
+
+ return okMsg;
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+}
+
+module.exports = Whapi;
diff --git a/server/notification-providers/wpush.js b/server/notification-providers/wpush.js
new file mode 100644
index 0000000..db043f9
--- /dev/null
+++ b/server/notification-providers/wpush.js
@@ -0,0 +1,51 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class WPush extends NotificationProvider {
+ name = "WPush";
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ const context = {
+ "title": this.checkStatus(heartbeatJSON, monitorJSON),
+ "content": msg,
+ "apikey": notification.wpushAPIkey,
+ "channel": notification.wpushChannel
+ };
+ const result = await axios.post("https://api.wpush.cn/api/v1/send", context);
+ if (result.data.code !== 0) {
+ throw result.data.message;
+ }
+
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+
+ /**
+ * Get the formatted title for message
+ * @param {?object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @param {?object} monitorJSON Monitor details (For Up/Down only)
+ * @returns {string} Formatted title
+ */
+ checkStatus(heartbeatJSON, monitorJSON) {
+ let title = "UptimeKuma Message";
+ if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {
+ title = "UptimeKuma Monitor Up " + monitorJSON["name"];
+ }
+ if (heartbeatJSON != null && heartbeatJSON["status"] === DOWN) {
+ title = "UptimeKuma Monitor Down " + monitorJSON["name"];
+ }
+ return title;
+ }
+}
+
+module.exports = WPush;
diff --git a/server/notification-providers/zoho-cliq.js b/server/notification-providers/zoho-cliq.js
new file mode 100644
index 0000000..3a504de
--- /dev/null
+++ b/server/notification-providers/zoho-cliq.js
@@ -0,0 +1,101 @@
+const NotificationProvider = require("./notification-provider");
+const axios = require("axios");
+const { DOWN, UP } = require("../../src/util");
+
+class ZohoCliq extends NotificationProvider {
+ name = "ZohoCliq";
+
+ /**
+ * Generate the message to send
+ * @param {const} status The status constant
+ * @param {string} monitorName Name of monitor
+ * @returns {string} Status message
+ */
+ _statusMessageFactory = (status, monitorName) => {
+ if (status === DOWN) {
+ return `🔴 [${monitorName}] went down\n`;
+ } else if (status === UP) {
+ return `### ✅ [${monitorName}] is back online\n`;
+ }
+ return "Notification\n";
+ };
+
+ /**
+ * Send the notification
+ * @param {string} webhookUrl URL to send the request to
+ * @param {Array} payload Payload generated by _notificationPayloadFactory
+ * @returns {Promise<void>}
+ */
+ _sendNotification = async (webhookUrl, payload) => {
+ await axios.post(webhookUrl, { text: payload.join("\n") });
+ };
+
+ /**
+ * Generate payload for notification
+ * @param {object} args Method arguments
+ * @param {const} args.status The status of the monitor
+ * @param {string} args.monitorMessage Message to send
+ * @param {string} args.monitorName Name of monitor affected
+ * @param {string} args.monitorUrl URL of monitor affected
+ * @returns {Array} Notification payload
+ */
+ _notificationPayloadFactory = ({
+ status,
+ monitorMessage,
+ monitorName,
+ monitorUrl,
+ }) => {
+ const payload = [];
+ payload.push(this._statusMessageFactory(status, monitorName));
+ payload.push(`*Description:* ${monitorMessage}`);
+
+ if (monitorUrl && monitorUrl !== "https://") {
+ payload.push(`*URL:* ${monitorUrl}`);
+ }
+
+ return payload;
+ };
+
+ /**
+ * Send a general notification
+ * @param {string} webhookUrl URL to send request to
+ * @param {string} msg Message to send
+ * @returns {Promise<void>}
+ */
+ _handleGeneralNotification = (webhookUrl, msg) => {
+ const payload = this._notificationPayloadFactory({
+ monitorMessage: msg
+ });
+
+ return this._sendNotification(webhookUrl, payload);
+ };
+
+ /**
+ * @inheritdoc
+ */
+ async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ const okMsg = "Sent Successfully.";
+
+ try {
+ if (heartbeatJSON == null) {
+ await this._handleGeneralNotification(notification.webhookUrl, msg);
+ return okMsg;
+ }
+
+ const payload = this._notificationPayloadFactory({
+ monitorMessage: heartbeatJSON.msg,
+ monitorName: monitorJSON.name,
+ monitorUrl: this.extractAddress(monitorJSON),
+ status: heartbeatJSON.status
+ });
+
+ await this._sendNotification(notification.webhookUrl, payload);
+ return okMsg;
+
+ } catch (error) {
+ this.throwGeneralAxiosError(error);
+ }
+ }
+}
+
+module.exports = ZohoCliq;
diff --git a/server/notification.js b/server/notification.js
new file mode 100644
index 0000000..e7977eb
--- /dev/null
+++ b/server/notification.js
@@ -0,0 +1,284 @@
+const { R } = require("redbean-node");
+const { log } = require("../src/util");
+const Alerta = require("./notification-providers/alerta");
+const AlertNow = require("./notification-providers/alertnow");
+const AliyunSms = require("./notification-providers/aliyun-sms");
+const Apprise = require("./notification-providers/apprise");
+const Bark = require("./notification-providers/bark");
+const Bitrix24 = require("./notification-providers/bitrix24");
+const ClickSendSMS = require("./notification-providers/clicksendsms");
+const CallMeBot = require("./notification-providers/call-me-bot");
+const SMSC = require("./notification-providers/smsc");
+const DingDing = require("./notification-providers/dingding");
+const Discord = require("./notification-providers/discord");
+const Elks = require("./notification-providers/46elks");
+const Feishu = require("./notification-providers/feishu");
+const FreeMobile = require("./notification-providers/freemobile");
+const GoogleChat = require("./notification-providers/google-chat");
+const Gorush = require("./notification-providers/gorush");
+const Gotify = require("./notification-providers/gotify");
+const GrafanaOncall = require("./notification-providers/grafana-oncall");
+const HomeAssistant = require("./notification-providers/home-assistant");
+const HeiiOnCall = require("./notification-providers/heii-oncall");
+const Keep = require("./notification-providers/keep");
+const Kook = require("./notification-providers/kook");
+const Line = require("./notification-providers/line");
+const LineNotify = require("./notification-providers/linenotify");
+const LunaSea = require("./notification-providers/lunasea");
+const Matrix = require("./notification-providers/matrix");
+const Mattermost = require("./notification-providers/mattermost");
+const Nostr = require("./notification-providers/nostr");
+const Ntfy = require("./notification-providers/ntfy");
+const Octopush = require("./notification-providers/octopush");
+const OneBot = require("./notification-providers/onebot");
+const Opsgenie = require("./notification-providers/opsgenie");
+const PagerDuty = require("./notification-providers/pagerduty");
+const FlashDuty = require("./notification-providers/flashduty");
+const PagerTree = require("./notification-providers/pagertree");
+const PromoSMS = require("./notification-providers/promosms");
+const Pushbullet = require("./notification-providers/pushbullet");
+const PushDeer = require("./notification-providers/pushdeer");
+const Pushover = require("./notification-providers/pushover");
+const Pushy = require("./notification-providers/pushy");
+const RocketChat = require("./notification-providers/rocket-chat");
+const SerwerSMS = require("./notification-providers/serwersms");
+const Signal = require("./notification-providers/signal");
+const SIGNL4 = require("./notification-providers/signl4");
+const Slack = require("./notification-providers/slack");
+const SMSPartner = require("./notification-providers/smspartner");
+const SMSEagle = require("./notification-providers/smseagle");
+const SMTP = require("./notification-providers/smtp");
+const Squadcast = require("./notification-providers/squadcast");
+const Stackfield = require("./notification-providers/stackfield");
+const Teams = require("./notification-providers/teams");
+const TechulusPush = require("./notification-providers/techulus-push");
+const Telegram = require("./notification-providers/telegram");
+const Threema = require("./notification-providers/threema");
+const Twilio = require("./notification-providers/twilio");
+const Splunk = require("./notification-providers/splunk");
+const Webhook = require("./notification-providers/webhook");
+const WeCom = require("./notification-providers/wecom");
+const GoAlert = require("./notification-providers/goalert");
+const SMSManager = require("./notification-providers/smsmanager");
+const ServerChan = require("./notification-providers/serverchan");
+const ZohoCliq = require("./notification-providers/zoho-cliq");
+const SevenIO = require("./notification-providers/sevenio");
+const Whapi = require("./notification-providers/whapi");
+const GtxMessaging = require("./notification-providers/gtx-messaging");
+const Cellsynt = require("./notification-providers/cellsynt");
+const Onesender = require("./notification-providers/onesender");
+const Wpush = require("./notification-providers/wpush");
+const SendGrid = require("./notification-providers/send-grid");
+
+class Notification {
+
+ providerList = {};
+
+ /**
+ * Initialize the notification providers
+ * @returns {void}
+ * @throws Notification provider does not have a name
+ * @throws Duplicate notification providers in list
+ */
+ static init() {
+ log.debug("notification", "Prepare Notification Providers");
+
+ this.providerList = {};
+
+ const list = [
+ new Alerta(),
+ new AlertNow(),
+ new AliyunSms(),
+ new Apprise(),
+ new Bark(),
+ new Bitrix24(),
+ new ClickSendSMS(),
+ new CallMeBot(),
+ new SMSC(),
+ new DingDing(),
+ new Discord(),
+ new Elks(),
+ new Feishu(),
+ new FreeMobile(),
+ new GoogleChat(),
+ new Gorush(),
+ new Gotify(),
+ new GrafanaOncall(),
+ new HomeAssistant(),
+ new HeiiOnCall(),
+ new Keep(),
+ new Kook(),
+ new Line(),
+ new LineNotify(),
+ new LunaSea(),
+ new Matrix(),
+ new Mattermost(),
+ new Nostr(),
+ new Ntfy(),
+ new Octopush(),
+ new OneBot(),
+ new Onesender(),
+ new Opsgenie(),
+ new PagerDuty(),
+ new FlashDuty(),
+ new PagerTree(),
+ new PromoSMS(),
+ new Pushbullet(),
+ new PushDeer(),
+ new Pushover(),
+ new Pushy(),
+ new RocketChat(),
+ new ServerChan(),
+ new SerwerSMS(),
+ new Signal(),
+ new SIGNL4(),
+ new SMSManager(),
+ new SMSPartner(),
+ new Slack(),
+ new SMSEagle(),
+ new SMTP(),
+ new Squadcast(),
+ new Stackfield(),
+ new Teams(),
+ new TechulusPush(),
+ new Telegram(),
+ new Threema(),
+ new Twilio(),
+ new Splunk(),
+ new Webhook(),
+ new WeCom(),
+ new GoAlert(),
+ new ZohoCliq(),
+ new SevenIO(),
+ new Whapi(),
+ new GtxMessaging(),
+ new Cellsynt(),
+ new Wpush(),
+ new SendGrid()
+ ];
+ for (let item of list) {
+ if (! item.name) {
+ throw new Error("Notification provider without name");
+ }
+
+ if (this.providerList[item.name]) {
+ throw new Error("Duplicate notification provider name");
+ }
+ this.providerList[item.name] = item;
+ }
+ }
+
+ /**
+ * Send a notification
+ * @param {BeanModel} notification Notification to send
+ * @param {string} msg General Message
+ * @param {object} monitorJSON Monitor details (For Up/Down only)
+ * @param {object} heartbeatJSON Heartbeat details (For Up/Down only)
+ * @returns {Promise<string>} Successful msg
+ * @throws Error with fail msg
+ */
+ static async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
+ if (this.providerList[notification.type]) {
+ return this.providerList[notification.type].send(notification, msg, monitorJSON, heartbeatJSON);
+ } else {
+ throw new Error("Notification type is not supported");
+ }
+ }
+
+ /**
+ * Save a notification
+ * @param {object} notification Notification to save
+ * @param {?number} notificationID ID of notification to update
+ * @param {number} userID ID of user who adds notification
+ * @returns {Promise<Bean>} Notification that was saved
+ */
+ static async save(notification, notificationID, userID) {
+ let bean;
+
+ if (notificationID) {
+ bean = await R.findOne("notification", " id = ? AND user_id = ? ", [
+ notificationID,
+ userID,
+ ]);
+
+ if (! bean) {
+ throw new Error("notification not found");
+ }
+
+ } else {
+ bean = R.dispense("notification");
+ }
+
+ bean.name = notification.name;
+ bean.user_id = userID;
+ bean.config = JSON.stringify(notification);
+ bean.is_default = notification.isDefault || false;
+ await R.store(bean);
+
+ if (notification.applyExisting) {
+ await applyNotificationEveryMonitor(bean.id, userID);
+ }
+
+ return bean;
+ }
+
+ /**
+ * Delete a notification
+ * @param {number} notificationID ID of notification to delete
+ * @param {number} userID ID of user who created notification
+ * @returns {Promise<void>}
+ */
+ static async delete(notificationID, userID) {
+ let bean = await R.findOne("notification", " id = ? AND user_id = ? ", [
+ notificationID,
+ userID,
+ ]);
+
+ if (! bean) {
+ throw new Error("notification not found");
+ }
+
+ await R.trash(bean);
+ }
+
+ /**
+ * Check if apprise exists
+ * @returns {boolean} Does the command apprise exist?
+ */
+ static checkApprise() {
+ let commandExistsSync = require("command-exists").sync;
+ let exists = commandExistsSync("apprise");
+ return exists;
+ }
+
+}
+
+/**
+ * Apply the notification to every monitor
+ * @param {number} notificationID ID of notification to apply
+ * @param {number} userID ID of user who created notification
+ * @returns {Promise<void>}
+ */
+async function applyNotificationEveryMonitor(notificationID, userID) {
+ let monitors = await R.getAll("SELECT id FROM monitor WHERE user_id = ?", [
+ userID
+ ]);
+
+ for (let i = 0; i < monitors.length; i++) {
+ let checkNotification = await R.findOne("monitor_notification", " monitor_id = ? AND notification_id = ? ", [
+ monitors[i].id,
+ notificationID,
+ ]);
+
+ if (! checkNotification) {
+ let relation = R.dispense("monitor_notification");
+ relation.monitor_id = monitors[i].id;
+ relation.notification_id = notificationID;
+ await R.store(relation);
+ }
+ }
+}
+
+module.exports = {
+ Notification,
+};
diff --git a/server/password-hash.js b/server/password-hash.js
new file mode 100644
index 0000000..83a23d9
--- /dev/null
+++ b/server/password-hash.js
@@ -0,0 +1,44 @@
+const passwordHashOld = require("password-hash");
+const bcrypt = require("bcryptjs");
+const saltRounds = 10;
+
+/**
+ * Hash a password
+ * @param {string} password Password to hash
+ * @returns {string} Hash
+ */
+exports.generate = function (password) {
+ return bcrypt.hashSync(password, saltRounds);
+};
+
+/**
+ * Verify a password against a hash
+ * @param {string} password Password to verify
+ * @param {string} hash Hash to verify against
+ * @returns {boolean} Does the password match the hash?
+ */
+exports.verify = function (password, hash) {
+ if (isSHA1(hash)) {
+ return passwordHashOld.verify(password, hash);
+ }
+
+ return bcrypt.compareSync(password, hash);
+};
+
+/**
+ * Is the hash a SHA1 hash
+ * @param {string} hash Hash to check
+ * @returns {boolean} Is SHA1 hash?
+ */
+function isSHA1(hash) {
+ return (typeof hash === "string" && hash.startsWith("sha1"));
+}
+
+/**
+ * Does the hash need to be rehashed?
+ * @param {string} hash Hash to check
+ * @returns {boolean} Needs to be rehashed?
+ */
+exports.needRehash = function (hash) {
+ return isSHA1(hash);
+};
diff --git a/server/prometheus.js b/server/prometheus.js
new file mode 100644
index 0000000..f26125d
--- /dev/null
+++ b/server/prometheus.js
@@ -0,0 +1,123 @@
+const PrometheusClient = require("prom-client");
+const { log } = require("../src/util");
+
+const commonLabels = [
+ "monitor_name",
+ "monitor_type",
+ "monitor_url",
+ "monitor_hostname",
+ "monitor_port",
+];
+
+const monitorCertDaysRemaining = new PrometheusClient.Gauge({
+ name: "monitor_cert_days_remaining",
+ help: "The number of days remaining until the certificate expires",
+ labelNames: commonLabels
+});
+
+const monitorCertIsValid = new PrometheusClient.Gauge({
+ name: "monitor_cert_is_valid",
+ help: "Is the certificate still valid? (1 = Yes, 0= No)",
+ labelNames: commonLabels
+});
+const monitorResponseTime = new PrometheusClient.Gauge({
+ name: "monitor_response_time",
+ help: "Monitor Response Time (ms)",
+ labelNames: commonLabels
+});
+
+const monitorStatus = new PrometheusClient.Gauge({
+ name: "monitor_status",
+ help: "Monitor Status (1 = UP, 0= DOWN, 2= PENDING, 3= MAINTENANCE)",
+ labelNames: commonLabels
+});
+
+class Prometheus {
+ monitorLabelValues = {};
+
+ /**
+ * @param {object} monitor Monitor object to monitor
+ */
+ constructor(monitor) {
+ this.monitorLabelValues = {
+ monitor_name: monitor.name,
+ monitor_type: monitor.type,
+ monitor_url: monitor.url,
+ monitor_hostname: monitor.hostname,
+ monitor_port: monitor.port
+ };
+ }
+
+ /**
+ * Update the metrics page
+ * @param {object} heartbeat Heartbeat details
+ * @param {object} tlsInfo TLS details
+ * @returns {void}
+ */
+ update(heartbeat, tlsInfo) {
+
+ if (typeof tlsInfo !== "undefined") {
+ try {
+ let isValid;
+ if (tlsInfo.valid === true) {
+ isValid = 1;
+ } else {
+ isValid = 0;
+ }
+ monitorCertIsValid.set(this.monitorLabelValues, isValid);
+ } catch (e) {
+ log.error("prometheus", "Caught error");
+ log.error("prometheus", e);
+ }
+
+ try {
+ if (tlsInfo.certInfo != null) {
+ monitorCertDaysRemaining.set(this.monitorLabelValues, tlsInfo.certInfo.daysRemaining);
+ }
+ } catch (e) {
+ log.error("prometheus", "Caught error");
+ log.error("prometheus", e);
+ }
+ }
+
+ if (heartbeat) {
+ try {
+ monitorStatus.set(this.monitorLabelValues, heartbeat.status);
+ } catch (e) {
+ log.error("prometheus", "Caught error");
+ log.error("prometheus", e);
+ }
+
+ try {
+ if (typeof heartbeat.ping === "number") {
+ monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping);
+ } else {
+ // Is it good?
+ monitorResponseTime.set(this.monitorLabelValues, -1);
+ }
+ } catch (e) {
+ log.error("prometheus", "Caught error");
+ log.error("prometheus", e);
+ }
+ }
+ }
+
+ /**
+ * Remove monitor from prometheus
+ * @returns {void}
+ */
+ remove() {
+ try {
+ monitorCertDaysRemaining.remove(this.monitorLabelValues);
+ monitorCertIsValid.remove(this.monitorLabelValues);
+ monitorResponseTime.remove(this.monitorLabelValues);
+ monitorStatus.remove(this.monitorLabelValues);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}
+
+module.exports = {
+ Prometheus
+};
diff --git a/server/proxy.js b/server/proxy.js
new file mode 100644
index 0000000..3f3771a
--- /dev/null
+++ b/server/proxy.js
@@ -0,0 +1,202 @@
+const { R } = require("redbean-node");
+const HttpProxyAgent = require("http-proxy-agent");
+const HttpsProxyAgent = require("https-proxy-agent");
+const SocksProxyAgent = require("socks-proxy-agent");
+const { debug } = require("../src/util");
+const { UptimeKumaServer } = require("./uptime-kuma-server");
+const { CookieJar } = require("tough-cookie");
+const { createCookieAgent } = require("http-cookie-agent/http");
+
+class Proxy {
+
+ static SUPPORTED_PROXY_PROTOCOLS = [ "http", "https", "socks", "socks5", "socks5h", "socks4" ];
+
+ /**
+ * Saves and updates given proxy entity
+ * @param {object} proxy Proxy to store
+ * @param {number} proxyID ID of proxy to update
+ * @param {number} userID ID of user the proxy belongs to
+ * @returns {Promise<Bean>} Updated proxy
+ */
+ static async save(proxy, proxyID, userID) {
+ let bean;
+
+ if (proxyID) {
+ bean = await R.findOne("proxy", " id = ? AND user_id = ? ", [ proxyID, userID ]);
+
+ if (!bean) {
+ throw new Error("proxy not found");
+ }
+
+ } else {
+ bean = R.dispense("proxy");
+ }
+
+ // Make sure given proxy protocol is supported
+ if (!this.SUPPORTED_PROXY_PROTOCOLS.includes(proxy.protocol)) {
+ throw new Error(`
+ Unsupported proxy protocol "${proxy.protocol}.
+ Supported protocols are ${this.SUPPORTED_PROXY_PROTOCOLS.join(", ")}."`
+ );
+ }
+
+ // When proxy is default update deactivate old default proxy
+ if (proxy.default) {
+ await R.exec("UPDATE proxy SET `default` = 0 WHERE `default` = 1");
+ }
+
+ bean.user_id = userID;
+ bean.protocol = proxy.protocol;
+ bean.host = proxy.host;
+ bean.port = proxy.port;
+ bean.auth = proxy.auth;
+ bean.username = proxy.username;
+ bean.password = proxy.password;
+ bean.active = proxy.active || true;
+ bean.default = proxy.default || false;
+
+ await R.store(bean);
+
+ if (proxy.applyExisting) {
+ await applyProxyEveryMonitor(bean.id, userID);
+ }
+
+ return bean;
+ }
+
+ /**
+ * Deletes proxy with given id and removes it from monitors
+ * @param {number} proxyID ID of proxy to delete
+ * @param {number} userID ID of proxy owner
+ * @returns {Promise<void>}
+ */
+ static async delete(proxyID, userID) {
+ const bean = await R.findOne("proxy", " id = ? AND user_id = ? ", [ proxyID, userID ]);
+
+ if (!bean) {
+ throw new Error("proxy not found");
+ }
+
+ // Delete removed proxy from monitors if exists
+ await R.exec("UPDATE monitor SET proxy_id = null WHERE proxy_id = ?", [ proxyID ]);
+
+ // Delete proxy from list
+ await R.trash(bean);
+ }
+
+ /**
+ * Create HTTP and HTTPS agents related with given proxy bean object
+ * @param {object} proxy proxy bean object
+ * @param {object} options http and https agent options
+ * @returns {{httpAgent: Agent, httpsAgent: Agent}} New HTTP and HTTPS agents
+ * @throws Proxy protocol is unsupported
+ */
+ static createAgents(proxy, options) {
+ const { httpAgentOptions, httpsAgentOptions } = options || {};
+ let agent;
+ let httpAgent;
+ let httpsAgent;
+
+ let jar = new CookieJar();
+
+ const proxyOptions = {
+ protocol: proxy.protocol,
+ host: proxy.host,
+ port: proxy.port,
+ cookies: { jar },
+ };
+
+ if (proxy.auth) {
+ proxyOptions.auth = `${proxy.username}:${proxy.password}`;
+ }
+
+ debug(`Proxy Options: ${JSON.stringify(proxyOptions)}`);
+ debug(`HTTP Agent Options: ${JSON.stringify(httpAgentOptions)}`);
+ debug(`HTTPS Agent Options: ${JSON.stringify(httpsAgentOptions)}`);
+
+ switch (proxy.protocol) {
+ case "http":
+ case "https":
+ // eslint-disable-next-line no-case-declarations
+ const HttpCookieProxyAgent = createCookieAgent(HttpProxyAgent);
+ // eslint-disable-next-line no-case-declarations
+ const HttpsCookieProxyAgent = createCookieAgent(HttpsProxyAgent);
+
+ httpAgent = new HttpCookieProxyAgent({
+ ...httpAgentOptions || {},
+ ...proxyOptions,
+ });
+
+ httpsAgent = new HttpsCookieProxyAgent({
+ ...httpsAgentOptions || {},
+ ...proxyOptions,
+ });
+ break;
+ case "socks":
+ case "socks5":
+ case "socks5h":
+ case "socks4":
+ // eslint-disable-next-line no-case-declarations
+ const SocksCookieProxyAgent = createCookieAgent(SocksProxyAgent);
+ agent = new SocksCookieProxyAgent({
+ ...httpAgentOptions,
+ ...httpsAgentOptions,
+ ...proxyOptions,
+ tls: {
+ rejectUnauthorized: httpsAgentOptions.rejectUnauthorized,
+ },
+ });
+
+ httpAgent = agent;
+ httpsAgent = agent;
+ break;
+
+ default: throw new Error(`Unsupported proxy protocol provided. ${proxy.protocol}`);
+ }
+
+ return {
+ httpAgent,
+ httpsAgent
+ };
+ }
+
+ /**
+ * Reload proxy settings for current monitors
+ * @returns {Promise<void>}
+ */
+ static async reloadProxy() {
+ const server = UptimeKumaServer.getInstance();
+
+ let updatedList = await R.getAssoc("SELECT id, proxy_id FROM monitor");
+
+ for (let monitorID in server.monitorList) {
+ let monitor = server.monitorList[monitorID];
+
+ if (updatedList[monitorID]) {
+ monitor.proxy_id = updatedList[monitorID].proxy_id;
+ }
+ }
+ }
+}
+
+/**
+ * Applies given proxy id to monitors
+ * @param {number} proxyID ID of proxy to apply
+ * @param {number} userID ID of proxy owner
+ * @returns {Promise<void>}
+ */
+async function applyProxyEveryMonitor(proxyID, userID) {
+ // Find all monitors with id and proxy id
+ const monitors = await R.getAll("SELECT id, proxy_id FROM monitor WHERE user_id = ?", [ userID ]);
+
+ // Update proxy id not match with given proxy id
+ for (const monitor of monitors) {
+ if (monitor.proxy_id !== proxyID) {
+ await R.exec("UPDATE monitor SET proxy_id = ? WHERE id = ?", [ proxyID, monitor.id ]);
+ }
+ }
+}
+
+module.exports = {
+ Proxy,
+};
diff --git a/server/rate-limiter.js b/server/rate-limiter.js
new file mode 100644
index 0000000..3c269b6
--- /dev/null
+++ b/server/rate-limiter.js
@@ -0,0 +1,75 @@
+const { RateLimiter } = require("limiter");
+const { log } = require("../src/util");
+
+class KumaRateLimiter {
+ /**
+ * @param {object} config Rate limiter configuration object
+ */
+ constructor(config) {
+ this.errorMessage = config.errorMessage;
+ this.rateLimiter = new RateLimiter(config);
+ }
+
+ /**
+ * Callback for pass
+ * @callback passCB
+ * @param {object} err Too many requests
+ */
+
+ /**
+ * Should the request be passed through
+ * @param {passCB} callback Callback function to call with decision
+ * @param {number} num Number of tokens to remove
+ * @returns {Promise<boolean>} Should the request be allowed?
+ */
+ async pass(callback, num = 1) {
+ const remainingRequests = await this.removeTokens(num);
+ log.info("rate-limit", "remaining requests: " + remainingRequests);
+ if (remainingRequests < 0) {
+ if (callback) {
+ callback({
+ ok: false,
+ msg: this.errorMessage,
+ });
+ }
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Remove a given number of tokens
+ * @param {number} num Number of tokens to remove
+ * @returns {Promise<number>} Number of remaining tokens
+ */
+ async removeTokens(num = 1) {
+ return await this.rateLimiter.removeTokens(num);
+ }
+}
+
+const loginRateLimiter = new KumaRateLimiter({
+ tokensPerInterval: 20,
+ interval: "minute",
+ fireImmediately: true,
+ errorMessage: "Too frequently, try again later."
+});
+
+const apiRateLimiter = new KumaRateLimiter({
+ tokensPerInterval: 60,
+ interval: "minute",
+ fireImmediately: true,
+ errorMessage: "Too frequently, try again later."
+});
+
+const twoFaRateLimiter = new KumaRateLimiter({
+ tokensPerInterval: 30,
+ interval: "minute",
+ fireImmediately: true,
+ errorMessage: "Too frequently, try again later."
+});
+
+module.exports = {
+ loginRateLimiter,
+ apiRateLimiter,
+ twoFaRateLimiter,
+};
diff --git a/server/remote-browser.js b/server/remote-browser.js
new file mode 100644
index 0000000..da8e9a5
--- /dev/null
+++ b/server/remote-browser.js
@@ -0,0 +1,74 @@
+const { R } = require("redbean-node");
+
+class RemoteBrowser {
+
+ /**
+ * Gets remote browser from ID
+ * @param {number} remoteBrowserID ID of the remote browser
+ * @param {number} userID ID of the user who created the remote browser
+ * @returns {Promise<Bean>} Remote Browser
+ */
+ static async get(remoteBrowserID, userID) {
+ let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
+
+ if (!bean) {
+ throw new Error("Remote browser not found");
+ }
+
+ return bean;
+ }
+
+ /**
+ * Save a Remote Browser
+ * @param {object} remoteBrowser Remote Browser to save
+ * @param {?number} remoteBrowserID ID of the Remote Browser to update
+ * @param {number} userID ID of the user who adds the Remote Browser
+ * @returns {Promise<Bean>} Updated Remote Browser
+ */
+ static async save(remoteBrowser, remoteBrowserID, userID) {
+ let bean;
+
+ if (remoteBrowserID) {
+ bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
+
+ if (!bean) {
+ throw new Error("Remote browser not found");
+ }
+
+ } else {
+ bean = R.dispense("remote_browser");
+ }
+
+ bean.user_id = userID;
+ bean.name = remoteBrowser.name;
+ bean.url = remoteBrowser.url;
+
+ await R.store(bean);
+
+ return bean;
+ }
+
+ /**
+ * Delete a Remote Browser
+ * @param {number} remoteBrowserID ID of the Remote Browser to delete
+ * @param {number} userID ID of the user who created the Remote Browser
+ * @returns {Promise<void>}
+ */
+ static async delete(remoteBrowserID, userID) {
+ let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]);
+
+ if (!bean) {
+ throw new Error("Remote Browser not found");
+ }
+
+ // Delete removed remote browser from monitors if exists
+ await R.exec("UPDATE monitor SET remote_browser = null WHERE remote_browser = ?", [ remoteBrowserID ]);
+
+ await R.trash(bean);
+ }
+
+}
+
+module.exports = {
+ RemoteBrowser,
+};
diff --git a/server/routers/api-router.js b/server/routers/api-router.js
new file mode 100644
index 0000000..ed6db2c
--- /dev/null
+++ b/server/routers/api-router.js
@@ -0,0 +1,631 @@
+let express = require("express");
+const {
+ setting,
+ allowDevAllOrigin,
+ allowAllOrigin,
+ percentageToColor,
+ filterAndJoin,
+ sendHttpError,
+} = require("../util-server");
+const { R } = require("redbean-node");
+const apicache = require("../modules/apicache");
+const Monitor = require("../model/monitor");
+const dayjs = require("dayjs");
+const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log, badgeConstants } = require("../../src/util");
+const StatusPage = require("../model/status_page");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const { makeBadge } = require("badge-maker");
+const { Prometheus } = require("../prometheus");
+const Database = require("../database");
+const { UptimeCalculator } = require("../uptime-calculator");
+
+let router = express.Router();
+
+let cache = apicache.middleware;
+const server = UptimeKumaServer.getInstance();
+let io = server.io;
+
+router.get("/api/entry-page", async (request, response) => {
+ allowDevAllOrigin(response);
+
+ let result = { };
+ let hostname = request.hostname;
+ if ((await setting("trustProxy")) && request.headers["x-forwarded-host"]) {
+ hostname = request.headers["x-forwarded-host"];
+ }
+
+ if (hostname in StatusPage.domainMappingList) {
+ result.type = "statusPageMatchedDomain";
+ result.statusPageSlug = StatusPage.domainMappingList[hostname];
+ } else {
+ result.type = "entryPage";
+ result.entryPage = server.entryPage;
+ }
+ response.json(result);
+});
+
+router.all("/api/push/:pushToken", async (request, response) => {
+ try {
+ let pushToken = request.params.pushToken;
+ let msg = request.query.msg || "OK";
+ let ping = parseFloat(request.query.ping) || null;
+ let statusString = request.query.status || "up";
+ let status = (statusString === "up") ? UP : DOWN;
+
+ let monitor = await R.findOne("monitor", " push_token = ? AND active = 1 ", [
+ pushToken
+ ]);
+
+ if (! monitor) {
+ throw new Error("Monitor not found or not active.");
+ }
+
+ const previousHeartbeat = await Monitor.getPreviousHeartbeat(monitor.id);
+
+ let isFirstBeat = true;
+
+ let bean = R.dispense("heartbeat");
+ bean.time = R.isoDateTimeMillis(dayjs.utc());
+ bean.monitor_id = monitor.id;
+ bean.ping = ping;
+ bean.msg = msg;
+ bean.downCount = previousHeartbeat?.downCount || 0;
+
+ if (previousHeartbeat) {
+ isFirstBeat = false;
+ bean.duration = dayjs(bean.time).diff(dayjs(previousHeartbeat.time), "second");
+ }
+
+ if (await Monitor.isUnderMaintenance(monitor.id)) {
+ msg = "Monitor under maintenance";
+ bean.status = MAINTENANCE;
+ } else {
+ determineStatus(status, previousHeartbeat, monitor.maxretries, monitor.isUpsideDown(), bean);
+ }
+
+ // Calculate uptime
+ let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitor.id);
+ let endTimeDayjs = await uptimeCalculator.update(bean.status, parseFloat(bean.ping));
+ bean.end_time = R.isoDateTimeMillis(endTimeDayjs);
+
+ log.debug("router", `/api/push/ called at ${dayjs().format("YYYY-MM-DD HH:mm:ss.SSS")}`);
+ log.debug("router", "PreviousStatus: " + previousHeartbeat?.status);
+ log.debug("router", "Current Status: " + bean.status);
+
+ bean.important = Monitor.isImportantBeat(isFirstBeat, previousHeartbeat?.status, status);
+
+ if (Monitor.isImportantForNotification(isFirstBeat, previousHeartbeat?.status, status)) {
+ // Reset down count
+ bean.downCount = 0;
+
+ log.debug("monitor", `[${this.name}] sendNotification`);
+ await Monitor.sendNotification(isFirstBeat, monitor, bean);
+ } else {
+ if (bean.status === DOWN && this.resendInterval > 0) {
+ ++bean.downCount;
+ if (bean.downCount >= this.resendInterval) {
+ // Send notification again, because we are still DOWN
+ log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`);
+ await Monitor.sendNotification(isFirstBeat, this, bean);
+
+ // Reset down count
+ bean.downCount = 0;
+ }
+ }
+ }
+
+ await R.store(bean);
+
+ io.to(monitor.user_id).emit("heartbeat", bean.toJSON());
+
+ Monitor.sendStats(io, monitor.id, monitor.user_id);
+ new Prometheus(monitor).update(bean, undefined);
+
+ response.json({
+ ok: true,
+ });
+ } catch (e) {
+ response.status(404).json({
+ ok: false,
+ msg: e.message
+ });
+ }
+});
+
+router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response) => {
+ allowAllOrigin(response);
+
+ const {
+ label,
+ upLabel = "Up",
+ downLabel = "Down",
+ pendingLabel = "Pending",
+ maintenanceLabel = "Maintenance",
+ upColor = badgeConstants.defaultUpColor,
+ downColor = badgeConstants.defaultDownColor,
+ pendingColor = badgeConstants.defaultPendingColor,
+ maintenanceColor = badgeConstants.defaultMaintenanceColor,
+ style = badgeConstants.defaultStyle,
+ value, // for demo purpose only
+ } = request.query;
+
+ try {
+ const requestedMonitorId = parseInt(request.params.id, 10);
+ const overrideValue = value !== undefined ? parseInt(value) : undefined;
+
+ let publicMonitor = await R.getRow(`
+ SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
+ WHERE monitor_group.group_id = \`group\`.id
+ AND monitor_group.monitor_id = ?
+ AND public = 1
+ `,
+ [ requestedMonitorId ]
+ );
+
+ const badgeValues = { style };
+
+ if (!publicMonitor) {
+ // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant
+
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const heartbeat = await Monitor.getPreviousHeartbeat(requestedMonitorId);
+ const state = overrideValue !== undefined ? overrideValue : heartbeat.status;
+
+ if (label === undefined) {
+ badgeValues.label = "Status";
+ } else {
+ badgeValues.label = label;
+ }
+ switch (state) {
+ case DOWN:
+ badgeValues.color = downColor;
+ badgeValues.message = downLabel;
+ break;
+ case UP:
+ badgeValues.color = upColor;
+ badgeValues.message = upLabel;
+ break;
+ case PENDING:
+ badgeValues.color = pendingColor;
+ badgeValues.message = pendingLabel;
+ break;
+ case MAINTENANCE:
+ badgeValues.color = maintenanceColor;
+ badgeValues.message = maintenanceLabel;
+ break;
+ default:
+ badgeValues.color = badgeConstants.naColor;
+ badgeValues.message = "N/A";
+ }
+ }
+
+ // build the svg based on given values
+ const svg = makeBadge(badgeValues);
+
+ response.type("image/svg+xml");
+ response.send(svg);
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (request, response) => {
+ allowAllOrigin(response);
+
+ const {
+ label,
+ labelPrefix,
+ labelSuffix = badgeConstants.defaultUptimeLabelSuffix,
+ prefix,
+ suffix = badgeConstants.defaultUptimeValueSuffix,
+ color,
+ labelColor,
+ style = badgeConstants.defaultStyle,
+ value, // for demo purpose only
+ } = request.query;
+
+ try {
+ const requestedMonitorId = parseInt(request.params.id, 10);
+ // if no duration is given, set value to 24 (h)
+ let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h";
+ const overrideValue = value && parseFloat(value);
+
+ if (/^[0-9]+$/.test(requestedDuration)) {
+ requestedDuration = `${requestedDuration}h`;
+ }
+
+ let publicMonitor = await R.getRow(`
+ SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
+ WHERE monitor_group.group_id = \`group\`.id
+ AND monitor_group.monitor_id = ?
+ AND public = 1
+ `,
+ [ requestedMonitorId ]
+ );
+
+ const badgeValues = { style };
+
+ if (!publicMonitor) {
+ // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId);
+ const uptime = overrideValue ?? uptimeCalculator.getDataByDuration(requestedDuration).uptime;
+
+ // limit the displayed uptime percentage to four (two, when displayed as percent) decimal digits
+ const cleanUptime = (uptime * 100).toPrecision(4);
+
+ // use a given, custom color or calculate one based on the uptime value
+ badgeValues.color = color ?? percentageToColor(uptime);
+ // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
+ badgeValues.labelColor = labelColor ?? "";
+ // build a label string. If a custom label is given, override the default one (requestedDuration)
+ badgeValues.label = filterAndJoin([
+ labelPrefix,
+ label ?? `Uptime (${requestedDuration.slice(0, -1)}${labelSuffix})`,
+ ]);
+ badgeValues.message = filterAndJoin([ prefix, cleanUptime, suffix ]);
+ }
+
+ // build the SVG based on given values
+ const svg = makeBadge(badgeValues);
+
+ response.type("image/svg+xml");
+ response.send(svg);
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request, response) => {
+ allowAllOrigin(response);
+
+ const {
+ label,
+ labelPrefix,
+ labelSuffix = badgeConstants.defaultPingLabelSuffix,
+ prefix,
+ suffix = badgeConstants.defaultPingValueSuffix,
+ color = badgeConstants.defaultPingColor,
+ labelColor,
+ style = badgeConstants.defaultStyle,
+ value, // for demo purpose only
+ } = request.query;
+
+ try {
+ const requestedMonitorId = parseInt(request.params.id, 10);
+
+ // Default duration is 24 (h) if not defined in queryParam, limited to 720h (30d)
+ let requestedDuration = request.params.duration !== undefined ? request.params.duration : "24h";
+ const overrideValue = value && parseFloat(value);
+
+ if (/^[0-9]+$/.test(requestedDuration)) {
+ requestedDuration = `${requestedDuration}h`;
+ }
+
+ // Check if monitor is public
+
+ const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(requestedMonitorId);
+ const publicAvgPing = uptimeCalculator.getDataByDuration(requestedDuration).avgPing;
+
+ const badgeValues = { style };
+
+ if (!publicAvgPing) {
+ // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant
+
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const avgPing = parseInt(overrideValue ?? publicAvgPing);
+
+ badgeValues.color = color;
+ // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
+ badgeValues.labelColor = labelColor ?? "";
+ // build a lable string. If a custom label is given, override the default one (requestedDuration)
+ badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration.slice(0, -1)}${labelSuffix})` ]);
+ badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]);
+ }
+
+ // build the SVG based on given values
+ const svg = makeBadge(badgeValues);
+
+ response.type("image/svg+xml");
+ response.send(svg);
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+router.get("/api/badge/:id/avg-response/:duration?", cache("5 minutes"), async (request, response) => {
+ allowAllOrigin(response);
+
+ const {
+ label,
+ labelPrefix,
+ labelSuffix,
+ prefix,
+ suffix = badgeConstants.defaultPingValueSuffix,
+ color = badgeConstants.defaultPingColor,
+ labelColor,
+ style = badgeConstants.defaultStyle,
+ value, // for demo purpose only
+ } = request.query;
+
+ try {
+ const requestedMonitorId = parseInt(request.params.id, 10);
+
+ // Default duration is 24 (h) if not defined in queryParam, limited to 720h (30d)
+ const requestedDuration = Math.min(
+ request.params.duration
+ ? parseInt(request.params.duration, 10)
+ : 24,
+ 720
+ );
+ const overrideValue = value && parseFloat(value);
+
+ const sqlHourOffset = Database.sqlHourOffset();
+
+ const publicAvgPing = parseInt(await R.getCell(`
+ SELECT AVG(ping) FROM monitor_group, \`group\`, heartbeat
+ WHERE monitor_group.group_id = \`group\`.id
+ AND heartbeat.time > ${sqlHourOffset}
+ AND heartbeat.ping IS NOT NULL
+ AND public = 1
+ AND heartbeat.monitor_id = ?
+ `,
+ [ -requestedDuration, requestedMonitorId ]
+ ));
+
+ const badgeValues = { style };
+
+ if (!publicAvgPing) {
+ // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
+
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const avgPing = parseInt(overrideValue ?? publicAvgPing);
+
+ badgeValues.color = color;
+ // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
+ badgeValues.labelColor = labelColor ?? "";
+ // build a label string. If a custom label is given, override the default one (requestedDuration)
+ badgeValues.label = filterAndJoin([
+ labelPrefix,
+ label ?? `Avg. Response (${requestedDuration}h)`,
+ labelSuffix,
+ ]);
+ badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]);
+ }
+
+ // build the SVG based on given values
+ const svg = makeBadge(badgeValues);
+
+ response.type("image/svg+xml");
+ response.send(svg);
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+router.get("/api/badge/:id/cert-exp", cache("5 minutes"), async (request, response) => {
+ allowAllOrigin(response);
+
+ const date = request.query.date;
+
+ const {
+ label,
+ labelPrefix,
+ labelSuffix,
+ prefix,
+ suffix = date ? "" : badgeConstants.defaultCertExpValueSuffix,
+ upColor = badgeConstants.defaultUpColor,
+ warnColor = badgeConstants.defaultWarnColor,
+ downColor = badgeConstants.defaultDownColor,
+ warnDays = badgeConstants.defaultCertExpireWarnDays,
+ downDays = badgeConstants.defaultCertExpireDownDays,
+ labelColor,
+ style = badgeConstants.defaultStyle,
+ value, // for demo purpose only
+ } = request.query;
+
+ try {
+ const requestedMonitorId = parseInt(request.params.id, 10);
+
+ const overrideValue = value && parseFloat(value);
+
+ let publicMonitor = await R.getRow(`
+ SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
+ WHERE monitor_group.group_id = \`group\`.id
+ AND monitor_group.monitor_id = ?
+ AND public = 1
+ `,
+ [ requestedMonitorId ]
+ );
+
+ const badgeValues = { style };
+
+ if (!publicMonitor) {
+ // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
+
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [
+ requestedMonitorId,
+ ]);
+
+ if (!tlsInfoBean) {
+ // return a "No/Bad Cert" badge in naColor (grey), if no cert saved (does not save bad certs?)
+ badgeValues.message = "No/Bad Cert";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const tlsInfo = JSON.parse(tlsInfoBean.info_json);
+
+ if (!tlsInfo.valid) {
+ // return a "Bad Cert" badge in naColor (grey), when cert is not valid
+ badgeValues.message = "Bad Cert";
+ badgeValues.color = downColor;
+ } else {
+ const daysRemaining = parseInt(overrideValue ?? tlsInfo.certInfo.daysRemaining);
+
+ if (daysRemaining > warnDays) {
+ badgeValues.color = upColor;
+ } else if (daysRemaining > downDays) {
+ badgeValues.color = warnColor;
+ } else {
+ badgeValues.color = downColor;
+ }
+ // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
+ badgeValues.labelColor = labelColor ?? "";
+ // build a label string. If a custom label is given, override the default one
+ badgeValues.label = filterAndJoin([
+ labelPrefix,
+ label ?? "Cert Exp.",
+ labelSuffix,
+ ]);
+ badgeValues.message = filterAndJoin([ prefix, date ? tlsInfo.certInfo.validTo : daysRemaining, suffix ]);
+ }
+ }
+ }
+
+ // build the SVG based on given values
+ const svg = makeBadge(badgeValues);
+
+ response.type("image/svg+xml");
+ response.send(svg);
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+router.get("/api/badge/:id/response", cache("5 minutes"), async (request, response) => {
+ allowAllOrigin(response);
+
+ const {
+ label,
+ labelPrefix,
+ labelSuffix,
+ prefix,
+ suffix = badgeConstants.defaultPingValueSuffix,
+ color = badgeConstants.defaultPingColor,
+ labelColor,
+ style = badgeConstants.defaultStyle,
+ value, // for demo purpose only
+ } = request.query;
+
+ try {
+ const requestedMonitorId = parseInt(request.params.id, 10);
+
+ const overrideValue = value && parseFloat(value);
+
+ let publicMonitor = await R.getRow(`
+ SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
+ WHERE monitor_group.group_id = \`group\`.id
+ AND monitor_group.monitor_id = ?
+ AND public = 1
+ `,
+ [ requestedMonitorId ]
+ );
+
+ const badgeValues = { style };
+
+ if (!publicMonitor) {
+ // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
+
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const heartbeat = await Monitor.getPreviousHeartbeat(
+ requestedMonitorId
+ );
+
+ if (!heartbeat.ping) {
+ // return a "N/A" badge in naColor (grey), if previous heartbeat has no ping
+
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+ } else {
+ const ping = parseInt(overrideValue ?? heartbeat.ping);
+
+ badgeValues.color = color;
+ // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
+ badgeValues.labelColor = labelColor ?? "";
+ // build a label string. If a custom label is given, override the default one
+ badgeValues.label = filterAndJoin([
+ labelPrefix,
+ label ?? "Response",
+ labelSuffix,
+ ]);
+ badgeValues.message = filterAndJoin([ prefix, ping, suffix ]);
+ }
+ }
+
+ // build the SVG based on given values
+ const svg = makeBadge(badgeValues);
+
+ response.type("image/svg+xml");
+ response.send(svg);
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+/**
+ * Determines the status of the next beat in the push route handling.
+ * @param {string} status - The reported new status.
+ * @param {object} previousHeartbeat - The previous heartbeat object.
+ * @param {number} maxretries - The maximum number of retries allowed.
+ * @param {boolean} isUpsideDown - Indicates if the monitor is upside down.
+ * @param {object} bean - The new heartbeat object.
+ * @returns {void}
+ */
+function determineStatus(status, previousHeartbeat, maxretries, isUpsideDown, bean) {
+ if (isUpsideDown) {
+ status = flipStatus(status);
+ }
+
+ if (previousHeartbeat) {
+ if (previousHeartbeat.status === UP && status === DOWN) {
+ // Going Down
+ if ((maxretries > 0) && (previousHeartbeat.retries < maxretries)) {
+ // Retries available
+ bean.retries = previousHeartbeat.retries + 1;
+ bean.status = PENDING;
+ } else {
+ // No more retries
+ bean.retries = 0;
+ bean.status = DOWN;
+ }
+ } else if (previousHeartbeat.status === PENDING && status === DOWN && previousHeartbeat.retries < maxretries) {
+ // Retries available
+ bean.retries = previousHeartbeat.retries + 1;
+ bean.status = PENDING;
+ } else {
+ // No more retries or not pending
+ if (status === DOWN) {
+ bean.retries = previousHeartbeat.retries + 1;
+ bean.status = status;
+ } else {
+ bean.retries = 0;
+ bean.status = status;
+ }
+ }
+ } else {
+ // First beat?
+ if (status === DOWN && maxretries > 0) {
+ // Retries available
+ bean.retries = 1;
+ bean.status = PENDING;
+ } else {
+ // Retires not enabled
+ bean.retries = 0;
+ bean.status = status;
+ }
+ }
+}
+
+module.exports = router;
diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js
new file mode 100644
index 0000000..b209d33
--- /dev/null
+++ b/server/routers/status-page-router.js
@@ -0,0 +1,241 @@
+let express = require("express");
+const apicache = require("../modules/apicache");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const StatusPage = require("../model/status_page");
+const { allowDevAllOrigin, sendHttpError } = require("../util-server");
+const { R } = require("redbean-node");
+const { badgeConstants } = require("../../src/util");
+const { makeBadge } = require("badge-maker");
+const { UptimeCalculator } = require("../uptime-calculator");
+
+let router = express.Router();
+
+let cache = apicache.middleware;
+const server = UptimeKumaServer.getInstance();
+
+router.get("/status/:slug", cache("5 minutes"), async (request, response) => {
+ let slug = request.params.slug;
+ await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
+});
+
+router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => {
+ let slug = request.params.slug;
+ await StatusPage.handleStatusPageRSSResponse(response, slug);
+});
+
+router.get("/status", cache("5 minutes"), async (request, response) => {
+ let slug = "default";
+ await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
+});
+
+router.get("/status-page", cache("5 minutes"), async (request, response) => {
+ let slug = "default";
+ await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
+});
+
+// Status page config, incident, monitor list
+router.get("/api/status-page/:slug", cache("5 minutes"), async (request, response) => {
+ allowDevAllOrigin(response);
+ let slug = request.params.slug;
+
+ try {
+ // Get Status Page
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (!statusPage) {
+ sendHttpError(response, "Status Page Not Found");
+ return null;
+ }
+
+ let statusPageData = await StatusPage.getStatusPageData(statusPage);
+
+ // Response
+ response.json(statusPageData);
+
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+// Status Page Polling Data
+// Can fetch only if published
+router.get("/api/status-page/heartbeat/:slug", cache("1 minutes"), async (request, response) => {
+ allowDevAllOrigin(response);
+
+ try {
+ let heartbeatList = {};
+ let uptimeList = {};
+
+ let slug = request.params.slug;
+ let statusPageID = await StatusPage.slugToID(slug);
+
+ let monitorIDList = await R.getCol(`
+ SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
+ WHERE monitor_group.group_id = \`group\`.id
+ AND public = 1
+ AND \`group\`.status_page_id = ?
+ `, [
+ statusPageID
+ ]);
+
+ for (let monitorID of monitorIDList) {
+ let list = await R.getAll(`
+ SELECT * FROM heartbeat
+ WHERE monitor_id = ?
+ ORDER BY time DESC
+ LIMIT 50
+ `, [
+ monitorID,
+ ]);
+
+ list = R.convertToBeans("heartbeat", list);
+ heartbeatList[monitorID] = list.reverse().map(row => row.toPublicJSON());
+
+ const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
+ uptimeList[`${monitorID}_24`] = uptimeCalculator.get24Hour().uptime;
+ }
+
+ response.json({
+ heartbeatList,
+ uptimeList
+ });
+
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+// Status page's manifest.json
+router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async (request, response) => {
+ allowDevAllOrigin(response);
+ let slug = request.params.slug;
+
+ try {
+ // Get Status Page
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (!statusPage) {
+ sendHttpError(response, "Not Found");
+ return;
+ }
+
+ // Response
+ response.json({
+ "name": statusPage.title,
+ "start_url": "/status/" + statusPage.slug,
+ "display": "standalone",
+ "icons": [
+ {
+ "src": statusPage.icon,
+ "sizes": "128x128",
+ "type": "image/png"
+ }
+ ]
+ });
+
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+// overall status-page status badge
+router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
+ allowDevAllOrigin(response);
+ const slug = request.params.slug;
+ const statusPageID = await StatusPage.slugToID(slug);
+ const {
+ label,
+ upColor = badgeConstants.defaultUpColor,
+ downColor = badgeConstants.defaultDownColor,
+ partialColor = "#F6BE00",
+ maintenanceColor = "#808080",
+ style = badgeConstants.defaultStyle
+ } = request.query;
+
+ try {
+ let monitorIDList = await R.getCol(`
+ SELECT monitor_group.monitor_id FROM monitor_group, \`group\`
+ WHERE monitor_group.group_id = \`group\`.id
+ AND public = 1
+ AND \`group\`.status_page_id = ?
+ `, [
+ statusPageID
+ ]);
+
+ let hasUp = false;
+ let hasDown = false;
+ let hasMaintenance = false;
+
+ for (let monitorID of monitorIDList) {
+ // retrieve the latest heartbeat
+ let beat = await R.getAll(`
+ SELECT * FROM heartbeat
+ WHERE monitor_id = ?
+ ORDER BY time DESC
+ LIMIT 1
+ `, [
+ monitorID,
+ ]);
+
+ // to be sure, when corresponding monitor not found
+ if (beat.length === 0) {
+ continue;
+ }
+ // handle status of beat
+ if (beat[0].status === 3) {
+ hasMaintenance = true;
+ } else if (beat[0].status === 2) {
+ // ignored
+ } else if (beat[0].status === 1) {
+ hasUp = true;
+ } else {
+ hasDown = true;
+ }
+
+ }
+
+ const badgeValues = { style };
+
+ if (!hasUp && !hasDown && !hasMaintenance) {
+ // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant
+
+ badgeValues.message = "N/A";
+ badgeValues.color = badgeConstants.naColor;
+
+ } else {
+ if (hasMaintenance) {
+ badgeValues.label = label ? label : "";
+ badgeValues.color = maintenanceColor;
+ badgeValues.message = "Maintenance";
+ } else if (hasUp && !hasDown) {
+ badgeValues.label = label ? label : "";
+ badgeValues.color = upColor;
+ badgeValues.message = "Up";
+ } else if (hasUp && hasDown) {
+ badgeValues.label = label ? label : "";
+ badgeValues.color = partialColor;
+ badgeValues.message = "Degraded";
+ } else {
+ badgeValues.label = label ? label : "";
+ badgeValues.color = downColor;
+ badgeValues.message = "Down";
+ }
+
+ }
+
+ // build the svg based on given values
+ const svg = makeBadge(badgeValues);
+
+ response.type("image/svg+xml");
+ response.send(svg);
+
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
+module.exports = router;
diff --git a/server/server.js b/server/server.js
new file mode 100644
index 0000000..ec5ad49
--- /dev/null
+++ b/server/server.js
@@ -0,0 +1,1877 @@
+/*
+ * Uptime Kuma Server
+ * node "server/server.js"
+ * DO NOT require("./server") in other modules, it likely creates circular dependency!
+ */
+console.log("Welcome to Uptime Kuma");
+
+// As the log function need to use dayjs, it should be very top
+const dayjs = require("dayjs");
+dayjs.extend(require("dayjs/plugin/utc"));
+dayjs.extend(require("./modules/dayjs/plugin/timezone"));
+dayjs.extend(require("dayjs/plugin/customParseFormat"));
+
+// Load environment variables from `.env`
+require("dotenv").config();
+
+// Check Node.js Version
+const nodeVersion = process.versions.node;
+
+// Get the required Node.js version from package.json
+const requiredNodeVersions = require("../package.json").engines.node;
+const bannedNodeVersions = " < 18 || 20.0.* || 20.1.* || 20.2.* || 20.3.* ";
+console.log(`Your Node.js version: ${nodeVersion}`);
+
+const semver = require("semver");
+const requiredNodeVersionsComma = requiredNodeVersions.split("||").map((version) => version.trim()).join(", ");
+
+// Exit Uptime Kuma immediately if the Node.js version is banned
+if (semver.satisfies(nodeVersion, bannedNodeVersions)) {
+ console.error("\x1b[31m%s\x1b[0m", `Error: Your Node.js version: ${nodeVersion} is not supported, please upgrade your Node.js to ${requiredNodeVersionsComma}.`);
+ process.exit(-1);
+}
+
+// Warning if the Node.js version is not in the support list, but it maybe still works
+if (!semver.satisfies(nodeVersion, requiredNodeVersions)) {
+ console.warn("\x1b[31m%s\x1b[0m", `Warning: Your Node.js version: ${nodeVersion} is not officially supported, please upgrade your Node.js to ${requiredNodeVersionsComma}.`);
+}
+
+const args = require("args-parser")(process.argv);
+const { sleep, log, getRandomInt, genSecret, isDev } = require("../src/util");
+const config = require("./config");
+
+log.debug("server", "Arguments");
+log.debug("server", args);
+
+if (! process.env.NODE_ENV) {
+ process.env.NODE_ENV = "production";
+}
+
+if (!process.env.UPTIME_KUMA_WS_ORIGIN_CHECK) {
+ process.env.UPTIME_KUMA_WS_ORIGIN_CHECK = "cors-like";
+}
+
+log.info("server", "Env: " + process.env.NODE_ENV);
+log.debug("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1"));
+
+if (process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass") {
+ log.warn("server", "WebSocket Origin Check: " + process.env.UPTIME_KUMA_WS_ORIGIN_CHECK);
+}
+
+const checkVersion = require("./check-version");
+log.info("server", "Uptime Kuma Version: " + checkVersion.version);
+
+log.info("server", "Loading modules");
+
+log.debug("server", "Importing express");
+const express = require("express");
+const expressStaticGzip = require("express-static-gzip");
+log.debug("server", "Importing redbean-node");
+const { R } = require("redbean-node");
+log.debug("server", "Importing jsonwebtoken");
+const jwt = require("jsonwebtoken");
+log.debug("server", "Importing http-graceful-shutdown");
+const gracefulShutdown = require("http-graceful-shutdown");
+log.debug("server", "Importing prometheus-api-metrics");
+const prometheusAPIMetrics = require("prometheus-api-metrics");
+const { passwordStrength } = require("check-password-strength");
+
+log.debug("server", "Importing 2FA Modules");
+const notp = require("notp");
+const base32 = require("thirty-two");
+
+const { UptimeKumaServer } = require("./uptime-kuma-server");
+const server = UptimeKumaServer.getInstance();
+const io = module.exports.io = server.io;
+const app = server.app;
+
+log.debug("server", "Importing Monitor");
+const Monitor = require("./model/monitor");
+const User = require("./model/user");
+
+log.debug("server", "Importing Settings");
+const { getSettings, setSettings, setting, initJWTSecret, checkLogin, doubleCheckPassword, shake256, SHAKE256_LENGTH, allowDevAllOrigin,
+} = require("./util-server");
+
+log.debug("server", "Importing Notification");
+const { Notification } = require("./notification");
+Notification.init();
+
+log.debug("server", "Importing Database");
+const Database = require("./database");
+
+log.debug("server", "Importing Background Jobs");
+const { initBackgroundJobs, stopBackgroundJobs } = require("./jobs");
+const { loginRateLimiter, twoFaRateLimiter } = require("./rate-limiter");
+
+const { apiAuth } = require("./auth");
+const { login } = require("./auth");
+const passwordHash = require("./password-hash");
+
+const hostname = config.hostname;
+
+if (hostname) {
+ log.info("server", "Custom hostname: " + hostname);
+}
+
+const port = config.port;
+
+const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false;
+const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined;
+
+// 2FA / notp verification defaults
+const twoFAVerifyOptions = {
+ "window": 1,
+ "time": 30
+};
+
+/**
+ * Run unit test after the server is ready
+ * @type {boolean}
+ */
+const testMode = !!args["test"] || false;
+
+// Must be after io instantiation
+const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList, sendMonitorTypeList } = require("./client");
+const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
+const { databaseSocketHandler } = require("./socket-handlers/database-socket-handler");
+const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler");
+const TwoFA = require("./2fa");
+const StatusPage = require("./model/status_page");
+const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler");
+const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler");
+const { dockerSocketHandler } = require("./socket-handlers/docker-socket-handler");
+const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-socket-handler");
+const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handler");
+const { generalSocketHandler } = require("./socket-handlers/general-socket-handler");
+const { Settings } = require("./settings");
+const apicache = require("./modules/apicache");
+const { resetChrome } = require("./monitor-types/real-browser-monitor-type");
+const { EmbeddedMariaDB } = require("./embedded-mariadb");
+const { SetupDatabase } = require("./setup-database");
+const { chartSocketHandler } = require("./socket-handlers/chart-socket-handler");
+
+app.use(express.json());
+
+// Global Middleware
+app.use(function (req, res, next) {
+ if (!disableFrameSameOrigin) {
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
+ }
+ res.removeHeader("X-Powered-By");
+ next();
+});
+
+/**
+ * Show Setup Page
+ * @type {boolean}
+ */
+let needSetup = false;
+
+(async () => {
+ // Create a data directory
+ Database.initDataDir(args);
+
+ // Check if is chosen a database type
+ let setupDatabase = new SetupDatabase(args, server);
+ if (setupDatabase.isNeedSetup()) {
+ // Hold here and start a special setup page until user choose a database type
+ await setupDatabase.start(hostname, port);
+ }
+
+ // Connect to database
+ try {
+ await initDatabase(testMode);
+ } catch (e) {
+ log.error("server", "Failed to prepare your database: " + e.message);
+ process.exit(1);
+ }
+
+ // Database should be ready now
+ await server.initAfterDatabaseReady();
+ server.entryPage = await Settings.get("entryPage");
+ await StatusPage.loadDomainMappingList();
+
+ log.debug("server", "Adding route");
+
+ // ***************************
+ // Normal Router here
+ // ***************************
+
+ // Entry Page
+ app.get("/", async (request, response) => {
+ let hostname = request.hostname;
+ if (await setting("trustProxy")) {
+ const proxy = request.headers["x-forwarded-host"];
+ if (proxy) {
+ hostname = proxy;
+ }
+ }
+
+ log.debug("entry", `Request Domain: ${hostname}`);
+
+ const uptimeKumaEntryPage = server.entryPage;
+ if (hostname in StatusPage.domainMappingList) {
+ log.debug("entry", "This is a status page domain");
+
+ let slug = StatusPage.domainMappingList[hostname];
+ await StatusPage.handleStatusPageResponse(response, server.indexHTML, slug);
+
+ } else if (uptimeKumaEntryPage && uptimeKumaEntryPage.startsWith("statusPage-")) {
+ response.redirect("/status/" + uptimeKumaEntryPage.replace("statusPage-", ""));
+
+ } else {
+ response.redirect("/dashboard");
+ }
+ });
+
+ app.get("/setup-database-info", (request, response) => {
+ allowDevAllOrigin(response);
+ response.json({
+ runningSetup: false,
+ needSetup: false,
+ });
+ });
+
+ if (isDev) {
+ app.use(express.urlencoded({ extended: true }));
+ app.post("/test-webhook", async (request, response) => {
+ log.debug("test", request.headers);
+ log.debug("test", request.body);
+ response.send("OK");
+ });
+
+ app.post("/test-x-www-form-urlencoded", async (request, response) => {
+ log.debug("test", request.headers);
+ log.debug("test", request.body);
+ response.send("OK");
+ });
+
+ const fs = require("fs");
+
+ app.get("/_e2e/take-sqlite-snapshot", async (request, response) => {
+ await Database.close();
+ try {
+ fs.cpSync(Database.sqlitePath, `${Database.sqlitePath}.e2e-snapshot`);
+ } catch (err) {
+ throw new Error("Unable to copy SQLite DB.");
+ }
+ await Database.connect();
+
+ response.send("Snapshot taken.");
+ });
+
+ app.get("/_e2e/restore-sqlite-snapshot", async (request, response) => {
+ if (!fs.existsSync(`${Database.sqlitePath}.e2e-snapshot`)) {
+ throw new Error("Snapshot doesn't exist.");
+ }
+
+ await Database.close();
+ try {
+ fs.cpSync(`${Database.sqlitePath}.e2e-snapshot`, Database.sqlitePath);
+ } catch (err) {
+ throw new Error("Unable to copy snapshot file.");
+ }
+ await Database.connect();
+
+ response.send("Snapshot restored.");
+ });
+ }
+
+ // Robots.txt
+ app.get("/robots.txt", async (_request, response) => {
+ let txt = "User-agent: *\nDisallow:";
+ if (!await setting("searchEngineIndex")) {
+ txt += " /";
+ }
+ response.setHeader("Content-Type", "text/plain");
+ response.send(txt);
+ });
+
+ // Basic Auth Router here
+
+ // Prometheus API metrics /metrics
+ // With Basic Auth using the first user's username/password
+ app.get("/metrics", apiAuth, prometheusAPIMetrics());
+
+ app.use("/", expressStaticGzip("dist", {
+ enableBrotli: true,
+ }));
+
+ // ./data/upload
+ app.use("/upload", express.static(Database.uploadDir));
+
+ app.get("/.well-known/change-password", async (_, response) => {
+ response.redirect("https://github.com/louislam/uptime-kuma/wiki/Reset-Password-via-CLI");
+ });
+
+ // API Router
+ const apiRouter = require("./routers/api-router");
+ app.use(apiRouter);
+
+ // Status Page Router
+ const statusPageRouter = require("./routers/status-page-router");
+ app.use(statusPageRouter);
+
+ // Universal Route Handler, must be at the end of all express routes.
+ app.get("*", async (_request, response) => {
+ if (_request.originalUrl.startsWith("/upload/")) {
+ response.status(404).send("File not found.");
+ } else {
+ response.send(server.indexHTML);
+ }
+ });
+
+ log.debug("server", "Adding socket handler");
+ io.on("connection", async (socket) => {
+
+ await sendInfo(socket, true);
+
+ if (needSetup) {
+ log.info("server", "Redirect to setup page");
+ socket.emit("setup");
+ }
+
+ // ***************************
+ // Public Socket API
+ // ***************************
+
+ socket.on("loginByToken", async (token, callback) => {
+ const clientIP = await server.getClientIP(socket);
+
+ log.info("auth", `Login by token. IP=${clientIP}`);
+
+ try {
+ let decoded = jwt.verify(token, server.jwtSecret);
+
+ log.info("auth", "Username from JWT: " + decoded.username);
+
+ let user = await R.findOne("user", " username = ? AND active = 1 ", [
+ decoded.username,
+ ]);
+
+ if (user) {
+ // Check if the password changed
+ if (decoded.h !== shake256(user.password, SHAKE256_LENGTH)) {
+ throw new Error("The token is invalid due to password change or old token");
+ }
+
+ log.debug("auth", "afterLogin");
+ await afterLogin(socket, user);
+ log.debug("auth", "afterLogin ok");
+
+ log.info("auth", `Successfully logged in user ${decoded.username}. IP=${clientIP}`);
+
+ callback({
+ ok: true,
+ });
+ } else {
+
+ log.info("auth", `Inactive or deleted user ${decoded.username}. IP=${clientIP}`);
+
+ callback({
+ ok: false,
+ msg: "authUserInactiveOrDeleted",
+ msgi18n: true,
+ });
+ }
+ } catch (error) {
+ log.error("auth", `Invalid token. IP=${clientIP}`);
+ if (error.message) {
+ log.error("auth", error.message, `IP=${clientIP}`);
+ }
+ callback({
+ ok: false,
+ msg: "authInvalidToken",
+ msgi18n: true,
+ });
+ }
+
+ });
+
+ socket.on("login", async (data, callback) => {
+ const clientIP = await server.getClientIP(socket);
+
+ log.info("auth", `Login by username + password. IP=${clientIP}`);
+
+ // Checking
+ if (typeof callback !== "function") {
+ return;
+ }
+
+ if (!data) {
+ return;
+ }
+
+ // Login Rate Limit
+ if (!await loginRateLimiter.pass(callback)) {
+ log.info("auth", `Too many failed requests for user ${data.username}. IP=${clientIP}`);
+ return;
+ }
+
+ let user = await login(data.username, data.password);
+
+ if (user) {
+ if (user.twofa_status === 0) {
+ await afterLogin(socket, user);
+
+ log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
+
+ callback({
+ ok: true,
+ token: User.createJWT(user, server.jwtSecret),
+ });
+ }
+
+ if (user.twofa_status === 1 && !data.token) {
+
+ log.info("auth", `2FA token required for user ${data.username}. IP=${clientIP}`);
+
+ callback({
+ tokenRequired: true,
+ });
+ }
+
+ if (data.token) {
+ let verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);
+
+ if (user.twofa_last_token !== data.token && verify) {
+ await afterLogin(socket, user);
+
+ await R.exec("UPDATE `user` SET twofa_last_token = ? WHERE id = ? ", [
+ data.token,
+ socket.userID,
+ ]);
+
+ log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
+
+ callback({
+ ok: true,
+ token: User.createJWT(user, server.jwtSecret),
+ });
+ } else {
+
+ log.warn("auth", `Invalid token provided for user ${data.username}. IP=${clientIP}`);
+
+ callback({
+ ok: false,
+ msg: "authInvalidToken",
+ msgi18n: true,
+ });
+ }
+ }
+ } else {
+
+ log.warn("auth", `Incorrect username or password for user ${data.username}. IP=${clientIP}`);
+
+ callback({
+ ok: false,
+ msg: "authIncorrectCreds",
+ msgi18n: true,
+ });
+ }
+
+ });
+
+ socket.on("logout", async (callback) => {
+ // Rate Limit
+ if (!await loginRateLimiter.pass(callback)) {
+ return;
+ }
+
+ socket.leave(socket.userID);
+ socket.userID = null;
+
+ if (typeof callback === "function") {
+ callback();
+ }
+ });
+
+ socket.on("prepare2FA", async (currentPassword, callback) => {
+ try {
+ if (!await twoFaRateLimiter.pass(callback)) {
+ return;
+ }
+
+ checkLogin(socket);
+ await doubleCheckPassword(socket, currentPassword);
+
+ let user = await R.findOne("user", " id = ? AND active = 1 ", [
+ socket.userID,
+ ]);
+
+ if (user.twofa_status === 0) {
+ let newSecret = genSecret();
+ let encodedSecret = base32.encode(newSecret);
+
+ // Google authenticator doesn't like equal signs
+ // The fix is found at https://github.com/guyht/notp
+ // Related issue: https://github.com/louislam/uptime-kuma/issues/486
+ encodedSecret = encodedSecret.toString().replace(/=/g, "");
+
+ let uri = `otpauth://totp/Uptime%20Kuma:${user.username}?secret=${encodedSecret}`;
+
+ await R.exec("UPDATE `user` SET twofa_secret = ? WHERE id = ? ", [
+ newSecret,
+ socket.userID,
+ ]);
+
+ callback({
+ ok: true,
+ uri: uri,
+ });
+ } else {
+ callback({
+ ok: false,
+ msg: "2faAlreadyEnabled",
+ msgi18n: true,
+ });
+ }
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("save2FA", async (currentPassword, callback) => {
+ const clientIP = await server.getClientIP(socket);
+
+ try {
+ if (!await twoFaRateLimiter.pass(callback)) {
+ return;
+ }
+
+ checkLogin(socket);
+ await doubleCheckPassword(socket, currentPassword);
+
+ await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [
+ socket.userID,
+ ]);
+
+ log.info("auth", `Saved 2FA token. IP=${clientIP}`);
+
+ callback({
+ ok: true,
+ msg: "2faEnabled",
+ msgi18n: true,
+ });
+ } catch (error) {
+
+ log.error("auth", `Error changing 2FA token. IP=${clientIP}`);
+
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("disable2FA", async (currentPassword, callback) => {
+ const clientIP = await server.getClientIP(socket);
+
+ try {
+ if (!await twoFaRateLimiter.pass(callback)) {
+ return;
+ }
+
+ checkLogin(socket);
+ await doubleCheckPassword(socket, currentPassword);
+ await TwoFA.disable2FA(socket.userID);
+
+ log.info("auth", `Disabled 2FA token. IP=${clientIP}`);
+
+ callback({
+ ok: true,
+ msg: "2faDisabled",
+ msgi18n: true,
+ });
+ } catch (error) {
+
+ log.error("auth", `Error disabling 2FA token. IP=${clientIP}`);
+
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("verifyToken", async (token, currentPassword, callback) => {
+ try {
+ checkLogin(socket);
+ await doubleCheckPassword(socket, currentPassword);
+
+ let user = await R.findOne("user", " id = ? AND active = 1 ", [
+ socket.userID,
+ ]);
+
+ let verify = notp.totp.verify(token, user.twofa_secret, twoFAVerifyOptions);
+
+ if (user.twofa_last_token !== token && verify) {
+ callback({
+ ok: true,
+ valid: true,
+ });
+ } else {
+ callback({
+ ok: false,
+ msg: "authInvalidToken",
+ msgi18n: true,
+ valid: false,
+ });
+ }
+
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("twoFAStatus", async (callback) => {
+ try {
+ checkLogin(socket);
+
+ let user = await R.findOne("user", " id = ? AND active = 1 ", [
+ socket.userID,
+ ]);
+
+ if (user.twofa_status === 1) {
+ callback({
+ ok: true,
+ status: true,
+ });
+ } else {
+ callback({
+ ok: true,
+ status: false,
+ });
+ }
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("needSetup", async (callback) => {
+ callback(needSetup);
+ });
+
+ socket.on("setup", async (username, password, callback) => {
+ try {
+ if (passwordStrength(password).value === "Too weak") {
+ throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
+ }
+
+ if ((await R.knex("user").count("id as count").first()).count !== 0) {
+ throw new Error("Uptime Kuma has been initialized. If you want to run setup again, please delete the database.");
+ }
+
+ let user = R.dispense("user");
+ user.username = username;
+ user.password = passwordHash.generate(password);
+ await R.store(user);
+
+ needSetup = false;
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // ***************************
+ // Auth Only API
+ // ***************************
+
+ // Add a new monitor
+ socket.on("add", async (monitor, callback) => {
+ try {
+ checkLogin(socket);
+ let bean = R.dispense("monitor");
+
+ let notificationIDList = monitor.notificationIDList;
+ delete monitor.notificationIDList;
+
+ // Ensure status code ranges are strings
+ if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) {
+ throw new Error("Accepted status codes are not all strings");
+ }
+ monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
+ delete monitor.accepted_statuscodes;
+
+ monitor.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers);
+ monitor.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
+
+ monitor.conditions = JSON.stringify(monitor.conditions);
+
+ monitor.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
+
+ bean.import(monitor);
+ bean.user_id = socket.userID;
+
+ bean.validate();
+
+ await R.store(bean);
+
+ await updateMonitorNotification(bean.id, notificationIDList);
+
+ await server.sendUpdateMonitorIntoList(socket, bean.id);
+
+ if (monitor.active !== false) {
+ await startMonitor(socket.userID, bean.id);
+ }
+
+ log.info("monitor", `Added Monitor: ${bean.id} User ID: ${socket.userID}`);
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ monitorID: bean.id,
+ });
+
+ } catch (e) {
+
+ log.error("monitor", `Error adding Monitor: ${monitor.id} User ID: ${socket.userID}`);
+
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // Edit a monitor
+ socket.on("editMonitor", async (monitor, callback) => {
+ try {
+ let removeGroupChildren = false;
+ checkLogin(socket);
+
+ let bean = await R.findOne("monitor", " id = ? ", [ monitor.id ]);
+
+ if (bean.user_id !== socket.userID) {
+ throw new Error("Permission denied.");
+ }
+
+ // Check if Parent is Descendant (would cause endless loop)
+ if (monitor.parent !== null) {
+ const childIDs = await Monitor.getAllChildrenIDs(monitor.id);
+ if (childIDs.includes(monitor.parent)) {
+ throw new Error("Invalid Monitor Group");
+ }
+ }
+
+ // Remove children if monitor type has changed (from group to non-group)
+ if (bean.type === "group" && monitor.type !== bean.type) {
+ removeGroupChildren = true;
+ }
+
+ // Ensure status code ranges are strings
+ if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) {
+ throw new Error("Accepted status codes are not all strings");
+ }
+
+ bean.name = monitor.name;
+ bean.description = monitor.description;
+ bean.parent = monitor.parent;
+ bean.type = monitor.type;
+ bean.url = monitor.url;
+ bean.method = monitor.method;
+ bean.body = monitor.body;
+ bean.headers = monitor.headers;
+ bean.basic_auth_user = monitor.basic_auth_user;
+ bean.basic_auth_pass = monitor.basic_auth_pass;
+ bean.timeout = monitor.timeout;
+ bean.oauth_client_id = monitor.oauth_client_id;
+ bean.oauth_client_secret = monitor.oauth_client_secret;
+ bean.oauth_auth_method = monitor.oauth_auth_method;
+ bean.oauth_token_url = monitor.oauth_token_url;
+ bean.oauth_scopes = monitor.oauth_scopes;
+ bean.tlsCa = monitor.tlsCa;
+ bean.tlsCert = monitor.tlsCert;
+ bean.tlsKey = monitor.tlsKey;
+ bean.interval = monitor.interval;
+ bean.retryInterval = monitor.retryInterval;
+ bean.resendInterval = monitor.resendInterval;
+ bean.hostname = monitor.hostname;
+ bean.game = monitor.game;
+ bean.maxretries = monitor.maxretries;
+ bean.port = parseInt(monitor.port);
+
+ if (isNaN(bean.port)) {
+ bean.port = null;
+ }
+
+ bean.keyword = monitor.keyword;
+ bean.invertKeyword = monitor.invertKeyword;
+ bean.ignoreTls = monitor.ignoreTls;
+ bean.expiryNotification = monitor.expiryNotification;
+ bean.upsideDown = monitor.upsideDown;
+ bean.packetSize = monitor.packetSize;
+ bean.maxredirects = monitor.maxredirects;
+ bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
+ bean.dns_resolve_type = monitor.dns_resolve_type;
+ bean.dns_resolve_server = monitor.dns_resolve_server;
+ bean.pushToken = monitor.pushToken;
+ bean.docker_container = monitor.docker_container;
+ bean.docker_host = monitor.docker_host;
+ bean.proxyId = Number.isInteger(monitor.proxyId) ? monitor.proxyId : null;
+ bean.mqttUsername = monitor.mqttUsername;
+ bean.mqttPassword = monitor.mqttPassword;
+ bean.mqttTopic = monitor.mqttTopic;
+ bean.mqttSuccessMessage = monitor.mqttSuccessMessage;
+ bean.mqttCheckType = monitor.mqttCheckType;
+ bean.databaseConnectionString = monitor.databaseConnectionString;
+ bean.databaseQuery = monitor.databaseQuery;
+ bean.authMethod = monitor.authMethod;
+ bean.authWorkstation = monitor.authWorkstation;
+ bean.authDomain = monitor.authDomain;
+ bean.grpcUrl = monitor.grpcUrl;
+ bean.grpcProtobuf = monitor.grpcProtobuf;
+ bean.grpcServiceName = monitor.grpcServiceName;
+ bean.grpcMethod = monitor.grpcMethod;
+ bean.grpcBody = monitor.grpcBody;
+ bean.grpcMetadata = monitor.grpcMetadata;
+ bean.grpcEnableTls = monitor.grpcEnableTls;
+ bean.radiusUsername = monitor.radiusUsername;
+ bean.radiusPassword = monitor.radiusPassword;
+ bean.radiusCalledStationId = monitor.radiusCalledStationId;
+ bean.radiusCallingStationId = monitor.radiusCallingStationId;
+ bean.radiusSecret = monitor.radiusSecret;
+ bean.httpBodyEncoding = monitor.httpBodyEncoding;
+ bean.expectedValue = monitor.expectedValue;
+ bean.jsonPath = monitor.jsonPath;
+ bean.kafkaProducerTopic = monitor.kafkaProducerTopic;
+ bean.kafkaProducerBrokers = JSON.stringify(monitor.kafkaProducerBrokers);
+ bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation;
+ bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions);
+ bean.kafkaProducerMessage = monitor.kafkaProducerMessage;
+ bean.cacheBust = monitor.cacheBust;
+ bean.kafkaProducerSsl = monitor.kafkaProducerSsl;
+ bean.kafkaProducerAllowAutoTopicCreation =
+ monitor.kafkaProducerAllowAutoTopicCreation;
+ bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly;
+ bean.remote_browser = monitor.remote_browser;
+ bean.snmpVersion = monitor.snmpVersion;
+ bean.snmpOid = monitor.snmpOid;
+ bean.jsonPathOperator = monitor.jsonPathOperator;
+ bean.timeout = monitor.timeout;
+ bean.rabbitmqNodes = JSON.stringify(monitor.rabbitmqNodes);
+ bean.rabbitmqUsername = monitor.rabbitmqUsername;
+ bean.rabbitmqPassword = monitor.rabbitmqPassword;
+ bean.conditions = JSON.stringify(monitor.conditions);
+
+ bean.validate();
+
+ await R.store(bean);
+
+ if (removeGroupChildren) {
+ await Monitor.unlinkAllChildren(monitor.id);
+ }
+
+ await updateMonitorNotification(bean.id, monitor.notificationIDList);
+
+ if (await Monitor.isActive(bean.id, bean.active)) {
+ await restartMonitor(socket.userID, bean.id);
+ }
+
+ await server.sendUpdateMonitorIntoList(socket, bean.id);
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ monitorID: bean.id,
+ });
+
+ } catch (e) {
+ log.error("monitor", e);
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getMonitorList", async (callback) => {
+ try {
+ checkLogin(socket);
+ await server.sendMonitorList(socket);
+ callback({
+ ok: true,
+ });
+ } catch (e) {
+ log.error("monitor", e);
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getMonitor", async (monitorID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.info("monitor", `Get Monitor: ${monitorID} User ID: ${socket.userID}`);
+
+ let monitor = await R.findOne("monitor", " id = ? AND user_id = ? ", [
+ monitorID,
+ socket.userID,
+ ]);
+ const monitorData = [{ id: monitor.id,
+ active: monitor.active
+ }];
+ const preloadData = await Monitor.preparePreloadData(monitorData);
+ callback({
+ ok: true,
+ monitor: monitor.toJSON(preloadData),
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getMonitorBeats", async (monitorID, period, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.info("monitor", `Get Monitor Beats: ${monitorID} User ID: ${socket.userID}`);
+
+ if (period == null) {
+ throw new Error("Invalid period.");
+ }
+
+ const sqlHourOffset = Database.sqlHourOffset();
+
+ let list = await R.getAll(`
+ SELECT *
+ FROM heartbeat
+ WHERE monitor_id = ?
+ AND time > ${sqlHourOffset}
+ ORDER BY time ASC
+ `, [
+ monitorID,
+ -period,
+ ]);
+
+ callback({
+ ok: true,
+ data: list,
+ });
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // Start or Resume the monitor
+ socket.on("resumeMonitor", async (monitorID, callback) => {
+ try {
+ checkLogin(socket);
+ await startMonitor(socket.userID, monitorID);
+ await server.sendUpdateMonitorIntoList(socket, monitorID);
+
+ callback({
+ ok: true,
+ msg: "successResumed",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("pauseMonitor", async (monitorID, callback) => {
+ try {
+ checkLogin(socket);
+ await pauseMonitor(socket.userID, monitorID);
+ await server.sendUpdateMonitorIntoList(socket, monitorID);
+
+ callback({
+ ok: true,
+ msg: "successPaused",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteMonitor", async (monitorID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.info("manage", `Delete Monitor: ${monitorID} User ID: ${socket.userID}`);
+
+ if (monitorID in server.monitorList) {
+ await server.monitorList[monitorID].stop();
+ delete server.monitorList[monitorID];
+ }
+
+ const startTime = Date.now();
+
+ await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
+ monitorID,
+ socket.userID,
+ ]);
+
+ // Fix #2880
+ apicache.clear();
+
+ const endTime = Date.now();
+
+ log.info("DB", `Delete Monitor completed in : ${endTime - startTime} ms`);
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+ await server.sendDeleteMonitorFromList(socket, monitorID);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getTags", async (callback) => {
+ try {
+ checkLogin(socket);
+
+ const list = await R.findAll("tag");
+
+ callback({
+ ok: true,
+ tags: list.map(bean => bean.toJSON()),
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("addTag", async (tag, callback) => {
+ try {
+ checkLogin(socket);
+
+ let bean = R.dispense("tag");
+ bean.name = tag.name;
+ bean.color = tag.color;
+ await R.store(bean);
+
+ callback({
+ ok: true,
+ tag: await bean.toJSON(),
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("editTag", async (tag, callback) => {
+ try {
+ checkLogin(socket);
+
+ let bean = await R.findOne("tag", " id = ? ", [ tag.id ]);
+ if (bean == null) {
+ callback({
+ ok: false,
+ msg: "tagNotFound",
+ msgi18n: true,
+ });
+ return;
+ }
+ bean.name = tag.name;
+ bean.color = tag.color;
+ await R.store(bean);
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ tag: await bean.toJSON(),
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteTag", async (tagID, callback) => {
+ try {
+ checkLogin(socket);
+
+ await R.exec("DELETE FROM tag WHERE id = ? ", [ tagID ]);
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("addMonitorTag", async (tagID, monitorID, value, callback) => {
+ try {
+ checkLogin(socket);
+
+ await R.exec("INSERT INTO monitor_tag (tag_id, monitor_id, value) VALUES (?, ?, ?)", [
+ tagID,
+ monitorID,
+ value,
+ ]);
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("editMonitorTag", async (tagID, monitorID, value, callback) => {
+ try {
+ checkLogin(socket);
+
+ await R.exec("UPDATE monitor_tag SET value = ? WHERE tag_id = ? AND monitor_id = ?", [
+ value,
+ tagID,
+ monitorID,
+ ]);
+
+ callback({
+ ok: true,
+ msg: "successEdited",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteMonitorTag", async (tagID, monitorID, value, callback) => {
+ try {
+ checkLogin(socket);
+
+ await R.exec("DELETE FROM monitor_tag WHERE tag_id = ? AND monitor_id = ? AND value = ?", [
+ tagID,
+ monitorID,
+ value,
+ ]);
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("monitorImportantHeartbeatListCount", async (monitorID, callback) => {
+ try {
+ checkLogin(socket);
+
+ let count;
+ if (monitorID == null) {
+ count = await R.count("heartbeat", "important = 1");
+ } else {
+ count = await R.count("heartbeat", "monitor_id = ? AND important = 1", [
+ monitorID,
+ ]);
+ }
+
+ callback({
+ ok: true,
+ count: count,
+ });
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("monitorImportantHeartbeatListPaged", async (monitorID, offset, count, callback) => {
+ try {
+ checkLogin(socket);
+
+ let list;
+ if (monitorID == null) {
+ list = await R.find("heartbeat", `
+ important = 1
+ ORDER BY time DESC
+ LIMIT ?
+ OFFSET ?
+ `, [
+ count,
+ offset,
+ ]);
+ } else {
+ list = await R.find("heartbeat", `
+ monitor_id = ?
+ AND important = 1
+ ORDER BY time DESC
+ LIMIT ?
+ OFFSET ?
+ `, [
+ monitorID,
+ count,
+ offset,
+ ]);
+ }
+
+ callback({
+ ok: true,
+ data: list,
+ });
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("changePassword", async (password, callback) => {
+ try {
+ checkLogin(socket);
+
+ if (!password.newPassword) {
+ throw new Error("Invalid new password");
+ }
+
+ if (passwordStrength(password.newPassword).value === "Too weak") {
+ throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
+ }
+
+ let user = await doubleCheckPassword(socket, password.currentPassword);
+ await user.resetPassword(password.newPassword);
+
+ server.disconnectAllSocketClients(user.id, socket.id);
+
+ callback({
+ ok: true,
+ token: User.createJWT(user, server.jwtSecret),
+ msg: "successAuthChangePassword",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getSettings", async (callback) => {
+ try {
+ checkLogin(socket);
+ const data = await getSettings("general");
+
+ if (!data.serverTimezone) {
+ data.serverTimezone = await server.getTimezone();
+ }
+
+ callback({
+ ok: true,
+ data: data,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("setSettings", async (data, currentPassword, callback) => {
+ try {
+ checkLogin(socket);
+
+ // If currently is disabled auth, don't need to check
+ // Disabled Auth + Want to Disable Auth => No Check
+ // Disabled Auth + Want to Enable Auth => No Check
+ // Enabled Auth + Want to Disable Auth => Check!!
+ // Enabled Auth + Want to Enable Auth => No Check
+ const currentDisabledAuth = await setting("disableAuth");
+ if (!currentDisabledAuth && data.disableAuth) {
+ await doubleCheckPassword(socket, currentPassword);
+ }
+
+ // Log out all clients if enabling auth
+ // GHSA-23q2-5gf8-gjpp
+ if (currentDisabledAuth && !data.disableAuth) {
+ server.disconnectAllSocketClients(socket.userID, socket.id);
+ }
+
+ const previousChromeExecutable = await Settings.get("chromeExecutable");
+ const previousNSCDStatus = await Settings.get("nscd");
+
+ await setSettings("general", data);
+ server.entryPage = data.entryPage;
+
+ // Also need to apply timezone globally
+ if (data.serverTimezone) {
+ await server.setTimezone(data.serverTimezone);
+ }
+
+ // If Chrome Executable is changed, need to reset the browser
+ if (previousChromeExecutable !== data.chromeExecutable) {
+ log.info("settings", "Chrome executable is changed. Resetting Chrome...");
+ await resetChrome();
+ }
+
+ // Update nscd status
+ if (previousNSCDStatus !== data.nscd) {
+ if (data.nscd) {
+ await server.startNSCDServices();
+ } else {
+ await server.stopNSCDServices();
+ }
+ }
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ });
+
+ await sendInfo(socket);
+ await server.sendMaintenanceList(socket);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // Add or Edit
+ socket.on("addNotification", async (notification, notificationID, callback) => {
+ try {
+ checkLogin(socket);
+
+ let notificationBean = await Notification.save(notification, notificationID, socket.userID);
+ await sendNotificationList(socket);
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ id: notificationBean.id,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteNotification", async (notificationID, callback) => {
+ try {
+ checkLogin(socket);
+
+ await Notification.delete(notificationID, socket.userID);
+ await sendNotificationList(socket);
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("testNotification", async (notification, callback) => {
+ try {
+ checkLogin(socket);
+
+ let msg = await Notification.send(notification, notification.name + " Testing");
+
+ callback({
+ ok: true,
+ msg,
+ });
+
+ } catch (e) {
+ console.error(e);
+
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("checkApprise", async (callback) => {
+ try {
+ checkLogin(socket);
+ callback(Notification.checkApprise());
+ } catch (e) {
+ callback(false);
+ }
+ });
+
+ socket.on("clearEvents", async (monitorID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.info("manage", `Clear Events Monitor: ${monitorID} User ID: ${socket.userID}`);
+
+ await R.exec("UPDATE heartbeat SET msg = ?, important = ? WHERE monitor_id = ? ", [
+ "",
+ "0",
+ monitorID,
+ ]);
+
+ callback({
+ ok: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("clearHeartbeats", async (monitorID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.info("manage", `Clear Heartbeats Monitor: ${monitorID} User ID: ${socket.userID}`);
+
+ await R.exec("DELETE FROM heartbeat WHERE monitor_id = ?", [
+ monitorID
+ ]);
+
+ await sendHeartbeatList(socket, monitorID, true, true);
+
+ callback({
+ ok: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("clearStatistics", async (callback) => {
+ try {
+ checkLogin(socket);
+
+ log.info("manage", `Clear Statistics User ID: ${socket.userID}`);
+
+ await R.exec("DELETE FROM heartbeat");
+ await R.exec("DELETE FROM stat_daily");
+ await R.exec("DELETE FROM stat_hourly");
+ await R.exec("DELETE FROM stat_minutely");
+
+ // Restart all monitors to reset the stats
+ for (let monitorID in server.monitorList) {
+ await restartMonitor(socket.userID, monitorID);
+ }
+
+ callback({
+ ok: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // Status Page Socket Handler for admin only
+ statusPageSocketHandler(socket);
+ cloudflaredSocketHandler(socket);
+ databaseSocketHandler(socket);
+ proxySocketHandler(socket);
+ dockerSocketHandler(socket);
+ maintenanceSocketHandler(socket);
+ apiKeySocketHandler(socket);
+ remoteBrowserSocketHandler(socket);
+ generalSocketHandler(socket, server);
+ chartSocketHandler(socket);
+
+ log.debug("server", "added all socket handlers");
+
+ // ***************************
+ // Better do anything after added all socket handlers here
+ // ***************************
+
+ log.debug("auth", "check auto login");
+ if (await setting("disableAuth")) {
+ log.info("auth", "Disabled Auth: auto login to admin");
+ await afterLogin(socket, await R.findOne("user"));
+ socket.emit("autoLogin");
+ } else {
+ socket.emit("loginRequired");
+ log.debug("auth", "need auth");
+ }
+
+ });
+
+ log.debug("server", "Init the server");
+
+ server.httpServer.once("error", async (err) => {
+ log.error("server", "Cannot listen: " + err.message);
+ await shutdownFunction();
+ process.exit(1);
+ });
+
+ await server.start();
+
+ server.httpServer.listen(port, hostname, async () => {
+ if (hostname) {
+ log.info("server", `Listening on ${hostname}:${port}`);
+ } else {
+ log.info("server", `Listening on ${port}`);
+ }
+ await startMonitors();
+
+ // Put this here. Start background jobs after the db and server is ready to prevent clear up during db migration.
+ await initBackgroundJobs();
+
+ checkVersion.startInterval();
+ });
+
+ // Start cloudflared at the end if configured
+ await cloudflaredAutoStart(cloudflaredToken);
+
+})();
+
+/**
+ * Update notifications for a given monitor
+ * @param {number} monitorID ID of monitor to update
+ * @param {number[]} notificationIDList List of new notification
+ * providers to add
+ * @returns {Promise<void>}
+ */
+async function updateMonitorNotification(monitorID, notificationIDList) {
+ await R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [
+ monitorID,
+ ]);
+
+ for (let notificationID in notificationIDList) {
+ if (notificationIDList[notificationID]) {
+ let relation = R.dispense("monitor_notification");
+ relation.monitor_id = monitorID;
+ relation.notification_id = notificationID;
+ await R.store(relation);
+ }
+ }
+}
+
+/**
+ * Check if a given user owns a specific monitor
+ * @param {number} userID ID of user to check
+ * @param {number} monitorID ID of monitor to check
+ * @returns {Promise<void>}
+ * @throws {Error} The specified user does not own the monitor
+ */
+async function checkOwner(userID, monitorID) {
+ let row = await R.getRow("SELECT id FROM monitor WHERE id = ? AND user_id = ? ", [
+ monitorID,
+ userID,
+ ]);
+
+ if (! row) {
+ throw new Error("You do not own this monitor.");
+ }
+}
+
+/**
+ * Function called after user login
+ * This function is used to send the heartbeat list of a monitor.
+ * @param {Socket} socket Socket.io instance
+ * @param {object} user User object
+ * @returns {Promise<void>}
+ */
+async function afterLogin(socket, user) {
+ socket.userID = user.id;
+ socket.join(user.id);
+
+ let monitorList = await server.sendMonitorList(socket);
+ await Promise.allSettled([
+ sendInfo(socket),
+ server.sendMaintenanceList(socket),
+ sendNotificationList(socket),
+ sendProxyList(socket),
+ sendDockerHostList(socket),
+ sendAPIKeyList(socket),
+ sendRemoteBrowserList(socket),
+ sendMonitorTypeList(socket),
+ ]);
+
+ await StatusPage.sendStatusPageList(io, socket);
+
+ const monitorPromises = [];
+ for (let monitorID in monitorList) {
+ monitorPromises.push(sendHeartbeatList(socket, monitorID));
+ monitorPromises.push(Monitor.sendStats(io, monitorID, user.id));
+ }
+
+ await Promise.all(monitorPromises);
+
+ // Set server timezone from client browser if not set
+ // It should be run once only
+ if (! await Settings.get("initServerTimezone")) {
+ log.debug("server", "emit initServerTimezone");
+ socket.emit("initServerTimezone");
+ }
+}
+
+/**
+ * Initialize the database
+ * @param {boolean} testMode Should the connection be
+ * started in test mode?
+ * @returns {Promise<void>}
+ */
+async function initDatabase(testMode = false) {
+ log.debug("server", "Connecting to the database");
+ await Database.connect(testMode);
+ log.info("server", "Connected to the database");
+
+ // Patch the database
+ await Database.patch(port, hostname);
+
+ let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
+ "jwtSecret",
+ ]);
+
+ if (! jwtSecretBean) {
+ log.info("server", "JWT secret is not found, generate one.");
+ jwtSecretBean = await initJWTSecret();
+ log.info("server", "Stored JWT secret into database");
+ } else {
+ log.debug("server", "Load JWT secret from database.");
+ }
+
+ // If there is no record in user table, it is a new Uptime Kuma instance, need to setup
+ if ((await R.knex("user").count("id as count").first()).count === 0) {
+ log.info("server", "No user, need setup");
+ needSetup = true;
+ }
+
+ server.jwtSecret = jwtSecretBean.value;
+}
+
+/**
+ * Start the specified monitor
+ * @param {number} userID ID of user who owns monitor
+ * @param {number} monitorID ID of monitor to start
+ * @returns {Promise<void>}
+ */
+async function startMonitor(userID, monitorID) {
+ await checkOwner(userID, monitorID);
+
+ log.info("manage", `Resume Monitor: ${monitorID} User ID: ${userID}`);
+
+ await R.exec("UPDATE monitor SET active = 1 WHERE id = ? AND user_id = ? ", [
+ monitorID,
+ userID,
+ ]);
+
+ let monitor = await R.findOne("monitor", " id = ? ", [
+ monitorID,
+ ]);
+
+ if (monitor.id in server.monitorList) {
+ await server.monitorList[monitor.id].stop();
+ }
+
+ server.monitorList[monitor.id] = monitor;
+ await monitor.start(io);
+}
+
+/**
+ * Restart a given monitor
+ * @param {number} userID ID of user who owns monitor
+ * @param {number} monitorID ID of monitor to start
+ * @returns {Promise<void>}
+ */
+async function restartMonitor(userID, monitorID) {
+ return await startMonitor(userID, monitorID);
+}
+
+/**
+ * Pause a given monitor
+ * @param {number} userID ID of user who owns monitor
+ * @param {number} monitorID ID of monitor to start
+ * @returns {Promise<void>}
+ */
+async function pauseMonitor(userID, monitorID) {
+ await checkOwner(userID, monitorID);
+
+ log.info("manage", `Pause Monitor: ${monitorID} User ID: ${userID}`);
+
+ await R.exec("UPDATE monitor SET active = 0 WHERE id = ? AND user_id = ? ", [
+ monitorID,
+ userID,
+ ]);
+
+ if (monitorID in server.monitorList) {
+ await server.monitorList[monitorID].stop();
+ server.monitorList[monitorID].active = 0;
+ }
+}
+
+/**
+ * Resume active monitors
+ * @returns {Promise<void>}
+ */
+async function startMonitors() {
+ let list = await R.find("monitor", " active = 1 ");
+
+ for (let monitor of list) {
+ server.monitorList[monitor.id] = monitor;
+ }
+
+ for (let monitor of list) {
+ try {
+ await monitor.start(io);
+ } catch (e) {
+ log.error("monitor", e);
+ }
+ // Give some delays, so all monitors won't make request at the same moment when just start the server.
+ await sleep(getRandomInt(300, 1000));
+ }
+}
+
+/**
+ * Shutdown the application
+ * Stops all monitors and closes the database connection.
+ * @param {string} signal The signal that triggered this function to be called.
+ * @returns {Promise<void>}
+ */
+async function shutdownFunction(signal) {
+ log.info("server", "Shutdown requested");
+ log.info("server", "Called signal: " + signal);
+
+ await server.stop();
+
+ log.info("server", "Stopping all monitors");
+ for (let id in server.monitorList) {
+ let monitor = server.monitorList[id];
+ await monitor.stop();
+ }
+ await sleep(2000);
+ await Database.close();
+
+ if (EmbeddedMariaDB.hasInstance()) {
+ EmbeddedMariaDB.getInstance().stop();
+ }
+
+ stopBackgroundJobs();
+ await cloudflaredStop();
+ Settings.stopCacheCleaner();
+}
+
+/**
+ * Final function called before application exits
+ * @returns {void}
+ */
+function finalFunction() {
+ log.info("server", "Graceful shutdown successful!");
+}
+
+gracefulShutdown(server.httpServer, {
+ signals: "SIGINT SIGTERM",
+ timeout: 30000, // timeout: 30 secs
+ development: false, // not in dev mode
+ forceExit: true, // triggers process.exit() at the end of shutdown process
+ onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ...
+ finally: finalFunction, // finally function (sync) - e.g. for logging
+});
+
+// Catch unexpected errors here
+let unexpectedErrorHandler = (error, promise) => {
+ console.trace(error);
+ UptimeKumaServer.errorLog(error, false);
+ console.error("If you keep encountering errors, please report to https://github.com/louislam/uptime-kuma/issues");
+};
+process.addListener("unhandledRejection", unexpectedErrorHandler);
+process.addListener("uncaughtException", unexpectedErrorHandler);
diff --git a/server/settings.js b/server/settings.js
new file mode 100644
index 0000000..4776c55
--- /dev/null
+++ b/server/settings.js
@@ -0,0 +1,177 @@
+const { R } = require("redbean-node");
+const { log } = require("../src/util");
+
+class Settings {
+
+ /**
+ * Example:
+ * {
+ * key1: {
+ * value: "value2",
+ * timestamp: 12345678
+ * },
+ * key2: {
+ * value: 2,
+ * timestamp: 12345678
+ * },
+ * }
+ * @type {{}}
+ */
+ static cacheList = {
+
+ };
+
+ static cacheCleaner = null;
+
+ /**
+ * Retrieve value of setting based on key
+ * @param {string} key Key of setting to retrieve
+ * @returns {Promise<any>} Value
+ */
+ static async get(key) {
+
+ // Start cache clear if not started yet
+ if (!Settings.cacheCleaner) {
+ Settings.cacheCleaner = setInterval(() => {
+ log.debug("settings", "Cache Cleaner is just started.");
+ for (key in Settings.cacheList) {
+ if (Date.now() - Settings.cacheList[key].timestamp > 60 * 1000) {
+ log.debug("settings", "Cache Cleaner deleted: " + key);
+ delete Settings.cacheList[key];
+ }
+ }
+
+ }, 60 * 1000);
+ }
+
+ // Query from cache
+ if (key in Settings.cacheList) {
+ const v = Settings.cacheList[key].value;
+ log.debug("settings", `Get Setting (cache): ${key}: ${v}`);
+ return v;
+ }
+
+ let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [
+ key,
+ ]);
+
+ try {
+ const v = JSON.parse(value);
+ log.debug("settings", `Get Setting: ${key}: ${v}`);
+
+ Settings.cacheList[key] = {
+ value: v,
+ timestamp: Date.now()
+ };
+
+ return v;
+ } catch (e) {
+ return value;
+ }
+ }
+
+ /**
+ * Sets the specified setting to specified value
+ * @param {string} key Key of setting to set
+ * @param {any} value Value to set to
+ * @param {?string} type Type of setting
+ * @returns {Promise<void>}
+ */
+ static async set(key, value, type = null) {
+
+ let bean = await R.findOne("setting", " `key` = ? ", [
+ key,
+ ]);
+ if (!bean) {
+ bean = R.dispense("setting");
+ bean.key = key;
+ }
+ bean.type = type;
+ bean.value = JSON.stringify(value);
+ await R.store(bean);
+
+ Settings.deleteCache([ key ]);
+ }
+
+ /**
+ * Get settings based on type
+ * @param {string} type The type of setting
+ * @returns {Promise<Bean>} Settings
+ */
+ static async getSettings(type) {
+ let list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [
+ type,
+ ]);
+
+ let result = {};
+
+ for (let row of list) {
+ try {
+ result[row.key] = JSON.parse(row.value);
+ } catch (e) {
+ result[row.key] = row.value;
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Set settings based on type
+ * @param {string} type Type of settings to set
+ * @param {object} data Values of settings
+ * @returns {Promise<void>}
+ */
+ static async setSettings(type, data) {
+ let keyList = Object.keys(data);
+
+ let promiseList = [];
+
+ for (let key of keyList) {
+ let bean = await R.findOne("setting", " `key` = ? ", [
+ key
+ ]);
+
+ if (bean == null) {
+ bean = R.dispense("setting");
+ bean.type = type;
+ bean.key = key;
+ }
+
+ if (bean.type === type) {
+ bean.value = JSON.stringify(data[key]);
+ promiseList.push(R.store(bean));
+ }
+ }
+
+ await Promise.all(promiseList);
+
+ Settings.deleteCache(keyList);
+ }
+
+ /**
+ * Delete selected keys from settings cache
+ * @param {string[]} keyList Keys to remove
+ * @returns {void}
+ */
+ static deleteCache(keyList) {
+ for (let key of keyList) {
+ delete Settings.cacheList[key];
+ }
+ }
+
+ /**
+ * Stop the cache cleaner if running
+ * @returns {void}
+ */
+ static stopCacheCleaner() {
+ if (Settings.cacheCleaner) {
+ clearInterval(Settings.cacheCleaner);
+ Settings.cacheCleaner = null;
+ }
+ }
+}
+
+module.exports = {
+ Settings,
+};
diff --git a/server/setup-database.js b/server/setup-database.js
new file mode 100644
index 0000000..483f2c9
--- /dev/null
+++ b/server/setup-database.js
@@ -0,0 +1,271 @@
+const express = require("express");
+const { log } = require("../src/util");
+const expressStaticGzip = require("express-static-gzip");
+const fs = require("fs");
+const path = require("path");
+const Database = require("./database");
+const { allowDevAllOrigin } = require("./util-server");
+const mysql = require("mysql2/promise");
+
+/**
+ * A standalone express app that is used to setup a database
+ * It is used when db-config.json and kuma.db are not found or invalid
+ * Once it is configured, it will shut down and start the main server
+ */
+class SetupDatabase {
+ /**
+ * Show Setup Page
+ * @type {boolean}
+ */
+ needSetup = true;
+ /**
+ * If the server has finished the setup
+ * @type {boolean}
+ * @private
+ */
+ runningSetup = false;
+ /**
+ * @inheritDoc
+ * @type {UptimeKumaServer}
+ * @private
+ */
+ server;
+
+ /**
+ * @param {object} args The arguments passed from the command line
+ * @param {UptimeKumaServer} server the main server instance
+ */
+ constructor(args, server) {
+ this.server = server;
+
+ // Priority: env > db-config.json
+ // If env is provided, write it to db-config.json
+ // If db-config.json is found, check if it is valid
+ // If db-config.json is not found or invalid, check if kuma.db is found
+ // If kuma.db is not found, show setup page
+
+ let dbConfig;
+
+ try {
+ dbConfig = Database.readDBConfig();
+ log.debug("setup-database", "db-config.json is found and is valid");
+ this.needSetup = false;
+
+ } catch (e) {
+ log.info("setup-database", "db-config.json is not found or invalid: " + e.message);
+
+ // Check if kuma.db is found (1.X.X users), generate db-config.json
+ if (fs.existsSync(path.join(Database.dataDir, "kuma.db"))) {
+ this.needSetup = false;
+
+ log.info("setup-database", "kuma.db is found, generate db-config.json");
+ Database.writeDBConfig({
+ type: "sqlite",
+ });
+ } else {
+ this.needSetup = true;
+ }
+ dbConfig = {};
+ }
+
+ if (process.env.UPTIME_KUMA_DB_TYPE) {
+ this.needSetup = false;
+ log.info("setup-database", "UPTIME_KUMA_DB_TYPE is provided by env, try to override db-config.json");
+ dbConfig.type = process.env.UPTIME_KUMA_DB_TYPE;
+ dbConfig.hostname = process.env.UPTIME_KUMA_DB_HOSTNAME;
+ dbConfig.port = process.env.UPTIME_KUMA_DB_PORT;
+ dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME;
+ dbConfig.username = process.env.UPTIME_KUMA_DB_USERNAME;
+ dbConfig.password = process.env.UPTIME_KUMA_DB_PASSWORD;
+ Database.writeDBConfig(dbConfig);
+ }
+
+ }
+
+ /**
+ * Show Setup Page
+ * @returns {boolean} true if the setup page should be shown
+ */
+ isNeedSetup() {
+ return this.needSetup;
+ }
+
+ /**
+ * Check if the embedded MariaDB is enabled
+ * @returns {boolean} true if the embedded MariaDB is enabled
+ */
+ isEnabledEmbeddedMariaDB() {
+ return process.env.UPTIME_KUMA_ENABLE_EMBEDDED_MARIADB === "1";
+ }
+
+ /**
+ * Start the setup-database server
+ * @param {string} hostname where the server is listening
+ * @param {number} port where the server is listening
+ * @returns {Promise<void>}
+ */
+ start(hostname, port) {
+ return new Promise((resolve) => {
+ const app = express();
+ let tempServer;
+ app.use(express.json());
+
+ // Disable Keep Alive, otherwise the server will not shutdown, as the client will keep the connection alive
+ app.use(function (req, res, next) {
+ res.setHeader("Connection", "close");
+ next();
+ });
+
+ app.get("/", async (request, response) => {
+ response.redirect("/setup-database");
+ });
+
+ app.get("/api/entry-page", async (request, response) => {
+ allowDevAllOrigin(response);
+ response.json({
+ type: "setup-database",
+ });
+ });
+
+ app.get("/setup-database-info", (request, response) => {
+ allowDevAllOrigin(response);
+ console.log("Request /setup-database-info");
+ response.json({
+ runningSetup: this.runningSetup,
+ needSetup: this.needSetup,
+ isEnabledEmbeddedMariaDB: this.isEnabledEmbeddedMariaDB(),
+ });
+ });
+
+ app.post("/setup-database", async (request, response) => {
+ allowDevAllOrigin(response);
+
+ if (this.runningSetup) {
+ response.status(400).json("Setup is already running");
+ return;
+ }
+
+ this.runningSetup = true;
+
+ let dbConfig = request.body.dbConfig;
+
+ let supportedDBTypes = [ "mariadb", "sqlite" ];
+
+ if (this.isEnabledEmbeddedMariaDB()) {
+ supportedDBTypes.push("embedded-mariadb");
+ }
+
+ // Validate input
+ if (typeof dbConfig !== "object") {
+ response.status(400).json("Invalid dbConfig");
+ this.runningSetup = false;
+ return;
+ }
+
+ if (!dbConfig.type) {
+ response.status(400).json("Database Type is required");
+ this.runningSetup = false;
+ return;
+ }
+
+ if (!supportedDBTypes.includes(dbConfig.type)) {
+ response.status(400).json("Unsupported Database Type");
+ this.runningSetup = false;
+ return;
+ }
+
+ // External MariaDB
+ if (dbConfig.type === "mariadb") {
+ if (!dbConfig.hostname) {
+ response.status(400).json("Hostname is required");
+ this.runningSetup = false;
+ return;
+ }
+
+ if (!dbConfig.port) {
+ response.status(400).json("Port is required");
+ this.runningSetup = false;
+ return;
+ }
+
+ if (!dbConfig.dbName) {
+ response.status(400).json("Database name is required");
+ this.runningSetup = false;
+ return;
+ }
+
+ if (!dbConfig.username) {
+ response.status(400).json("Username is required");
+ this.runningSetup = false;
+ return;
+ }
+
+ if (!dbConfig.password) {
+ response.status(400).json("Password is required");
+ this.runningSetup = false;
+ return;
+ }
+
+ // Test connection
+ try {
+ const connection = await mysql.createConnection({
+ host: dbConfig.hostname,
+ port: dbConfig.port,
+ user: dbConfig.username,
+ password: dbConfig.password,
+ });
+ await connection.execute("SELECT 1");
+ connection.end();
+ } catch (e) {
+ response.status(400).json("Cannot connect to the database: " + e.message);
+ this.runningSetup = false;
+ return;
+ }
+ }
+
+ // Write db-config.json
+ Database.writeDBConfig(dbConfig);
+
+ response.json({
+ ok: true,
+ });
+
+ // Shutdown down this express and start the main server
+ log.info("setup-database", "Database is configured, close the setup-database server and start the main server now.");
+ if (tempServer) {
+ tempServer.close(() => {
+ log.info("setup-database", "The setup-database server is closed");
+ resolve();
+ });
+ } else {
+ resolve();
+ }
+
+ });
+
+ app.use("/", expressStaticGzip("dist", {
+ enableBrotli: true,
+ }));
+
+ app.get("*", async (_request, response) => {
+ response.send(this.server.indexHTML);
+ });
+
+ app.options("*", async (_request, response) => {
+ allowDevAllOrigin(response);
+ response.end();
+ });
+
+ tempServer = app.listen(port, hostname, () => {
+ log.info("setup-database", `Starting Setup Database on ${port}`);
+ let domain = (hostname) ? hostname : "localhost";
+ log.info("setup-database", `Open http://${domain}:${port} in your browser`);
+ log.info("setup-database", "Waiting for user action...");
+ });
+ });
+ }
+}
+
+module.exports = {
+ SetupDatabase,
+};
diff --git a/server/socket-handlers/api-key-socket-handler.js b/server/socket-handlers/api-key-socket-handler.js
new file mode 100644
index 0000000..f76b909
--- /dev/null
+++ b/server/socket-handlers/api-key-socket-handler.js
@@ -0,0 +1,155 @@
+const { checkLogin } = require("../util-server");
+const { log } = require("../../src/util");
+const { R } = require("redbean-node");
+const { nanoid } = require("nanoid");
+const passwordHash = require("../password-hash");
+const apicache = require("../modules/apicache");
+const APIKey = require("../model/api_key");
+const { Settings } = require("../settings");
+const { sendAPIKeyList } = require("../client");
+
+/**
+ * Handlers for API keys
+ * @param {Socket} socket Socket.io instance
+ * @returns {void}
+ */
+module.exports.apiKeySocketHandler = (socket) => {
+ // Add a new api key
+ socket.on("addAPIKey", async (key, callback) => {
+ try {
+ checkLogin(socket);
+
+ let clearKey = nanoid(40);
+ let hashedKey = passwordHash.generate(clearKey);
+ key["key"] = hashedKey;
+ let bean = await APIKey.save(key, socket.userID);
+
+ log.debug("apikeys", "Added API Key");
+ log.debug("apikeys", key);
+
+ // Append key ID and prefix to start of key seperated by _, used to get
+ // correct hash when validating key.
+ let formattedKey = "uk" + bean.id + "_" + clearKey;
+ await sendAPIKeyList(socket);
+
+ // Enable API auth if the user creates a key, otherwise only basic
+ // auth will be used for API.
+ await Settings.set("apiKeysEnabled", true);
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ key: formattedKey,
+ keyID: bean.id,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getAPIKeyList", async (callback) => {
+ try {
+ checkLogin(socket);
+ await sendAPIKeyList(socket);
+ callback({
+ ok: true,
+ });
+ } catch (e) {
+ console.error(e);
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteAPIKey", async (keyID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("apikeys", `Deleted API Key: ${keyID} User ID: ${socket.userID}`);
+
+ await R.exec("DELETE FROM api_key WHERE id = ? AND user_id = ? ", [
+ keyID,
+ socket.userID,
+ ]);
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ await sendAPIKeyList(socket);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("disableAPIKey", async (keyID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("apikeys", `Disabled Key: ${keyID} User ID: ${socket.userID}`);
+
+ await R.exec("UPDATE api_key SET active = 0 WHERE id = ? ", [
+ keyID,
+ ]);
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successDisabled",
+ msgi18n: true,
+ });
+
+ await sendAPIKeyList(socket);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("enableAPIKey", async (keyID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("apikeys", `Enabled Key: ${keyID} User ID: ${socket.userID}`);
+
+ await R.exec("UPDATE api_key SET active = 1 WHERE id = ? ", [
+ keyID,
+ ]);
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successEnabled",
+ msgi18n: true,
+ });
+
+ await sendAPIKeyList(socket);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+};
diff --git a/server/socket-handlers/chart-socket-handler.js b/server/socket-handlers/chart-socket-handler.js
new file mode 100644
index 0000000..654db0e
--- /dev/null
+++ b/server/socket-handlers/chart-socket-handler.js
@@ -0,0 +1,38 @@
+const { checkLogin } = require("../util-server");
+const { UptimeCalculator } = require("../uptime-calculator");
+const { log } = require("../../src/util");
+
+module.exports.chartSocketHandler = (socket) => {
+ socket.on("getMonitorChartData", async (monitorID, period, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("monitor", `Get Monitor Chart Data: ${monitorID} User ID: ${socket.userID}`);
+
+ if (period == null) {
+ throw new Error("Invalid period.");
+ }
+
+ let uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
+
+ let data;
+ if (period <= 24) {
+ data = uptimeCalculator.getDataArray(period * 60, "minute");
+ } else if (period <= 720) {
+ data = uptimeCalculator.getDataArray(period, "hour");
+ } else {
+ data = uptimeCalculator.getDataArray(period / 24, "day");
+ }
+
+ callback({
+ ok: true,
+ data,
+ });
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+};
diff --git a/server/socket-handlers/cloudflared-socket-handler.js b/server/socket-handlers/cloudflared-socket-handler.js
new file mode 100644
index 0000000..809191f
--- /dev/null
+++ b/server/socket-handlers/cloudflared-socket-handler.js
@@ -0,0 +1,122 @@
+const { checkLogin, setSetting, setting, doubleCheckPassword } = require("../util-server");
+const { CloudflaredTunnel } = require("node-cloudflared-tunnel");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const { log } = require("../../src/util");
+const io = UptimeKumaServer.getInstance().io;
+
+const prefix = "cloudflared_";
+const cloudflared = new CloudflaredTunnel();
+
+/**
+ * Change running state
+ * @param {string} running Is it running?
+ * @param {string} message Message to pass
+ * @returns {void}
+ */
+cloudflared.change = (running, message) => {
+ io.to("cloudflared").emit(prefix + "running", running);
+ io.to("cloudflared").emit(prefix + "message", message);
+};
+
+/**
+ * Emit an error message
+ * @param {string} errorMessage Error message to send
+ * @returns {void}
+ */
+cloudflared.error = (errorMessage) => {
+ io.to("cloudflared").emit(prefix + "errorMessage", errorMessage);
+};
+
+/**
+ * Handler for cloudflared
+ * @param {Socket} socket Socket.io instance
+ * @returns {void}
+ */
+module.exports.cloudflaredSocketHandler = (socket) => {
+
+ socket.on(prefix + "join", async () => {
+ try {
+ checkLogin(socket);
+ socket.join("cloudflared");
+ io.to(socket.userID).emit(prefix + "installed", cloudflared.checkInstalled());
+ io.to(socket.userID).emit(prefix + "running", cloudflared.running);
+ io.to(socket.userID).emit(prefix + "token", await setting("cloudflaredTunnelToken"));
+ } catch (error) { }
+ });
+
+ socket.on(prefix + "leave", async () => {
+ try {
+ checkLogin(socket);
+ socket.leave("cloudflared");
+ } catch (error) { }
+ });
+
+ socket.on(prefix + "start", async (token) => {
+ try {
+ checkLogin(socket);
+ if (token && typeof token === "string") {
+ await setSetting("cloudflaredTunnelToken", token);
+ cloudflared.token = token;
+ } else {
+ cloudflared.token = null;
+ }
+ cloudflared.start();
+ } catch (error) { }
+ });
+
+ socket.on(prefix + "stop", async (currentPassword, callback) => {
+ try {
+ checkLogin(socket);
+ const disabledAuth = await setting("disableAuth");
+ if (!disabledAuth) {
+ await doubleCheckPassword(socket, currentPassword);
+ }
+ cloudflared.stop();
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on(prefix + "removeToken", async () => {
+ try {
+ checkLogin(socket);
+ await setSetting("cloudflaredTunnelToken", "");
+ } catch (error) { }
+ });
+
+};
+
+/**
+ * Automatically start cloudflared
+ * @param {string} token Cloudflared tunnel token
+ * @returns {Promise<void>}
+ */
+module.exports.autoStart = async (token) => {
+ if (!token) {
+ token = await setting("cloudflaredTunnelToken");
+ } else {
+ // Override the current token via args or env var
+ await setSetting("cloudflaredTunnelToken", token);
+ console.log("Use cloudflared token from args or env var");
+ }
+
+ if (token) {
+ console.log("Start cloudflared");
+ cloudflared.token = token;
+ cloudflared.start();
+ }
+};
+
+/**
+ * Stop cloudflared
+ * @returns {Promise<void>}
+ */
+module.exports.stop = async () => {
+ log.info("cloudflared", "Stop cloudflared");
+ if (cloudflared) {
+ cloudflared.stop();
+ }
+};
diff --git a/server/socket-handlers/database-socket-handler.js b/server/socket-handlers/database-socket-handler.js
new file mode 100644
index 0000000..ee2394b
--- /dev/null
+++ b/server/socket-handlers/database-socket-handler.js
@@ -0,0 +1,42 @@
+const { checkLogin } = require("../util-server");
+const Database = require("../database");
+
+/**
+ * Handlers for database
+ * @param {Socket} socket Socket.io instance
+ * @returns {void}
+ */
+module.exports.databaseSocketHandler = (socket) => {
+
+ // Post or edit incident
+ socket.on("getDatabaseSize", async (callback) => {
+ try {
+ checkLogin(socket);
+ callback({
+ ok: true,
+ size: Database.getSize(),
+ });
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("shrinkDatabase", async (callback) => {
+ try {
+ checkLogin(socket);
+ await Database.shrink();
+ callback({
+ ok: true,
+ });
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+};
diff --git a/server/socket-handlers/docker-socket-handler.js b/server/socket-handlers/docker-socket-handler.js
new file mode 100644
index 0000000..95a60bc
--- /dev/null
+++ b/server/socket-handlers/docker-socket-handler.js
@@ -0,0 +1,82 @@
+const { sendDockerHostList } = require("../client");
+const { checkLogin } = require("../util-server");
+const { DockerHost } = require("../docker");
+const { log } = require("../../src/util");
+
+/**
+ * Handlers for docker hosts
+ * @param {Socket} socket Socket.io instance
+ * @returns {void}
+ */
+module.exports.dockerSocketHandler = (socket) => {
+ socket.on("addDockerHost", async (dockerHost, dockerHostID, callback) => {
+ try {
+ checkLogin(socket);
+
+ let dockerHostBean = await DockerHost.save(dockerHost, dockerHostID, socket.userID);
+ await sendDockerHostList(socket);
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ id: dockerHostBean.id,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteDockerHost", async (dockerHostID, callback) => {
+ try {
+ checkLogin(socket);
+
+ await DockerHost.delete(dockerHostID, socket.userID);
+ await sendDockerHostList(socket);
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("testDockerHost", async (dockerHost, callback) => {
+ try {
+ checkLogin(socket);
+
+ let amount = await DockerHost.testDockerHost(dockerHost);
+ let msg;
+
+ if (amount >= 1) {
+ msg = "Connected Successfully. Amount of containers: " + amount;
+ } else {
+ msg = "Connected Successfully, but there are no containers?";
+ }
+
+ callback({
+ ok: true,
+ msg,
+ });
+
+ } catch (e) {
+ log.error("docker", e);
+
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+};
diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js
new file mode 100644
index 0000000..50dcd94
--- /dev/null
+++ b/server/socket-handlers/general-socket-handler.js
@@ -0,0 +1,127 @@
+const { log } = require("../../src/util");
+const { Settings } = require("../settings");
+const { sendInfo } = require("../client");
+const { checkLogin } = require("../util-server");
+const GameResolver = require("gamedig/lib/GameResolver");
+const { testChrome } = require("../monitor-types/real-browser-monitor-type");
+const fs = require("fs");
+const path = require("path");
+
+let gameResolver = new GameResolver();
+let gameList = null;
+
+/**
+ * Get a game list via GameDig
+ * @returns {object[]} list of games supported by GameDig
+ */
+function getGameList() {
+ if (gameList == null) {
+ gameList = gameResolver._readGames().games.sort((a, b) => {
+ if ( a.pretty < b.pretty ) {
+ return -1;
+ }
+ if ( a.pretty > b.pretty ) {
+ return 1;
+ }
+ return 0;
+ });
+ }
+ return gameList;
+}
+
+/**
+ * Handler for general events
+ * @param {Socket} socket Socket.io instance
+ * @param {UptimeKumaServer} server Uptime Kuma server
+ * @returns {void}
+ */
+module.exports.generalSocketHandler = (socket, server) => {
+ socket.on("initServerTimezone", async (timezone) => {
+ try {
+ checkLogin(socket);
+ log.debug("generalSocketHandler", "Timezone: " + timezone);
+ await Settings.set("initServerTimezone", true);
+ await server.setTimezone(timezone);
+ await sendInfo(socket);
+ } catch (e) {
+ log.warn("initServerTimezone", e.message);
+ }
+ });
+
+ socket.on("getGameList", async (callback) => {
+ try {
+ checkLogin(socket);
+ callback({
+ ok: true,
+ gameList: getGameList(),
+ });
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("testChrome", (executable, callback) => {
+ try {
+ checkLogin(socket);
+ // Just noticed that await call could block the whole socket.io server!!! Use pure promise instead.
+ testChrome(executable).then((version) => {
+ callback({
+ ok: true,
+ msg: {
+ key: "foundChromiumVersion",
+ values: [ version ],
+ },
+ msgi18n: true,
+ });
+ }).catch((e) => {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ });
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getPushExample", (language, callback) => {
+
+ try {
+ let dir = path.join("./extra/push-examples", language);
+ let files = fs.readdirSync(dir);
+
+ for (let file of files) {
+ if (file.startsWith("index.")) {
+ callback({
+ ok: true,
+ code: fs.readFileSync(path.join(dir, file), "utf8"),
+ });
+ return;
+ }
+ }
+ } catch (e) {
+
+ }
+
+ callback({
+ ok: false,
+ msg: "Not found",
+ });
+ });
+
+ // Disconnect all other socket clients of the user
+ socket.on("disconnectOtherSocketClients", async () => {
+ try {
+ checkLogin(socket);
+ server.disconnectAllSocketClients(socket.userID, socket.id);
+ } catch (e) {
+ log.warn("disconnectAllSocketClients", e.message);
+ }
+ });
+};
diff --git a/server/socket-handlers/maintenance-socket-handler.js b/server/socket-handlers/maintenance-socket-handler.js
new file mode 100644
index 0000000..7de13fe
--- /dev/null
+++ b/server/socket-handlers/maintenance-socket-handler.js
@@ -0,0 +1,337 @@
+const { checkLogin } = require("../util-server");
+const { log } = require("../../src/util");
+const { R } = require("redbean-node");
+const apicache = require("../modules/apicache");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const Maintenance = require("../model/maintenance");
+const server = UptimeKumaServer.getInstance();
+
+/**
+ * Handlers for Maintenance
+ * @param {Socket} socket Socket.io instance
+ * @returns {void}
+ */
+module.exports.maintenanceSocketHandler = (socket) => {
+ // Add a new maintenance
+ socket.on("addMaintenance", async (maintenance, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("maintenance", maintenance);
+
+ let bean = await Maintenance.jsonToBean(R.dispense("maintenance"), maintenance);
+ bean.user_id = socket.userID;
+ let maintenanceID = await R.store(bean);
+
+ server.maintenanceList[maintenanceID] = bean;
+ await bean.run(true);
+
+ await server.sendMaintenanceList(socket);
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ maintenanceID,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // Edit a maintenance
+ socket.on("editMaintenance", async (maintenance, callback) => {
+ try {
+ checkLogin(socket);
+
+ let bean = server.getMaintenance(maintenance.id);
+
+ if (bean.user_id !== socket.userID) {
+ throw new Error("Permission denied.");
+ }
+
+ await Maintenance.jsonToBean(bean, maintenance);
+ await R.store(bean);
+ await bean.run(true);
+ await server.sendMaintenanceList(socket);
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ maintenanceID: bean.id,
+ });
+
+ } catch (e) {
+ console.error(e);
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // Add a new monitor_maintenance
+ socket.on("addMonitorMaintenance", async (maintenanceID, monitors, callback) => {
+ try {
+ checkLogin(socket);
+
+ await R.exec("DELETE FROM monitor_maintenance WHERE maintenance_id = ?", [
+ maintenanceID
+ ]);
+
+ for await (const monitor of monitors) {
+ let bean = R.dispense("monitor_maintenance");
+
+ bean.import({
+ monitor_id: monitor.id,
+ maintenance_id: maintenanceID
+ });
+ await R.store(bean);
+ }
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ // Add a new monitor_maintenance
+ socket.on("addMaintenanceStatusPage", async (maintenanceID, statusPages, callback) => {
+ try {
+ checkLogin(socket);
+
+ await R.exec("DELETE FROM maintenance_status_page WHERE maintenance_id = ?", [
+ maintenanceID
+ ]);
+
+ for await (const statusPage of statusPages) {
+ let bean = R.dispense("maintenance_status_page");
+
+ bean.import({
+ status_page_id: statusPage.id,
+ maintenance_id: maintenanceID
+ });
+ await R.store(bean);
+ }
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getMaintenance", async (maintenanceID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("maintenance", `Get Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
+
+ let bean = await R.findOne("maintenance", " id = ? AND user_id = ? ", [
+ maintenanceID,
+ socket.userID,
+ ]);
+
+ callback({
+ ok: true,
+ maintenance: await bean.toJSON(),
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getMaintenanceList", async (callback) => {
+ try {
+ checkLogin(socket);
+ await server.sendMaintenanceList(socket);
+ callback({
+ ok: true,
+ });
+ } catch (e) {
+ console.error(e);
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getMonitorMaintenance", async (maintenanceID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("maintenance", `Get Monitors for Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
+
+ let monitors = await R.getAll("SELECT monitor.id FROM monitor_maintenance mm JOIN monitor ON mm.monitor_id = monitor.id WHERE mm.maintenance_id = ? ", [
+ maintenanceID,
+ ]);
+
+ callback({
+ ok: true,
+ monitors,
+ });
+
+ } catch (e) {
+ console.error(e);
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("getMaintenanceStatusPage", async (maintenanceID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("maintenance", `Get Status Pages for Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
+
+ let statusPages = await R.getAll("SELECT status_page.id, status_page.title FROM maintenance_status_page msp JOIN status_page ON msp.status_page_id = status_page.id WHERE msp.maintenance_id = ? ", [
+ maintenanceID,
+ ]);
+
+ callback({
+ ok: true,
+ statusPages,
+ });
+
+ } catch (e) {
+ console.error(e);
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteMaintenance", async (maintenanceID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("maintenance", `Delete Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
+
+ if (maintenanceID in server.maintenanceList) {
+ server.maintenanceList[maintenanceID].stop();
+ delete server.maintenanceList[maintenanceID];
+ }
+
+ await R.exec("DELETE FROM maintenance WHERE id = ? AND user_id = ? ", [
+ maintenanceID,
+ socket.userID,
+ ]);
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ await server.sendMaintenanceList(socket);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("pauseMaintenance", async (maintenanceID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("maintenance", `Pause Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
+
+ let maintenance = server.getMaintenance(maintenanceID);
+
+ if (!maintenance) {
+ throw new Error("Maintenance not found");
+ }
+
+ maintenance.active = false;
+ await R.store(maintenance);
+ maintenance.stop();
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successPaused",
+ msgi18n: true,
+ });
+
+ await server.sendMaintenanceList(socket);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("resumeMaintenance", async (maintenanceID, callback) => {
+ try {
+ checkLogin(socket);
+
+ log.debug("maintenance", `Resume Maintenance: ${maintenanceID} User ID: ${socket.userID}`);
+
+ let maintenance = server.getMaintenance(maintenanceID);
+
+ if (!maintenance) {
+ throw new Error("Maintenance not found");
+ }
+
+ maintenance.active = true;
+ await R.store(maintenance);
+ await maintenance.run();
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ msg: "successResumed",
+ msgi18n: true,
+ });
+
+ await server.sendMaintenanceList(socket);
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+};
diff --git a/server/socket-handlers/proxy-socket-handler.js b/server/socket-handlers/proxy-socket-handler.js
new file mode 100644
index 0000000..9e80371
--- /dev/null
+++ b/server/socket-handlers/proxy-socket-handler.js
@@ -0,0 +1,61 @@
+const { checkLogin } = require("../util-server");
+const { Proxy } = require("../proxy");
+const { sendProxyList } = require("../client");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const server = UptimeKumaServer.getInstance();
+
+/**
+ * Handlers for proxy
+ * @param {Socket} socket Socket.io instance
+ * @returns {void}
+ */
+module.exports.proxySocketHandler = (socket) => {
+ socket.on("addProxy", async (proxy, proxyID, callback) => {
+ try {
+ checkLogin(socket);
+
+ const proxyBean = await Proxy.save(proxy, proxyID, socket.userID);
+ await sendProxyList(socket);
+
+ if (proxy.applyExisting) {
+ await Proxy.reloadProxy();
+ await server.sendMonitorList(socket);
+ }
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ id: proxyBean.id,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteProxy", async (proxyID, callback) => {
+ try {
+ checkLogin(socket);
+
+ await Proxy.delete(proxyID, socket.userID);
+ await sendProxyList(socket);
+ await Proxy.reloadProxy();
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+};
diff --git a/server/socket-handlers/remote-browser-socket-handler.js b/server/socket-handlers/remote-browser-socket-handler.js
new file mode 100644
index 0000000..ae53030
--- /dev/null
+++ b/server/socket-handlers/remote-browser-socket-handler.js
@@ -0,0 +1,82 @@
+const { sendRemoteBrowserList } = require("../client");
+const { checkLogin } = require("../util-server");
+const { RemoteBrowser } = require("../remote-browser");
+
+const { log } = require("../../src/util");
+const { testRemoteBrowser } = require("../monitor-types/real-browser-monitor-type");
+
+/**
+ * Handlers for docker hosts
+ * @param {Socket} socket Socket.io instance
+ * @returns {void}
+ */
+module.exports.remoteBrowserSocketHandler = (socket) => {
+ socket.on("addRemoteBrowser", async (remoteBrowser, remoteBrowserID, callback) => {
+ try {
+ checkLogin(socket);
+
+ let remoteBrowserBean = await RemoteBrowser.save(remoteBrowser, remoteBrowserID, socket.userID);
+ await sendRemoteBrowserList(socket);
+
+ callback({
+ ok: true,
+ msg: "Saved.",
+ msgi18n: true,
+ id: remoteBrowserBean.id,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("deleteRemoteBrowser", async (dockerHostID, callback) => {
+ try {
+ checkLogin(socket);
+
+ await RemoteBrowser.delete(dockerHostID, socket.userID);
+ await sendRemoteBrowserList(socket);
+
+ callback({
+ ok: true,
+ msg: "successDeleted",
+ msgi18n: true,
+ });
+
+ } catch (e) {
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+
+ socket.on("testRemoteBrowser", async (remoteBrowser, callback) => {
+ try {
+ checkLogin(socket);
+ let check = await testRemoteBrowser(remoteBrowser.url);
+ log.info("remoteBrowser", "Tested remote browser: " + check);
+ let msg;
+
+ if (check) {
+ msg = "Connected Successfully.";
+ }
+
+ callback({
+ ok: true,
+ msg,
+ });
+
+ } catch (e) {
+ log.error("remoteBrowser", e);
+
+ callback({
+ ok: false,
+ msg: e.message,
+ });
+ }
+ });
+};
diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js
new file mode 100644
index 0000000..0804da1
--- /dev/null
+++ b/server/socket-handlers/status-page-socket-handler.js
@@ -0,0 +1,374 @@
+const { R } = require("redbean-node");
+const { checkLogin, setSetting } = require("../util-server");
+const dayjs = require("dayjs");
+const { log } = require("../../src/util");
+const ImageDataURI = require("../image-data-uri");
+const Database = require("../database");
+const apicache = require("../modules/apicache");
+const StatusPage = require("../model/status_page");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+
+/**
+ * Socket handlers for status page
+ * @param {Socket} socket Socket.io instance to add listeners on
+ * @returns {void}
+ */
+module.exports.statusPageSocketHandler = (socket) => {
+
+ // Post or edit incident
+ socket.on("postIncident", async (slug, incident, callback) => {
+ try {
+ checkLogin(socket);
+
+ let statusPageID = await StatusPage.slugToID(slug);
+
+ if (!statusPageID) {
+ throw new Error("slug is not found");
+ }
+
+ await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [
+ statusPageID
+ ]);
+
+ let incidentBean;
+
+ if (incident.id) {
+ incidentBean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [
+ incident.id,
+ statusPageID
+ ]);
+ }
+
+ if (incidentBean == null) {
+ incidentBean = R.dispense("incident");
+ }
+
+ incidentBean.title = incident.title;
+ incidentBean.content = incident.content;
+ incidentBean.style = incident.style;
+ incidentBean.pin = true;
+ incidentBean.status_page_id = statusPageID;
+
+ if (incident.id) {
+ incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
+ } else {
+ incidentBean.createdDate = R.isoDateTime(dayjs.utc());
+ }
+
+ await R.store(incidentBean);
+
+ callback({
+ ok: true,
+ incident: incidentBean.toPublicJSON(),
+ });
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("unpinIncident", async (slug, callback) => {
+ try {
+ checkLogin(socket);
+
+ let statusPageID = await StatusPage.slugToID(slug);
+
+ await R.exec("UPDATE incident SET pin = 0 WHERE pin = 1 AND status_page_id = ? ", [
+ statusPageID
+ ]);
+
+ callback({
+ ok: true,
+ });
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ socket.on("getStatusPage", async (slug, callback) => {
+ try {
+ checkLogin(socket);
+
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (!statusPage) {
+ throw new Error("No slug?");
+ }
+
+ callback({
+ ok: true,
+ config: await statusPage.toJSON(),
+ });
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ // Save Status Page
+ // imgDataUrl Only Accept PNG!
+ socket.on("saveStatusPage", async (slug, config, imgDataUrl, publicGroupList, callback) => {
+ try {
+ checkLogin(socket);
+
+ // Save Config
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (!statusPage) {
+ throw new Error("No slug?");
+ }
+
+ checkSlug(config.slug);
+
+ const header = "data:image/png;base64,";
+
+ // Check logo format
+ // If is image data url, convert to png file
+ // Else assume it is a url, nothing to do
+ if (imgDataUrl.startsWith("data:")) {
+ if (! imgDataUrl.startsWith(header)) {
+ throw new Error("Only allowed PNG logo.");
+ }
+
+ const filename = `logo${statusPage.id}.png`;
+
+ // Convert to file
+ await ImageDataURI.outputFile(imgDataUrl, Database.uploadDir + filename);
+ config.logo = `/upload/${filename}?t=` + Date.now();
+
+ } else {
+ config.logo = imgDataUrl;
+ }
+
+ statusPage.slug = config.slug;
+ statusPage.title = config.title;
+ statusPage.description = config.description;
+ statusPage.icon = config.logo;
+ statusPage.autoRefreshInterval = config.autoRefreshInterval,
+ statusPage.theme = config.theme;
+ //statusPage.published = ;
+ //statusPage.search_engine_index = ;
+ statusPage.show_tags = config.showTags;
+ //statusPage.password = null;
+ statusPage.footer_text = config.footerText;
+ statusPage.custom_css = config.customCSS;
+ statusPage.show_powered_by = config.showPoweredBy;
+ statusPage.show_certificate_expiry = config.showCertificateExpiry;
+ statusPage.modified_date = R.isoDateTime();
+ statusPage.google_analytics_tag_id = config.googleAnalyticsId;
+
+ await R.store(statusPage);
+
+ await statusPage.updateDomainNameList(config.domainNameList);
+ await StatusPage.loadDomainMappingList();
+
+ // Save Public Group List
+ const groupIDList = [];
+ let groupOrder = 1;
+
+ for (let group of publicGroupList) {
+ let groupBean;
+ if (group.id) {
+ groupBean = await R.findOne("group", " id = ? AND public = 1 AND status_page_id = ? ", [
+ group.id,
+ statusPage.id
+ ]);
+ } else {
+ groupBean = R.dispense("group");
+ }
+
+ groupBean.status_page_id = statusPage.id;
+ groupBean.name = group.name;
+ groupBean.public = true;
+ groupBean.weight = groupOrder++;
+
+ await R.store(groupBean);
+
+ await R.exec("DELETE FROM monitor_group WHERE group_id = ? ", [
+ groupBean.id
+ ]);
+
+ let monitorOrder = 1;
+
+ for (let monitor of group.monitorList) {
+ let relationBean = R.dispense("monitor_group");
+ relationBean.weight = monitorOrder++;
+ relationBean.group_id = groupBean.id;
+ relationBean.monitor_id = monitor.id;
+
+ if (monitor.sendUrl !== undefined) {
+ relationBean.send_url = monitor.sendUrl;
+ }
+
+ await R.store(relationBean);
+ }
+
+ groupIDList.push(groupBean.id);
+ group.id = groupBean.id;
+ }
+
+ // Delete groups that are not in the list
+ log.debug("socket", "Delete groups that are not in the list");
+ const slots = groupIDList.map(() => "?").join(",");
+
+ const data = [
+ ...groupIDList,
+ statusPage.id
+ ];
+ await R.exec(`DELETE FROM \`group\` WHERE id NOT IN (${slots}) AND status_page_id = ?`, data);
+
+ const server = UptimeKumaServer.getInstance();
+
+ // Also change entry page to new slug if it is the default one, and slug is changed.
+ if (server.entryPage === "statusPage-" + slug && statusPage.slug !== slug) {
+ server.entryPage = "statusPage-" + statusPage.slug;
+ await setSetting("entryPage", server.entryPage, "general");
+ }
+
+ apicache.clear();
+
+ callback({
+ ok: true,
+ publicGroupList,
+ });
+
+ } catch (error) {
+ log.error("socket", error);
+
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ // Add a new status page
+ socket.on("addStatusPage", async (title, slug, callback) => {
+ try {
+ checkLogin(socket);
+
+ title = title?.trim();
+ slug = slug?.trim();
+
+ // Check empty
+ if (!title || !slug) {
+ throw new Error("Please input all fields");
+ }
+
+ // Make sure slug is string
+ if (typeof slug !== "string") {
+ throw new Error("Slug -Accept string only");
+ }
+
+ // lower case only
+ slug = slug.toLowerCase();
+
+ checkSlug(slug);
+
+ let statusPage = R.dispense("status_page");
+ statusPage.slug = slug;
+ statusPage.title = title;
+ statusPage.theme = "auto";
+ statusPage.icon = "";
+ statusPage.autoRefreshInterval = 300;
+ await R.store(statusPage);
+
+ callback({
+ ok: true,
+ msg: "successAdded",
+ msgi18n: true,
+ });
+
+ } catch (error) {
+ console.error(error);
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+
+ // Delete a status page
+ socket.on("deleteStatusPage", async (slug, callback) => {
+ const server = UptimeKumaServer.getInstance();
+
+ try {
+ checkLogin(socket);
+
+ let statusPageID = await StatusPage.slugToID(slug);
+
+ if (statusPageID) {
+
+ // Reset entry page if it is the default one.
+ if (server.entryPage === "statusPage-" + slug) {
+ server.entryPage = "dashboard";
+ await setSetting("entryPage", server.entryPage, "general");
+ }
+
+ // No need to delete records from `status_page_cname`, because it has cascade foreign key.
+ // But for incident & group, it is hard to add cascade foreign key during migration, so they have to be deleted manually.
+
+ // Delete incident
+ await R.exec("DELETE FROM incident WHERE status_page_id = ? ", [
+ statusPageID
+ ]);
+
+ // Delete group
+ await R.exec("DELETE FROM `group` WHERE status_page_id = ? ", [
+ statusPageID
+ ]);
+
+ // Delete status_page
+ await R.exec("DELETE FROM status_page WHERE id = ? ", [
+ statusPageID
+ ]);
+
+ } else {
+ throw new Error("Status Page is not found");
+ }
+
+ callback({
+ ok: true,
+ });
+ } catch (error) {
+ callback({
+ ok: false,
+ msg: error.message,
+ });
+ }
+ });
+};
+
+/**
+ * Check slug a-z, 0-9, - only
+ * Regex from: https://stackoverflow.com/questions/22454258/js-regex-string-validation-for-slug
+ * @param {string} slug Slug to test
+ * @returns {void}
+ * @throws Slug is not valid
+ */
+function checkSlug(slug) {
+ if (typeof slug !== "string") {
+ throw new Error("Slug must be string");
+ }
+
+ slug = slug.trim();
+
+ if (!slug) {
+ throw new Error("Slug cannot be empty");
+ }
+
+ if (!slug.match(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/)) {
+ throw new Error("Invalid Slug");
+ }
+}
diff --git a/server/uptime-calculator.js b/server/uptime-calculator.js
new file mode 100644
index 0000000..71d1d45
--- /dev/null
+++ b/server/uptime-calculator.js
@@ -0,0 +1,865 @@
+const dayjs = require("dayjs");
+const { UP, MAINTENANCE, DOWN, PENDING } = require("../src/util");
+const { LimitQueue } = require("./utils/limit-queue");
+const { log } = require("../src/util");
+const { R } = require("redbean-node");
+
+/**
+ * Calculates the uptime of a monitor.
+ */
+class UptimeCalculator {
+ /**
+ * @private
+ * @type {{string:UptimeCalculator}}
+ */
+ static list = {};
+
+ /**
+ * For testing purposes, we can set the current date to a specific date.
+ * @type {dayjs.Dayjs}
+ */
+ static currentDate = null;
+
+ /**
+ * monitorID the id of the monitor
+ * @type {number}
+ */
+ monitorID;
+
+ /**
+ * Recent 24-hour uptime, each item is a 1-minute interval
+ * Key: {number} DivisionKey
+ * @type {LimitQueue<number,string>}
+ */
+ minutelyUptimeDataList = new LimitQueue(24 * 60);
+
+ /**
+ * Recent 30-day uptime, each item is a 1-hour interval
+ * Key: {number} DivisionKey
+ * @type {LimitQueue<number,string>}
+ */
+ hourlyUptimeDataList = new LimitQueue(30 * 24);
+
+ /**
+ * Daily uptime data,
+ * Key: {number} DailyKey
+ */
+ dailyUptimeDataList = new LimitQueue(365);
+
+ lastUptimeData = null;
+ lastHourlyUptimeData = null;
+ lastDailyUptimeData = null;
+
+ lastDailyStatBean = null;
+ lastHourlyStatBean = null;
+ lastMinutelyStatBean = null;
+
+ /**
+ * For migration purposes.
+ * @type {boolean}
+ */
+ migrationMode = false;
+
+ statMinutelyKeepHour = 24;
+ statHourlyKeepDay = 30;
+
+ /**
+ * Get the uptime calculator for a monitor
+ * Initializes and returns the monitor if it does not exist
+ * @param {number} monitorID the id of the monitor
+ * @returns {Promise<UptimeCalculator>} UptimeCalculator
+ */
+ static async getUptimeCalculator(monitorID) {
+ if (!monitorID) {
+ throw new Error("Monitor ID is required");
+ }
+
+ if (!UptimeCalculator.list[monitorID]) {
+ UptimeCalculator.list[monitorID] = new UptimeCalculator();
+ await UptimeCalculator.list[monitorID].init(monitorID);
+ }
+ return UptimeCalculator.list[monitorID];
+ }
+
+ /**
+ * Remove a monitor from the list
+ * @param {number} monitorID the id of the monitor
+ * @returns {Promise<void>}
+ */
+ static async remove(monitorID) {
+ delete UptimeCalculator.list[monitorID];
+ }
+
+ /**
+ *
+ */
+ constructor() {
+ if (process.env.TEST_BACKEND) {
+ // Override the getCurrentDate() method to return a specific date
+ // Only for testing
+ this.getCurrentDate = () => {
+ if (UptimeCalculator.currentDate) {
+ return UptimeCalculator.currentDate;
+ } else {
+ return dayjs.utc();
+ }
+ };
+ }
+ }
+
+ /**
+ * Initialize the uptime calculator for a monitor
+ * @param {number} monitorID the id of the monitor
+ * @returns {Promise<void>}
+ */
+ async init(monitorID) {
+ this.monitorID = monitorID;
+
+ let now = this.getCurrentDate();
+
+ // Load minutely data from database (recent 24 hours only)
+ let minutelyStatBeans = await R.find("stat_minutely", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
+ monitorID,
+ this.getMinutelyKey(now.subtract(24, "hour")),
+ ]);
+
+ for (let bean of minutelyStatBeans) {
+ let data = {
+ up: bean.up,
+ down: bean.down,
+ avgPing: bean.ping,
+ minPing: bean.pingMin,
+ maxPing: bean.pingMax,
+ };
+
+ if (bean.extras != null) {
+ data = {
+ ...data,
+ ...JSON.parse(bean.extras),
+ };
+ }
+
+ let key = bean.timestamp;
+ this.minutelyUptimeDataList.push(key, data);
+ }
+
+ // Load hourly data from database (recent 30 days only)
+ let hourlyStatBeans = await R.find("stat_hourly", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
+ monitorID,
+ this.getHourlyKey(now.subtract(30, "day")),
+ ]);
+
+ for (let bean of hourlyStatBeans) {
+ let data = {
+ up: bean.up,
+ down: bean.down,
+ avgPing: bean.ping,
+ minPing: bean.pingMin,
+ maxPing: bean.pingMax,
+ };
+
+ if (bean.extras != null) {
+ data = {
+ ...data,
+ ...JSON.parse(bean.extras),
+ };
+ }
+
+ this.hourlyUptimeDataList.push(bean.timestamp, data);
+ }
+
+ // Load daily data from database (recent 365 days only)
+ let dailyStatBeans = await R.find("stat_daily", " monitor_id = ? AND timestamp > ? ORDER BY timestamp", [
+ monitorID,
+ this.getDailyKey(now.subtract(365, "day")),
+ ]);
+
+ for (let bean of dailyStatBeans) {
+ let data = {
+ up: bean.up,
+ down: bean.down,
+ avgPing: bean.ping,
+ minPing: bean.pingMin,
+ maxPing: bean.pingMax,
+ };
+
+ if (bean.extras != null) {
+ data = {
+ ...data,
+ ...JSON.parse(bean.extras),
+ };
+ }
+
+ this.dailyUptimeDataList.push(bean.timestamp, data);
+ }
+ }
+
+ /**
+ * @param {number} status status
+ * @param {number} ping Ping
+ * @param {dayjs.Dayjs} date Date (Only for migration)
+ * @returns {dayjs.Dayjs} date
+ * @throws {Error} Invalid status
+ */
+ async update(status, ping = 0, date) {
+ if (!date) {
+ date = this.getCurrentDate();
+ }
+
+ let flatStatus = this.flatStatus(status);
+
+ if (flatStatus === DOWN && ping > 0) {
+ log.debug("uptime-calc", "The ping is not effective when the status is DOWN");
+ }
+
+ let divisionKey = this.getMinutelyKey(date);
+ let hourlyKey = this.getHourlyKey(date);
+ let dailyKey = this.getDailyKey(date);
+
+ let minutelyData = this.minutelyUptimeDataList[divisionKey];
+ let hourlyData = this.hourlyUptimeDataList[hourlyKey];
+ let dailyData = this.dailyUptimeDataList[dailyKey];
+
+ if (status === MAINTENANCE) {
+ minutelyData.maintenance = minutelyData.maintenance ? minutelyData.maintenance + 1 : 1;
+ hourlyData.maintenance = hourlyData.maintenance ? hourlyData.maintenance + 1 : 1;
+ dailyData.maintenance = dailyData.maintenance ? dailyData.maintenance + 1 : 1;
+
+ } else if (flatStatus === UP) {
+ minutelyData.up += 1;
+ hourlyData.up += 1;
+ dailyData.up += 1;
+
+ // Only UP status can update the ping
+ if (!isNaN(ping)) {
+ // Add avg ping
+ // The first beat of the minute, the ping is the current ping
+ if (minutelyData.up === 1) {
+ minutelyData.avgPing = ping;
+ minutelyData.minPing = ping;
+ minutelyData.maxPing = ping;
+ } else {
+ minutelyData.avgPing = (minutelyData.avgPing * (minutelyData.up - 1) + ping) / minutelyData.up;
+ minutelyData.minPing = Math.min(minutelyData.minPing, ping);
+ minutelyData.maxPing = Math.max(minutelyData.maxPing, ping);
+ }
+
+ // Add avg ping
+ // The first beat of the hour, the ping is the current ping
+ if (hourlyData.up === 1) {
+ hourlyData.avgPing = ping;
+ hourlyData.minPing = ping;
+ hourlyData.maxPing = ping;
+ } else {
+ hourlyData.avgPing = (hourlyData.avgPing * (hourlyData.up - 1) + ping) / hourlyData.up;
+ hourlyData.minPing = Math.min(hourlyData.minPing, ping);
+ hourlyData.maxPing = Math.max(hourlyData.maxPing, ping);
+ }
+
+ // Add avg ping (daily)
+ // The first beat of the day, the ping is the current ping
+ if (dailyData.up === 1) {
+ dailyData.avgPing = ping;
+ dailyData.minPing = ping;
+ dailyData.maxPing = ping;
+ } else {
+ dailyData.avgPing = (dailyData.avgPing * (dailyData.up - 1) + ping) / dailyData.up;
+ dailyData.minPing = Math.min(dailyData.minPing, ping);
+ dailyData.maxPing = Math.max(dailyData.maxPing, ping);
+ }
+ }
+
+ } else if (flatStatus === DOWN) {
+ minutelyData.down += 1;
+ hourlyData.down += 1;
+ dailyData.down += 1;
+ }
+
+ if (minutelyData !== this.lastUptimeData) {
+ this.lastUptimeData = minutelyData;
+ }
+
+ if (hourlyData !== this.lastHourlyUptimeData) {
+ this.lastHourlyUptimeData = hourlyData;
+ }
+
+ if (dailyData !== this.lastDailyUptimeData) {
+ this.lastDailyUptimeData = dailyData;
+ }
+
+ // Don't store data in test mode
+ if (process.env.TEST_BACKEND) {
+ log.debug("uptime-calc", "Skip storing data in test mode");
+ return date;
+ }
+
+ let dailyStatBean = await this.getDailyStatBean(dailyKey);
+ dailyStatBean.up = dailyData.up;
+ dailyStatBean.down = dailyData.down;
+ dailyStatBean.ping = dailyData.avgPing;
+ dailyStatBean.pingMin = dailyData.minPing;
+ dailyStatBean.pingMax = dailyData.maxPing;
+ {
+ // eslint-disable-next-line no-unused-vars
+ const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = dailyData;
+ if (Object.keys(extras).length > 0) {
+ dailyStatBean.extras = JSON.stringify(extras);
+ }
+ }
+ await R.store(dailyStatBean);
+
+ let currentDate = this.getCurrentDate();
+
+ // For migration mode, we don't need to store old hourly and minutely data, but we need 30-day's hourly data
+ // Run anyway for non-migration mode
+ if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statHourlyKeepDay, "day"))) {
+ let hourlyStatBean = await this.getHourlyStatBean(hourlyKey);
+ hourlyStatBean.up = hourlyData.up;
+ hourlyStatBean.down = hourlyData.down;
+ hourlyStatBean.ping = hourlyData.avgPing;
+ hourlyStatBean.pingMin = hourlyData.minPing;
+ hourlyStatBean.pingMax = hourlyData.maxPing;
+ {
+ // eslint-disable-next-line no-unused-vars
+ const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = hourlyData;
+ if (Object.keys(extras).length > 0) {
+ hourlyStatBean.extras = JSON.stringify(extras);
+ }
+ }
+ await R.store(hourlyStatBean);
+ }
+
+ // For migration mode, we don't need to store old hourly and minutely data, but we need 24-hour's minutely data
+ // Run anyway for non-migration mode
+ if (!this.migrationMode || date.isAfter(currentDate.subtract(this.statMinutelyKeepHour, "hour"))) {
+ let minutelyStatBean = await this.getMinutelyStatBean(divisionKey);
+ minutelyStatBean.up = minutelyData.up;
+ minutelyStatBean.down = minutelyData.down;
+ minutelyStatBean.ping = minutelyData.avgPing;
+ minutelyStatBean.pingMin = minutelyData.minPing;
+ minutelyStatBean.pingMax = minutelyData.maxPing;
+ {
+ // eslint-disable-next-line no-unused-vars
+ const { up, down, avgPing, minPing, maxPing, timestamp, ...extras } = minutelyData;
+ if (Object.keys(extras).length > 0) {
+ minutelyStatBean.extras = JSON.stringify(extras);
+ }
+ }
+ await R.store(minutelyStatBean);
+ }
+
+ // No need to remove old data in migration mode
+ if (!this.migrationMode) {
+ // Remove the old data
+ // TODO: Improvement: Convert it to a job?
+ log.debug("uptime-calc", "Remove old data");
+ await R.exec("DELETE FROM stat_minutely WHERE monitor_id = ? AND timestamp < ?", [
+ this.monitorID,
+ this.getMinutelyKey(currentDate.subtract(this.statMinutelyKeepHour, "hour")),
+ ]);
+
+ await R.exec("DELETE FROM stat_hourly WHERE monitor_id = ? AND timestamp < ?", [
+ this.monitorID,
+ this.getHourlyKey(currentDate.subtract(this.statHourlyKeepDay, "day")),
+ ]);
+ }
+
+ return date;
+ }
+
+ /**
+ * Get the daily stat bean
+ * @param {number} timestamp milliseconds
+ * @returns {Promise<import("redbean-node").Bean>} stat_daily bean
+ */
+ async getDailyStatBean(timestamp) {
+ if (this.lastDailyStatBean && this.lastDailyStatBean.timestamp === timestamp) {
+ return this.lastDailyStatBean;
+ }
+
+ let bean = await R.findOne("stat_daily", " monitor_id = ? AND timestamp = ?", [
+ this.monitorID,
+ timestamp,
+ ]);
+
+ if (!bean) {
+ bean = R.dispense("stat_daily");
+ bean.monitor_id = this.monitorID;
+ bean.timestamp = timestamp;
+ }
+
+ this.lastDailyStatBean = bean;
+ return this.lastDailyStatBean;
+ }
+
+ /**
+ * Get the hourly stat bean
+ * @param {number} timestamp milliseconds
+ * @returns {Promise<import("redbean-node").Bean>} stat_hourly bean
+ */
+ async getHourlyStatBean(timestamp) {
+ if (this.lastHourlyStatBean && this.lastHourlyStatBean.timestamp === timestamp) {
+ return this.lastHourlyStatBean;
+ }
+
+ let bean = await R.findOne("stat_hourly", " monitor_id = ? AND timestamp = ?", [
+ this.monitorID,
+ timestamp,
+ ]);
+
+ if (!bean) {
+ bean = R.dispense("stat_hourly");
+ bean.monitor_id = this.monitorID;
+ bean.timestamp = timestamp;
+ }
+
+ this.lastHourlyStatBean = bean;
+ return this.lastHourlyStatBean;
+ }
+
+ /**
+ * Get the minutely stat bean
+ * @param {number} timestamp milliseconds
+ * @returns {Promise<import("redbean-node").Bean>} stat_minutely bean
+ */
+ async getMinutelyStatBean(timestamp) {
+ if (this.lastMinutelyStatBean && this.lastMinutelyStatBean.timestamp === timestamp) {
+ return this.lastMinutelyStatBean;
+ }
+
+ let bean = await R.findOne("stat_minutely", " monitor_id = ? AND timestamp = ?", [
+ this.monitorID,
+ timestamp,
+ ]);
+
+ if (!bean) {
+ bean = R.dispense("stat_minutely");
+ bean.monitor_id = this.monitorID;
+ bean.timestamp = timestamp;
+ }
+
+ this.lastMinutelyStatBean = bean;
+ return this.lastMinutelyStatBean;
+ }
+
+ /**
+ * Convert timestamp to minutely key
+ * @param {dayjs.Dayjs} date The heartbeat date
+ * @returns {number} Timestamp
+ */
+ getMinutelyKey(date) {
+ // Truncate value to minutes (e.g. 2021-01-01 12:34:56 -> 2021-01-01 12:34:00)
+ date = date.startOf("minute");
+
+ // Convert to timestamp in second
+ let divisionKey = date.unix();
+
+ if (! (divisionKey in this.minutelyUptimeDataList)) {
+ this.minutelyUptimeDataList.push(divisionKey, {
+ up: 0,
+ down: 0,
+ avgPing: 0,
+ minPing: 0,
+ maxPing: 0,
+ });
+ }
+
+ return divisionKey;
+ }
+
+ /**
+ * Convert timestamp to hourly key
+ * @param {dayjs.Dayjs} date The heartbeat date
+ * @returns {number} Timestamp
+ */
+ getHourlyKey(date) {
+ // Truncate value to hours (e.g. 2021-01-01 12:34:56 -> 2021-01-01 12:00:00)
+ date = date.startOf("hour");
+
+ // Convert to timestamp in second
+ let divisionKey = date.unix();
+
+ if (! (divisionKey in this.hourlyUptimeDataList)) {
+ this.hourlyUptimeDataList.push(divisionKey, {
+ up: 0,
+ down: 0,
+ avgPing: 0,
+ minPing: 0,
+ maxPing: 0,
+ });
+ }
+
+ return divisionKey;
+ }
+
+ /**
+ * Convert timestamp to daily key
+ * @param {dayjs.Dayjs} date The heartbeat date
+ * @returns {number} Timestamp
+ */
+ getDailyKey(date) {
+ // Truncate value to start of day (e.g. 2021-01-01 12:34:56 -> 2021-01-01 00:00:00)
+ // Considering if the user keep changing could affect the calculation, so use UTC time to avoid this problem.
+ date = date.utc().startOf("day");
+ let dailyKey = date.unix();
+
+ if (!this.dailyUptimeDataList[dailyKey]) {
+ this.dailyUptimeDataList.push(dailyKey, {
+ up: 0,
+ down: 0,
+ avgPing: 0,
+ minPing: 0,
+ maxPing: 0,
+ });
+ }
+
+ return dailyKey;
+ }
+
+ /**
+ * Convert timestamp to key
+ * @param {dayjs.Dayjs} datetime Datetime
+ * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned
+ * @returns {number} Timestamp
+ * @throws {Error} If the type is invalid
+ */
+ getKey(datetime, type) {
+ switch (type) {
+ case "day":
+ return this.getDailyKey(datetime);
+ case "hour":
+ return this.getHourlyKey(datetime);
+ case "minute":
+ return this.getMinutelyKey(datetime);
+ default:
+ throw new Error("Invalid type");
+ }
+ }
+
+ /**
+ * Flat status to UP or DOWN
+ * @param {number} status the status which should be turned into a flat status
+ * @returns {UP|DOWN|PENDING} The flat status
+ * @throws {Error} Invalid status
+ */
+ flatStatus(status) {
+ switch (status) {
+ case UP:
+ case MAINTENANCE:
+ return UP;
+ case DOWN:
+ case PENDING:
+ return DOWN;
+ }
+ throw new Error("Invalid status");
+ }
+
+ /**
+ * @param {number} num the number of data points which are expected to be returned
+ * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned
+ * @returns {UptimeDataResult} UptimeDataResult
+ * @throws {Error} The maximum number of minutes greater than 1440
+ */
+ getData(num, type = "day") {
+
+ if (type === "hour" && num > 24 * 30) {
+ throw new Error("The maximum number of hours is 720");
+ }
+ if (type === "minute" && num > 24 * 60) {
+ throw new Error("The maximum number of minutes is 1440");
+ }
+ if (type === "day" && num > 365) {
+ throw new Error("The maximum number of days is 365");
+ }
+ // Get the current time period key based on the type
+ let key = this.getKey(this.getCurrentDate(), type);
+
+ let total = {
+ up: 0,
+ down: 0,
+ };
+
+ let totalPing = 0;
+ let endTimestamp;
+
+ // Get the eariest timestamp of the required period based on the type
+ switch (type) {
+ case "day":
+ endTimestamp = key - 86400 * (num - 1);
+ break;
+ case "hour":
+ endTimestamp = key - 3600 * (num - 1);
+ break;
+ case "minute":
+ endTimestamp = key - 60 * (num - 1);
+ break;
+ default:
+ throw new Error("Invalid type");
+ }
+
+ // Sum up all data in the specified time range
+ while (key >= endTimestamp) {
+ let data;
+
+ switch (type) {
+ case "day":
+ data = this.dailyUptimeDataList[key];
+ break;
+ case "hour":
+ data = this.hourlyUptimeDataList[key];
+ break;
+ case "minute":
+ data = this.minutelyUptimeDataList[key];
+ break;
+ default:
+ throw new Error("Invalid type");
+ }
+
+ if (data) {
+ total.up += data.up;
+ total.down += data.down;
+ totalPing += data.avgPing * data.up;
+ }
+
+ // Set key to the previous time period
+ switch (type) {
+ case "day":
+ key -= 86400;
+ break;
+ case "hour":
+ key -= 3600;
+ break;
+ case "minute":
+ key -= 60;
+ break;
+ default:
+ throw new Error("Invalid type");
+ }
+ }
+
+ let uptimeData = new UptimeDataResult();
+
+ // If there is no data in the previous time ranges, use the last data?
+ if (total.up === 0 && total.down === 0) {
+ switch (type) {
+ case "day":
+ if (this.lastDailyUptimeData) {
+ total = this.lastDailyUptimeData;
+ totalPing = total.avgPing * total.up;
+ } else {
+ return uptimeData;
+ }
+ break;
+ case "hour":
+ if (this.lastHourlyUptimeData) {
+ total = this.lastHourlyUptimeData;
+ totalPing = total.avgPing * total.up;
+ } else {
+ return uptimeData;
+ }
+ break;
+ case "minute":
+ if (this.lastUptimeData) {
+ total = this.lastUptimeData;
+ totalPing = total.avgPing * total.up;
+ } else {
+ return uptimeData;
+ }
+ break;
+ default:
+ throw new Error("Invalid type");
+ }
+ }
+
+ let avgPing;
+
+ if (total.up === 0) {
+ avgPing = null;
+ } else {
+ avgPing = totalPing / total.up;
+ }
+
+ if (total.up + total.down === 0) {
+ uptimeData.uptime = 0;
+ } else {
+ uptimeData.uptime = total.up / (total.up + total.down);
+ }
+ uptimeData.avgPing = avgPing;
+ return uptimeData;
+ }
+
+ /**
+ * Get data in form of an array
+ * @param {number} num the number of data points which are expected to be returned
+ * @param {"day" | "hour" | "minute"} type the type of data which is expected to be returned
+ * @returns {Array<object>} uptime data
+ * @throws {Error} The maximum number of minutes greater than 1440
+ */
+ getDataArray(num, type = "day") {
+ if (type === "hour" && num > 24 * 30) {
+ throw new Error("The maximum number of hours is 720");
+ }
+ if (type === "minute" && num > 24 * 60) {
+ throw new Error("The maximum number of minutes is 1440");
+ }
+
+ // Get the current time period key based on the type
+ let key = this.getKey(this.getCurrentDate(), type);
+
+ let result = [];
+
+ let endTimestamp;
+
+ // Get the eariest timestamp of the required period based on the type
+ switch (type) {
+ case "day":
+ endTimestamp = key - 86400 * (num - 1);
+ break;
+ case "hour":
+ endTimestamp = key - 3600 * (num - 1);
+ break;
+ case "minute":
+ endTimestamp = key - 60 * (num - 1);
+ break;
+ default:
+ throw new Error("Invalid type");
+ }
+
+ // Get datapoints in the specified time range
+ while (key >= endTimestamp) {
+ let data;
+
+ switch (type) {
+ case "day":
+ data = this.dailyUptimeDataList[key];
+ break;
+ case "hour":
+ data = this.hourlyUptimeDataList[key];
+ break;
+ case "minute":
+ data = this.minutelyUptimeDataList[key];
+ break;
+ default:
+ throw new Error("Invalid type");
+ }
+
+ if (data) {
+ data.timestamp = key;
+ result.push(data);
+ }
+
+ // Set key to the previous time period
+ switch (type) {
+ case "day":
+ key -= 86400;
+ break;
+ case "hour":
+ key -= 3600;
+ break;
+ case "minute":
+ key -= 60;
+ break;
+ default:
+ throw new Error("Invalid type");
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the uptime data for given duration.
+ * @param {string} duration A string with a number and a unit (m,h,d,w,M,y), such as 24h, 30d, 1y.
+ * @returns {UptimeDataResult} UptimeDataResult
+ * @throws {Error} Invalid duration / Unsupported unit
+ */
+ getDataByDuration(duration) {
+ const durationNumStr = duration.slice(0, -1);
+
+ if (!/^[0-9]+$/.test(durationNumStr)) {
+ throw new Error(`Invalid duration: ${duration}`);
+ }
+ const num = Number(durationNumStr);
+ const unit = duration.slice(-1);
+
+ switch (unit) {
+ case "m":
+ return this.getData(num, "minute");
+ case "h":
+ return this.getData(num, "hour");
+ case "d":
+ return this.getData(num, "day");
+ case "w":
+ return this.getData(7 * num, "day");
+ case "M":
+ return this.getData(30 * num, "day");
+ case "y":
+ return this.getData(365 * num, "day");
+ default:
+ throw new Error(`Unsupported unit (${unit}) for badge duration ${duration}`
+ );
+ }
+ }
+
+ /**
+ * 1440 = 24 * 60mins
+ * @returns {UptimeDataResult} UptimeDataResult
+ */
+ get24Hour() {
+ return this.getData(1440, "minute");
+ }
+
+ /**
+ * @returns {UptimeDataResult} UptimeDataResult
+ */
+ get7Day() {
+ return this.getData(168, "hour");
+ }
+
+ /**
+ * @returns {UptimeDataResult} UptimeDataResult
+ */
+ get30Day() {
+ return this.getData(30);
+ }
+
+ /**
+ * @returns {UptimeDataResult} UptimeDataResult
+ */
+ get1Year() {
+ return this.getData(365);
+ }
+
+ /**
+ * @returns {dayjs.Dayjs} Current datetime in UTC
+ */
+ getCurrentDate() {
+ return dayjs.utc();
+ }
+
+ /**
+ * For migration purposes.
+ * @param {boolean} value Migration mode on/off
+ * @returns {void}
+ */
+ setMigrationMode(value) {
+ this.migrationMode = value;
+ }
+}
+
+class UptimeDataResult {
+ /**
+ * @type {number} Uptime
+ */
+ uptime = 0;
+
+ /**
+ * @type {number} Average ping
+ */
+ avgPing = null;
+}
+
+module.exports = {
+ UptimeCalculator,
+ UptimeDataResult,
+};
diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js
new file mode 100644
index 0000000..062f098
--- /dev/null
+++ b/server/uptime-kuma-server.js
@@ -0,0 +1,557 @@
+const express = require("express");
+const https = require("https");
+const fs = require("fs");
+const http = require("http");
+const { Server } = require("socket.io");
+const { R } = require("redbean-node");
+const { log, isDev } = require("../src/util");
+const Database = require("./database");
+const util = require("util");
+const { Settings } = require("./settings");
+const dayjs = require("dayjs");
+const childProcessAsync = require("promisify-child-process");
+const path = require("path");
+const axios = require("axios");
+const { isSSL, sslKey, sslCert, sslKeyPassphrase } = require("./config");
+// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
+
+/**
+ * `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue.
+ * @type {UptimeKumaServer}
+ */
+class UptimeKumaServer {
+ /**
+ * Current server instance
+ * @type {UptimeKumaServer}
+ */
+ static instance = null;
+
+ /**
+ * Main monitor list
+ * @type {{}}
+ */
+ monitorList = {};
+
+ /**
+ * Main maintenance list
+ * @type {{}}
+ */
+ maintenanceList = {};
+
+ entryPage = "dashboard";
+ app = undefined;
+ httpServer = undefined;
+ io = undefined;
+
+ /**
+ * Cache Index HTML
+ * @type {string}
+ */
+ indexHTML = "";
+
+ /**
+ * @type {{}}
+ */
+ static monitorTypeList = {
+
+ };
+
+ /**
+ * Use for decode the auth object
+ * @type {null}
+ */
+ jwtSecret = null;
+
+ /**
+ * Get the current instance of the server if it exists, otherwise
+ * create a new instance.
+ * @returns {UptimeKumaServer} Server instance
+ */
+ static getInstance() {
+ if (UptimeKumaServer.instance == null) {
+ UptimeKumaServer.instance = new UptimeKumaServer();
+ }
+ return UptimeKumaServer.instance;
+ }
+
+ /**
+ *
+ */
+ constructor() {
+ // Set axios default user-agent to Uptime-Kuma/version
+ axios.defaults.headers.common["User-Agent"] = this.getUserAgent();
+
+ // Set default axios timeout to 5 minutes instead of infinity
+ axios.defaults.timeout = 300 * 1000;
+
+ log.info("server", "Creating express and socket.io instance");
+ this.app = express();
+ if (isSSL) {
+ log.info("server", "Server Type: HTTPS");
+ this.httpServer = https.createServer({
+ key: fs.readFileSync(sslKey),
+ cert: fs.readFileSync(sslCert),
+ passphrase: sslKeyPassphrase,
+ }, this.app);
+ } else {
+ log.info("server", "Server Type: HTTP");
+ this.httpServer = http.createServer(this.app);
+ }
+
+ try {
+ this.indexHTML = fs.readFileSync("./dist/index.html").toString();
+ } catch (e) {
+ // "dist/index.html" is not necessary for development
+ if (process.env.NODE_ENV !== "development") {
+ log.error("server", "Error: Cannot find 'dist/index.html', did you install correctly?");
+ process.exit(1);
+ }
+ }
+
+ // Set Monitor Types
+ UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
+ UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();
+ UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType();
+ UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType();
+ UptimeKumaServer.monitorTypeList["snmp"] = new SNMPMonitorType();
+ UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
+ UptimeKumaServer.monitorTypeList["rabbitmq"] = new RabbitMqMonitorType();
+
+ // Allow all CORS origins (polling) in development
+ let cors = undefined;
+ if (isDev) {
+ cors = {
+ origin: "*",
+ };
+ }
+
+ this.io = new Server(this.httpServer, {
+ cors,
+ allowRequest: async (req, callback) => {
+ let transport;
+ // It should be always true, but just in case, because this property is not documented
+ if (req._query) {
+ transport = req._query.transport;
+ } else {
+ log.error("socket", "Ops!!! Cannot get transport type, assume that it is polling");
+ transport = "polling";
+ }
+
+ const clientIP = await this.getClientIPwithProxy(req.connection.remoteAddress, req.headers);
+ log.info("socket", `New ${transport} connection, IP = ${clientIP}`);
+
+ // The following check is only for websocket connections, polling connections are already protected by CORS
+ if (transport === "polling") {
+ callback(null, true);
+ } else if (transport === "websocket") {
+ const bypass = process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass";
+ if (bypass) {
+ log.info("auth", "WebSocket origin check is bypassed");
+ callback(null, true);
+ } else if (!req.headers.origin) {
+ log.info("auth", "WebSocket with no origin is allowed");
+ callback(null, true);
+ } else {
+ let host = req.headers.host;
+ let origin = req.headers.origin;
+
+ try {
+ let originURL = new URL(origin);
+ let xForwardedFor;
+ if (await Settings.get("trustProxy")) {
+ xForwardedFor = req.headers["x-forwarded-for"];
+ }
+
+ if (host !== originURL.host && xForwardedFor !== originURL.host) {
+ callback(null, false);
+ log.error("auth", `Origin (${origin}) does not match host (${host}), IP: ${clientIP}`);
+ } else {
+ callback(null, true);
+ }
+ } catch (e) {
+ // Invalid origin url, probably not from browser
+ callback(null, false);
+ log.error("auth", `Invalid origin url (${origin}), IP: ${clientIP}`);
+ }
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Initialise app after the database has been set up
+ * @returns {Promise<void>}
+ */
+ async initAfterDatabaseReady() {
+ // Static
+ this.app.use("/screenshots", express.static(Database.screenshotDir));
+
+ process.env.TZ = await this.getTimezone();
+ dayjs.tz.setDefault(process.env.TZ);
+ log.debug("DEBUG", "Timezone: " + process.env.TZ);
+ log.debug("DEBUG", "Current Time: " + dayjs.tz().format());
+
+ await this.loadMaintenanceList();
+ }
+
+ /**
+ * Send list of monitors to client
+ * @param {Socket} socket Socket to send list on
+ * @returns {Promise<object>} List of monitors
+ */
+ async sendMonitorList(socket) {
+ let list = await this.getMonitorJSONList(socket.userID);
+ this.io.to(socket.userID).emit("monitorList", list);
+ return list;
+ }
+
+ /**
+ * Update Monitor into list
+ * @param {Socket} socket Socket to send list on
+ * @param {number} monitorID update or deleted monitor id
+ * @returns {Promise<void>}
+ */
+ async sendUpdateMonitorIntoList(socket, monitorID) {
+ let list = await this.getMonitorJSONList(socket.userID, monitorID);
+ this.io.to(socket.userID).emit("updateMonitorIntoList", list);
+ }
+
+ /**
+ * Delete Monitor from list
+ * @param {Socket} socket Socket to send list on
+ * @param {number} monitorID update or deleted monitor id
+ * @returns {Promise<void>}
+ */
+ async sendDeleteMonitorFromList(socket, monitorID) {
+ this.io.to(socket.userID).emit("deleteMonitorFromList", monitorID);
+ }
+
+ /**
+ * Get a list of monitors for the given user.
+ * @param {string} userID - The ID of the user to get monitors for.
+ * @param {number} monitorID - The ID of monitor for.
+ * @returns {Promise<object>} A promise that resolves to an object with monitor IDs as keys and monitor objects as values.
+ *
+ * Generated by Trelent
+ */
+ async getMonitorJSONList(userID, monitorID = null) {
+
+ let query = " user_id = ? ";
+ let queryParams = [ userID ];
+
+ if (monitorID) {
+ query += "AND id = ? ";
+ queryParams.push(monitorID);
+ }
+
+ let monitorList = await R.find("monitor", query + "ORDER BY weight DESC, name", queryParams);
+
+ const monitorData = monitorList.map(monitor => ({
+ id: monitor.id,
+ active: monitor.active,
+ name: monitor.name,
+ }));
+ const preloadData = await Monitor.preparePreloadData(monitorData);
+
+ const result = {};
+ monitorList.forEach(monitor => result[monitor.id] = monitor.toJSON(preloadData));
+ return result;
+ }
+
+ /**
+ * Send maintenance list to client
+ * @param {Socket} socket Socket.io instance to send to
+ * @returns {Promise<object>} Maintenance list
+ */
+ async sendMaintenanceList(socket) {
+ return await this.sendMaintenanceListByUserID(socket.userID);
+ }
+
+ /**
+ * Send list of maintenances to user
+ * @param {number} userID User to send list to
+ * @returns {Promise<object>} Maintenance list
+ */
+ async sendMaintenanceListByUserID(userID) {
+ let list = await this.getMaintenanceJSONList(userID);
+ this.io.to(userID).emit("maintenanceList", list);
+ return list;
+ }
+
+ /**
+ * Get a list of maintenances for the given user.
+ * @param {string} userID - The ID of the user to get maintenances for.
+ * @returns {Promise<object>} A promise that resolves to an object with maintenance IDs as keys and maintenances objects as values.
+ */
+ async getMaintenanceJSONList(userID) {
+ let result = {};
+ for (let maintenanceID in this.maintenanceList) {
+ result[maintenanceID] = await this.maintenanceList[maintenanceID].toJSON();
+ }
+ return result;
+ }
+
+ /**
+ * Load maintenance list and run
+ * @param {any} userID Unused
+ * @returns {Promise<void>}
+ */
+ async loadMaintenanceList(userID) {
+ let maintenanceList = await R.findAll("maintenance", " ORDER BY end_date DESC, title", [
+
+ ]);
+
+ for (let maintenance of maintenanceList) {
+ this.maintenanceList[maintenance.id] = maintenance;
+ maintenance.run(this);
+ }
+ }
+
+ /**
+ * Retrieve a specific maintenance
+ * @param {number} maintenanceID ID of maintenance to retrieve
+ * @returns {(object|null)} Maintenance if it exists
+ */
+ getMaintenance(maintenanceID) {
+ if (this.maintenanceList[maintenanceID]) {
+ return this.maintenanceList[maintenanceID];
+ }
+ return null;
+ }
+
+ /**
+ * Write error to log file
+ * @param {any} error The error to write
+ * @param {boolean} outputToConsole Should the error also be output to console?
+ * @returns {void}
+ */
+ static errorLog(error, outputToConsole = true) {
+ const errorLogStream = fs.createWriteStream(path.join(Database.dataDir, "/error.log"), {
+ flags: "a"
+ });
+
+ errorLogStream.on("error", () => {
+ log.info("", "Cannot write to error.log");
+ });
+
+ if (errorLogStream) {
+ const dateTime = R.isoDateTime();
+ errorLogStream.write(`[${dateTime}] ` + util.format(error) + "\n");
+
+ if (outputToConsole) {
+ console.error(error);
+ }
+ }
+
+ errorLogStream.end();
+ }
+
+ /**
+ * Get the IP of the client connected to the socket
+ * @param {Socket} socket Socket to query
+ * @returns {Promise<string>} IP of client
+ */
+ getClientIP(socket) {
+ return this.getClientIPwithProxy(socket.client.conn.remoteAddress, socket.client.conn.request.headers);
+ }
+
+ /**
+ * @param {string} clientIP Raw client IP
+ * @param {IncomingHttpHeaders} headers HTTP headers
+ * @returns {Promise<string>} Client IP with proxy (if trusted)
+ */
+ async getClientIPwithProxy(clientIP, headers) {
+ if (clientIP === undefined) {
+ clientIP = "";
+ }
+
+ if (await Settings.get("trustProxy")) {
+ const forwardedFor = headers["x-forwarded-for"];
+
+ return (typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : null)
+ || headers["x-real-ip"]
+ || clientIP.replace(/^::ffff:/, "");
+ } else {
+ return clientIP.replace(/^::ffff:/, "");
+ }
+ }
+
+ /**
+ * Attempt to get the current server timezone
+ * If this fails, fall back to environment variables and then make a
+ * guess.
+ * @returns {Promise<string>} Current timezone
+ */
+ async getTimezone() {
+ // From process.env.TZ
+ try {
+ if (process.env.TZ) {
+ this.checkTimezone(process.env.TZ);
+ return process.env.TZ;
+ }
+ } catch (e) {
+ log.warn("timezone", e.message + " in process.env.TZ");
+ }
+
+ let timezone = await Settings.get("serverTimezone");
+
+ // From Settings
+ try {
+ log.debug("timezone", "Using timezone from settings: " + timezone);
+ if (timezone) {
+ this.checkTimezone(timezone);
+ return timezone;
+ }
+ } catch (e) {
+ log.warn("timezone", e.message + " in settings");
+ }
+
+ // Guess
+ try {
+ let guess = dayjs.tz.guess();
+ log.debug("timezone", "Guessing timezone: " + guess);
+ if (guess) {
+ this.checkTimezone(guess);
+ return guess;
+ } else {
+ return "UTC";
+ }
+ } catch (e) {
+ // Guess failed, fall back to UTC
+ log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback");
+ return "UTC";
+ }
+ }
+
+ /**
+ * Get the current offset
+ * @returns {string} Time offset
+ */
+ getTimezoneOffset() {
+ return dayjs().format("Z");
+ }
+
+ /**
+ * Throw an error if the timezone is invalid
+ * @param {string} timezone Timezone to test
+ * @returns {void}
+ * @throws The timezone is invalid
+ */
+ checkTimezone(timezone) {
+ try {
+ dayjs.utc("2013-11-18 11:55").tz(timezone).format();
+ } catch (e) {
+ throw new Error("Invalid timezone:" + timezone);
+ }
+ }
+
+ /**
+ * Set the current server timezone and environment variables
+ * @param {string} timezone Timezone to set
+ * @returns {Promise<void>}
+ */
+ async setTimezone(timezone) {
+ this.checkTimezone(timezone);
+ await Settings.set("serverTimezone", timezone, "general");
+ process.env.TZ = timezone;
+ dayjs.tz.setDefault(timezone);
+ }
+
+ /**
+ * TODO: Listen logic should be moved to here
+ * @returns {Promise<void>}
+ */
+ async start() {
+ let enable = await Settings.get("nscd");
+
+ if (enable || enable === null) {
+ await this.startNSCDServices();
+ }
+ }
+
+ /**
+ * Stop the server
+ * @returns {Promise<void>}
+ */
+ async stop() {
+ let enable = await Settings.get("nscd");
+
+ if (enable || enable === null) {
+ await this.stopNSCDServices();
+ }
+ }
+
+ /**
+ * Start all system services (e.g. nscd)
+ * For now, only used in Docker
+ * @returns {void}
+ */
+ async startNSCDServices() {
+ if (process.env.UPTIME_KUMA_IS_CONTAINER) {
+ try {
+ log.info("services", "Starting nscd");
+ await childProcessAsync.exec("sudo service nscd start");
+ } catch (e) {
+ log.info("services", "Failed to start nscd");
+ }
+ }
+ }
+
+ /**
+ * Stop all system services
+ * @returns {void}
+ */
+ async stopNSCDServices() {
+ if (process.env.UPTIME_KUMA_IS_CONTAINER) {
+ try {
+ log.info("services", "Stopping nscd");
+ await childProcessAsync.exec("sudo service nscd stop");
+ } catch (e) {
+ log.info("services", "Failed to stop nscd");
+ }
+ }
+ }
+
+ /**
+ * Default User-Agent when making HTTP requests
+ * @returns {string} User-Agent
+ */
+ getUserAgent() {
+ return "Uptime-Kuma/" + require("../package.json").version;
+ }
+
+ /**
+ * Force connected sockets of a user to refresh and disconnect.
+ * Used for resetting password.
+ * @param {string} userID User ID
+ * @param {string?} currentSocketID Current socket ID
+ * @returns {void}
+ */
+ disconnectAllSocketClients(userID, currentSocketID = undefined) {
+ for (const socket of this.io.sockets.sockets.values()) {
+ if (socket.userID === userID && socket.id !== currentSocketID) {
+ try {
+ socket.emit("refresh");
+ socket.disconnect();
+ } catch (e) {
+
+ }
+ }
+ }
+ }
+}
+
+module.exports = {
+ UptimeKumaServer
+};
+
+// Must be at the end to avoid circular dependencies
+const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");
+const { TailscalePing } = require("./monitor-types/tailscale-ping");
+const { DnsMonitorType } = require("./monitor-types/dns");
+const { MqttMonitorType } = require("./monitor-types/mqtt");
+const { SNMPMonitorType } = require("./monitor-types/snmp");
+const { MongodbMonitorType } = require("./monitor-types/mongodb");
+const { RabbitMqMonitorType } = require("./monitor-types/rabbitmq");
+const Monitor = require("./model/monitor");
diff --git a/server/util-server.js b/server/util-server.js
new file mode 100644
index 0000000..5ebc62a
--- /dev/null
+++ b/server/util-server.js
@@ -0,0 +1,1064 @@
+const tcpp = require("tcp-ping");
+const ping = require("@louislam/ping");
+const { R } = require("redbean-node");
+const { log, genSecret, badgeConstants } = require("../src/util");
+const passwordHash = require("./password-hash");
+const { Resolver } = require("dns");
+const iconv = require("iconv-lite");
+const chardet = require("chardet");
+const chroma = require("chroma-js");
+const mssql = require("mssql");
+const { Client } = require("pg");
+const postgresConParse = require("pg-connection-string").parse;
+const mysql = require("mysql2");
+const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js");
+const { Settings } = require("./settings");
+const grpc = require("@grpc/grpc-js");
+const protojs = require("protobufjs");
+const radiusClient = require("node-radius-client");
+const redis = require("redis");
+const oidc = require("openid-client");
+const tls = require("tls");
+
+const {
+ dictionaries: {
+ rfc2865: { file, attributes },
+ },
+} = require("node-radius-utils");
+const dayjs = require("dayjs");
+
+// SASLOptions used in JSDoc
+// eslint-disable-next-line no-unused-vars
+const { Kafka, SASLOptions } = require("kafkajs");
+const crypto = require("crypto");
+
+const isWindows = process.platform === /^win/.test(process.platform);
+/**
+ * Init or reset JWT secret
+ * @returns {Promise<Bean>} JWT secret
+ */
+exports.initJWTSecret = async () => {
+ let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [
+ "jwtSecret",
+ ]);
+
+ if (!jwtSecretBean) {
+ jwtSecretBean = R.dispense("setting");
+ jwtSecretBean.key = "jwtSecret";
+ }
+
+ jwtSecretBean.value = passwordHash.generate(genSecret());
+ await R.store(jwtSecretBean);
+ return jwtSecretBean;
+};
+
+/**
+ * Decodes a jwt and returns the payload portion without verifying the jqt.
+ * @param {string} jwt The input jwt as a string
+ * @returns {object} Decoded jwt payload object
+ */
+exports.decodeJwt = (jwt) => {
+ return JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString());
+};
+
+/**
+ * Gets a Access Token form a oidc/oauth2 provider
+ * @param {string} tokenEndpoint The token URI form the auth service provider
+ * @param {string} clientId The oidc/oauth application client id
+ * @param {string} clientSecret The oidc/oauth application client secret
+ * @param {string} scope The scope the for which the token should be issued for
+ * @param {string} authMethod The method on how to sent the credentials. Default client_secret_basic
+ * @returns {Promise<oidc.TokenSet>} TokenSet promise if the token request was successful
+ */
+exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSecret, scope, authMethod = "client_secret_basic") => {
+ const oauthProvider = new oidc.Issuer({ token_endpoint: tokenEndpoint });
+ let client = new oauthProvider.Client({
+ client_id: clientId,
+ client_secret: clientSecret,
+ token_endpoint_auth_method: authMethod
+ });
+
+ // Increase default timeout and clock tolerance
+ client[oidc.custom.http_options] = () => ({ timeout: 10000 });
+ client[oidc.custom.clock_tolerance] = 5;
+
+ let grantParams = { grant_type: "client_credentials" };
+ if (scope) {
+ grantParams.scope = scope;
+ }
+ return await client.grant(grantParams);
+};
+
+/**
+ * Send TCP request to specified hostname and port
+ * @param {string} hostname Hostname / address of machine
+ * @param {number} port TCP port to test
+ * @returns {Promise<number>} Maximum time in ms rounded to nearest integer
+ */
+exports.tcping = function (hostname, port) {
+ return new Promise((resolve, reject) => {
+ tcpp.ping({
+ address: hostname,
+ port: port,
+ attempts: 1,
+ }, function (err, data) {
+
+ if (err) {
+ reject(err);
+ }
+
+ if (data.results.length >= 1 && data.results[0].err) {
+ reject(data.results[0].err);
+ }
+
+ resolve(Math.round(data.max));
+ });
+ });
+};
+
+/**
+ * Ping the specified machine
+ * @param {string} hostname Hostname / address of machine
+ * @param {number} size Size of packet to send
+ * @returns {Promise<number>} Time for ping in ms rounded to nearest integer
+ */
+exports.ping = async (hostname, size = 56) => {
+ try {
+ return await exports.pingAsync(hostname, false, size);
+ } catch (e) {
+ // If the host cannot be resolved, try again with ipv6
+ log.debug("ping", "IPv6 error message: " + e.message);
+
+ // As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what.
+ if (!e.message) {
+ return await exports.pingAsync(hostname, true, size);
+ } else {
+ throw e;
+ }
+ }
+};
+
+/**
+ * Ping the specified machine
+ * @param {string} hostname Hostname / address of machine to ping
+ * @param {boolean} ipv6 Should IPv6 be used?
+ * @param {number} size Size of ping packet to send
+ * @returns {Promise<number>} Time for ping in ms rounded to nearest integer
+ */
+exports.pingAsync = function (hostname, ipv6 = false, size = 56) {
+ return new Promise((resolve, reject) => {
+ ping.promise.probe(hostname, {
+ v6: ipv6,
+ min_reply: 1,
+ deadline: 10,
+ packetSize: size,
+ }).then((res) => {
+ // If ping failed, it will set field to unknown
+ if (res.alive) {
+ resolve(res.time);
+ } else {
+ if (isWindows) {
+ reject(new Error(exports.convertToUTF8(res.output)));
+ } else {
+ reject(new Error(res.output));
+ }
+ }
+ }).catch((err) => {
+ reject(err);
+ });
+ });
+};
+
+/**
+ * Monitor Kafka using Producer
+ * @param {string[]} brokers List of kafka brokers to connect, host and
+ * port joined by ':'
+ * @param {string} topic Topic name to produce into
+ * @param {string} message Message to produce
+ * @param {object} options Kafka client options. Contains ssl, clientId,
+ * allowAutoTopicCreation and interval (interval defaults to 20,
+ * allowAutoTopicCreation defaults to false, clientId defaults to
+ * "Uptime-Kuma" and ssl defaults to false)
+ * @param {SASLOptions} saslOptions Options for kafka client
+ * Authentication (SASL) (defaults to {})
+ * @returns {Promise<string>} Status message
+ */
+exports.kafkaProducerAsync = function (brokers, topic, message, options = {}, saslOptions = {}) {
+ return new Promise((resolve, reject) => {
+ const { interval = 20, allowAutoTopicCreation = false, ssl = false, clientId = "Uptime-Kuma" } = options;
+
+ let connectedToKafka = false;
+
+ const timeoutID = setTimeout(() => {
+ log.debug("kafkaProducer", "KafkaProducer timeout triggered");
+ connectedToKafka = true;
+ reject(new Error("Timeout"));
+ }, interval * 1000 * 0.8);
+
+ if (saslOptions.mechanism === "None") {
+ saslOptions = undefined;
+ }
+
+ let client = new Kafka({
+ brokers: brokers,
+ clientId: clientId,
+ sasl: saslOptions,
+ retry: {
+ retries: 0,
+ },
+ ssl: ssl,
+ });
+
+ let producer = client.producer({
+ allowAutoTopicCreation: allowAutoTopicCreation,
+ retry: {
+ retries: 0,
+ }
+ });
+
+ producer.connect().then(
+ () => {
+ producer.send({
+ topic: topic,
+ messages: [{
+ value: message,
+ }],
+ }).then((_) => {
+ resolve("Message sent successfully");
+ }).catch((e) => {
+ connectedToKafka = true;
+ producer.disconnect();
+ clearTimeout(timeoutID);
+ reject(new Error("Error sending message: " + e.message));
+ }).finally(() => {
+ connectedToKafka = true;
+ clearTimeout(timeoutID);
+ });
+ }
+ ).catch(
+ (e) => {
+ connectedToKafka = true;
+ producer.disconnect();
+ clearTimeout(timeoutID);
+ reject(new Error("Error in producer connection: " + e.message));
+ }
+ );
+
+ producer.on("producer.network.request_timeout", (_) => {
+ if (!connectedToKafka) {
+ clearTimeout(timeoutID);
+ reject(new Error("producer.network.request_timeout"));
+ }
+ });
+
+ producer.on("producer.disconnect", (_) => {
+ if (!connectedToKafka) {
+ clearTimeout(timeoutID);
+ reject(new Error("producer.disconnect"));
+ }
+ });
+ });
+};
+
+/**
+ * Use NTLM Auth for a http request.
+ * @param {object} options The http request options
+ * @param {object} ntlmOptions The auth options
+ * @returns {Promise<(string[] | object[] | object)>} NTLM response
+ */
+exports.httpNtlm = function (options, ntlmOptions) {
+ return new Promise((resolve, reject) => {
+ let client = NtlmClient(ntlmOptions);
+
+ client(options)
+ .then((resp) => {
+ resolve(resp);
+ })
+ .catch((err) => {
+ reject(err);
+ });
+ });
+};
+
+/**
+ * Resolves a given record using the specified DNS server
+ * @param {string} hostname The hostname of the record to lookup
+ * @param {string} resolverServer The DNS server to use
+ * @param {string} resolverPort Port the DNS server is listening on
+ * @param {string} rrtype The type of record to request
+ * @returns {Promise<(string[] | object[] | object)>} DNS response
+ */
+exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) {
+ const resolver = new Resolver();
+ // Remove brackets from IPv6 addresses so we can re-add them to
+ // prevent issues with ::1:5300 (::1 port 5300)
+ resolverServer = resolverServer.replace("[", "").replace("]", "");
+ resolver.setServers([ `[${resolverServer}]:${resolverPort}` ]);
+ return new Promise((resolve, reject) => {
+ if (rrtype === "PTR") {
+ resolver.reverse(hostname, (err, records) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(records);
+ }
+ });
+ } else {
+ resolver.resolve(hostname, rrtype, (err, records) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(records);
+ }
+ });
+ }
+ });
+};
+
+/**
+ * Run a query on SQL Server
+ * @param {string} connectionString The database connection string
+ * @param {string} query The query to validate the database with
+ * @returns {Promise<(string[] | object[] | object)>} Response from
+ * server
+ */
+exports.mssqlQuery = async function (connectionString, query) {
+ let pool;
+ try {
+ pool = new mssql.ConnectionPool(connectionString);
+ await pool.connect();
+ if (!query) {
+ query = "SELECT 1";
+ }
+ await pool.request().query(query);
+ pool.close();
+ } catch (e) {
+ if (pool) {
+ pool.close();
+ }
+ throw e;
+ }
+};
+
+/**
+ * Run a query on Postgres
+ * @param {string} connectionString The database connection string
+ * @param {string} query The query to validate the database with
+ * @returns {Promise<(string[] | object[] | object)>} Response from
+ * server
+ */
+exports.postgresQuery = function (connectionString, query) {
+ return new Promise((resolve, reject) => {
+ const config = postgresConParse(connectionString);
+
+ // Fix #3868, which true/false is not parsed to boolean
+ if (typeof config.ssl === "string") {
+ config.ssl = config.ssl === "true";
+ }
+
+ if (config.password === "") {
+ // See https://github.com/brianc/node-postgres/issues/1927
+ reject(new Error("Password is undefined."));
+ return;
+ }
+ const client = new Client(config);
+
+ client.on("error", (error) => {
+ log.debug("postgres", "Error caught in the error event handler.");
+ reject(error);
+ });
+
+ client.connect((err) => {
+ if (err) {
+ reject(err);
+ client.end();
+ } else {
+ // Connected here
+ try {
+ // No query provided by user, use SELECT 1
+ if (!query || (typeof query === "string" && query.trim() === "")) {
+ query = "SELECT 1";
+ }
+
+ client.query(query, (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ client.end();
+ });
+ } catch (e) {
+ reject(e);
+ client.end();
+ }
+ }
+ });
+
+ });
+};
+
+/**
+ * Run a query on MySQL/MariaDB
+ * @param {string} connectionString The database connection string
+ * @param {string} query The query to validate the database with
+ * @param {?string} password The password to use
+ * @returns {Promise<(string)>} Response from server
+ */
+exports.mysqlQuery = function (connectionString, query, password = undefined) {
+ return new Promise((resolve, reject) => {
+ const connection = mysql.createConnection({
+ uri: connectionString,
+ password
+ });
+
+ connection.on("error", (err) => {
+ reject(err);
+ });
+
+ connection.query(query, (err, res) => {
+ if (err) {
+ reject(err);
+ } else {
+ if (Array.isArray(res)) {
+ resolve("Rows: " + res.length);
+ } else {
+ resolve("No Error, but the result is not an array. Type: " + typeof res);
+ }
+ }
+
+ try {
+ connection.end();
+ } catch (_) {
+ connection.destroy();
+ }
+ });
+ });
+};
+
+/**
+ * Query radius server
+ * @param {string} hostname Hostname of radius server
+ * @param {string} username Username to use
+ * @param {string} password Password to use
+ * @param {string} calledStationId ID of called station
+ * @param {string} callingStationId ID of calling station
+ * @param {string} secret Secret to use
+ * @param {number} port Port to contact radius server on
+ * @param {number} timeout Timeout for connection to use
+ * @returns {Promise<any>} Response from server
+ */
+exports.radius = function (
+ hostname,
+ username,
+ password,
+ calledStationId,
+ callingStationId,
+ secret,
+ port = 1812,
+ timeout = 2500,
+) {
+ const client = new radiusClient({
+ host: hostname,
+ hostPort: port,
+ timeout: timeout,
+ retries: 1,
+ dictionaries: [ file ],
+ });
+
+ return client.accessRequest({
+ secret: secret,
+ attributes: [
+ [ attributes.USER_NAME, username ],
+ [ attributes.USER_PASSWORD, password ],
+ [ attributes.CALLING_STATION_ID, callingStationId ],
+ [ attributes.CALLED_STATION_ID, calledStationId ],
+ ],
+ }).catch((error) => {
+ if (error.response?.code) {
+ throw Error(error.response.code);
+ } else {
+ throw Error(error.message);
+ }
+ });
+};
+
+/**
+ * Redis server ping
+ * @param {string} dsn The redis connection string
+ * @param {boolean} rejectUnauthorized If false, allows unverified server certificates.
+ * @returns {Promise<any>} Response from server
+ */
+exports.redisPingAsync = function (dsn, rejectUnauthorized) {
+ return new Promise((resolve, reject) => {
+ const client = redis.createClient({
+ url: dsn,
+ socket: {
+ rejectUnauthorized
+ }
+ });
+ client.on("error", (err) => {
+ if (client.isOpen) {
+ client.disconnect();
+ }
+ reject(err);
+ });
+ client.connect().then(() => {
+ if (!client.isOpen) {
+ client.emit("error", new Error("connection isn't open"));
+ }
+ client.ping().then((res, err) => {
+ if (client.isOpen) {
+ client.disconnect();
+ }
+ if (err) {
+ reject(err);
+ } else {
+ resolve(res);
+ }
+ }).catch(error => reject(error));
+ });
+ });
+};
+
+/**
+ * Retrieve value of setting based on key
+ * @param {string} key Key of setting to retrieve
+ * @returns {Promise<any>} Value
+ * @deprecated Use await Settings.get(key)
+ */
+exports.setting = async function (key) {
+ return await Settings.get(key);
+};
+
+/**
+ * Sets the specified setting to specified value
+ * @param {string} key Key of setting to set
+ * @param {any} value Value to set to
+ * @param {?string} type Type of setting
+ * @returns {Promise<void>}
+ */
+exports.setSetting = async function (key, value, type = null) {
+ await Settings.set(key, value, type);
+};
+
+/**
+ * Get settings based on type
+ * @param {string} type The type of setting
+ * @returns {Promise<Bean>} Settings of requested type
+ */
+exports.getSettings = async function (type) {
+ return await Settings.getSettings(type);
+};
+
+/**
+ * Set settings based on type
+ * @param {string} type Type of settings to set
+ * @param {object} data Values of settings
+ * @returns {Promise<void>}
+ */
+exports.setSettings = async function (type, data) {
+ await Settings.setSettings(type, data);
+};
+
+// ssl-checker by @dyaa
+//https://github.com/dyaa/ssl-checker/blob/master/src/index.ts
+
+/**
+ * Get number of days between two dates
+ * @param {Date} validFrom Start date
+ * @param {Date} validTo End date
+ * @returns {number} Number of days
+ */
+const getDaysBetween = (validFrom, validTo) =>
+ Math.round(Math.abs(+validFrom - +validTo) / 8.64e7);
+
+/**
+ * Get days remaining from a time range
+ * @param {Date} validFrom Start date
+ * @param {Date} validTo End date
+ * @returns {number} Number of days remaining
+ */
+const getDaysRemaining = (validFrom, validTo) => {
+ const daysRemaining = getDaysBetween(validFrom, validTo);
+ if (new Date(validTo).getTime() < new Date().getTime()) {
+ return -daysRemaining;
+ }
+ return daysRemaining;
+};
+
+/**
+ * Fix certificate info for display
+ * @param {object} info The chain obtained from getPeerCertificate()
+ * @returns {object} An object representing certificate information
+ * @throws The certificate chain length exceeded 500.
+ */
+const parseCertificateInfo = function (info) {
+ let link = info;
+ let i = 0;
+
+ const existingList = {};
+
+ while (link) {
+ log.debug("cert", `[${i}] ${link.fingerprint}`);
+
+ if (!link.valid_from || !link.valid_to) {
+ break;
+ }
+ link.validTo = new Date(link.valid_to);
+ link.validFor = link.subjectaltname?.replace(/DNS:|IP Address:/g, "").split(", ");
+ link.daysRemaining = getDaysRemaining(new Date(), link.validTo);
+
+ existingList[link.fingerprint] = true;
+
+ // Move up the chain until loop is encountered
+ if (link.issuerCertificate == null) {
+ link.certType = (i === 0) ? "self-signed" : "root CA";
+ break;
+ } else if (link.issuerCertificate.fingerprint in existingList) {
+ // a root CA certificate is typically "signed by itself" (=> "self signed certificate") and thus the "issuerCertificate" is a reference to itself.
+ log.debug("cert", `[Last] ${link.issuerCertificate.fingerprint}`);
+ link.certType = (i === 0) ? "self-signed" : "root CA";
+ link.issuerCertificate = null;
+ break;
+ } else {
+ link.certType = (i === 0) ? "server" : "intermediate CA";
+ link = link.issuerCertificate;
+ }
+
+ // Should be no use, but just in case.
+ if (i > 500) {
+ throw new Error("Dead loop occurred in parseCertificateInfo");
+ }
+ i++;
+ }
+
+ return info;
+};
+
+/**
+ * Check if certificate is valid
+ * @param {tls.TLSSocket} socket TLSSocket, which may or may not be connected
+ * @returns {object} Object containing certificate information
+ */
+exports.checkCertificate = function (socket) {
+ let certInfoStartTime = dayjs().valueOf();
+
+ // Return null if there is no socket
+ if (socket === undefined || socket == null) {
+ return null;
+ }
+
+ const info = socket.getPeerCertificate(true);
+ const valid = socket.authorized || false;
+
+ log.debug("cert", "Parsing Certificate Info");
+ const parsedInfo = parseCertificateInfo(info);
+
+ if (process.env.TIMELOGGER === "1") {
+ log.debug("monitor", "Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms");
+ }
+
+ return {
+ valid: valid,
+ certInfo: parsedInfo
+ };
+};
+
+/**
+ * Check if the provided status code is within the accepted ranges
+ * @param {number} status The status code to check
+ * @param {string[]} acceptedCodes An array of accepted status codes
+ * @returns {boolean} True if status code within range, false otherwise
+ */
+exports.checkStatusCode = function (status, acceptedCodes) {
+ if (acceptedCodes == null || acceptedCodes.length === 0) {
+ return false;
+ }
+
+ for (const codeRange of acceptedCodes) {
+ if (typeof codeRange !== "string") {
+ log.error("monitor", `Accepted status code not a string. ${codeRange} is of type ${typeof codeRange}`);
+ continue;
+ }
+
+ const codeRangeSplit = codeRange.split("-").map(string => parseInt(string));
+ if (codeRangeSplit.length === 1) {
+ if (status === codeRangeSplit[0]) {
+ return true;
+ }
+ } else if (codeRangeSplit.length === 2) {
+ if (status >= codeRangeSplit[0] && status <= codeRangeSplit[1]) {
+ return true;
+ }
+ } else {
+ log.error("monitor", `${codeRange} is not a valid status code range`);
+ continue;
+ }
+ }
+
+ return false;
+};
+
+/**
+ * Get total number of clients in room
+ * @param {Server} io Socket server instance
+ * @param {string} roomName Name of room to check
+ * @returns {number} Total clients in room
+ */
+exports.getTotalClientInRoom = (io, roomName) => {
+
+ const sockets = io.sockets;
+
+ if (!sockets) {
+ return 0;
+ }
+
+ const adapter = sockets.adapter;
+
+ if (!adapter) {
+ return 0;
+ }
+
+ const room = adapter.rooms.get(roomName);
+
+ if (room) {
+ return room.size;
+ } else {
+ return 0;
+ }
+};
+
+/**
+ * Allow CORS all origins if development
+ * @param {object} res Response object from axios
+ * @returns {void}
+ */
+exports.allowDevAllOrigin = (res) => {
+ if (process.env.NODE_ENV === "development") {
+ exports.allowAllOrigin(res);
+ }
+};
+
+/**
+ * Allow CORS all origins
+ * @param {object} res Response object from axios
+ * @returns {void}
+ */
+exports.allowAllOrigin = (res) => {
+ res.header("Access-Control-Allow-Origin", "*");
+ res.header("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
+ res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
+};
+
+/**
+ * Check if a user is logged in
+ * @param {Socket} socket Socket instance
+ * @returns {void}
+ * @throws The user is not logged in
+ */
+exports.checkLogin = (socket) => {
+ if (!socket.userID) {
+ throw new Error("You are not logged in.");
+ }
+};
+
+/**
+ * For logged-in users, double-check the password
+ * @param {Socket} socket Socket.io instance
+ * @param {string} currentPassword Password to validate
+ * @returns {Promise<Bean>} User
+ * @throws The current password is not a string
+ * @throws The provided password is not correct
+ */
+exports.doubleCheckPassword = async (socket, currentPassword) => {
+ if (typeof currentPassword !== "string") {
+ throw new Error("Wrong data type?");
+ }
+
+ let user = await R.findOne("user", " id = ? AND active = 1 ", [
+ socket.userID,
+ ]);
+
+ if (!user || !passwordHash.verify(currentPassword, user.password)) {
+ throw new Error("Incorrect current password");
+ }
+
+ return user;
+};
+
+/**
+ * Convert unknown string to UTF8
+ * @param {Uint8Array} body Buffer
+ * @returns {string} UTF8 string
+ */
+exports.convertToUTF8 = (body) => {
+ const guessEncoding = chardet.detect(body);
+ const str = iconv.decode(body, guessEncoding);
+ return str.toString();
+};
+
+/**
+ * Returns a color code in hex format based on a given percentage:
+ * 0% => hue = 10 => red
+ * 100% => hue = 90 => green
+ * @param {number} percentage float, 0 to 1
+ * @param {number} maxHue Maximum hue - int
+ * @param {number} minHue Minimum hue - int
+ * @returns {string} Color in hex
+ */
+exports.percentageToColor = (percentage, maxHue = 90, minHue = 10) => {
+ const hue = percentage * (maxHue - minHue) + minHue;
+ try {
+ return chroma(`hsl(${hue}, 90%, 40%)`).hex();
+ } catch (err) {
+ return badgeConstants.naColor;
+ }
+};
+
+/**
+ * Joins and array of string to one string after filtering out empty values
+ * @param {string[]} parts Strings to join
+ * @param {string} connector Separator for joined strings
+ * @returns {string} Joined strings
+ */
+exports.filterAndJoin = (parts, connector = "") => {
+ return parts.filter((part) => !!part && part !== "").join(connector);
+};
+
+/**
+ * Send an Error response
+ * @param {object} res Express response object
+ * @param {string} msg Message to send
+ * @returns {void}
+ */
+module.exports.sendHttpError = (res, msg = "") => {
+ if (msg.includes("SQLITE_BUSY") || msg.includes("SQLITE_LOCKED")) {
+ res.status(503).json({
+ "status": "fail",
+ "msg": msg,
+ });
+ } else if (msg.toLowerCase().includes("not found")) {
+ res.status(404).json({
+ "status": "fail",
+ "msg": msg,
+ });
+ } else {
+ res.status(403).json({
+ "status": "fail",
+ "msg": msg,
+ });
+ }
+};
+
+/**
+ * Convert timezone of time object
+ * @param {object} obj Time object to update
+ * @param {string} timezone New timezone to set
+ * @param {boolean} timeObjectToUTC Convert time object to UTC
+ * @returns {object} Time object with updated timezone
+ */
+function timeObjectConvertTimezone(obj, timezone, timeObjectToUTC = true) {
+ let offsetString;
+
+ if (timezone) {
+ offsetString = dayjs().tz(timezone).format("Z");
+ } else {
+ offsetString = dayjs().format("Z");
+ }
+
+ let hours = parseInt(offsetString.substring(1, 3));
+ let minutes = parseInt(offsetString.substring(4, 6));
+
+ if (
+ (timeObjectToUTC && offsetString.startsWith("+")) ||
+ (!timeObjectToUTC && offsetString.startsWith("-"))
+ ) {
+ hours *= -1;
+ minutes *= -1;
+ }
+
+ obj.hours += hours;
+ obj.minutes += minutes;
+
+ // Handle out of bound
+ if (obj.minutes < 0) {
+ obj.minutes += 60;
+ obj.hours--;
+ } else if (obj.minutes > 60) {
+ obj.minutes -= 60;
+ obj.hours++;
+ }
+
+ if (obj.hours < 0) {
+ obj.hours += 24;
+ } else if (obj.hours > 24) {
+ obj.hours -= 24;
+ }
+
+ return obj;
+}
+
+/**
+ * Convert time object to UTC
+ * @param {object} obj Object to convert
+ * @param {string} timezone Timezone of time object
+ * @returns {object} Updated time object
+ */
+module.exports.timeObjectToUTC = (obj, timezone = undefined) => {
+ return timeObjectConvertTimezone(obj, timezone, true);
+};
+
+/**
+ * Convert time object to local time
+ * @param {object} obj Object to convert
+ * @param {string} timezone Timezone to convert to
+ * @returns {object} Updated object
+ */
+module.exports.timeObjectToLocal = (obj, timezone = undefined) => {
+ return timeObjectConvertTimezone(obj, timezone, false);
+};
+
+/**
+ * Create gRPC client stib
+ * @param {object} options from gRPC client
+ * @returns {Promise<object>} Result of gRPC query
+ */
+module.exports.grpcQuery = async (options) => {
+ const { grpcUrl, grpcProtobufData, grpcServiceName, grpcEnableTls, grpcMethod, grpcBody } = options;
+ const protocObject = protojs.parse(grpcProtobufData);
+ const protoServiceObject = protocObject.root.lookupService(grpcServiceName);
+ const Client = grpc.makeGenericClientConstructor({});
+ const credentials = grpcEnableTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure();
+ const client = new Client(
+ grpcUrl,
+ credentials
+ );
+ const grpcService = protoServiceObject.create(function (method, requestData, cb) {
+ const fullServiceName = method.fullName;
+ const serviceFQDN = fullServiceName.split(".");
+ const serviceMethod = serviceFQDN.pop();
+ const serviceMethodClientImpl = `/${serviceFQDN.slice(1).join(".")}/${serviceMethod}`;
+ log.debug("monitor", `gRPC method ${serviceMethodClientImpl}`);
+ client.makeUnaryRequest(
+ serviceMethodClientImpl,
+ arg => arg,
+ arg => arg,
+ requestData,
+ cb);
+ }, false, false);
+ return new Promise((resolve, _) => {
+ try {
+ return grpcService[`${grpcMethod}`](JSON.parse(grpcBody), function (err, response) {
+ const responseData = JSON.stringify(response);
+ if (err) {
+ return resolve({
+ code: err.code,
+ errorMessage: err.details,
+ data: ""
+ });
+ } else {
+ log.debug("monitor:", `gRPC response: ${JSON.stringify(response)}`);
+ return resolve({
+ code: 1,
+ errorMessage: "",
+ data: responseData
+ });
+ }
+ });
+ } catch (err) {
+ return resolve({
+ code: -1,
+ errorMessage: `Error ${err}. Please review your gRPC configuration option. The service name must not include package name value, and the method name must follow camelCase format`,
+ data: ""
+ });
+ }
+
+ });
+};
+
+/**
+ * Returns an array of SHA256 fingerprints for all known root certificates.
+ * @returns {Set} A set of SHA256 fingerprints.
+ */
+module.exports.rootCertificatesFingerprints = () => {
+ let fingerprints = tls.rootCertificates.map(cert => {
+ let certLines = cert.split("\n");
+ certLines.shift();
+ certLines.pop();
+ let certBody = certLines.join("");
+ let buf = Buffer.from(certBody, "base64");
+
+ const shasum = crypto.createHash("sha256");
+ shasum.update(buf);
+
+ return shasum.digest("hex").toUpperCase().replace(/(.{2})(?!$)/g, "$1:");
+ });
+
+ fingerprints.push("6D:99:FB:26:5E:B1:C5:B3:74:47:65:FC:BC:64:8F:3C:D8:E1:BF:FA:FD:C4:C2:F9:9B:9D:47:CF:7F:F1:C2:4F"); // ISRG X1 cross-signed with DST X3
+ fingerprints.push("8B:05:B6:8C:C6:59:E5:ED:0F:CB:38:F2:C9:42:FB:FD:20:0E:6F:2F:F9:F8:5D:63:C6:99:4E:F5:E0:B0:27:01"); // ISRG X2 cross-signed with ISRG X1
+
+ return new Set(fingerprints);
+};
+
+module.exports.SHAKE256_LENGTH = 16;
+
+/**
+ * @param {string} data The data to be hashed
+ * @param {number} len Output length of the hash
+ * @returns {string} The hashed data in hex format
+ */
+module.exports.shake256 = (data, len) => {
+ if (!data) {
+ return "";
+ }
+ return crypto.createHash("shake256", { outputLength: len })
+ .update(data)
+ .digest("hex");
+};
+
+/**
+ * Non await sleep
+ * Source: https://stackoverflow.com/questions/59099454/is-there-a-way-to-call-sleep-without-await-keyword
+ * @param {number} n Milliseconds to wait
+ * @returns {void}
+ */
+module.exports.wait = (n) => {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, n);
+};
+
+// For unit test, export functions
+if (process.env.TEST_BACKEND) {
+ module.exports.__test = {
+ parseCertificateInfo,
+ };
+ module.exports.__getPrivateFunction = (functionName) => {
+ return module.exports.__test[functionName];
+ };
+}
+
+/**
+ * Generates an abort signal with the specified timeout.
+ * @param {number} timeoutMs - The timeout in milliseconds.
+ * @returns {AbortSignal | null} - The generated abort signal, or null if not supported.
+ */
+module.exports.axiosAbortSignal = (timeoutMs) => {
+ try {
+ // Just in case, as 0 timeout here will cause the request to be aborted immediately
+ if (!timeoutMs || timeoutMs <= 0) {
+ timeoutMs = 5000;
+ }
+ return AbortSignal.timeout(timeoutMs);
+ } catch (_) {
+ // v16-: AbortSignal.timeout is not supported
+ try {
+ const abortController = new AbortController();
+ setTimeout(() => abortController.abort(), timeoutMs);
+
+ return abortController.signal;
+ } catch (_) {
+ // v15-: AbortController is not supported
+ return null;
+ }
+ }
+};
diff --git a/server/utils/array-with-key.js b/server/utils/array-with-key.js
new file mode 100644
index 0000000..94afc79
--- /dev/null
+++ b/server/utils/array-with-key.js
@@ -0,0 +1,85 @@
+/**
+ * An object that can be used as an array with a key
+ * Like PHP's array
+ * @template K
+ * @template V
+ */
+class ArrayWithKey {
+ /**
+ * All keys that are stored in the current object
+ * @type {K[]}
+ * @private
+ */
+ __stack = [];
+
+ /**
+ * Push an element to the end of the array
+ * @param {K} key The key of the element
+ * @param {V} value The value of the element
+ * @returns {void}
+ */
+ push(key, value) {
+ this[key] = value;
+ this.__stack.push(key);
+ }
+
+ /**
+ * Get the last element and remove it from the array
+ * @returns {V|undefined} The first value, or undefined if there is no element to pop
+ */
+ pop() {
+ let key = this.__stack.pop();
+ let prop = this[key];
+ delete this[key];
+ return prop;
+ }
+
+ /**
+ * Get the last key
+ * @returns {K|null} The last key, or null if the array is empty
+ */
+ getLastKey() {
+ if (this.__stack.length === 0) {
+ return null;
+ }
+ return this.__stack[this.__stack.length - 1];
+ }
+
+ /**
+ * Get the first element
+ * @returns {{key:K,value:V}|null} The first element, or null if the array is empty
+ */
+ shift() {
+ let key = this.__stack.shift();
+ let value = this[key];
+ delete this[key];
+ return {
+ key,
+ value,
+ };
+ }
+
+ /**
+ * Get the length of the array
+ * @returns {number} Amount of elements stored
+ */
+ length() {
+ return this.__stack.length;
+ }
+
+ /**
+ * Get the last value
+ * @returns {V|null} The last element without removing it, or null if the array is empty
+ */
+ last() {
+ let key = this.getLastKey();
+ if (key === null) {
+ return null;
+ }
+ return this[key];
+ }
+}
+
+module.exports = {
+ ArrayWithKey
+};
diff --git a/server/utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler.js b/server/utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler.js
new file mode 100644
index 0000000..d05a6bc
--- /dev/null
+++ b/server/utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler.js
@@ -0,0 +1,22 @@
+const ColumnCompilerMySQL = require("knex/lib/dialects/mysql/schema/mysql-columncompiler");
+const { formatDefault } = require("knex/lib/formatter/formatterUtils");
+const { log } = require("../../../../../../../src/util");
+
+class KumaColumnCompiler extends ColumnCompilerMySQL {
+ /**
+ * Override defaultTo method to handle default value for TEXT fields
+ * @param {any} value Value
+ * @returns {string|void} Default value (Don't understand why it can return void or string, but it's the original code, lol)
+ */
+ defaultTo(value) {
+ if (this.type === "text" && typeof value === "string") {
+ log.debug("defaultTo", `${this.args[0]}: ${this.type} ${value} ${typeof value}`);
+ // MySQL 8.0 is required and only if the value is written as an expression: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html
+ // MariaDB 10.2 is required: https://mariadb.com/kb/en/text/
+ return `default (${formatDefault(value, this.type, this.client)})`;
+ }
+ return super.defaultTo.apply(this, arguments);
+ }
+}
+
+module.exports = KumaColumnCompiler;
diff --git a/server/utils/limit-queue.js b/server/utils/limit-queue.js
new file mode 100644
index 0000000..9da6d41
--- /dev/null
+++ b/server/utils/limit-queue.js
@@ -0,0 +1,48 @@
+const { ArrayWithKey } = require("./array-with-key");
+
+/**
+ * Limit Queue
+ * The first element will be removed when the length exceeds the limit
+ */
+class LimitQueue extends ArrayWithKey {
+
+ /**
+ * The limit of the queue after which the first element will be removed
+ * @private
+ * @type {number}
+ */
+ __limit;
+ /**
+ * The callback function when the queue exceeds the limit
+ * @private
+ * @callback onExceedCallback
+ * @param {{key:K,value:V}|nul} item
+ */
+ __onExceed = null;
+
+ /**
+ * @param {number} limit The limit of the queue after which the first element will be removed
+ */
+ constructor(limit) {
+ super();
+ this.__limit = limit;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ push(key, value) {
+ super.push(key, value);
+ if (this.length() > this.__limit) {
+ let item = this.shift();
+ if (this.__onExceed) {
+ this.__onExceed(item);
+ }
+ }
+ }
+
+}
+
+module.exports = {
+ LimitQueue
+};
diff --git a/server/utils/simple-migration-server.js b/server/utils/simple-migration-server.js
new file mode 100644
index 0000000..680f8df
--- /dev/null
+++ b/server/utils/simple-migration-server.js
@@ -0,0 +1,84 @@
+const express = require("express");
+const http = require("node:http");
+const { log } = require("../../src/util");
+
+/**
+ * SimpleMigrationServer
+ * For displaying the migration status of the server
+ * Also, it is used to let Docker healthcheck know the status of the server, as the main server is not started yet, healthcheck will think the server is down incorrectly.
+ */
+class SimpleMigrationServer {
+ /**
+ * Express app instance
+ * @type {?Express}
+ */
+ app;
+
+ /**
+ * Server instance
+ * @type {?Server}
+ */
+ server;
+
+ /**
+ * Response object
+ * @type {?Response}
+ */
+ response;
+
+ /**
+ * Start the server
+ * @param {number} port Port
+ * @param {string} hostname Hostname
+ * @returns {Promise<void>}
+ */
+ start(port, hostname) {
+ this.app = express();
+ this.server = http.createServer(this.app);
+
+ this.app.get("/", (req, res) => {
+ res.set("Content-Type", "text/plain");
+ res.write("Migration is in progress, listening message...\n");
+ if (this.response) {
+ this.response.write("Disconnected\n");
+ this.response.end();
+ }
+ this.response = res;
+ // never ending response
+ });
+
+ return new Promise((resolve) => {
+ this.server.listen(port, hostname, () => {
+ if (hostname) {
+ log.info("migration", `Migration server is running on http://${hostname}:${port}`);
+ } else {
+ log.info("migration", `Migration server is running on http://localhost:${port}`);
+ }
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Update the message
+ * @param {string} msg Message to update
+ * @returns {void}
+ */
+ update(msg) {
+ this.response?.write(msg + "\n");
+ }
+
+ /**
+ * Stop the server
+ * @returns {Promise<void>}
+ */
+ async stop() {
+ this.response?.write("Finished, please refresh this page.\n");
+ this.response?.end();
+ await this.server?.close();
+ }
+}
+
+module.exports = {
+ SimpleMigrationServer,
+};