diff options
Diffstat (limited to 'server/routers/api-router.js')
-rw-r--r-- | server/routers/api-router.js | 631 |
1 files changed, 631 insertions, 0 deletions
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; |