Merge remote-tracking branch 'origin/master' into skaempfe#2593

# Conflicts:
#	server/model/monitor.js
#	src/pages/Details.vue
This commit is contained in:
Louis Lam 2023-05-26 21:32:58 +08:00
commit 0b8dddba24
247 changed files with 34964 additions and 32467 deletions

View file

@ -33,7 +33,7 @@ tsconfig.json
/ecosystem.config.js /ecosystem.config.js
/extra/healthcheck.exe /extra/healthcheck.exe
/extra/healthcheck /extra/healthcheck
extra/exe-builder
### .gitignore content (commented rules are duplicated) ### .gitignore content (commented rules are duplicated)
@ -48,6 +48,4 @@ dist-ssr
#!/data/.gitkeep #!/data/.gitkeep
#.vscode #.vscode
### End of .gitignore content ### End of .gitignore content

View file

@ -26,6 +26,12 @@ body:
label: "📝 Describe your problem" label: "📝 Describe your problem"
description: "Please walk us through it step by step." description: "Please walk us through it step by step."
placeholder: "Describe what are you asking for..." placeholder: "Describe what are you asking for..."
- type: textarea
id: error-msg
validations:
required: false
attributes:
label: "📝 Error Message(s) or Log"
- type: input - type: input
id: uptime-kuma-version id: uptime-kuma-version
attributes: attributes:

19
.github/ISSUE_TEMPLATE/security.md vendored Normal file
View file

@ -0,0 +1,19 @@
---
name: "Security Issue"
about: "Just for alerting @louislam, do not provide any details here"
title: "Security Issue"
ref: "main"
labels:
- security
---
DO NOT PROVIDE ANY DETAILS HERE. Please privately report to https://github.com/louislam/uptime-kuma/security/advisories/new.
Why need this issue? It is because GitHub Advisory do not send a notification to @louislam, it is a workaround to do so.
Your GitHub Advisory URL:

View file

@ -16,7 +16,6 @@ Please delete any options that are not relevant.
- User interface (UI) - User interface (UI)
- New feature (non-breaking change which adds functionality) - New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected) - Breaking change (fix or feature that would cause existing functionality to not work as expected)
- Translation update
- Other - Other
- This change requires a documentation update - This change requires a documentation update

1
.github/config/exclude.txt vendored Normal file
View file

@ -0,0 +1 @@
# This is a .gitignore style file for 'GrantBirki/json-yaml-validate' Action workflow

View file

@ -6,8 +6,12 @@ name: Auto Test
on: on:
push: push:
branches: [ master ] branches: [ master ]
paths-ignore:
- '*.md'
pull_request: pull_request:
branches: [ master ] branches: [ master ]
paths-ignore:
- '*.md'
jobs: jobs:
auto-test: auto-test:
@ -18,7 +22,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest, ubuntu-latest, windows-latest] os: [macos-latest, ubuntu-latest, windows-latest]
node: [ 14, 16, 18, 19 ] node: [ 14, 16, 18, 20 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:
@ -36,6 +40,7 @@ jobs:
env: env:
HEADLESS_TEST: 1 HEADLESS_TEST: 1
JUST_FOR_TEST: ${{ secrets.JUST_FOR_TEST }} JUST_FOR_TEST: ${{ secrets.JUST_FOR_TEST }}
check-linters: check-linters:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -1,4 +1,3 @@
name: Close Incorrect Issue name: Close Incorrect Issue
on: on:
@ -12,13 +11,13 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
node-version: [16.x] node-version: [16]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'

View file

@ -0,0 +1,26 @@
name: json-yaml-validate
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
permissions:
contents: read
pull-requests: write # enable write permissions for pull request comments
jobs:
json-yaml-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: json-yaml-validate
id: json-yaml-validate
uses: GrantBirki/json-yaml-validate@v1.3.0
with:
comment: "true" # enable comment mode
exclude_file: ".github/config/exclude.txt" # gitignore style file for exclusions

View file

@ -9,7 +9,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v5 - uses: actions/stale@v7
with: with:
stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.' stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.'
close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.' close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.'

6
.gitignore vendored
View file

@ -20,3 +20,9 @@ cypress/screenshots
/extra/healthcheck.exe /extra/healthcheck.exe
/extra/healthcheck /extra/healthcheck
/extra/healthcheck-armv7 /extra/healthcheck-armv7
extra/exe-builder/bin
extra/exe-builder/obj
.vs
.vscode

View file

@ -10,5 +10,6 @@
"color-function-notation": "legacy", "color-function-notation": "legacy",
"shorthand-property-no-redundant-values": null, "shorthand-property-no-redundant-values": null,
"color-hex-length": null, "color-hex-length": null,
"declaration-block-no-redundant-longhand-properties": null
} }
} }

View file

@ -17,8 +17,11 @@ The frontend code build into "dist" directory. The server (express.js) exposes t
## Directories ## Directories
- config (dev config files)
- data (App data) - data (App data)
- db (Base database and migration scripts)
- dist (Frontend build) - dist (Frontend build)
- docker (Dockerfiles)
- extra (Extra useful scripts) - extra (Extra useful scripts)
- public (Frontend resources for dev only) - public (Frontend resources for dev only)
- server (Server source code) - server (Server source code)
@ -32,15 +35,18 @@ Yes or no, it depends on what you will try to do. Since I don't want to waste yo
Here are some references: Here are some references:
✅ Usually Accept: ✅ Usually Accept:
- Bug/Security fix - Bug fix
- Translations - Security fix
- Adding notification providers - Adding notification providers
- Adding new language files (You should go to https://weblate.kuma.pet for existing languages)
- Adding new language keys: `$t("...")`
⚠️ Discussion First ⚠️ Discussion First
- Large pull requests - Large pull requests
- New features - New features
❌ Won't Merge ❌ Won't Merge
- A dedicated pr for translating existing languages (You can now translate on https://weblate.kuma.pet)
- Do not pass auto test - Do not pass auto test
- Any breaking changes - Any breaking changes
- Duplicated pull request - Duplicated pull request
@ -77,13 +83,13 @@ Before deep into coding, discussion first is preferred. Creating an empty pull r
## Project Styles ## Project Styles
I personally do not like it when something requires so much learning and configuration before you can finally start the app. I personally do not like something that requires so many configurations before you can finally start the app. I hope Uptime Kuma installation could be as easy as like installing a mobile app.
- Easy to install for non-Docker users, no native build dependency is needed (at least for x86_64), no extra config, no extra effort required to get it running - Easy to install for non-Docker users, no native build dependency is needed (for x86_64/armv7/arm64), no extra config, no extra effort required to get it running
- Single container for Docker users, no very complex docker-compose file. Just map the volume and expose the port, then good to go - Single container for Docker users, no very complex docker-compose file. Just map the volume and expose the port, then good to go
- Settings should be configurable in the frontend. Environment variable is not encouraged, unless it is related to startup such as `DATA_DIR`. - Settings should be configurable in the frontend. Environment variable is not encouraged, unless it is related to startup such as `DATA_DIR`
- Easy to use - Easy to use
- The web UI styling should be consistent and nice. - The web UI styling should be consistent and nice
## Coding Styles ## Coding Styles
@ -92,7 +98,7 @@ I personally do not like it when something requires so much learning and configu
- Follow ESLint - Follow ESLint
- Methods and functions should be documented with JSDoc - Methods and functions should be documented with JSDoc
## Name convention ## Name Conventions
- Javascript/Typescript: camelCaseType - Javascript/Typescript: camelCaseType
- SQLite: snake_case (Underscore) - SQLite: snake_case (Underscore)
@ -106,7 +112,7 @@ I personally do not like it when something requires so much learning and configu
- IDE that supports ESLint and EditorConfig (I am using IntelliJ IDEA) - IDE that supports ESLint and EditorConfig (I am using IntelliJ IDEA)
- A SQLite GUI tool (SQLite Expert Personal is suggested) - A SQLite GUI tool (SQLite Expert Personal is suggested)
## Install dependencies ## Install Dependencies for Development
```bash ```bash
npm ci npm ci
@ -124,6 +130,12 @@ Port `3000` and port `3001` will be used.
npm run dev npm run dev
``` ```
But sometimes, you would like to keep restart the server, but not the frontend, you can run these command in two terminals:
```
npm run start-frontend-dev
npm run start-server-dev
```
## Backend Server ## Backend Server
It binds to `0.0.0.0:3001` by default. It binds to `0.0.0.0:3001` by default.
@ -139,12 +151,15 @@ express.js is used for:
### Structure in /server/ ### Structure in /server/
- jobs/ (Jobs that are running in another process)
- model/ (Object model, auto mapping to the database table name) - model/ (Object model, auto mapping to the database table name)
- modules/ (Modified 3rd-party modules) - modules/ (Modified 3rd-party modules)
- monitor_types (Monitor Types)
- notification-providers/ (individual notification logic) - notification-providers/ (individual notification logic)
- routers/ (Express Routers) - routers/ (Express Routers)
- socket-handler (Socket.io Handlers) - socket-handler (Socket.io Handlers)
- server.js (Server entry point and main logic) - server.js (Server entry point)
- uptime-kuma-server.js (UptimeKumaServer class, main logic should be here, but some still in `server.js`)
## Frontend Dev Server ## Frontend Dev Server
@ -195,18 +210,12 @@ Both frontend and backend share the same package.json. However, the frontend dep
### Update Dependencies ### Update Dependencies
Install `ncu`
https://github.com/raineorshine/npm-check-updates
```bash
ncu -u -t patch
npm install
```
Since previously updating Vite 2.5.10 to 2.6.0 broke the application completely, from now on, it should update patch release version only. Since previously updating Vite 2.5.10 to 2.6.0 broke the application completely, from now on, it should update patch release version only.
Patch release = the third digit ([Semantic Versioning](https://semver.org/)) Patch release = the third digit ([Semantic Versioning](https://semver.org/))
If for maybe security reasons, a library must be updated. Then you must need to check if there are any breaking changes.
## Translations ## Translations
Please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages Please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages
@ -226,12 +235,13 @@ https://github.com/louislam/uptime-kuma/issues?q=sort%3Aupdated-desc
1. Draft a release note 1. Draft a release note
2. Make sure the repo is cleared 2. Make sure the repo is cleared
3. If the healthcheck is updated, remember to re-compile it: `npm run build-docker-builder-go`
3. `npm run release-final with env vars: `VERSION` and `GITHUB_TOKEN` 3. `npm run release-final with env vars: `VERSION` and `GITHUB_TOKEN`
4. Wait until the `Press any key to continue` 4. Wait until the `Press any key to continue`
5. `git push` 5. `git push`
6. Publish the release note as 1.X.X 6. Publish the release note as 1.X.X
7. Press any key to continue 7. Press any key to continue
8. SSH to demo site server and update to 1.X.X 8. Deploy to the demo server: `npm run deploy-demo-server`
Checking: Checking:

View file

@ -1,22 +1,23 @@
# Uptime Kuma # Uptime Kuma
<a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/stars/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/pulls/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/v/louislam/uptime-kuma/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/last-commit/louislam/uptime-kuma" /></a> <a target="_blank" href="https://opencollective.com/uptime-kuma"><img src="https://opencollective.com/uptime-kuma/total/badge.svg?label=Open%20Collective%20Backers&color=brightgreen" /></a> <a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/stars/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/pulls/louislam/uptime-kuma" /></a> <a target="_blank" href="https://hub.docker.com/r/louislam/uptime-kuma"><img src="https://img.shields.io/docker/v/louislam/uptime-kuma/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://github.com/louislam/uptime-kuma"><img src="https://img.shields.io/github/last-commit/louislam/uptime-kuma" /></a> <a target="_blank" href="https://opencollective.com/uptime-kuma"><img src="https://opencollective.com/uptime-kuma/total/badge.svg?label=Open%20Collective%20Backers&color=brightgreen" /></a>
[![GitHub Sponsors](https://img.shields.io/github/sponsors/louislam?label=GitHub%20Sponsors)](https://github.com/sponsors/louislam) [![GitHub Sponsors](https://img.shields.io/github/sponsors/louislam?label=GitHub%20Sponsors)](https://github.com/sponsors/louislam) <a href="https://weblate.kuma.pet/projects/uptime-kuma/uptime-kuma/">
<img src="https://weblate.kuma.pet/widgets/uptime-kuma/-/svg-badge.svg" alt="Translation status" />
</a>
<div align="center" width="100%"> <div align="center" width="100%">
<img src="./public/icon.svg" width="128" alt="" /> <img src="./public/icon.svg" width="128" alt="" />
</div> </div>
It is a self-hosted monitoring tool like "Uptime Robot". Uptime Kuma is an easy-to-use self-hosted monitoring tool.
<img src="https://uptime.kuma.pet/img/dark.jpg" width="700" alt="" /> <img src="https://user-images.githubusercontent.com/1336778/212262296-e6205815-ad62-488c-83ec-a5b0d0689f7c.jpg" width="700" alt="" />
## 🥔 Live Demo ## 🥔 Live Demo
Try it! Try it!
- Tokyo Demo Server: https://demo.uptime.kuma.pet (Sponsored by [Uptime Kuma Sponsors](https://github.com/louislam/uptime-kuma#%EF%B8%8F-sponsors)) - Tokyo Demo Server: https://demo.uptime.kuma.pet (Sponsored by [Uptime Kuma Sponsors](https://github.com/louislam/uptime-kuma#%EF%B8%8F-sponsors))
- Europe Demo Server: https://demo.uptime-kuma.karimi.dev:27000 (Provided by [@mhkarimi1383](https://github.com/mhkarimi1383))
It is a temporary live demo, all data will be deleted after 10 minutes. Use the one that is closer to you, but I suggest that you should install and try it out for the best demo experience. It is a temporary live demo, all data will be deleted after 10 minutes. Use the one that is closer to you, but I suggest that you should install and try it out for the best demo experience.
@ -26,7 +27,7 @@ It is a temporary live demo, all data will be deleted after 10 minutes. Use the
* Fancy, Reactive, Fast UI/UX * Fancy, Reactive, Fast UI/UX
* Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications) * Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications)
* 20 second intervals * 20 second intervals
* [Multi Languages](https://github.com/louislam/uptime-kuma/tree/master/src/languages) * [Multi Languages](https://github.com/louislam/uptime-kuma/tree/master/src/lang)
* Multiple status pages * Multiple status pages
* Map status pages to specific domains * Map status pages to specific domains
* Ping chart * Ping chart
@ -49,7 +50,8 @@ Uptime Kuma is now running on http://localhost:3001
### 💪🏻 Non-Docker ### 💪🏻 Non-Docker
Required Tools: Required Tools:
- [Node.js](https://nodejs.org/en/download/) >= 14 - [Node.js](https://nodejs.org/en/download/) 14 / 16 / 18 (20 is not supported)
- [npm](https://docs.npmjs.com/cli/) >= 7
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) - For running Uptime Kuma in the background - [pm2](https://pm2.keymetrics.io/) - For running Uptime Kuma in the background
@ -85,6 +87,10 @@ pm2 monit
pm2 save && pm2 startup pm2 save && pm2 startup
``` ```
### Windows Portable (x64)
https://github.com/louislam/uptime-kuma/releases/download/1.21.0/uptime-kuma-win64-portable-1.0.0.zip
### Advanced Installation ### Advanced Installation
If you need more options or need to browse via a reverse proxy, please read: If you need more options or need to browse via a reverse proxy, please read:
@ -171,7 +177,7 @@ Check out the latest beta release here: https://github.com/louislam/uptime-kuma/
If you want to report a bug or request a new feature, feel free to open a [new issue](https://github.com/louislam/uptime-kuma/issues). If you want to report a bug or request a new feature, feel free to open a [new issue](https://github.com/louislam/uptime-kuma/issues).
### Translations ### Translations
If you want to translate Uptime Kuma into your language, please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages If you want to translate Uptime Kuma into your language, please visit [Weblate Readme](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md).
Feel free to correct my grammar in this README, source code, or wiki, as my mother language is not English and my grammar is not that great. Feel free to correct my grammar in this README, source code, or wiki, as my mother language is not English and my grammar is not that great.

View file

@ -2,10 +2,15 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
Please report security issues to https://github.com/louislam/uptime-kuma/security/advisories/new. 1. Please report security issues to https://github.com/louislam/uptime-kuma/security/advisories/new.
1. Please also create a empty security issues for alerting me, as GitHub Advisory do not send a notification, I probably will miss without this. https://github.com/louislam/uptime-kuma/issues/new?assignees=&labels=help&template=security.md
Do not use the public issue tracker or discuss it in the public as it will cause more damage. Do not use the public issue tracker or discuss it in the public as it will cause more damage.
## Do you accept other 3rd-party bug bounty platforms?
At this moment, I DO NOT accept other bug bounty platforms, because I am not familiar with these platforms and someone have tried to send a phishing link to me by this already. To minimize my own risk, please report through GitHub Advisories only. I will ignore all 3rd-party bug bounty platforms emails.
## Supported Versions ## Supported Versions
### Uptime Kuma Versions ### Uptime Kuma Versions

View file

View file

@ -0,0 +1,7 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD description TEXT default null;
COMMIT;

View file

@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD game VARCHAR(255);
COMMIT

View file

@ -0,0 +1,4 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE status_page ADD google_analytics_tag_id VARCHAR;
COMMIT;

View file

@ -0,0 +1,13 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
CREATE TABLE [api_key] (
[id] INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
[key] VARCHAR(255) NOT NULL,
[name] VARCHAR(255) NOT NULL,
[user_id] INTEGER NOT NULL,
[created_date] DATETIME DEFAULT (DATETIME('now')) NOT NULL,
[active] BOOLEAN DEFAULT 1 NOT NULL,
[expires] DATETIME DEFAULT NULL,
CONSTRAINT FK_user FOREIGN KEY ([user_id]) REFERENCES [user]([id]) ON DELETE CASCADE ON UPDATE CASCADE
);
COMMIT;

View file

@ -0,0 +1,12 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor ADD http_body_encoding VARCHAR(25);
COMMIT;
BEGIN TRANSACTION;
UPDATE monitor SET http_body_encoding = 'json' WHERE (type = 'http' or type = 'keyword') AND http_body_encoding IS NULL;
COMMIT;

View file

@ -0,0 +1,11 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
DROP TABLE maintenance_timeslot;
-- 999 characters. https://stackoverflow.com/questions/46134830/maximum-length-for-cron-job
ALTER TABLE maintenance ADD cron TEXT;
ALTER TABLE maintenance ADD timezone VARCHAR(255);
ALTER TABLE maintenance ADD duration INTEGER;
COMMIT;

13
db/patch-monitor-tls.sql Normal file
View file

@ -0,0 +1,13 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD tls_ca TEXT default null;
ALTER TABLE monitor
ADD tls_cert TEXT default null;
ALTER TABLE monitor
ADD tls_key TEXT default null;
COMMIT;

View file

@ -0,0 +1,5 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD packet_size INTEGER DEFAULT 56 NOT NULL;
COMMIT;

View file

@ -3,6 +3,6 @@ FROM node:16-alpine3.12
WORKDIR /app WORKDIR /app
# Install apprise, iputils for non-root ping, setpriv # Install apprise, iputils for non-root ping, setpriv
RUN apk add --no-cache iputils setpriv dumb-init python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib && \ RUN apk add --no-cache iputils setpriv dumb-init python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib git && \
pip3 --no-cache-dir install apprise==1.2.1 && \ pip3 --no-cache-dir install apprise==1.4.0 && \
rm -rf /root/.cache rm -rf /root/.cache

View file

@ -2,7 +2,7 @@
# Build in Golang # Build in Golang
# Run npm run build-healthcheck-armv7 in the host first, another it will be super slow where it is building the armv7 healthcheck # Run npm run build-healthcheck-armv7 in the host first, another it will be super slow where it is building the armv7 healthcheck
############################################ ############################################
FROM golang:1.19.4-buster FROM golang:1.19-buster
WORKDIR /app WORKDIR /app
ARG TARGETPLATFORM ARG TARGETPLATFORM
COPY ./extra/ ./extra/ COPY ./extra/ ./extra/

View file

@ -8,21 +8,21 @@ WORKDIR /app
# Install Curl # Install Curl
# Install Apprise, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv # Install Apprise, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv
# Stupid python3 and python3-pip actually install a lot of useless things into Debian, specify --no-install-recommends to skip them, make the base even smaller than alpine! # Stupid python3 and python3-pip actually install a lot of useless things into Debian, specify --no-install-recommends to skip them, make the base even smaller than alpine!
RUN apt update && \ RUN apt-get update && \
apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \ apt-get --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \
sqlite3 iputils-ping util-linux dumb-init && \ sqlite3 iputils-ping util-linux dumb-init git curl ca-certificates && \
pip3 --no-cache-dir install apprise==1.2.1 && \ pip3 --no-cache-dir install apprise==1.4.0 && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove apt --yes autoremove
# Install cloudflared # Install cloudflared
# dpkg --add-architecture arm: cloudflared do not provide armhf, this is workaround. Read more: https://github.com/cloudflare/cloudflared/issues/583 RUN set -eux && \
COPY extra/download-cloudflared.js ./extra/download-cloudflared.js mkdir -p --mode=0755 /usr/share/keyrings && \
RUN node ./extra/download-cloudflared.js $TARGETPLATFORM && \ curl --fail --show-error --silent --location --insecure https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyrings/cloudflare-main.gpg && \
dpkg --add-architecture arm && \ echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared buster main' | tee /etc/apt/sources.list.d/cloudflared.list && \
apt update && \ apt-get update && \
apt --yes --no-install-recommends install ./cloudflared.deb && \ apt-get install --yes --no-install-recommends cloudflared && \
cloudflared version && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
rm -f cloudflared.deb && \
apt --yes autoremove apt --yes autoremove

View file

@ -71,7 +71,7 @@ RUN npm ci
EXPOSE 3000 3001 EXPOSE 3000 3001
VOLUME ["/app/data"] VOLUME ["/app/data"]
HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD node extra/healthcheck.js HEALTHCHECK --interval=60s --timeout=30s --start-period=180s --retries=5 CMD extra/healthcheck
CMD ["npm", "run", "start-pr-test"] CMD ["npm", "run", "start-pr-test"]
############################################ ############################################

View file

@ -22,7 +22,8 @@ if (! exists) {
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n"); fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
// Also update package-lock.json // Also update package-lock.json
childProcess.spawnSync("npm", [ "install" ]); const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";
childProcess.spawnSync(npm, [ "install" ]);
commit(version); commit(version);
tag(version); tag(version);
@ -32,6 +33,10 @@ if (! exists) {
process.exit(1); process.exit(1);
} }
/**
* Commit updated files
* @param {string} version Version to update to
*/
function commit(version) { function commit(version) {
let msg = "Update to " + version; let msg = "Update to " + version;
@ -47,6 +52,10 @@ function commit(version) {
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
} }
/**
* Create a tag with the specified version
* @param {string} version Tag to create
*/
function tag(version) { function tag(version) {
let res = childProcess.spawnSync("git", [ "tag", version ]); let res = childProcess.spawnSync("git", [ "tag", version ]);
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
@ -55,6 +64,11 @@ function tag(version) {
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
} }
/**
* Check if a tag exists for the specified version
* @param {string} version Version to check
* @returns {boolean} Does the tag already exist
*/
function tagExists(version) { function tagExists(version) {
if (! version) { if (! version) {
throw new Error("invalid version"); throw new Error("invalid version");

View file

@ -0,0 +1,60 @@
require("dotenv").config();
const { NodeSSH } = require("node-ssh");
const readline = require("readline");
const rl = readline.createInterface({ input: process.stdin,
output: process.stdout });
const prompt = (query) => new Promise((resolve) => rl.question(query, resolve));
(async () => {
try {
console.log("SSH to demo server");
const ssh = new NodeSSH();
await ssh.connect({
host: process.env.UPTIME_KUMA_DEMO_HOST,
port: process.env.UPTIME_KUMA_DEMO_PORT,
username: process.env.UPTIME_KUMA_DEMO_USERNAME,
privateKeyPath: process.env.UPTIME_KUMA_DEMO_PRIVATE_KEY_PATH
});
let cwd = process.env.UPTIME_KUMA_DEMO_CWD;
let result;
const version = await prompt("Enter Version: ");
result = await ssh.execCommand("git fetch --all", {
cwd,
});
console.log(result.stdout + result.stderr);
await prompt("Press any key to continue...");
result = await ssh.execCommand(`git checkout ${version} --force`, {
cwd,
});
console.log(result.stdout + result.stderr);
result = await ssh.execCommand("npm run download-dist", {
cwd,
});
console.log(result.stdout + result.stderr);
result = await ssh.execCommand("npm install --production", {
cwd,
});
console.log(result.stdout + result.stderr);
/*
result = await ssh.execCommand("pm2 restart 1", {
cwd,
});
console.log(result.stdout + result.stderr);*/
} catch (e) {
console.log(e);
} finally {
rl.close();
}
})();
// When done reading prompt, exit program
rl.on("close", () => process.exit(0));

View file

@ -1,44 +0,0 @@
//
const http = require("https"); // or 'https' for https:// URLs
const fs = require("fs");
const platform = process.argv[2];
if (!platform) {
console.error("No platform??");
process.exit(1);
}
let arch = null;
if (platform === "linux/amd64") {
arch = "amd64";
} else if (platform === "linux/arm64") {
arch = "arm64";
} else if (platform === "linux/arm/v7") {
arch = "arm";
} else {
console.error("Invalid platform?? " + platform);
}
const file = fs.createWriteStream("cloudflared.deb");
get("https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch + ".deb");
function get(url) {
http.get(url, function (res) {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
console.log("Redirect to " + res.headers.location);
get(res.headers.location);
} else if (res.statusCode >= 200 && res.statusCode < 300) {
res.pipe(file);
res.on("end", function () {
console.log("Downloaded");
});
} else {
console.error(res.statusCode);
process.exit(1);
}
});
}

View file

@ -47,6 +47,7 @@ function download(url) {
}); });
} }
console.log("Done"); console.log("Done");
process.exit(0);
}); });
tarStream.on("error", () => { tarStream.on("error", () => {

1
extra/exe-builder/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
packages/

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.1.0" newVersion="4.0.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.Tracing" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Reflection" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.1" newVersion="4.1.1.1" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime.InteropServices" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

View file

@ -0,0 +1,84 @@
using System.ComponentModel;
namespace UptimeKuma {
partial class DownloadForm {
/// <summary>
/// Required designer variable.
/// </summary>
private IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing) {
if (disposing && (components != null)) {
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent() {
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(DownloadForm));
this.progressBar = new System.Windows.Forms.ProgressBar();
this.label = new System.Windows.Forms.Label();
this.labelData = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// progressBar
//
this.progressBar.Location = new System.Drawing.Point(12, 12);
this.progressBar.Name = "progressBar";
this.progressBar.Size = new System.Drawing.Size(472, 41);
this.progressBar.TabIndex = 0;
//
// label
//
this.label.Location = new System.Drawing.Point(12, 59);
this.label.Name = "label";
this.label.Size = new System.Drawing.Size(472, 23);
this.label.TabIndex = 1;
this.label.Text = "Preparing...";
//
// labelData
//
this.labelData.Location = new System.Drawing.Point(12, 82);
this.labelData.Name = "labelData";
this.labelData.Size = new System.Drawing.Size(472, 23);
this.labelData.TabIndex = 2;
//
// DownloadForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(496, 117);
this.Controls.Add(this.labelData);
this.Controls.Add(this.label);
this.Controls.Add(this.progressBar);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
this.MaximizeBox = false;
this.Name = "DownloadForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Uptime Kuma";
this.Load += new System.EventHandler(this.DownloadForm_Load);
this.ResumeLayout(false);
}
private System.Windows.Forms.Label labelData;
private System.Windows.Forms.Label label;
private System.Windows.Forms.ProgressBar progressBar;
#endregion
}
}

View file

@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Newtonsoft.Json;
namespace UptimeKuma {
public partial class DownloadForm : Form {
private readonly Queue<DownloadItem> downloadQueue = new();
private readonly WebClient webClient = new();
private DownloadItem currentDownloadItem;
public DownloadForm() {
InitializeComponent();
}
private void DownloadForm_Load(object sender, EventArgs e) {
webClient.DownloadProgressChanged += DownloadProgressChanged;
webClient.DownloadFileCompleted += DownloadFileCompleted;
label.Text = "Reading latest version...";
// Read json from https://uptime.kuma.pet/version
var versionJson = new WebClient().DownloadString("https://uptime.kuma.pet/version");
var versionObj = JsonConvert.DeserializeObject<Version>(versionJson);
var nodeVersion = versionObj.nodejs;
var uptimeKumaVersion = versionObj.latest;
var hasUpdateFile = File.Exists("update");
if (!Directory.Exists("node")) {
downloadQueue.Enqueue(new DownloadItem {
URL = $"https://nodejs.org/dist/v{nodeVersion}/node-v{nodeVersion}-win-x64.zip",
Filename = "node.zip",
TargetFolder = "node"
});
}
if (!Directory.Exists("core") || hasUpdateFile) {
// It is update, rename the core folder to core.old
if (Directory.Exists("core")) {
// Remove the old core.old folder
if (Directory.Exists("core.old")) {
Directory.Delete("core.old", true);
}
Directory.Move("core", "core.old");
}
downloadQueue.Enqueue(new DownloadItem {
URL = $"https://github.com/louislam/uptime-kuma/archive/refs/tags/{uptimeKumaVersion}.zip",
Filename = "core.zip",
TargetFolder = "core"
});
File.WriteAllText("version.json", versionJson);
// Delete the update file
if (hasUpdateFile) {
File.Delete("update");
}
}
DownloadNextFile();
}
void DownloadNextFile() {
if (downloadQueue.Count > 0) {
var item = downloadQueue.Dequeue();
currentDownloadItem = item;
// Download if the zip file is not existing
if (!File.Exists(item.Filename)) {
label.Text = item.URL;
webClient.DownloadFileAsync(new Uri(item.URL), item.Filename);
} else {
progressBar.Value = 100;
label.Text = "Use local " + item.Filename;
DownloadFileCompleted(null, null);
}
} else {
npmSetup();
}
}
void npmSetup() {
labelData.Text = "";
var npm = "..\\node\\npm.cmd";
var cmd = $"{npm} ci --production & {npm} run download-dist & exit";
var startInfo = new ProcessStartInfo {
FileName = "cmd.exe",
Arguments = $"/k \"{cmd}\"",
RedirectStandardOutput = false,
RedirectStandardError = false,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = false,
WorkingDirectory = "core"
};
var process = new Process();
process.StartInfo = startInfo;
process.EnableRaisingEvents = true;
process.Exited += (_, e) => {
progressBar.Value = 100;
if (process.ExitCode == 0) {
Task.Delay(2000).ContinueWith(_ => {
Application.Restart();
});
label.Text = "Done";
} else {
label.Text = "Failed, exit code: " + process.ExitCode;
}
};
process.Start();
label.Text = "Installing dependencies and download dist files";
progressBar.Value = 50;
process.WaitForExit();
}
void DownloadProgressChanged(object sender, DownloadProgressChangedEventArgs e) {
progressBar.Value = e.ProgressPercentage;
var total = e.TotalBytesToReceive / 1024;
var current = e.BytesReceived / 1024;
if (total > 0) {
labelData.Text = $"{current}KB/{total}KB";
}
}
void DownloadFileCompleted(object sender, AsyncCompletedEventArgs e) {
Extract(currentDownloadItem);
DownloadNextFile();
}
void Extract(DownloadItem item) {
if (Directory.Exists(item.TargetFolder)) {
var dir = new DirectoryInfo(item.TargetFolder);
dir.Delete(true);
}
if (Directory.Exists("temp")) {
var dir = new DirectoryInfo("temp");
dir.Delete(true);
}
labelData.Text = $"Extracting {item.Filename}...";
ZipFile.ExtractToDirectory(item.Filename, "temp");
string[] dirList;
// Move to the correct level
dirList = Directory.GetDirectories("temp");
if (dirList.Length > 0) {
var dir = dirList[0];
// As sometime ExtractToDirectory is still locking the directory, loop until ok
while (true) {
try {
Directory.Move(dir, item.TargetFolder);
break;
} catch (Exception exception) {
Thread.Sleep(1000);
}
}
} else {
MessageBox.Show("Unexcepted Error: Cannot move extracted files, folder not found.");
}
labelData.Text = $"Extracted";
if (Directory.Exists("temp")) {
var dir = new DirectoryInfo("temp");
dir.Delete(true);
}
File.Delete(item.Filename);
}
}
public class DownloadItem {
public string URL { get; set; }
public string Filename { get; set; }
public string TargetFolder { get; set; }
}
}

View file

@ -0,0 +1,377 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="$this.Icon" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
AAABAAMAMDAAAAEAIACoJQAANgAAACAgAAABACAAqBAAAN4lAAAQEAAAAQAgAGgEAACGNgAAKAAAADAA
AABgAAAAAQAgAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA////BPT09Bfu7u4e8fHxJPPz8yv19fUy9fX1M/Pz8yvx8fEk9vb2HPPz8xXMzMwFAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//
/wHv7+8f7u7uPPPz81Tx8fFs8fHxgPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGB8fHxcfHx8V3x8fFI9PT0MOvr6w0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADy8vIU8fHxS/Dw8Hbx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fFr9PT0R/Dw8CIAAAABAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA8vLyFPHx8Vnx8fGB8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fFs9fX1Mb+/vwQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAICAgALy8vI88fHxfvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvLy8nby8vI8gICAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAzMzMBfHx8Vrx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8vLyYf///wwAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMwF8vLyYPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8W/z8/MWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADv7+9R8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLw8PB26urqDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPLy8ijx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgu7w7Ifj79ud2u7PtNLrw83P677dzeu85c3r
u+rM67rwzOu68c7rverQ68Dj0uvD3NbuyM3b7c+64u7apujv5ZPx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxXgAAAAEAAAAAAAAAAAAAAAAAAAAA4+PjCfDw
8Hfx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLd7tSmzeu92MbqsvvG6bH/xumy/8fq
s//H6rP/yOq0/8jqtf/J6rb/yeq2/8rrt//K67j/y+u4/8vruf/M67r/zOu7/83ru//Q7MDx1u7Kz9/t
163s8OuJ8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgu/v7y8AAAAAAAAAAAAA
AAAAAAAA7u7uPfHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC5PDdl8jqtuTE6a7/xOmv/8Xp
sP/G6bH/xumx/8bpsv/H6rP/x+qz/8jqtP/I6rX/yeq2/8nqtv/K67f/yuu4/8vruP/L67n/zOu6/8zr
u//N67v/zey8/87svf/P67742e3Mx+jv5ZLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvDw
8HWAgIACAAAAAAAAAACqqqoD8vLyc/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLf7degxOiu+cPo
rf/D6a7/xOmu/8Xpr//F6bD/xumx/8bpsf/G6bL/x+qz/8fqs//I6rT/yOq1/8nqtv/J6rb/yuu3/8rr
uP/L67j/y+u5/8zruv/M67v/zeu7/83svP/O7L3/zuy9/87svfzc7tK28fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fEkAAAAAAAAAADz8/Mq8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgunv
5o3D6a/0wuis/8Lorf/D6K3/xOmu/8Tprv/F6a//xemw/8bpsf/G6bH/xumy/8fqs//H6rP/yOq0/8jq
tf/J6rb/yeq2/8rrt//K67j/y+u4/8vruf/M67r/zOu7/83ru//N7Lz/zuy9/87svf/O7L3/3e/TtPHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLy8vJNAAAAAAAAAADy8vJM8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgszqutDB6Kv/weir/8LorP/D6K3/w+it/8Tprv/E6a7/xemv/8XpsP/G6bH/xumx/8bp
sv/H6rP/x+qz/8jqtP/I6rX/yeq2/8nqtv/K67f/yuu4/8vruP/L67n/zOu6/8zru//N67v/zey8/87s
vf/O7L3/zuy++u3w6Yzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLy8vJ1AAAAAAAAAADx8fFr8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC6O/kjsDoqvzA6Kr/weir/8Loq//C6Kz/w+it/8Porf/E6a7/xOmu/8Xp
r//F6bD/xumx/8bpsf/G6bL/x+qz/8fqtP/I6rT/yOq1/8nqtv/J6rb/yuu3/8rruP/L67n/y+u5/8zr
uv/M67v/zeu7/83svP/O7L3/zuy9/93u07Xx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC////Bv//
/wfx8fGB8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC1ezJsr/nqf/A56n/weiq/8Hoq//C6Kv/wuis/8Po
rf/D6K3/xOmu/8Pprv+856T/uOed/7bmmv+05Zf/teWZ/7jnnf+86KP/wOio/8fqs//J6rb/yeq2/8rr
t//K67j/y+u5/8vruf/M67r/zOu7/83ru//N7Lz/zuy9/9buyNLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8vLyE/Ly8hPx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCy+q6zr/nqP/A56n/wOep/8Ho
qv/B6Kv/wuir/8LorP+u5Y//neF2/5bgav+V4Gr/luBr/5fhbP+Y4W7/meFv/5rhcf+b4nL/nOJ0/53i
dv+j5H//reaM/7nnnf/E6q//y+y4/8vruf/L67n/zOu6/8zru//N67v/zey8/9Lsxd/x8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC7+/vIPb29hzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCx+m03L/n
qP+/56j/wOep/8Dnqf/B6Kr/weir/7nmn/+R32T/kt9l/5PfZ/+U4Gj/leBq/5bga/+X4W3/mOFu/5nh
b/+a4XH/m+Jy/5zidP+d4nX/nuN3/5/jeP+f4nn/weqq/8rruP/L67n/y+u5/8zruv/M67v/zeu7/9Ls
w+Lx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8PDwI/Hx8SXx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGCxeix5L/nqP+/56j/v+eo/8Dnqf/A56n/weiq/7Pllv+Q3mP/kd9k/5LfZf+T32f/lOBo/5Xg
av+W4Gv/l+Ft/5jhbv+Z4W//muFx/5vicv+c4nT/neJ1/57jd/+f43j/xOmu/8rrt//K67j/y+u5/8vr
uf/M67r/zOu7/9Tsxtfx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC9PT0GO/v7yDx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGCx+m037/nqP+/56j/v+eo/7/nqP/A56n/wOip/7TmmP+P3mH/kN5j/5Hf
ZP+S32b/k99n/5TgaP+V4Gr/luBr/5fhbf+Y4W7/meFw/5rhcf+b4nL/nOJ0/53idf+h5Hz/yuu2/8nq
t//K67f/yuu4/8vruf/L67n/zOu6/9ftysrx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC7e3tDvT0
9Bfx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCyOq117/nqP+/56j/v+eo/7/nqP+/56j/wOep/7vn
of+O3mD/j95h/5DeY/+R32T/kt9m/5PfZ/+U4Gj/leBq/5bga/+X4W3/mOFu/5nhcP+a4nH/m+Jy/5zi
dP+r5Yr/yOq1/8nqtv/J6rf/yuu3/8rruP/L67n/y+u5/9zu1LHx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLz8/OA////A+7u7g/x8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCz+q+xb/nqP+/56j/v+eo/7/n
qP+/56j/v+eo/8Dnqf+S4Gb/jt5g/4/eYf+Q3mP/kd9k/5LfZv+T32f/lOBo/5Xgav+W4Gv/l+Ft/5jh
bv+Z4XD/muJx/5vic/+4553/yOq0/8jqtf/J6rb/yeq3/8rrt//K67j/y+u5/+bw4Zfx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fFrAAAAAP///wHz8/N88fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC1+zMrr/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+f4Xn/jd5f/47eYP+P3mH/kN5j/5HfZP+S32b/k99n/5Tg
af+V4Gr/luBr/5fhbf+Y4W7/meFw/5vic//F6rD/x+q0/8jqtP/I6rX/yeq2/8nqt//K67f/zOu88u/x
74Px8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLv7+9QAAAAAAAAAADw8PBm8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC5e7gk7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+u5I//jN1d/43eX/+O3mD/j95h/5De
Y/+R32T/kt9m/5PfZ/+U4Gn/leBq/5bga/+X4W3/mOFu/6rliP/G6rL/x+qz/8fqtP/I6rT/yOq1/8nq
tv/J6rf/1OzGy/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YL19fUzAAAAAAAAAADy8vJO8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgsPoru2/56j/v+eo/7/nqP+/56j/v+eo/7/nqP++6Kf/j95i/4zd
Xf+N3l//jt5g/4/eYv+Q3mP/kd9k/5LfZv+T32f/lOBp/5Xgav+W4Gz/l+Ft/7voov/G6bL/xuqy/8fq
s//H6rT/yOq1/8jqtf/J6rb/4e/Zo/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLw8PARAAAAAAAA
AADu7u4u8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgszpvMm/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/q+SL/4vdXP+M3V3/jd5f/47eYP+P3mL/kN9j/5HfZP+S32b/k99n/5Tgaf+V4Gr/qOOH/8Xp
sP/G6bH/xumy/8bqsv/H6rP/x+q0/8jqtf/K67jy8PHwhPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8WoAAAAAAAAAAAAAAADo6OgL8fHxgfHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxguDv2J2/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/6Xjgv+L3Vz/jN1d/43eX/+O3mD/j95i/5DfY/+R32T/kt9m/5Pf
Z/+k44D/xOmu/8XpsP/F6bD/xumx/8bpsv/G6rL/x+qz/8fqtP/W7cnB8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvPz80AAAAAAAAAAAAAAAAAAAAAA8PDwZ/Hx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLD6K/rv+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+u5I//kt5n/4zdXf+N3l//jt5g/4/e
Yv+Q32P/luFs/67kj//D6K3/xOmu/8Tpr//F6bD/xemw/8bpsf/G6bL/xuqy/8fqtP7o7+WR8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz8xYAAAAAAAAAAAAAAAAAAAAA8vLyPPHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLV7ci0v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/wOio/7Xl
mv+u5I7/rOSM/67kj/+35pz/wumr/8Lorf/D6K3/w+it/8Tprv/E6a//xemw/8XpsP/G6bH/xumy/9Ds
wNPx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8vLyZQAAAAAAAAAAAAAAAAAAAAAAAAAA////DPHx
8YDx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGCx+m03L/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/wOep/8Doqv/B6Kr/weir/8LorP/C6K3/w+it/8Porv/E6a7/xOmv/8Xp
sP/F6bD/yOq18uvw6Yvx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC7+/vMQAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAPHx8Vzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC6O/ij8LorPG/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/8Dnqf/A6Kr/weiq/8Hoq//C6Kz/wuit/8Po
rf/D6K7/xOmu/8Tpr//F6bH74u/anvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLw8PB6////BQAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz8yrx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxguHu
2pnB56v2v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP/A56n/wOiq/8Ho
q//B6Kv/wuis/8Lorf/D6K3/w+mu/8Tprv3b7dKq8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fFJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHy8vJf8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLi7tyXwumt8L/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/wOep/8Doqv/B6Kv/weir/8LorP/C6K3/xOiv+d7u1aTx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvLy8nb///8KAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADv7+8Q8/Pze/Hx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC6/Dpiszqu82/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/8Dnqf/A6Kr/weir/8Hoq//H6bTj5e7elfHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz8yoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA9fX1MvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLe7tShx+mz3r/n
qP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/v+eo/7/nqP/A56n/xumy5drtz6rv8e+D8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8vLyTgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPHx8Unx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgubv45DU68e2y+q6z8XoseTD6a7uweir9MPpru7F6bHly+q50tLsxLrl796U8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLy8vJh////AwAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wHx8fFZ8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8Wzf398IAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8D8/PzVfHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8PDwZujo
6AsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA////AfHx8Ujx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fFa////BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/Mp8vLydvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8/PzfPHx8TcAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////CvLy8lDz8/N/8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvPz84Hx8fFa8PDwEQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADw8PAR8vLyTvHx8X3x8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fF/8/PzVvT09BgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wXz8/Mq8/PzU/Hx8XDx8fGB8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLy8vJz8fHxWO/v7y////8IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8G7e3tHfLy
8ifu7u4u8PDwNPT09C/y8vIo7+/vH+Pj4wkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAP///////wAA////////AAD///////8AAP//gAf//wAA//gAAD//AAD/wAAAB/8AAP+A
AAAB/wAA/gAAAAB/AAD8AAAAAD8AAPgAAAAAHwAA8AAAAAAPAADwAAAAAAcAAOAAAAAABwAA4AAAAAAD
AADAAAAAAAMAAMAAAAAAAwAAwAAAAAABAACAAAAAAAEAAIAAAAAAAQAAgAAAAAABAACAAAAAAAEAAIAA
AAAAAQAAgAAAAAABAACAAAAAAAMAAMAAAAAAAwAAwAAAAAADAADAAAAAAAMAAMAAAAAABwAAwAAAAAAH
AADgAAAAAAcAAOAAAAAADwAA4AAAAAAPAADwAAAAAB8AAPAAAAAAHwAA+AAAAAA/AAD8AAAAAD8AAPwA
AAAAfwAA/gAAAAD/AAD/AAAAAf8AAP+AAAAD/wAA/8AAAAf/AAD/8AAAH/8AAP/8AAA//wAA//8AAf//
AAD//+AP//8AAP///////wAA////////AAD///////8AACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAgICAAu/v7xD09PQX7u7uHvDw8CP29vYb8vLyFOrq6gwAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICA
gALy8vIm7+/vT/Pz82fz8/N98fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvDw8Hrw8PBm7+/vUPT0
9C3o6OgLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOPj
4wnz8/NC8vLydPHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YHy8vJj8/PzKoCAgAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADx8fEl8vLydfHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxcfHx8SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA9PT0LfHx8YDx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8/PzgPLy8j0AAAABAAAAAAAA
AAAAAAAAAAAAAO3t7Rzx8fGA8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLr8OmM5O7emeTv
3Z7h79mj5fDem+nv45Tu8u6H8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvLy
8joAAAAAAAAAAAAAAAD///8E8fHxbvHx8YLx8fGC8fHxgvHx8YLx8fGC7vDshtns0K7N67zayeq288fq
s//I6rT/yOq1/8nqtv/K67f/y+u4/8vruf/P7L7w0+zF29vv0Lrn8OKX8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8/PzfvPz8xUAAAAAAAAAAPX19TLx8fGC8fHxgvHx8YLx8fGC8fHxgt3u1KXF6rHzxOmv/8Xp
sP/G6bH/xumy/8fqs//I6rT/yOq1/8nqtv/K67f/y+u4/8vruf/M67v/zey8/87svf/S7MPj4u7Zp/Hx
8YLx8fGC8fHxgvHx8YLx8fGC8/PzVQAAAAAAAAAA8fHxavHx8YLx8fGC8fHxgvHx8YLf7defwuis/cPo
rf/E6a7/xOmv/8XpsP/G6bH/xumy/8fqs//I6rT/yOq1/8nqtv/K67f/y+u4/8vruv/M67v/zey8/87s
vf/N67z/3e7SufHx8YLx8fGC8fHxgvHx8YLz8/N8////Bf///w3x8fGC8fHxgvHx8YLx8fGC8fHxgsXp
sOnB6Kv/wuis/8Porf/E6a7/xOmv/8XpsP/G6bH/xumy/8fqs//I6rT/yOq1/8nqtv/K67f/y+u4/8vr
uv/M67v/zey8/87svf/O67z96/Hoj/Hx8YLx8fGC8fHxgvHx8YLy8vIm8/PzK/Hx8YLx8fGC8fHxgvHx
8YLg79icwOep/8Hoqv/B6Kv/wuis/8Porf/E6a7/wuit/73opP+76KL/u+eh/77opv/D6a3/yeu1/8nq
tv/K67f/y+u5/8zruv/M67v/zey8/87svf/d7tSz8fHxgvHx8YLx8fGC8fHxgvHx8Tby8vI68fHxgvHx
8YLx8fGC8fHxgtTrxre/56j/wOep/8Hoqv/B6Kv/uOad/53idv+V4Gn/leBq/5fhbP+Y4W//muFx/5vi
c/+e4Xb/puWD/7PmlP/D6a3/y+u5/8zruv/M67v/zey8/9rtzsHx8fGC8fHxgvHx8YLx8fGC8/PzQfPz
80Lx8fGC8fHxgvHx8YLx8fGC0OvAwr/nqP+/56j/wOep/8Hoqv+o44b/kd9k/5LfZv+U4Gj/leBq/5fh
bf+Y4W//muFx/5vic/+d4nX/n+N3/7fnm//K67j/y+u5/8zruv/M67v/2u3QvPHx8YLx8fGC8fHxgvHx
8YLy8vI98/PzP/Hx8YLx8fGC8fHxgvHx8YLQ6sK/v+eo/7/nqP+/56j/wOep/6jjhv+P3mL/kd9k/5Lf
Zv+U4Gj/leBr/5fhbf+Y4W//muFx/5zic/+d4nX/v+mm/8nqt//K67j/y+u5/8zruv/f79au8fHxgvHx
8YLx8fGC8fHxgvX19TLx8fE38fHxgvHx8YLx8fGC8fHxgtTrybO/56j/v+eo/7/nqP+/56j/sOSS/47e
YP+P3mL/kd9k/5LfZv+U4Gj/leBr/5fhbf+Z4W//muJx/5/jd//H6bP/yeq2/8nqt//K67j/y+u5/+nv
45Tx8fGC8fHxgvHx8YLx8fGC7+/vIPHx8SXx8fGC8fHxgvHx8YLx8fGC4e/Zm7/nqP+/56j/v+eo/7/n
qP+956X/jt5h/47eYP+P3mL/kd9k/5LfZv+U4Gn/luBr/5fhbf+Z4W//q+aK/8fqs//I6rT/yeq2/8nq
t//N7Lvw8fHxgvHx8YLx8fGC8fHxgvPz84D///8G6+vrDfHx8YLx8fGC8fHxgvHx8YLv8e+Dweis87/n
qP+/56j/v+eo/7/nqP+d4XX/jN1e/47eYP+P3mL/kd9k/5PfZ/+U4Gn/luBr/5fhbf+86KP/xuqy/8fq
s//I6rX/yeq2/9Tsx8nx8fGC8fHxgvHx8YLx8fGC8PDwaAAAAAAAAAAA8fHxbPHx8YLx8fGC8fHxgvHx
8YLM6rrMv+eo/7/nqP+/56j/v+eo/7blmv+N3V//jN1e/47eYP+Q3mL/kd9k/5PfZ/+U4Gn/qeSH/8Xp
sP/G6bH/xuqy/8fqs//I6rX/5fDem/Hx8YLx8fGC8fHxgvHx8YLz8/M/AAAAAAAAAADz8/NB8fHxgvHx
8YLx8fGC8fHxgt3s06O/56j/v+eo/7/nqP+/56j/v+eo/7Xmmf+U32n/jN1e/47eYP+Q3mL/k99o/6zk
i//D6a7/xemv/8XpsP/G6bH/xuqy/8vqu+jx8fGC8fHxgvHx8YLx8fGC8fHxgvPz8xUAAAAAAAAAAPT0
9Bfx8fGC8fHxgvHx8YLx8fGC8fHvg8Tpsee/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+35pz/suWV/7Xm
mf/A6Kj/wuit/8Porf/E6a7/xemv/8XpsP/G6bH/3e3UqvHx8YLx8fGC8fHxgvHx8YLw8PBmAAAAAAAA
AAAAAAAAAAAAAPHx8W7x8fGC8fHxgvHx8YLx8fGC4u7cmMHnqvm/56j/v+eo/7/nqP+/56j/v+eo/7/n
qP+/56j/wOep/8Hoqv/C6Kz/wuit/8Porf/E6a7/xemv/9Hrwszx8fGC8fHxgvHx8YLx8fGC8fHxgvX1
9TEAAAAAAAAAAAAAAAAAAAAA7u7uO/Hx8YLx8fGC8fHxgvHx8YLx8fGC3e7SpMHoqfq/56j/v+eo/7/n
qP+/56j/v+eo/7/nqP+/56j/wOip/8Hoq//C6Kz/wuit/8Porf/O67zV8PHwhPHx8YLx8fGC8fHxgvHx
8YLy8vJ2////BQAAAAAAAAAAAAAAAAAAAACqqqoD8PDwafHx8YLx8fGC8fHxgvHx8YLx8fGC4O/YnMTo
ruy/56j/v+eo/7/nqP+/56j/v+eo/7/nqP+/56j/wOip/8Hoq//C6Kz90uvEwe/x74Px8fGC8fHxgvHx
8YLx8fGC8fHxgvPz8ykAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/MW8fHxfPHx8YLx8fGC8fHxgvHx
8YLx8fGC8PLuhdXtyLXF6bHlv+eo/7/nqP+/56j/v+eo/7/nqP/B6Kv0zeq8zOXv4JTx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLy8vJNAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADy8vIm8fHxgPHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLs8OmJ4e/Zm93u06Pf7def5+/hkvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxXf///wIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADy8vIo8/PzffHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8VnMzMwFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAD29vYb8fHxbvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz83/v7+9BgICAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMwF8/PzQPLy8nnx8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgvPz84Hx8fFc9PT0GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////B/X19TLx8fFc8PDwevHx
8YLx8fGC8fHxgvHx8YLx8fGC8fHxgPHx8Wv09PRE9PT0FwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAA7+/vEPb29hvw8PAj7+/vH/T09Be/v78EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////////////8B///wAA//wAAD/wAAAP4AAAB+AA
AAfAAAADwAAAA4AAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAADwAAAA8AAAAPAAAAH4AAAB+AA
AA/wAAAP+AAAH/gAAD/+AAB//wAB///AA///+B////////////8oAAAAEAAAACAAAAABACAAAAAAAAAE
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////CfDw8BH///8GAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgICAAu7u7i7x8fFe8PDwevHx8YLx8fGC8fHxgvDw
8Hvx8fFs7+/vT/Dw8CMAAAABAAAAAAAAAAAAAAAA5ubmCvLy8l/x8fGC8fHxgvHx8YLx8fGC8fHxgvHx
8YLx8fGC8fHxgvHx8YLx8fGC8/PzZu7u7g8AAAAAAAAAAPHx8V3x8fGC8fHxgunv5o7Z7c200+vFytTs
xc7W7cnH2+7QueLu2qbu8OyH8fHxgvHx8YLx8fFu////BfHx8STx8fGC8fHxgtrtzq3D6a/8xemw/8bp
sv/I6rT/yeq2/8vruP/M67v/z+u++Nzu0bjx8fGC8fHxgu/v7zDx8fFI8fHxguzw6ojC56z3wuis/8Tp
rv/E6q3/weiq/8fqsv/J6rb/y+u5/8zru//N67z/6/HpjfHx8YLy8vJN8fHxXPHx8YLg79icv+eo/8Ho
qv+k4n//lOBo/5fhbf+a4XH/n+J5/7Pmlv/L67n/zOu7/+Xw353x8fGC8fHxXvHx8Vrx8fGC4O3Zm7/n
qP+/56j/nuF3/5HfZP+U4Gj/l+Ft/5ricf+x5pL/yeq3/8vruf/r8emN8fHxgu/v70/x8fFK8fHxguzw
6ojA6Kn8v+eo/6njiP+O3mD/kd9k/5Tgaf+X4W3/vuim/8jqtP/N67zr8fHxgvHx8YLy8vI68/PzK/Hx
8YLx8fGCx+m03L/nqP++6Kb/meBw/47eYP+S32X/q+SL/8XpsP/G6rL/1+zLvvHx8YLz8/OB8PDwEdXV
1Qbx8fF98fHxgt/t1Z/A56j9v+eo/7/nqP+656H/vuim/8Lorf/E6a7/yOq18Ovw6Yvx8fGC8vLyYwAA
AAAAAAAA8fHxR/Hx8YLx8fGC2O3NrMDnqfq/56j/v+eo/7/nqP/B6Kv/xumy7OTu3Zfx8fGC8/PzgfLy
8icAAAAAAAAAAP///wPz8/Nm8fHxgvHx8YLo7+SO0+zFuczquszM6bzJ1+zMru7w7Ibx8fGC8fHxgvHx
8UcAAAAAAAAAAAAAAAAAAAAA4+PjCfHx8Vzx8fGC8fHxgvHx8YLx8fGC8fHxgvHx8YLx8fGC8fHxgfPz
80D///8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8/PzK/Ly8mDz8/N+8fHxgvHx8YLy8vJ68vLyUezs
7BsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAevr6w3j4+MJAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAD8fwAA4AcAAMADAACAAQAAgAEAAIABAACAAQAAgAEAAIAB
AADAAwAAwAMAAOAHAADwDwAA/n8AAP//AAA=
</value>
</data>
</root>

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura />
</Weavers>

View file

@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:all>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 32 bit assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 64 bit assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
</xs:all>
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCompression" type="xs:boolean">
<xs:annotation>
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCleanup" type="xs:boolean">
<xs:annotation>
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 32 bit assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged 64 bit assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

View file

@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Microsoft.Win32;
using Newtonsoft.Json;
using UptimeKuma.Properties;
namespace UptimeKuma {
static class Program {
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main(string[] args) {
var cwd = Path.GetDirectoryName(Application.ExecutablePath);
if (cwd != null) {
Environment.CurrentDirectory = cwd;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new UptimeKumaApplicationContext());
}
}
public class UptimeKumaApplicationContext : ApplicationContext
{
private static Mutex mutex = null;
const string appName = "Uptime Kuma";
private NotifyIcon trayIcon;
private Process process;
private MenuItem statusMenuItem;
private MenuItem runWhenStarts;
private MenuItem openMenuItem;
private RegistryKey registryKey = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", true);
public UptimeKumaApplicationContext() {
// Single instance only
bool createdNew;
mutex = new Mutex(true, appName, out createdNew);
if (!createdNew) {
return;
}
var startingText = "Starting server...";
trayIcon = new NotifyIcon();
trayIcon.Text = startingText;
runWhenStarts = new MenuItem("Run when system starts", RunWhenStarts);
runWhenStarts.Checked = registryKey.GetValue(appName) != null;
statusMenuItem = new MenuItem(startingText);
statusMenuItem.Enabled = false;
openMenuItem = new MenuItem("Open", Open);
openMenuItem.Enabled = false;
trayIcon.Icon = Icon.ExtractAssociatedIcon(Assembly.GetExecutingAssembly().Location);
trayIcon.ContextMenu = new ContextMenu(new MenuItem[] {
statusMenuItem,
openMenuItem,
//new("Debug Console", DebugConsole),
runWhenStarts,
new("Check for Update...", CheckForUpdate),
new("Visit GitHub...", VisitGitHub),
new("About", About),
new("Exit", Exit),
});
trayIcon.MouseDoubleClick += new MouseEventHandler(Open);
trayIcon.Visible = true;
var hasUpdateFile = File.Exists("update");
if (!hasUpdateFile && Directory.Exists("core") && Directory.Exists("node") && Directory.Exists("core/node_modules") && Directory.Exists("core/dist")) {
// Go go go
StartProcess();
} else {
DownloadFiles();
}
}
void DownloadFiles() {
var form = new DownloadForm();
form.Closed += Exit;
form.Show();
}
private void RunWhenStarts(object sender, EventArgs e) {
if (registryKey == null) {
MessageBox.Show("Error: Unable to set startup registry key.");
return;
}
if (runWhenStarts.Checked) {
registryKey.DeleteValue(appName, false);
runWhenStarts.Checked = false;
} else {
registryKey.SetValue(appName, Application.ExecutablePath);
runWhenStarts.Checked = true;
}
}
void StartProcess() {
var startInfo = new ProcessStartInfo {
FileName = "node/node.exe",
Arguments = "server/server.js --data-dir=\"../data/\"",
RedirectStandardOutput = false,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = "core"
};
process = new Process();
process.StartInfo = startInfo;
process.EnableRaisingEvents = true;
process.Exited += ProcessExited;
try {
process.Start();
//Open(null, null);
// Async task to check if the server is ready
Task.Run(() => {
var runningText = "Server is running";
using TcpClient tcpClient = new TcpClient();
while (true) {
try {
tcpClient.Connect("127.0.0.1", 3001);
statusMenuItem.Text = runningText;
openMenuItem.Enabled = true;
trayIcon.Text = runningText;
break;
} catch (Exception) {
System.Threading.Thread.Sleep(2000);
}
}
});
} catch (Exception e) {
MessageBox.Show("Startup failed: " + e.Message, "Uptime Kuma Error");
}
}
void StopProcess() {
process?.Kill();
}
void Open(object sender, EventArgs e) {
Process.Start("http://localhost:3001");
}
void DebugConsole(object sender, EventArgs e) {
}
void CheckForUpdate(object sender, EventArgs e) {
var needUpdate = false;
// Check version.json exists
if (File.Exists("version.json")) {
// Load version.json and compare with the latest version from GitHub
var currentVersionObj = JsonConvert.DeserializeObject<Version>(File.ReadAllText("version.json"));
var versionJson = new WebClient().DownloadString("https://uptime.kuma.pet/version");
var latestVersionObj = JsonConvert.DeserializeObject<Version>(versionJson);
// Compare version, if the latest version is newer, then update
if (new System.Version(latestVersionObj.latest).CompareTo(new System.Version(currentVersionObj.latest)) > 0) {
var result = MessageBox.Show("A new version is available. Do you want to update?", "Update", MessageBoxButtons.YesNo);
if (result == DialogResult.Yes) {
// Create a empty file `update`, so the app will download the core files again at startup
File.Create("update").Close();
trayIcon.Visible = false;
process?.Kill();
// Restart the app, it will download the core files again at startup
Application.Restart();
}
} else {
MessageBox.Show("You are using the latest version.");
}
}
}
void VisitGitHub(object sender, EventArgs e)
{
Process.Start("https://github.com/louislam/uptime-kuma");
}
void About(object sender, EventArgs e)
{
MessageBox.Show("Uptime Kuma Windows Runtime v1.0.0" + Environment.NewLine + "© 2023 Louis Lam", "Info");
}
void Exit(object sender, EventArgs e)
{
// Hide tray icon, otherwise it will remain shown until user mouses over it
trayIcon.Visible = false;
process?.Kill();
Application.Exit();
}
void ProcessExited(object sender, EventArgs e) {
if (process.ExitCode != 0) {
var line = "";
while (!process.StandardOutput.EndOfStream)
{
line += process.StandardOutput.ReadLine();
}
MessageBox.Show("Uptime Kuma exited unexpectedly. Exit code: " + process.ExitCode + " " + line);
}
trayIcon.Visible = false;
Application.Exit();
}
}
}

View file

@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Uptime Kuma")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Uptime Kuma")]
[assembly: AssemblyCopyright("Copyright © 2023 Louis Lam")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("2DB53988-1D93-4AC0-90C4-96ADEAAC5C04")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View file

@ -0,0 +1,62 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace UptimeKuma.Properties {
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder",
"4.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance",
"CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState
.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if ((resourceMan == null)) {
global::System.Resources.ResourceManager temp =
new global::System.Resources.ResourceManager("UptimeKuma.Properties.Resources",
typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState
.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get { return resourceCulture; }
set { resourceCulture = value; }
}
}
}

View file

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View file

@ -0,0 +1,23 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace UptimeKuma.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute(
"Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance =
((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default {
get { return defaultInstance; }
}
}
}

View file

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings />
</SettingsFile>

View file

@ -0,0 +1,212 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="packages\Costura.Fody.5.7.0\build\Costura.Fody.props" Condition="Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.props')" />
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>UptimeKuma</RootNamespace>
<AssemblyName>uptime-kuma</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<ApplicationIcon>..\..\public\favicon.ico</ApplicationIcon>
<LangVersion>9</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup>
<PostBuildEvent>COPY "$(SolutionDir)bin\Debug\uptime-kuma.exe" "%UserProfile%\Desktop\uptime-kuma-win64\"</PostBuildEvent>
</PropertyGroup>
<ItemGroup>
<Reference Include="Costura, Version=5.7.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>packages\Costura.Fody.5.7.0\lib\netstandard1.0\Costura.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Win32.Primitives, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\Microsoft.Win32.Primitives.4.3.0\lib\net46\Microsoft.Win32.Primitives.dll</HintPath>
</Reference>
<Reference Include="mscorlib" />
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>packages\Newtonsoft.Json.13.0.2\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.AppContext, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.AppContext.4.3.0\lib\net463\System.AppContext.dll</HintPath>
</Reference>
<Reference Include="System.Buffers, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll</HintPath>
</Reference>
<Reference Include="System.ComponentModel.Composition" />
<Reference Include="System.Console, Version=4.0.1.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Console.4.3.1\lib\net46\System.Console.dll</HintPath>
</Reference>
<Reference Include="System.Core" />
<Reference Include="System.Diagnostics.DiagnosticSource, Version=7.0.0.1, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Diagnostics.DiagnosticSource.7.0.1\lib\net462\System.Diagnostics.DiagnosticSource.dll</HintPath>
</Reference>
<Reference Include="System.Diagnostics.Tracing, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Diagnostics.Tracing.4.3.0\lib\net462\System.Diagnostics.Tracing.dll</HintPath>
</Reference>
<Reference Include="System.Globalization.Calendars, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Globalization.Calendars.4.3.0\lib\net46\System.Globalization.Calendars.dll</HintPath>
</Reference>
<Reference Include="System.IO, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.IO.4.3.0\lib\net462\System.IO.dll</HintPath>
</Reference>
<Reference Include="System.IO.Compression, Version=4.1.2.0, Culture=neutral, PublicKeyToken=b77a5c561934e089, processorArchitecture=MSIL">
<HintPath>packages\System.IO.Compression.4.3.0\lib\net46\System.IO.Compression.dll</HintPath>
</Reference>
<Reference Include="System.IO.Compression.FileSystem" />
<Reference Include="System.IO.Compression.ZipFile, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b77a5c561934e089, processorArchitecture=MSIL">
<HintPath>packages\System.IO.Compression.ZipFile.4.3.0\lib\net46\System.IO.Compression.ZipFile.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.IO.FileSystem.4.3.0\lib\net46\System.IO.FileSystem.dll</HintPath>
</Reference>
<Reference Include="System.IO.FileSystem.Primitives, Version=4.0.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.IO.FileSystem.Primitives.4.3.0\lib\net46\System.IO.FileSystem.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Linq, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Linq.4.3.0\lib\net463\System.Linq.dll</HintPath>
</Reference>
<Reference Include="System.Linq.Expressions, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Linq.Expressions.4.3.0\lib\net463\System.Linq.Expressions.dll</HintPath>
</Reference>
<Reference Include="System.Memory, Version=4.0.1.2, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>packages\System.Memory.4.5.5\lib\net461\System.Memory.dll</HintPath>
</Reference>
<Reference Include="System.Net.Http, Version=4.1.1.3, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Net.Http.4.3.4\lib\net46\System.Net.Http.dll</HintPath>
</Reference>
<Reference Include="System.Net.Sockets, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Net.Sockets.4.3.0\lib\net46\System.Net.Sockets.dll</HintPath>
</Reference>
<Reference Include="System.Numerics" />
<Reference Include="System.Numerics.Vectors, Version=4.1.4.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll</HintPath>
</Reference>
<Reference Include="System.Reflection, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Reflection.4.3.0\lib\net462\System.Reflection.dll</HintPath>
</Reference>
<Reference Include="System.Runtime, Version=4.1.1.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.4.3.1\lib\net462\System.Runtime.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.CompilerServices.Unsafe, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.Extensions, Version=4.1.1.1, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.Extensions.4.3.1\lib\net462\System.Runtime.Extensions.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.InteropServices, Version=4.1.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.InteropServices.4.3.0\lib\net463\System.Runtime.InteropServices.dll</HintPath>
</Reference>
<Reference Include="System.Runtime.InteropServices.RuntimeInformation, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Runtime.InteropServices.RuntimeInformation.4.3.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Algorithms, Version=4.2.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.Algorithms.4.3.1\lib\net463\System.Security.Cryptography.Algorithms.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Encoding, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.Encoding.4.3.0\lib\net46\System.Security.Cryptography.Encoding.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.Primitives, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.Primitives.4.3.0\lib\net46\System.Security.Cryptography.Primitives.dll</HintPath>
</Reference>
<Reference Include="System.Security.Cryptography.X509Certificates, Version=4.1.1.2, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Security.Cryptography.X509Certificates.4.3.2\lib\net461\System.Security.Cryptography.X509Certificates.dll</HintPath>
</Reference>
<Reference Include="System.Text.RegularExpressions, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Text.RegularExpressions.4.3.1\lib\net463\System.Text.RegularExpressions.dll</HintPath>
</Reference>
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Deployment" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
<Reference Include="System.Xml.ReaderWriter, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>packages\System.Xml.ReaderWriter.4.3.1\lib\net46\System.Xml.ReaderWriter.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="DownloadForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="DownloadForm.Designer.cs">
<DependentUpon>DownloadForm.cs</DependentUpon>
</Compile>
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Version.cs" />
<EmbeddedResource Include="DownloadForm.resx">
<DependentUpon>DownloadForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<SubType>Designer</SubType>
</EmbeddedResource>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<None Include="..\..\public\favicon.ico">
<Link>favicon.ico</Link>
</None>
<None Include="packages.config" />
<None Include="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
<Compile Include="Properties\Settings.Designer.cs">
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
<DesignTimeSharedInput>True</DesignTimeSharedInput>
</Compile>
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<ItemGroup>
<Content Include=".gitignore" />
<Content Include="app.manifest" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105.The missing file is {0}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.props')" Text="$([System.String]::Format('$(ErrorText)', 'packages\Costura.Fody.5.7.0\build\Costura.Fody.props'))" />
<Error Condition="!Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.targets')" Text="$([System.String]::Format('$(ErrorText)', 'packages\Costura.Fody.5.7.0\build\Costura.Fody.targets'))" />
<Error Condition="!Exists('packages\Fody.6.6.4\build\Fody.targets')" Text="$([System.String]::Format('$(ErrorText)', 'packages\Fody.6.6.4\build\Fody.targets'))" />
<Error Condition="!Exists('packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets')" Text="$([System.String]::Format('$(ErrorText)', 'packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets'))" />
</Target>
<Import Project="packages\Costura.Fody.5.7.0\build\Costura.Fody.targets" Condition="Exists('packages\Costura.Fody.5.7.0\build\Costura.Fody.targets')" />
<Import Project="packages\Fody.6.6.4\build\Fody.targets" Condition="Exists('packages\Fody.6.6.4\build\Fody.targets')" />
<Import Project="packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets" Condition="Exists('packages\NETStandard.Library.2.0.3\build\netstandard2.0\NETStandard.Library.targets')" />
</Project>

View file

@ -0,0 +1,16 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UptimeKuma", "UptimeKuma.csproj", "{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2DB53988-1D93-4AC0-90C4-96ADEAAC5C04}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=UptimeKuma_002FProperties_002FResources/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/Initialized/@EntryValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -0,0 +1,9 @@
namespace UptimeKuma {
public class Version {
public string latest { get; set; }
public string slow { get; set; }
public string beta { get; set; }
public string nodejs { get; set; }
public string exe { get; set; }
}
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Costura.Fody" version="5.7.0" targetFramework="net472" developmentDependency="true" />
<package id="Fody" version="6.6.4" targetFramework="net472" developmentDependency="true" />
<package id="Microsoft.NETCore.Platforms" version="7.0.0" targetFramework="net472" />
<package id="Microsoft.Win32.Primitives" version="4.3.0" targetFramework="net472" />
<package id="NETStandard.Library" version="2.0.3" targetFramework="net472" />
<package id="Newtonsoft.Json" version="13.0.2" targetFramework="net472" />
<package id="System.AppContext" version="4.3.0" targetFramework="net472" />
<package id="System.Console" version="4.3.1" targetFramework="net472" />
<package id="System.Diagnostics.DiagnosticSource" version="7.0.1" targetFramework="net472" />
<package id="System.Net.Http" version="4.3.4" targetFramework="net472" />
<package id="System.Runtime.Extensions" version="4.3.1" targetFramework="net472" />
<package id="System.Security.Cryptography.Algorithms" version="4.3.1" targetFramework="net472" />
<package id="System.Security.Cryptography.X509Certificates" version="4.3.2" targetFramework="net472" />
<package id="System.Text.RegularExpressions" version="4.3.1" targetFramework="net472" />
<package id="System.Xml.ReaderWriter" version="4.3.1" targetFramework="net472" />
<package id="System.Memory" version="4.5.5" targetFramework="net472" />
<package id="System.Net.Primitives" version="4.3.1" targetFramework="net472" />
<package id="System.Runtime" version="4.3.1" targetFramework="net472" />
<package id="System.Buffers" version="4.5.1" targetFramework="net472" />
<package id="System.Collections" version="4.3.0" targetFramework="net472" />
<package id="System.Collections.Concurrent" version="4.3.0" targetFramework="net472" />
<package id="System.Diagnostics.Debug" version="4.3.0" targetFramework="net472" />
<package id="System.Diagnostics.Tools" version="4.3.0" targetFramework="net472" />
<package id="System.Diagnostics.Tracing" version="4.3.0" targetFramework="net472" />
<package id="System.Globalization" version="4.3.0" targetFramework="net472" />
<package id="System.Globalization.Calendars" version="4.3.0" targetFramework="net472" />
<package id="System.IO" version="4.3.0" targetFramework="net472" />
<package id="System.IO.Compression" version="4.3.0" targetFramework="net472" />
<package id="System.IO.Compression.ZipFile" version="4.3.0" targetFramework="net472" />
<package id="System.IO.FileSystem" version="4.3.0" targetFramework="net472" />
<package id="System.IO.FileSystem.Primitives" version="4.3.0" targetFramework="net472" />
<package id="System.Linq" version="4.3.0" targetFramework="net472" />
<package id="System.Linq.Expressions" version="4.3.0" targetFramework="net472" />
<package id="System.Net.Sockets" version="4.3.0" targetFramework="net472" />
<package id="System.Numerics.Vectors" version="4.5.0" targetFramework="net472" />
<package id="System.ObjectModel" version="4.3.0" targetFramework="net472" />
<package id="System.Reflection" version="4.3.0" targetFramework="net472" />
<package id="System.Reflection.Extensions" version="4.3.0" targetFramework="net472" />
<package id="System.Reflection.Primitives" version="4.3.0" targetFramework="net472" />
<package id="System.Resources.ResourceManager" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.CompilerServices.Unsafe" version="6.0.0" targetFramework="net472" />
<package id="System.Runtime.Handles" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.InteropServices" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.InteropServices.RuntimeInformation" version="4.3.0" targetFramework="net472" />
<package id="System.Runtime.Numerics" version="4.3.0" targetFramework="net472" />
<package id="System.Security.Cryptography.Encoding" version="4.3.0" targetFramework="net472" />
<package id="System.Security.Cryptography.Primitives" version="4.3.0" targetFramework="net472" />
<package id="System.Text.Encoding" version="4.3.0" targetFramework="net472" />
<package id="System.Text.Encoding.Extensions" version="4.3.0" targetFramework="net472" />
<package id="System.Threading" version="4.3.0" targetFramework="net472" />
<package id="System.Threading.Tasks" version="4.3.0" targetFramework="net472" />
<package id="System.Threading.Timer" version="4.3.0" targetFramework="net472" />
<package id="System.Xml.XDocument" version="4.3.0" targetFramework="net472" />
</packages>

View file

@ -11,12 +11,17 @@ import (
"net/http" "net/http"
"os" "os"
"runtime" "runtime"
"strings"
"time" "time"
) )
func main() { func main() {
isFreeBSD := runtime.GOOS == "freebsd" isFreeBSD := runtime.GOOS == "freebsd"
// Is K8S + uptime-kuma as the container name
// See #2083
isK8s := strings.HasPrefix(os.Getenv("UPTIME_KUMA_PORT"), "tcp://")
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{ http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true,
@ -44,7 +49,11 @@ func main() {
hostname = "127.0.0.1" hostname = "127.0.0.1"
} }
port := os.Getenv("UPTIME_KUMA_PORT") port := ""
// UPTIME_KUMA_PORT is override by K8S unexpectedly,
if !isK8s {
port = os.Getenv("UPTIME_KUMA_PORT")
}
if len(port) == 0 { if len(port) == 0 {
port = os.Getenv("PORT") port = os.Getenv("PORT")
} }

View file

@ -1,4 +1,8 @@
/* /*
* Due to the weird issue in Portainer that the healthcheck script is still pointing to this script for unknown reason.
* IT CANNOT BE DROPPED, even though it looks like it is not used.
* See more: https://github.com/louislam/uptime-kuma/issues/2774#issuecomment-1429092359
*
* Deprecated: Changed to healthcheck.go, it will be deleted in the future. * Deprecated: Changed to healthcheck.go, it will be deleted in the future.
* This script should be run after a period of time (180s), because the server may need some time to prepare. * This script should be run after a period of time (180s), because the server may need some time to prepare.
*/ */

View file

@ -1,11 +1,12 @@
const pkg = require("../package.json"); const pkg = require("../package.json");
const fs = require("fs"); const fs = require("fs");
const util = require("../src/util"); const util = require("../src/util");
const dayjs = require("dayjs");
util.polyfill(); util.polyfill();
const oldVersion = pkg.version; const oldVersion = pkg.version;
const newVersion = oldVersion + "-nightly-" + util.genSecret(8); const newVersion = oldVersion + "-nightly-" + dayjs().format("YYYYMMDDHHmmss");
console.log("Old Version: " + oldVersion); console.log("Old Version: " + oldVersion);
console.log("New Version: " + newVersion); console.log("New Version: " + newVersion);

View file

@ -43,6 +43,11 @@ const main = async () => {
console.log("Finished."); console.log("Finished.");
}; };
/**
* Ask question of user
* @param {string} question Question to ask
* @returns {Promise<string>} Users response
*/
function question(question) { function question(question) {
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(question, (answer) => { rl.question(question, (answer) => {

View file

@ -53,6 +53,11 @@ const main = async () => {
console.log("Finished."); console.log("Finished.");
}; };
/**
* Ask question of user
* @param {string} question Question to ask
* @returns {Promise<string>} Users response
*/
function question(question) { function question(question) {
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(question, (answer) => { rl.question(question, (answer) => {

View file

@ -135,6 +135,11 @@ server.listen({
udp: 5300 udp: 5300
}); });
/**
* Get human readable request type from request code
* @param {number} code Request code to translate
* @returns {string} Human readable request type
*/
function type(code) { function type(code) {
for (let name in Packet.TYPE) { for (let name in Packet.TYPE) {
if (Packet.TYPE[name] === code) { if (Packet.TYPE[name] === code) {

View file

@ -11,6 +11,7 @@ class SimpleMqttServer {
this.port = port; this.port = port;
} }
/** Start the MQTT server */
start() { start() {
this.server.listen(this.port, () => { this.server.listen(this.port, () => {
console.log("server started and listening on port ", this.port); console.log("server started and listening on port ", this.port);

View file

@ -0,0 +1,22 @@
const fs = require("fs");
// Read the file from private/sort-contributors.txt
const file = fs.readFileSync("private/sort-contributors.txt", "utf8");
// Convert to an array of lines
let lines = file.split("\n");
// Remove empty lines
lines = lines.filter((line) => line !== "");
// Remove duplicates
lines = [ ...new Set(lines) ];
// Remove @weblate and @UptimeKumaBot
lines = lines.filter((line) => line !== "@weblate" && line !== "@UptimeKumaBot" && line !== "@louislam");
// Sort the lines
lines = lines.sort();
// Output the lines, concat with " "
console.log(lines.join(" "));

View file

@ -26,7 +26,8 @@ if (! exists) {
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n"); fs.writeFileSync("package.json", JSON.stringify(pkg, null, 4) + "\n");
// Also update package-lock.json // Also update package-lock.json
childProcess.spawnSync("npm", [ "install" ]); const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";
childProcess.spawnSync(npm, [ "install" ]);
commit(newVersion); commit(newVersion);
tag(newVersion); tag(newVersion);
@ -36,10 +37,8 @@ if (! exists) {
} }
/** /**
* Updates the version number in package.json and commits it to git. * Commit updated files
* @param {string} version - The new version number * @param {string} version Version to update to
*
* Generated by Trelent
*/ */
function commit(version) { function commit(version) {
let msg = "Update to " + version; let msg = "Update to " + version;
@ -53,16 +52,19 @@ function commit(version) {
} }
} }
/**
* Create a tag with the specified version
* @param {string} version Tag to create
*/
function tag(version) { function tag(version) {
let res = childProcess.spawnSync("git", [ "tag", version ]); let res = childProcess.spawnSync("git", [ "tag", version ]);
console.log(res.stdout.toString().trim()); console.log(res.stdout.toString().trim());
} }
/** /**
* Checks if a given version is already tagged in the git repository. * Check if a tag exists for the specified version
* @param {string} version - The version to check for. * @param {string} version Version to check
* * @returns {boolean} Does the tag already exist
* Generated by Trelent
*/ */
function tagExists(version) { function tagExists(version) {
if (! version) { if (! version) {

View file

@ -10,6 +10,10 @@ if (!newVersion) {
updateWiki(newVersion); updateWiki(newVersion);
/**
* Update the wiki with new version number
* @param {string} newVersion Version to update to
*/
function updateWiki(newVersion) { function updateWiki(newVersion) {
const wikiDir = "./tmp/wiki"; const wikiDir = "./tmp/wiki";
const howToUpdateFilename = "./tmp/wiki/🆙-How-to-Update.md"; const howToUpdateFilename = "./tmp/wiki/🆙-How-to-Update.md";
@ -39,6 +43,10 @@ function updateWiki(newVersion) {
safeDelete(wikiDir); safeDelete(wikiDir);
} }
/**
* Check if a directory exists and then delete it
* @param {string} dir Directory to delete
*/
function safeDelete(dir) { function safeDelete(dir) {
if (fs.existsSync(dir)) { if (fs.existsSync(dir)) {
fs.rm(dir, { fs.rm(dir, {

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />

18108
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "uptime-kuma", "name": "uptime-kuma",
"version": "1.19.4", "version": "1.21.3",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
@ -39,7 +39,7 @@
"build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain", "build-docker-nightly-amd64": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push --progress plain",
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test --target pr-test . --push", "build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test --target pr-test . --push",
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
"setup": "git checkout 1.19.4 && npm ci --production && npm run download-dist", "setup": "git checkout 1.21.3 && npm ci --production && npm run download-dist",
"download-dist": "node extra/download-dist.js", "download-dist": "node extra/download-dist.js",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js", "reset-password": "node extra/reset-password.js",
@ -63,18 +63,19 @@
"cy:run": "npx cypress run --browser chrome --headless --config-file ./config/cypress.config.js", "cy:run": "npx cypress run --browser chrome --headless --config-file ./config/cypress.config.js",
"cy:run:unit": "npx cypress run --browser chrome --headless --config-file ./config/cypress.frontend.config.js", "cy:run:unit": "npx cypress run --browser chrome --headless --config-file ./config/cypress.frontend.config.js",
"cypress-open": "concurrently -k -r \"node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/\" \"cypress open --config-file ./config/cypress.config.js\"", "cypress-open": "concurrently -k -r \"node test/prepare-test-server.js && node server/server.js --port=3002 --data-dir=./data/test/\" \"cypress open --config-file ./config/cypress.config.js\"",
"build-healthcheck-armv7": "cross-env GOOS=linux GOARCH=arm GOARM=7 go build -x -o ./extra/healthcheck-armv7 ./extra/healthcheck.go" "build-healthcheck-armv7": "cross-env GOOS=linux GOARCH=arm GOARM=7 go build -x -o ./extra/healthcheck-armv7 ./extra/healthcheck.go",
"deploy-demo-server": "node extra/deploy-demo-server.js",
"sort-contributors": "node extra/sort-contributors.js"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "~1.7.3", "@grpc/grpc-js": "~1.7.3",
"@louislam/ping": "~0.4.2-mod.0", "@louislam/ping": "~0.4.4-mod.0",
"@louislam/sqlite3": "15.1.2", "@louislam/sqlite3": "15.1.6",
"args-parser": "~1.3.0", "args-parser": "~1.3.0",
"axios": "~0.27.0", "axios": "~0.27.0",
"axios-ntlm": "1.3.0", "axios-ntlm": "1.3.0",
"badge-maker": "~3.3.1", "badge-maker": "~3.3.1",
"bcryptjs": "~2.4.3", "bcryptjs": "~2.4.3",
"bree": "~7.1.5",
"cacheable-lookup": "~6.0.4", "cacheable-lookup": "~6.0.4",
"chardet": "~1.4.0", "chardet": "~1.4.0",
"check-password-strength": "^2.0.5", "check-password-strength": "^2.0.5",
@ -83,11 +84,14 @@
"command-exists": "~1.2.9", "command-exists": "~1.2.9",
"compare-versions": "~3.6.0", "compare-versions": "~3.6.0",
"compression": "~1.7.4", "compression": "~1.7.4",
"croner": "^6.0.3",
"dayjs": "~1.11.5", "dayjs": "~1.11.5",
"dotenv": "~16.0.3",
"express": "~4.17.3", "express": "~4.17.3",
"express-basic-auth": "~1.2.1", "express-basic-auth": "~1.2.1",
"express-static-gzip": "~2.1.7", "express-static-gzip": "~2.1.7",
"form-data": "~4.0.0", "form-data": "~4.0.0",
"gamedig": "^4.0.5",
"http-graceful-shutdown": "~3.1.7", "http-graceful-shutdown": "~3.1.7",
"http-proxy-agent": "~5.0.0", "http-proxy-agent": "~5.0.0",
"https-proxy-agent": "~5.0.1", "https-proxy-agent": "~5.0.1",
@ -96,9 +100,11 @@
"jsonwebtoken": "~9.0.0", "jsonwebtoken": "~9.0.0",
"jwt-decode": "~3.1.2", "jwt-decode": "~3.1.2",
"limiter": "~2.1.0", "limiter": "~2.1.0",
"mongodb": "~4.14.0",
"mqtt": "~4.3.7", "mqtt": "~4.3.7",
"mssql": "~8.1.4", "mssql": "~8.1.4",
"mysql2": "~2.3.3", "mysql2": "~2.3.3",
"nanoid": "^3.3.4",
"node-cloudflared-tunnel": "~1.0.9", "node-cloudflared-tunnel": "~1.0.9",
"node-radius-client": "~1.0.0", "node-radius-client": "~1.0.0",
"nodemailer": "~6.6.5", "nodemailer": "~6.6.5",
@ -109,9 +115,11 @@
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"protobufjs": "~7.1.1", "protobufjs": "~7.1.1",
"redbean-node": "0.1.4", "qs": "~6.10.4",
"socket.io": "~4.5.3", "redbean-node": "~0.2.0",
"socket.io-client": "~4.5.3", "redis": "~4.5.1",
"socket.io": "~4.6.1",
"socket.io-client": "~4.6.1",
"socks-proxy-agent": "6.1.1", "socks-proxy-agent": "6.1.1",
"tar": "~6.1.11", "tar": "~6.1.11",
"tcp-ping": "~0.1.1", "tcp-ping": "~0.1.1",
@ -134,18 +142,22 @@
"aedes": "^0.46.3", "aedes": "^0.46.3",
"babel-plugin-rewire": "~1.2.0", "babel-plugin-rewire": "~1.2.0",
"bootstrap": "5.1.3", "bootstrap": "5.1.3",
"chart.js": "~3.6.2", "chart.js": "~4.2.1",
"chartjs-adapter-dayjs": "~1.0.0", "chartjs-adapter-dayjs-4": "~1.0.4",
"concurrently": "^7.1.0", "concurrently": "^7.1.0",
"core-js": "~3.26.1", "core-js": "~3.26.1",
"cronstrue": "~2.24.0",
"cross-env": "~7.0.3", "cross-env": "~7.0.3",
"cypress": "^10.1.0", "cypress": "^10.1.0",
"delay": "^5.0.0", "delay": "^5.0.0",
"dns2": "~2.0.1", "dns2": "~2.0.1",
"dompurify": "~2.4.3",
"eslint": "~8.14.0", "eslint": "~8.14.0",
"eslint-plugin-vue": "~8.7.1", "eslint-plugin-vue": "~8.7.1",
"favico.js": "~0.3.10", "favico.js": "~0.3.10",
"jest": "~27.2.5", "jest": "~27.2.5",
"marked": "~4.2.5",
"node-ssh": "~13.0.1",
"postcss-html": "~1.5.0", "postcss-html": "~1.5.0",
"postcss-rtlcss": "~3.7.2", "postcss-rtlcss": "~3.7.2",
"postcss-scss": "~4.0.4", "postcss-scss": "~4.0.4",
@ -161,8 +173,8 @@
"v-pagination-3": "~0.1.7", "v-pagination-3": "~0.1.7",
"vite": "~3.1.0", "vite": "~3.1.0",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vue": "next", "vue": "~3.2.47",
"vue-chart-3": "3.0.9", "vue-chartjs": "~5.2.0",
"vue-confirm-dialog": "~1.0.2", "vue-confirm-dialog": "~1.0.2",
"vue-contenteditable": "~3.0.4", "vue-contenteditable": "~3.0.4",
"vue-i18n": "~9.2.2", "vue-i18n": "~9.2.2",
@ -173,6 +185,7 @@
"vue-router": "~4.0.14", "vue-router": "~4.0.14",
"vue-toastification": "~2.0.0-rc.5", "vue-toastification": "~2.0.0-rc.5",
"vuedraggable": "~4.1.0", "vuedraggable": "~4.1.0",
"wait-on": "^6.0.1" "wait-on": "^6.0.1",
"whatwg-url": "~12.0.1"
} }
} }

View file

@ -2,7 +2,9 @@ const basicAuth = require("express-basic-auth");
const passwordHash = require("./password-hash"); const passwordHash = require("./password-hash");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { setting } = require("./util-server"); const { setting } = require("./util-server");
const { loginRateLimiter } = require("./rate-limiter"); const { loginRateLimiter, apiRateLimiter } = require("./rate-limiter");
const { Settings } = require("./settings");
const dayjs = require("dayjs");
/** /**
* Login to web app * Login to web app
@ -34,8 +36,36 @@ exports.login = async function (username, password) {
}; };
/** /**
* Callback for myAuthorizer * Validate a provided API key
* @callback myAuthorizerCB * @param {string} key API key to verify
*/
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 {any} err Any error encountered
* @param {boolean} authorized Is the client authorized? * @param {boolean} authorized Is the client authorized?
*/ */
@ -44,9 +74,31 @@ exports.login = async function (username, password) {
* Custom authorizer for express-basic-auth * Custom authorizer for express-basic-auth
* @param {string} username * @param {string} username
* @param {string} password * @param {string} password
* @param {myAuthorizerCB} callback * @param {authCallback} callback
*/ */
function myAuthorizer(username, password, callback) { function apiAuthorizer(username, password, callback) {
// API Rate Limit
apiRateLimiter.pass(null, 0).then((pass) => {
if (pass) {
verifyAPIKey(password).then((valid) => {
callback(null, valid);
// Only allow a set number of api requests per minute
// (currently set to 60)
apiRateLimiter.removeTokens(1);
});
} else {
callback(null, false);
}
});
}
/**
* Custom authorizer for express-basic-auth
* @param {string} username
* @param {string} password
* @param {authCallback} callback
*/
function userAuthorizer(username, password, callback) {
// Login Rate Limit // Login Rate Limit
loginRateLimiter.pass(null, 0).then((pass) => { loginRateLimiter.pass(null, 0).then((pass) => {
if (pass) { if (pass) {
@ -63,9 +115,15 @@ function myAuthorizer(username, password, callback) {
}); });
} }
/**
* 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
*/
exports.basicAuth = async function (req, res, next) { exports.basicAuth = async function (req, res, next) {
const middleware = basicAuth({ const middleware = basicAuth({
authorizer: myAuthorizer, authorizer: userAuthorizer,
authorizeAsync: true, authorizeAsync: true,
challenge: true, challenge: true,
}); });
@ -78,3 +136,32 @@ exports.basicAuth = async function (req, res, next) {
next(); 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
*/
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();
}
};

View file

@ -37,6 +37,10 @@ class CacheableDnsHttpAgent {
this.enable = isEnable; this.enable = isEnable;
} }
/**
* Attach cacheable to HTTP agent
* @param {http.Agent} agent Agent to install
*/
static install(agent) { static install(agent) {
this.cacheable.install(agent); this.cacheable.install(agent);
} }

View file

@ -113,6 +113,31 @@ async function sendProxyList(socket) {
return 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. * Emits the version information to the client.
* @param {Socket} socket Socket.io socket instance * @param {Socket} socket Socket.io socket instance
@ -157,6 +182,7 @@ module.exports = {
sendImportantHeartbeatList, sendImportantHeartbeatList,
sendHeartbeatList, sendHeartbeatList,
sendProxyList, sendProxyList,
sendAPIKeyList,
sendInfo, sendInfo,
sendDockerHostList sendDockerHostList
}; };

View file

@ -4,13 +4,21 @@ const demoMode = args["demo"] || false;
const badgeConstants = { const badgeConstants = {
naColor: "#999", naColor: "#999",
defaultUpColor: "#66c20a", defaultUpColor: "#66c20a",
defaultWarnColor: "#eed202",
defaultDownColor: "#c2290a", defaultDownColor: "#c2290a",
defaultPendingColor: "#f8a306",
defaultMaintenanceColor: "#1747f5",
defaultPingColor: "blue", // as defined by badge-maker / shields.io defaultPingColor: "blue", // as defined by badge-maker / shields.io
defaultStyle: "flat", defaultStyle: "flat",
defaultPingValueSuffix: "ms", defaultPingValueSuffix: "ms",
defaultPingLabelSuffix: "h", defaultPingLabelSuffix: "h",
defaultUptimeValueSuffix: "%", defaultUptimeValueSuffix: "%",
defaultUptimeLabelSuffix: "h", defaultUptimeLabelSuffix: "h",
defaultCertExpValueSuffix: " days",
defaultCertExpLabelSuffix: "h",
// Values Come From Default Notification Times
defaultCertExpireWarnDays: "14",
defaultCertExpireDownDays: "7"
}; };
module.exports = { module.exports = {

View file

@ -2,8 +2,8 @@ const fs = require("fs");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { setSetting, setting } = require("./util-server"); const { setSetting, setting } = require("./util-server");
const { log, sleep } = require("../src/util"); const { log, sleep } = require("../src/util");
const dayjs = require("dayjs");
const knex = require("knex"); const knex = require("knex");
const { PluginsManager } = require("./plugins-manager");
/** /**
* Database & App Data Folder * Database & App Data Folder
@ -29,11 +29,6 @@ class Database {
*/ */
static patched = false; static patched = false;
/**
* For Backup only
*/
static backupPath = null;
/** /**
* Add patch filename in key * Add patch filename in key
* Values: * Values:
@ -65,7 +60,15 @@ class Database {
"patch-grpc-monitor.sql": true, "patch-grpc-monitor.sql": true,
"patch-add-radius-monitor.sql": true, "patch-add-radius-monitor.sql": true,
"patch-monitor-add-resend-interval.sql": true, "patch-monitor-add-resend-interval.sql": true,
"patch-ping-packet-size.sql": true,
"patch-maintenance-table2.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,
}; };
/** /**
@ -83,6 +86,13 @@ class Database {
static init(args) { static init(args) {
// Data Directory (must be end with "/") // Data Directory (must be end with "/")
Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/";
// Plugin feature is working only if the dataDir = "./data";
if (Database.dataDir !== "./data/") {
log.warn("PLUGIN", "Warning: In order to enable plugin feature, you need to use the default data directory: ./data/");
PluginsManager.disable = true;
}
Database.path = Database.dataDir + "kuma.db"; Database.path = Database.dataDir + "kuma.db";
if (! fs.existsSync(Database.dataDir)) { if (! fs.existsSync(Database.dataDir)) {
fs.mkdirSync(Database.dataDir, { recursive: true }); fs.mkdirSync(Database.dataDir, { recursive: true });
@ -183,15 +193,7 @@ class Database {
} else { } else {
log.info("db", "Database patch is needed"); log.info("db", "Database patch is needed");
try { // Try catch anything here
this.backup(version);
} catch (e) {
log.error("db", e);
log.error("db", "Unable to create a backup before patching the database. Please make sure you have enough space and permission.");
process.exit(1);
}
// Try catch anything here, if gone wrong, restore the backup
try { try {
for (let i = version + 1; i <= this.latestVersion; i++) { for (let i = version + 1; i <= this.latestVersion; i++) {
const sqlFile = `./db/patch${i}.sql`; const sqlFile = `./db/patch${i}.sql`;
@ -207,7 +209,6 @@ class Database {
log.error("db", "Start Uptime-Kuma failed due to issue patching the database"); 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"); log.error("db", "Please submit a bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues");
this.restore();
process.exit(1); process.exit(1);
} }
} }
@ -249,8 +250,6 @@ class Database {
log.error("db", "Start Uptime-Kuma failed due to issue patching the database"); 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"); log.error("db", "Please submit the bug report if you still encounter the problem after restart: https://github.com/louislam/uptime-kuma/issues");
this.restore();
process.exit(1); process.exit(1);
} }
@ -352,8 +351,6 @@ class Database {
} }
} }
this.backup(dayjs().format("YYYYMMDDHHmmss"));
log.info("db", sqlFilename + " is patching"); log.info("db", sqlFilename + " is patching");
this.patched = true; this.patched = true;
await this.importSQLFile("./db/" + sqlFilename); await this.importSQLFile("./db/" + sqlFilename);
@ -419,6 +416,9 @@ class Database {
log.info("db", "Closing the database"); log.info("db", "Closing the database");
// Flush WAL to main database
await R.exec("PRAGMA wal_checkpoint(TRUNCATE)");
while (true) { while (true) {
Database.noReject = true; Database.noReject = true;
await R.close(); await R.close();
@ -435,90 +435,6 @@ class Database {
process.removeListener("unhandledRejection", listener); process.removeListener("unhandledRejection", listener);
} }
/**
* One backup one time in this process.
* Reset this.backupPath if you want to backup again
* @param {string} version Version code of backup
*/
static backup(version) {
if (! this.backupPath) {
log.info("db", "Backing up the database");
this.backupPath = this.dataDir + "kuma.db.bak" + version;
fs.copyFileSync(Database.path, this.backupPath);
const shmPath = Database.path + "-shm";
if (fs.existsSync(shmPath)) {
this.backupShmPath = shmPath + ".bak" + version;
fs.copyFileSync(shmPath, this.backupShmPath);
}
const walPath = Database.path + "-wal";
if (fs.existsSync(walPath)) {
this.backupWalPath = walPath + ".bak" + version;
fs.copyFileSync(walPath, this.backupWalPath);
}
// Double confirm if all files actually backup
if (!fs.existsSync(this.backupPath)) {
throw new Error("Backup failed! " + this.backupPath);
}
if (fs.existsSync(shmPath)) {
if (!fs.existsSync(this.backupShmPath)) {
throw new Error("Backup failed! " + this.backupShmPath);
}
}
if (fs.existsSync(walPath)) {
if (!fs.existsSync(this.backupWalPath)) {
throw new Error("Backup failed! " + this.backupWalPath);
}
}
}
}
/** Restore from most recent backup */
static restore() {
if (this.backupPath) {
log.error("db", "Patching the database failed!!! Restoring the backup");
const shmPath = Database.path + "-shm";
const walPath = Database.path + "-wal";
// Delete patch failed db
try {
if (fs.existsSync(Database.path)) {
fs.unlinkSync(Database.path);
}
if (fs.existsSync(shmPath)) {
fs.unlinkSync(shmPath);
}
if (fs.existsSync(walPath)) {
fs.unlinkSync(walPath);
}
} catch (e) {
log.error("db", "Restore failed; you may need to restore the backup manually");
process.exit(1);
}
// Restore backup
fs.copyFileSync(this.backupPath, Database.path);
if (this.backupShmPath) {
fs.copyFileSync(this.backupShmPath, shmPath);
}
if (this.backupWalPath) {
fs.copyFileSync(this.backupWalPath, walPath);
}
} else {
log.info("db", "Nothing to restore");
}
}
/** Get the size of the database */ /** Get the size of the database */
static getSize() { static getSize() {
log.debug("db", "Database.getSize()"); log.debug("db", "Database.getSize()");

24
server/git.js Normal file
View file

@ -0,0 +1,24 @@
const childProcess = require("child_process");
class Git {
static clone(repoURL, cwd, targetDir = ".") {
let result = childProcess.spawnSync("git", [
"clone",
repoURL,
targetDir,
], {
cwd: cwd,
});
if (result.status !== 0) {
throw new Error(result.stderr.toString("utf-8"));
} else {
return result.stdout.toString("utf-8") + result.stderr.toString("utf-8");
}
}
}
module.exports = {
Git,
};

View file

@ -0,0 +1,24 @@
const jsesc = require("jsesc");
/**
* Returns a string that represents the javascript that is required to insert the Google Analytics scripts
* into a webpage.
* @param tagId Google UA/G/AW/DC Property ID to use with the Google Analytics script.
* @returns {string}
*/
function getGoogleAnalyticsScript(tagId) {
let escapedTagId = jsesc(tagId, { isScriptContext: true });
if (escapedTagId) {
escapedTagId = escapedTagId.trim();
}
return `
<script async src="https://www.googletagmanager.com/gtag/js?id=${escapedTagId}"></script>
<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date());gtag('config', '${escapedTagId}'); </script>
`;
}
module.exports = {
getGoogleAnalyticsScript,
};

View file

@ -1,40 +1,44 @@
const path = require("path"); const { UptimeKumaServer } = require("./uptime-kuma-server");
const Bree = require("bree"); const { clearOldData } = require("./jobs/clear-old-data");
const { SHARE_ENV } = require("worker_threads"); const Cron = require("croner");
const { log } = require("../src/util");
let bree;
const jobs = [ const jobs = [
{ {
name: "clear-old-data", name: "clear-old-data",
interval: "at 03:14", interval: "14 03 * * *",
jobFunc: clearOldData,
croner: null,
}, },
]; ];
/** /**
* Initialize background jobs * Initialize background jobs
* @param {Object} args Arguments to pass to workers * @returns {Promise<void>}
* @returns {Bree}
*/ */
const initBackgroundJobs = function (args) { const initBackgroundJobs = async function () {
bree = new Bree({ const timezone = await UptimeKumaServer.getInstance().getTimezone();
root: path.resolve("server", "jobs"),
jobs, for (const job of jobs) {
worker: { const cornerJob = new Cron(
env: SHARE_ENV, job.interval,
workerData: args, {
}, name: job.name,
workerMessageHandler: (message) => { timezone,
log.info("jobs", message); },
} job.jobFunc,
}); );
job.croner = cornerJob;
}
bree.start();
return bree;
}; };
/** Stop all background jobs if running */
const stopBackgroundJobs = function () { const stopBackgroundJobs = function () {
if (bree) { for (const job of jobs) {
bree.stop(); if (job.croner) {
job.croner.stop();
job.croner = null;
}
} }
}; };

View file

@ -1,12 +1,15 @@
const { log, exit, connectDb } = require("./util-worker");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { log } = require("../../src/util");
const { setSetting, setting } = require("../util-server"); const { setSetting, setting } = require("../util-server");
const DEFAULT_KEEP_PERIOD = 180; const DEFAULT_KEEP_PERIOD = 180;
(async () => { /**
await connectDb(); * Clears old data from the heartbeat table of the database.
* @return {Promise<void>} A promise that resolves when the data has been cleared.
*/
const clearOldData = async () => {
let period = await setting("keepDataPeriodDays"); let period = await setting("keepDataPeriodDays");
// Set Default Period // Set Default Period
@ -20,16 +23,16 @@ const DEFAULT_KEEP_PERIOD = 180;
try { try {
parsedPeriod = parseInt(period); parsedPeriod = parseInt(period);
} catch (_) { } catch (_) {
log("Failed to parse setting, resetting to default.."); log.warn("clearOldData", "Failed to parse setting, resetting to default..");
await setSetting("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general"); await setSetting("keepDataPeriodDays", DEFAULT_KEEP_PERIOD, "general");
parsedPeriod = DEFAULT_KEEP_PERIOD; parsedPeriod = DEFAULT_KEEP_PERIOD;
} }
if (parsedPeriod < 1) { if (parsedPeriod < 1) {
log(`Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`); log.info("clearOldData", `Data deletion has been disabled as period is less than 1. Period is ${parsedPeriod} days.`);
} else { } else {
log(`Clearing Data older than ${parsedPeriod} days...`); log.debug("clearOldData", `Clearing Data older than ${parsedPeriod} days...`);
try { try {
await R.exec( await R.exec(
@ -37,9 +40,11 @@ const DEFAULT_KEEP_PERIOD = 180;
[ parsedPeriod ] [ parsedPeriod ]
); );
} catch (e) { } catch (e) {
log(`Failed to clear old data: ${e.message}`); log.error("clearOldData", `Failed to clear old data: ${e.message}`);
} }
} }
};
exit(); module.exports = {
})(); clearOldData,
};

View file

@ -1,50 +0,0 @@
const { parentPort, workerData } = require("worker_threads");
const Database = require("../database");
const path = require("path");
/**
* Send message to parent process for logging
* since worker_thread does not have access to stdout, this is used
* instead of console.log()
* @param {any} any The message to log
*/
const log = function (any) {
if (parentPort) {
parentPort.postMessage(any);
}
};
/**
* Exit the worker process
* @param {number} error The status code to exit
*/
const exit = function (error) {
if (error && error !== 0) {
process.exit(error);
} else {
if (parentPort) {
parentPort.postMessage("done");
} else {
process.exit(0);
}
}
};
/** Connects to the database */
const connectDb = async function () {
const dbPath = path.join(
process.env.DATA_DIR || workerData["data-dir"] || "./data/"
);
Database.init({
"data-dir": dbPath,
});
await Database.connect();
};
module.exports = {
log,
exit,
connectDb,
};

76
server/model/api_key.js Normal file
View file

@ -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}
*/
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}
*/
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>}
*/
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;

View file

@ -1,8 +1,10 @@
const { BeanModel } = require("redbean-node/dist/bean-model"); const { BeanModel } = require("redbean-node/dist/bean-model");
const { parseTimeObject, parseTimeFromTimeObject, utcToLocal, localToUTC, log } = require("../../src/util"); const { parseTimeObject, parseTimeFromTimeObject, log } = require("../../src/util");
const { timeObjectToUTC, timeObjectToLocal } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const Cron = require("croner");
const { UptimeKumaServer } = require("../uptime-kuma-server");
const apicache = require("../modules/apicache");
class Maintenance extends BeanModel { class Maintenance extends BeanModel {
@ -15,16 +17,19 @@ class Maintenance extends BeanModel {
let dateRange = []; let dateRange = [];
if (this.start_date) { if (this.start_date) {
dateRange.push(utcToLocal(this.start_date)); dateRange.push(this.start_date);
if (this.end_date) { } else {
dateRange.push(utcToLocal(this.end_date)); dateRange.push(null);
} }
if (this.end_date) {
dateRange.push(this.end_date);
} }
let timeRange = []; let timeRange = [];
let startTime = timeObjectToLocal(parseTimeObject(this.start_time)); let startTime = parseTimeObject(this.start_time);
timeRange.push(startTime); timeRange.push(startTime);
let endTime = timeObjectToLocal(parseTimeObject(this.end_time)); let endTime = parseTimeObject(this.end_time);
timeRange.push(endTime); timeRange.push(endTime);
let obj = { let obj = {
@ -39,12 +44,44 @@ class Maintenance extends BeanModel {
weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [], weekdays: (this.weekdays) ? JSON.parse(this.weekdays) : [],
daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [], daysOfMonth: (this.days_of_month) ? JSON.parse(this.days_of_month) : [],
timeslotList: [], 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(),
}; };
const timeslotList = await this.getTimeslotList(); 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();
for (let timeslot of timeslotList) { if (runningTimeslot) {
obj.timeslotList.push(await timeslot.toPublicJSON()); 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)) { if (!Array.isArray(obj.weekdays)) {
@ -55,54 +92,9 @@ class Maintenance extends BeanModel {
obj.daysOfMonth = []; obj.daysOfMonth = [];
} }
// Maintenance Status
if (!obj.active) {
obj.status = "inactive";
} else if (obj.strategy === "manual") {
obj.status = "under-maintenance";
} else if (obj.timeslotList.length > 0) {
let currentTimestamp = dayjs().unix();
for (let timeslot of obj.timeslotList) {
if (dayjs.utc(timeslot.startDate).unix() <= currentTimestamp && dayjs.utc(timeslot.endDate).unix() >= currentTimestamp) {
log.debug("timeslot", "Timeslot ID: " + timeslot.id);
log.debug("timeslot", "currentTimestamp:" + currentTimestamp);
log.debug("timeslot", "timeslot.start_date:" + dayjs.utc(timeslot.startDate).unix());
log.debug("timeslot", "timeslot.end_date:" + dayjs.utc(timeslot.endDate).unix());
obj.status = "under-maintenance";
break;
}
}
if (!obj.status) {
obj.status = "scheduled";
}
} else if (obj.timeslotList.length === 0) {
obj.status = "ended";
} else {
obj.status = "unknown";
}
return obj; return obj;
} }
/**
* Only get future or current timeslots only
* @returns {Promise<[]>}
*/
async getTimeslotList() {
return R.convertToBeans("maintenance_timeslot", await R.getAll(`
SELECT maintenance_timeslot.*
FROM maintenance_timeslot, maintenance
WHERE maintenance_timeslot.maintenance_id = maintenance.id
AND maintenance.id = ?
AND ${Maintenance.getActiveAndFutureMaintenanceSQLCondition()}
`, [
this.id
]));
}
/** /**
* Return an object that ready to parse to JSON * Return an object that ready to parse to JSON
* @param {string} timezone If not specified, the timeRange will be in UTC * @param {string} timezone If not specified, the timeRange will be in UTC
@ -112,6 +104,11 @@ class Maintenance extends BeanModel {
return this.toPublicJSON(timezone); 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() { getDayOfWeekList() {
log.debug("timeslot", "List: " + this.weekdays); log.debug("timeslot", "List: " + this.weekdays);
return JSON.parse(this.weekdays).sort(function (a, b) { return JSON.parse(this.weekdays).sort(function (a, b) {
@ -119,25 +116,21 @@ class Maintenance extends BeanModel {
}); });
} }
/**
* Get a list of days in month that maintenance is active for
* @returns {number[]|string[]} Array of active days in month
*/
getDayOfMonthList() { getDayOfMonthList() {
return JSON.parse(this.days_of_month).sort(function (a, b) { return JSON.parse(this.days_of_month).sort(function (a, b) {
return a - b; return a - b;
}); });
} }
getStartDateTime() { /**
let startOfTheDay = dayjs.utc(this.start_date).format("HH:mm"); * Get the duration of maintenance in seconds
log.debug("timeslot", "startOfTheDay: " + startOfTheDay); * @returns {number} Duration of maintenance
*/
// Start Time calcDuration() {
let startTimeSecond = dayjs.utc(this.start_time, "HH:mm").diff(dayjs.utc(startOfTheDay, "HH:mm"), "second");
log.debug("timeslot", "startTime: " + startTimeSecond);
// Bake StartDate + StartTime = Start DateTime
return dayjs.utc(this.start_date).add(startTimeSecond, "second");
}
getDuration() {
let duration = dayjs.utc(this.end_time, "HH:mm").diff(dayjs.utc(this.start_time, "HH:mm"), "second"); 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 // Add 24hours if it is across day
if (duration < 0) { if (duration < 0) {
@ -146,71 +139,276 @@ class Maintenance extends BeanModel {
return duration; return duration;
} }
static jsonToBean(bean, obj) { /**
* Convert data from socket to bean
* @param {Bean} bean Bean to fill in
* @param {Object} obj Data to fill bean with
* @returns {Bean} Filled bean
*/
static async jsonToBean(bean, obj) {
if (obj.id) { if (obj.id) {
bean.id = obj.id; bean.id = obj.id;
} }
// Apply timezone offset to timeRange, as it cannot apply automatically.
if (obj.timeRange[0]) {
timeObjectToUTC(obj.timeRange[0]);
if (obj.timeRange[1]) {
timeObjectToUTC(obj.timeRange[1]);
}
}
bean.title = obj.title; bean.title = obj.title;
bean.description = obj.description; bean.description = obj.description;
bean.strategy = obj.strategy; bean.strategy = obj.strategy;
bean.interval_day = obj.intervalDay; bean.interval_day = obj.intervalDay;
bean.timezone = obj.timezoneOption;
bean.active = obj.active; bean.active = obj.active;
if (obj.dateRange[0]) { if (obj.dateRange[0]) {
bean.start_date = localToUTC(obj.dateRange[0]); bean.start_date = obj.dateRange[0];
} else {
bean.start_date = null;
}
if (obj.dateRange[1]) { if (obj.dateRange[1]) {
bean.end_date = localToUTC(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.start_time = parseTimeFromTimeObject(obj.timeRange[0]);
bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]); bean.end_time = parseTimeFromTimeObject(obj.timeRange[1]);
bean.weekdays = JSON.stringify(obj.weekdays); bean.weekdays = JSON.stringify(obj.weekdays);
bean.days_of_month = JSON.stringify(obj.daysOfMonth); bean.days_of_month = JSON.stringify(obj.daysOfMonth);
await bean.generateCron();
this.validateCron(bean.cron);
}
return bean; return bean;
} }
/** /**
* SQL conditions for active maintenance * Throw error if cron is invalid
* @returns {string} * @param cron
* @returns {Promise<void>}
*/ */
static getActiveMaintenanceSQLCondition() { static async validateCron(cron) {
return ` let job = new Cron(cron, () => {});
( job.stop();
(maintenance_timeslot.start_date <= DATETIME('now')
AND maintenance_timeslot.end_date >= DATETIME('now')
AND maintenance.active = 1)
OR
(maintenance.strategy = 'manual' AND active = 1)
)
`;
} }
/** /**
* SQL conditions for active and future maintenance * Run the cron
* @returns {string}
*/ */
static getActiveAndFutureMaintenanceSQLCondition() { async run(throwError = false) {
return ` if (this.beanMeta.job) {
( log.debug("maintenance", "Maintenance is already running, stop it first. id: " + this.id);
((maintenance_timeslot.end_date >= DATETIME('now') this.stop();
AND maintenance.active = 1) }
OR
(maintenance.strategy = 'manual' AND active = 1)) 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);
// Check if duration is still in the window. If not, use the duration from the current time to the end of the window
let duration;
if (customDuration > 0) {
duration = customDuration;
} else if (this.end_date) {
let d = dayjs(this.end_date).diff(dayjs(), "second");
if (d < this.duration) {
duration = d * 1000;
}
} else {
duration = this.duration * 1000;
}
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
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");
}
}
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;
}
}
stop() {
if (this.beanMeta.job) {
this.beanMeta.job.stop();
delete this.beanMeta.job;
}
}
async isUnderMaintenance() {
return (await this.getStatus()) === "under-maintenance";
}
async getTimezone() {
if (!this.timezone || this.timezone === "SAME_AS_SERVER") {
return await UptimeKumaServer.getInstance().getTimezone();
}
return this.timezone;
}
async getTimezoneOffset() {
return dayjs.tz(dayjs(), await this.getTimezone()).format("Z");
}
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") {
let array = this.start_time.split(":");
let hour = parseInt(array[0]);
let minute = parseInt(array[1]);
this.cron = minute + " " + hour + " */" + this.interval_day + " * *";
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();
}
} }
} }

View file

@ -1,189 +0,0 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
const { R } = require("redbean-node");
const dayjs = require("dayjs");
const { log, utcToLocal, SQL_DATETIME_FORMAT_WITHOUT_SECOND, localToUTC } = require("../../src/util");
const { UptimeKumaServer } = require("../uptime-kuma-server");
class MaintenanceTimeslot extends BeanModel {
async toPublicJSON() {
const serverTimezoneOffset = UptimeKumaServer.getInstance().getTimezoneOffset();
const obj = {
id: this.id,
startDate: this.start_date,
endDate: this.end_date,
startDateServerTimezone: utcToLocal(this.start_date, SQL_DATETIME_FORMAT_WITHOUT_SECOND),
endDateServerTimezone: utcToLocal(this.end_date, SQL_DATETIME_FORMAT_WITHOUT_SECOND),
serverTimezoneOffset,
};
return obj;
}
async toJSON() {
return await this.toPublicJSON();
}
/**
* @param {Maintenance} maintenance
* @param {dayjs} minDate (For recurring type only) Generate a next timeslot from this date.
* @param {boolean} removeExist Remove existing timeslot before create
* @returns {Promise<MaintenanceTimeslot>}
*/
static async generateTimeslot(maintenance, minDate = null, removeExist = false) {
if (removeExist) {
await R.exec("DELETE FROM maintenance_timeslot WHERE maintenance_id = ? ", [
maintenance.id
]);
}
if (maintenance.strategy === "manual") {
log.debug("maintenance", "No need to generate timeslot for manual type");
} else if (maintenance.strategy === "single") {
let bean = R.dispense("maintenance_timeslot");
bean.maintenance_id = maintenance.id;
bean.start_date = maintenance.start_date;
bean.end_date = maintenance.end_date;
bean.generated_next = true;
return await R.store(bean);
} else if (maintenance.strategy === "recurring-interval") {
// Prevent dead loop, in case interval_day is not set
if (!maintenance.interval_day || maintenance.interval_day <= 0) {
maintenance.interval_day = 1;
}
return await this.handleRecurringType(maintenance, minDate, (startDateTime) => {
return startDateTime.add(maintenance.interval_day, "day");
}, () => {
return true;
});
} else if (maintenance.strategy === "recurring-weekday") {
let dayOfWeekList = maintenance.getDayOfWeekList();
log.debug("timeslot", dayOfWeekList);
if (dayOfWeekList.length <= 0) {
log.debug("timeslot", "No weekdays selected?");
return null;
}
const isValid = (startDateTime) => {
log.debug("timeslot", "nextDateTime: " + startDateTime);
let day = startDateTime.local().day();
log.debug("timeslot", "nextDateTime.day(): " + day);
return dayOfWeekList.includes(day);
};
return await this.handleRecurringType(maintenance, minDate, (startDateTime) => {
while (true) {
startDateTime = startDateTime.add(1, "day");
if (isValid(startDateTime)) {
return startDateTime;
}
}
}, isValid);
} else if (maintenance.strategy === "recurring-day-of-month") {
let dayOfMonthList = maintenance.getDayOfMonthList();
if (dayOfMonthList.length <= 0) {
log.debug("timeslot", "No day selected?");
return null;
}
const isValid = (startDateTime) => {
let day = parseInt(startDateTime.local().format("D"));
log.debug("timeslot", "day: " + day);
// Check 1-31
if (dayOfMonthList.includes(day)) {
return startDateTime;
}
// Check "lastDay1","lastDay2"...
let daysInMonth = startDateTime.daysInMonth();
let lastDayList = [];
// Small first, e.g. 28 > 29 > 30 > 31
for (let i = 4; i >= 1; i--) {
if (dayOfMonthList.includes("lastDay" + i)) {
lastDayList.push(daysInMonth - i + 1);
}
}
log.debug("timeslot", lastDayList);
return lastDayList.includes(day);
};
return await this.handleRecurringType(maintenance, minDate, (startDateTime) => {
while (true) {
startDateTime = startDateTime.add(1, "day");
if (isValid(startDateTime)) {
return startDateTime;
}
}
}, isValid);
} else {
throw new Error("Unknown maintenance strategy");
}
}
/**
* Generate a next timeslot for all recurring types
* @param maintenance
* @param minDate
* @param {function} nextDayCallback The logic how to get the next possible day
* @param {function} isValidCallback Check the day whether is matched the current strategy
* @returns {Promise<null|MaintenanceTimeslot>}
*/
static async handleRecurringType(maintenance, minDate, nextDayCallback, isValidCallback) {
let bean = R.dispense("maintenance_timeslot");
let duration = maintenance.getDuration();
let startDateTime = maintenance.getStartDateTime();
let endDateTime;
// Keep generating from the first possible date, until it is ok
while (true) {
log.debug("timeslot", "startDateTime: " + startDateTime.format());
// Handling out of effective date range
if (startDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) {
log.debug("timeslot", "Out of effective date range");
return null;
}
endDateTime = startDateTime.add(duration, "second");
// If endDateTime is out of effective date range, use the end datetime from effective date range
if (endDateTime.diff(dayjs.utc(maintenance.end_date)) > 0) {
endDateTime = dayjs.utc(maintenance.end_date);
}
// If minDate is set, the endDateTime must be bigger than it.
// And the endDateTime must be bigger current time
// Is valid under current recurring strategy
if (
(!minDate || endDateTime.diff(minDate) > 0) &&
endDateTime.diff(dayjs()) > 0 &&
isValidCallback(startDateTime)
) {
break;
}
startDateTime = nextDayCallback(startDateTime);
}
bean.maintenance_id = maintenance.id;
bean.start_date = localToUTC(startDateTime);
bean.end_date = localToUTC(endDateTime);
bean.generated_next = false;
return await R.store(bean);
}
}
module.exports = MaintenanceTimeslot;

View file

@ -2,8 +2,12 @@ const https = require("https");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const axios = require("axios"); const axios = require("axios");
const { Prometheus } = require("../prometheus"); const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND } = require("../../src/util"); const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery } = require("../util-server"); SQL_DATETIME_FORMAT
} = require("../../src/util");
const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery,
redisPingAsync, mongodbPing,
} = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model"); const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification"); const { Notification } = require("../notification");
@ -14,8 +18,8 @@ const apicache = require("../modules/apicache");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent"); const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent");
const { DockerHost } = require("../docker"); const { DockerHost } = require("../docker");
const Maintenance = require("./maintenance");
const { UptimeCacheList } = require("../uptime-cache-list"); const { UptimeCacheList } = require("../uptime-cache-list");
const Gamedig = require("gamedig");
/** /**
* status: * status:
@ -36,7 +40,6 @@ class Monitor extends BeanModel {
id: this.id, id: this.id,
name: this.name, name: this.name,
sendUrl: this.sendUrl, sendUrl: this.sendUrl,
maintenance: await Monitor.isUnderMaintenance(this.id),
}; };
if (this.sendUrl) { if (this.sendUrl) {
@ -70,6 +73,7 @@ class Monitor extends BeanModel {
let data = { let data = {
id: this.id, id: this.id,
name: this.name, name: this.name,
description: this.description,
url: this.url, url: this.url,
method: this.method, method: this.method,
hostname: this.hostname, hostname: this.hostname,
@ -85,6 +89,7 @@ class Monitor extends BeanModel {
expiryNotification: this.isEnabledExpiryNotification(), expiryNotification: this.isEnabledExpiryNotification(),
ignoreTls: this.getIgnoreTls(), ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(), upsideDown: this.isUpsideDown(),
packetSize: this.packetSize,
maxredirects: this.maxredirects, maxredirects: this.maxredirects,
accepted_statuscodes: this.getAcceptedStatuscodes(), accepted_statuscodes: this.getAcceptedStatuscodes(),
dns_resolve_type: this.dns_resolve_type, dns_resolve_type: this.dns_resolve_type,
@ -107,6 +112,8 @@ class Monitor extends BeanModel {
grpcEnableTls: this.getGrpcEnableTls(), grpcEnableTls: this.getGrpcEnableTls(),
radiusCalledStationId: this.radiusCalledStationId, radiusCalledStationId: this.radiusCalledStationId,
radiusCallingStationId: this.radiusCallingStationId, radiusCallingStationId: this.radiusCallingStationId,
game: this.game,
httpBodyEncoding: this.httpBodyEncoding
}; };
if (includeSensitiveData) { if (includeSensitiveData) {
@ -127,6 +134,9 @@ class Monitor extends BeanModel {
mqttPassword: this.mqttPassword, mqttPassword: this.mqttPassword,
authWorkstation: this.authWorkstation, authWorkstation: this.authWorkstation,
authDomain: this.authDomain, authDomain: this.authDomain,
tlsCa: this.tlsCa,
tlsCert: this.tlsCert,
tlsKey: this.tlsKey,
}; };
} }
@ -139,7 +149,7 @@ class Monitor extends BeanModel {
* @returns {Promise<LooseObject<any>[]>} * @returns {Promise<LooseObject<any>[]>}
*/ */
async getTags() { 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 = ?", [ this.id ]); 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 ]);
} }
/** /**
@ -199,7 +209,7 @@ class Monitor extends BeanModel {
let previousBeat = null; let previousBeat = null;
let retries = 0; let retries = 0;
let prometheus = new Prometheus(this); this.prometheus = new Prometheus(this);
const beat = async () => { const beat = async () => {
@ -268,17 +278,34 @@ class Monitor extends BeanModel {
log.debug("monitor", `[${this.name}] Prepare Options for axios`); 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 === "xml") {
bodyValue = this.body;
contentType = "text/xml; charset=utf-8";
}
}
// Axios Options // Axios Options
const options = { const options = {
url: this.url, url: this.url,
method: (this.method || "get").toLowerCase(), method: (this.method || "get").toLowerCase(),
...(this.body ? { data: JSON.parse(this.body) } : {}),
timeout: this.interval * 1000 * 0.8, timeout: this.interval * 1000 * 0.8,
headers: { 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", "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",
"User-Agent": "Uptime-Kuma/" + version, "User-Agent": "Uptime-Kuma/" + version,
...(this.headers ? JSON.parse(this.headers) : {}), ...(contentType ? { "Content-Type": contentType } : {}),
...(basicAuthHeader), ...(basicAuthHeader),
...(this.headers ? JSON.parse(this.headers) : {})
}, },
maxRedirects: this.maxredirects, maxRedirects: this.maxredirects,
validateStatus: (status) => { validateStatus: (status) => {
@ -286,6 +313,10 @@ class Monitor extends BeanModel {
}, },
}; };
if (bodyValue) {
options.data = bodyValue;
}
if (this.proxy_id) { if (this.proxy_id) {
const proxy = await R.load("proxy", this.proxy_id); const proxy = await R.load("proxy", this.proxy_id);
@ -304,6 +335,18 @@ class Monitor extends BeanModel {
options.httpsAgent = new https.Agent(httpsAgentOptions); options.httpsAgent = new https.Agent(httpsAgentOptions);
} }
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);
}
}
log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`); log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`);
log.debug("monitor", `[${this.name}] Axios Request`); log.debug("monitor", `[${this.name}] Axios Request`);
@ -357,7 +400,7 @@ class Monitor extends BeanModel {
bean.msg += ", keyword is found"; bean.msg += ", keyword is found";
bean.status = UP; bean.status = UP;
} else { } else {
data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " "); data = data.replace(/<[^>]*>?|[\n\r]|\s+/gm, " ").trim();
if (data.length > 50) { if (data.length > 50) {
data = data.substring(0, 47) + "..."; data = data.substring(0, 47) + "...";
} }
@ -372,7 +415,7 @@ class Monitor extends BeanModel {
bean.status = UP; bean.status = UP;
} else if (this.type === "ping") { } else if (this.type === "ping") {
bean.ping = await ping(this.hostname); bean.ping = await ping(this.hostname, this.packetSize);
bean.msg = ""; bean.msg = "";
bean.status = UP; bean.status = UP;
} else if (this.type === "dns") { } else if (this.type === "dns") {
@ -482,25 +525,44 @@ class Monitor extends BeanModel {
bean.msg = res.data.response.servers[0].name; bean.msg = res.data.response.servers[0].name;
try { try {
bean.ping = await ping(this.hostname); bean.ping = await ping(this.hostname, this.packetSize);
} catch (_) { } } catch (_) { }
} else { } else {
throw new Error("Server not found on Steam"); 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: true,
});
bean.msg = state.name;
bean.status = UP;
bean.ping = state.ping;
} catch (e) {
throw new Error(e.message);
}
} else if (this.type === "docker") { } else if (this.type === "docker") {
log.debug(`[${this.name}] Prepare Options for Axios`); log.debug("monitor", `[${this.name}] Prepare Options for Axios`);
const dockerHost = await R.load("docker_host", this.docker_host); const dockerHost = await R.load("docker_host", this.docker_host);
const options = { const options = {
url: `/containers/${this.docker_container}/json`, url: `/containers/${this.docker_container}/json`,
timeout: this.interval * 1000 * 0.8,
headers: { headers: {
"Accept": "*/*", "Accept": "*/*",
"User-Agent": "Uptime-Kuma/" + version, "User-Agent": "Uptime-Kuma/" + version,
}, },
httpsAgent: new https.Agent({ httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: ! this.getIgnoreTls(), rejectUnauthorized: !this.getIgnoreTls(),
}),
httpAgent: CacheableDnsHttpAgent.getHttpAgent({
maxCachedSessions: 0,
}), }),
}; };
@ -510,7 +572,7 @@ class Monitor extends BeanModel {
options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon); options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
} }
log.debug(`[${this.name}] Axios Request`); log.debug("monitor", `[${this.name}] Axios Request`);
let res = await axios.request(options); let res = await axios.request(options);
if (res.data.State.Running) { if (res.data.State.Running) {
bean.status = UP; bean.status = UP;
@ -576,11 +638,18 @@ class Monitor extends BeanModel {
} else if (this.type === "mysql") { } else if (this.type === "mysql") {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
await mysqlQuery(this.databaseConnectionString, this.databaseQuery); bean.msg = await mysqlQuery(this.databaseConnectionString, this.databaseQuery);
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "mongodb") {
let startTime = dayjs().valueOf();
await mongodbPing(this.databaseConnectionString);
bean.msg = ""; bean.msg = "";
bean.status = UP; bean.status = UP;
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "radius") { } else if (this.type === "radius") {
let startTime = dayjs().valueOf(); let startTime = dayjs().valueOf();
@ -617,9 +686,23 @@ class Monitor extends BeanModel {
} }
} }
bean.ping = dayjs().valueOf() - startTime; bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "redis") {
let startTime = dayjs().valueOf();
bean.msg = await redisPingAsync(this.databaseConnectionString);
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);
if (!bean.ping) {
bean.ping = dayjs().valueOf() - startTime;
}
} else { } else {
bean.msg = "Unknown Monitor Type"; throw new Error("Unknown Monitor Type");
bean.status = PENDING;
} }
if (this.isUpsideDown()) { if (this.isUpsideDown()) {
@ -709,7 +792,7 @@ class Monitor extends BeanModel {
await R.store(bean); await R.store(bean);
log.debug("monitor", `[${this.name}] prometheus.update`); log.debug("monitor", `[${this.name}] prometheus.update`);
prometheus.update(bean, tlsInfo); this.prometheus?.update(bean, tlsInfo);
previousBeat = bean; previousBeat = bean;
@ -748,6 +831,13 @@ class Monitor extends BeanModel {
} }
} }
/**
* 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 faliure
* @returns {Object} Axios response
*/
async makeAxiosRequest(options, finalCall = false) { async makeAxiosRequest(options, finalCall = false) {
try { try {
let res; let res;
@ -760,7 +850,6 @@ class Monitor extends BeanModel {
domain: this.authDomain, domain: this.authDomain,
workstation: this.authWorkstation ? this.authWorkstation : undefined workstation: this.authWorkstation ? this.authWorkstation : undefined
}); });
} else { } else {
res = await axios.request(options); res = await axios.request(options);
} }
@ -787,15 +876,15 @@ class Monitor extends BeanModel {
clearTimeout(this.heartbeatInterval); clearTimeout(this.heartbeatInterval);
this.isStop = true; this.isStop = true;
this.prometheus().remove(); this.prometheus?.remove();
} }
/** /**
* Get a new prometheus instance * Get prometheus instance
* @returns {Prometheus} * @returns {Prometheus|undefined}
*/ */
prometheus() { getPrometheus() {
return new Prometheus(this); return this.prometheus;
} }
/** /**
@ -1089,12 +1178,18 @@ class Monitor extends BeanModel {
for (let notification of notificationList) { for (let notification of notificationList) {
try { try {
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
const heartbeatJSON = bean.toJSON(); const heartbeatJSON = bean.toJSON();
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
if (!heartbeatJSON["msg"]) { if (!heartbeatJSON["msg"]) {
heartbeatJSON["msg"] = "N/A"; 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, await monitor.toJSON(false), heartbeatJSON); await Notification.send(JSON.parse(notification.config), msg, await monitor.toJSON(false), heartbeatJSON);
} catch (e) { } catch (e) {
log.error("monitor", "Cannot send notification to " + notification.name); log.error("monitor", "Cannot send notification to " + notification.name);
@ -1167,7 +1262,7 @@ class Monitor extends BeanModel {
*/ */
async sendCertNotificationByTargetDays(certCN, certType, daysRemaining, targetDays, notificationList) { async sendCertNotificationByTargetDays(certCN, certType, daysRemaining, targetDays, notificationList) {
let row = await R.getRow("SELECT * FROM notification_sent_history WHERE type = ? AND monitor_id = ? AND days = ?", [ let row = await R.getRow("SELECT * FROM notification_sent_history WHERE type = ? AND monitor_id = ? AND days <= ?", [
"certificate", "certificate",
this.id, this.id,
targetDays, targetDays,
@ -1209,7 +1304,7 @@ class Monitor extends BeanModel {
*/ */
static async getPreviousHeartbeat(monitorID) { static async getPreviousHeartbeat(monitorID) {
return await R.getRow(` return await R.getRow(`
SELECT status, time FROM heartbeat SELECT ping, status, time FROM heartbeat
WHERE id = (select MAX(id) from heartbeat where monitor_id = ?) WHERE id = (select MAX(id) from heartbeat where monitor_id = ?)
`, [ `, [
monitorID monitorID
@ -1222,20 +1317,22 @@ class Monitor extends BeanModel {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
static async isUnderMaintenance(monitorID) { static async isUnderMaintenance(monitorID) {
let activeCondition = Maintenance.getActiveMaintenanceSQLCondition(); const maintenanceIDList = await R.getCol(`
const maintenance = await R.getRow(` SELECT maintenance_id FROM monitor_maintenance
SELECT COUNT(*) AS count WHERE monitor_id = ?
FROM monitor_maintenance mm `, [ monitorID ]);
JOIN maintenance
ON mm.maintenance_id = maintenance.id for (const maintenanceID of maintenanceIDList) {
AND mm.monitor_id = ? const maintenance = await UptimeKumaServer.getInstance().getMaintenance(maintenanceID);
LEFT JOIN maintenance_timeslot if (maintenance && await maintenance.isUnderMaintenance()) {
ON maintenance_timeslot.maintenance_id = maintenance.id return true;
WHERE ${activeCondition} }
LIMIT 1`, [ monitorID ]);
return maintenance.count !== 0;
} }
return false;
}
/** Make sure monitor interval is between bounds */
validate() { validate() {
if (this.interval > MAX_INTERVAL_SECOND) { if (this.interval > MAX_INTERVAL_SECOND) {
throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`); throw new Error(`Interval cannot be more than ${MAX_INTERVAL_SECOND} seconds`);

View file

@ -3,7 +3,7 @@ const { R } = require("redbean-node");
const cheerio = require("cheerio"); const cheerio = require("cheerio");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const jsesc = require("jsesc"); const jsesc = require("jsesc");
const Maintenance = require("./maintenance"); const googleAnalytics = require("../google-analytics");
class StatusPage extends BeanModel { class StatusPage extends BeanModel {
@ -53,9 +53,17 @@ class StatusPage extends BeanModel {
const head = $("head"); const head = $("head");
if (statusPage.googleAnalyticsTagId) {
let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId);
head.append($(escapedGoogleAnalyticsScript));
}
// OG Meta Tags // OG Meta Tags
head.append(`<meta property="og:title" content="${statusPage.title}" />`); let ogTitle = $("<meta property=\"og:title\" content=\"\" />").attr("content", statusPage.title);
head.append(`<meta property="og:description" content="${description155}" />`); head.append(ogTitle);
let ogDescription = $("<meta property=\"og:description\" content=\"\" />").attr("content", description155);
head.append(ogDescription);
// Preload data // Preload data
// Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186 // Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186
@ -225,6 +233,7 @@ class StatusPage extends BeanModel {
customCSS: this.custom_css, customCSS: this.custom_css,
footerText: this.footer_text, footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by, showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
}; };
} }
@ -245,6 +254,7 @@ class StatusPage extends BeanModel {
customCSS: this.custom_css, customCSS: this.custom_css,
footerText: this.footer_text, footerText: this.footer_text,
showPoweredBy: !!this.show_powered_by, showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
}; };
} }
@ -279,21 +289,17 @@ class StatusPage extends BeanModel {
try { try {
const publicMaintenanceList = []; const publicMaintenanceList = [];
let activeCondition = Maintenance.getActiveMaintenanceSQLCondition(); let maintenanceIDList = await R.getCol(`
let maintenanceBeanList = R.convertToBeans("maintenance", await R.getAll(` SELECT DISTINCT maintenance_id
SELECT DISTINCT maintenance.* FROM maintenance_status_page
FROM maintenance WHERE status_page_id = ?
JOIN maintenance_status_page `, [ statusPageId ]);
ON maintenance_status_page.maintenance_id = maintenance.id
AND maintenance_status_page.status_page_id = ?
LEFT JOIN maintenance_timeslot
ON maintenance_timeslot.maintenance_id = maintenance.id
WHERE ${activeCondition}
ORDER BY maintenance.end_date
`, [ statusPageId ]));
for (const bean of maintenanceBeanList) { for (const maintenanceID of maintenanceIDList) {
publicMaintenanceList.push(await bean.toPublicJSON()); let maintenance = UptimeKumaServer.getInstance().getMaintenance(maintenanceID);
if (maintenance && await maintenance.isUnderMaintenance()) {
publicMaintenanceList.push(await maintenance.toPublicJSON());
}
} }
return publicMaintenanceList; return publicMaintenanceList;

View file

@ -0,0 +1,19 @@
class MonitorType {
name = undefined;
/**
*
* @param {Monitor} monitor
* @param {Heartbeat} heartbeat
* @returns {Promise<void>}
*/
async check(monitor, heartbeat) {
throw new Error("You need to override check()");
}
}
module.exports = {
MonitorType,
};

View file

@ -8,7 +8,6 @@ class ClickSendSMS extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
try { try {
console.log({ notification });
let config = { let config = {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -15,7 +15,7 @@ class DingDing extends NotificationProvider {
msgtype: "markdown", msgtype: "markdown",
markdown: { markdown: {
title: `[${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]}`, title: `[${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]}`,
text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n > ${heartbeatJSON["msg"]} \n > Time(UTC):${heartbeatJSON["time"]}`, text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n> ${heartbeatJSON["msg"]}\n> Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
} }
}; };
if (this.sendToDingDing(notification, params)) { if (this.sendToDingDing(notification, params)) {

View file

@ -8,7 +8,12 @@ class LunaSea extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
let lunaseadevice = "https://notify.lunasea.app/v1/custom/device/" + notification.lunaseaDevice; let lunaseaurl = "";
if (notification.lunaseaTarget === "user") {
lunaseaurl = "https://notify.lunasea.app/v1/custom/user/" + notification.lunaseaUserID;
} else {
lunaseaurl = "https://notify.lunasea.app/v1/custom/device/" + notification.lunaseaDevice;
}
try { try {
if (heartbeatJSON == null) { if (heartbeatJSON == null) {
@ -16,7 +21,7 @@ class LunaSea extends NotificationProvider {
"title": "Uptime Kuma Alert", "title": "Uptime Kuma Alert",
"body": msg, "body": msg,
}; };
await axios.post(lunaseadevice, testdata); await axios.post(lunaseaurl, testdata);
return okMsg; return okMsg;
} }
@ -25,7 +30,7 @@ class LunaSea extends NotificationProvider {
"title": "UptimeKuma Alert: " + monitorJSON["name"], "title": "UptimeKuma Alert: " + monitorJSON["name"],
"body": "[🔴 Down] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"], "body": "[🔴 Down] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
}; };
await axios.post(lunaseadevice, downdata); await axios.post(lunaseaurl, downdata);
return okMsg; return okMsg;
} }
@ -34,7 +39,7 @@ class LunaSea extends NotificationProvider {
"title": "UptimeKuma Alert: " + monitorJSON["name"], "title": "UptimeKuma Alert: " + monitorJSON["name"],
"body": "[✅ Up] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"], "body": "[✅ Up] " + heartbeatJSON["msg"] + "\nTime (UTC): " + heartbeatJSON["time"],
}; };
await axios.post(lunaseadevice, updata); await axios.post(lunaseaurl, updata);
return okMsg; return okMsg;
} }

View file

@ -10,7 +10,7 @@ class Mattermost extends NotificationProvider {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
try { try {
const mattermostUserName = notification.mattermostusername || "Uptime Kuma"; const mattermostUserName = notification.mattermostusername || "Uptime Kuma";
// If heartbeatJSON is null, assume we're testing. // If heartbeatJSON is null, assume non monitoring notification (Certificate warning) or testing.
if (heartbeatJSON == null) { if (heartbeatJSON == null) {
let mattermostTestData = { let mattermostTestData = {
username: mattermostUserName, username: mattermostUserName,
@ -27,86 +27,69 @@ class Mattermost extends NotificationProvider {
} }
const mattermostIconEmoji = notification.mattermosticonemo; const mattermostIconEmoji = notification.mattermosticonemo;
const mattermostIconUrl = notification.mattermosticonurl; let mattermostIconEmojiOnline = "";
let mattermostIconEmojiOffline = "";
if (heartbeatJSON["status"] === DOWN) { if (mattermostIconEmoji && typeof mattermostIconEmoji === "string") {
let mattermostdowndata = { const emojiArray = mattermostIconEmoji.split(" ");
username: mattermostUserName, if (emojiArray.length >= 2) {
text: "Uptime Kuma Alert", mattermostIconEmojiOnline = emojiArray[0];
channel: mattermostChannel, mattermostIconEmojiOffline = emojiArray[1];
icon_emoji: mattermostIconEmoji, }
icon_url: mattermostIconUrl, }
attachments: [ const mattermostIconUrl = notification.mattermosticonurl;
{ let iconEmoji = mattermostIconEmoji;
fallback: let statusField = {
"Your " +
monitorJSON["name"] +
" service went down.",
color: "#FF0000",
title:
"❌ " +
monitorJSON["name"] +
" service went down. ❌",
title_link: monitorJSON["url"],
fields: [
{
short: true,
title: "Service Name",
value: monitorJSON["name"],
},
{
short: true,
title: "Time (UTC)",
value: heartbeatJSON["time"],
},
{
short: false, short: false,
title: "Error", title: "Error",
value: heartbeatJSON["msg"], value: heartbeatJSON.msg,
},
],
},
],
}; };
await axios.post( let statusText = "unknown";
notification.mattermostWebhookUrl, let color = "#000000";
mattermostdowndata if (heartbeatJSON.status === DOWN) {
); iconEmoji = mattermostIconEmojiOffline || mattermostIconEmoji;
return okMsg; statusField = {
} else if (heartbeatJSON["status"] === UP) { short: false,
let mattermostupdata = { title: "Error",
username: mattermostUserName, value: heartbeatJSON.msg,
text: "Uptime Kuma Alert", };
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, channel: mattermostChannel,
icon_emoji: mattermostIconEmoji, icon_emoji: iconEmoji,
icon_url: mattermostIconUrl, icon_url: mattermostIconUrl,
attachments: [ attachments: [
{ {
fallback: fallback:
"Your " + "Your " +
monitorJSON["name"] + monitorJSON.name +
" service went up!", " service went " +
color: "#32CD32", statusText,
color: color,
title: title:
"✅ " + monitorJSON.name +
monitorJSON["name"] + " service went " +
" service went up! ✅", statusText,
title_link: monitorJSON["url"], title_link: monitorJSON.url,
fields: [ fields: [
{ statusField,
short: true,
title: "Service Name",
value: monitorJSON["name"],
},
{ {
short: true, short: true,
title: "Time (UTC)", title: "Time (UTC)",
value: heartbeatJSON["time"], value: heartbeatJSON.time,
},
{
short: false,
title: "Ping",
value: heartbeatJSON["ping"] + "ms",
}, },
], ],
}, },
@ -114,10 +97,9 @@ class Mattermost extends NotificationProvider {
}; };
await axios.post( await axios.post(
notification.mattermostWebhookUrl, notification.mattermostWebhookUrl,
mattermostupdata mattermostdata
); );
return okMsg; return okMsg;
}
} catch (error) { } catch (error) {
this.throwGeneralAxiosError(error); this.throwGeneralAxiosError(error);
} }

View file

@ -1,5 +1,6 @@
const NotificationProvider = require("./notification-provider"); const NotificationProvider = require("./notification-provider");
const axios = require("axios"); const axios = require("axios");
const { DOWN, UP } = require("../../src/util");
class Ntfy extends NotificationProvider { class Ntfy extends NotificationProvider {
@ -9,16 +10,54 @@ class Ntfy extends NotificationProvider {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
try { try {
let headers = {}; let headers = {};
if (notification.ntfyusername) { if (notification.ntfyAuthenticationMethod === "usernamePassword") {
headers = { headers = {
"Authorization": "Basic " + Buffer.from(notification.ntfyusername + ":" + notification.ntfypassword).toString("base64"), "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 = { let data = {
"topic": notification.ntfytopic, "topic": notification.ntfytopic,
"message": msg, "message": heartbeatJSON.msg,
"priority": notification.ntfyPriority || 4, "priority": priority,
"title": "Uptime-Kuma", "title": monitorJSON.name + " " + status + " [Uptime-Kuma]",
"tags": tags,
"actions": [
{
"action": "view",
"label": "Open " + monitorJSON.name,
"url": monitorJSON.url,
}
]
}; };
if (notification.ntfyIcon) { if (notification.ntfyIcon) {

View file

@ -0,0 +1,97 @@
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";
let 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);
}
}
/**
*
* @param {BeanModel} notification
* @param {string} url Request url
* @param {Object} data Request body
* @returns {Promise<string>}
*/
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;

View file

@ -0,0 +1,91 @@
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
* @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 {?string} eventAction Action event for PagerTree (create, resolve)
* @returns {string}
*/
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;

View file

@ -8,6 +8,14 @@ class PromoSMS extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
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 { try {
let config = { let config = {
headers: { headers: {
@ -18,8 +26,9 @@ class PromoSMS extends NotificationProvider {
}; };
let data = { let data = {
"recipients": [ notification.promosmsPhoneNumber ], "recipients": [ notification.promosmsPhoneNumber ],
//Lets remove non ascii char //Trim message to maximum length of 1 SMS or 4 if we allowed long messages
"text": msg.replace(/[^\x00-\x7F]/g, ""), "text": notification.promosmsAllowLongSMS ? cleanMsg.substring(0, 639) : cleanMsg.substring(0, 159),
"long-sms": notification.promosmsAllowLongSMS,
"type": Number(notification.promosmsSMSType), "type": Number(notification.promosmsSMSType),
"sender": notification.promosmsSenderName "sender": notification.promosmsSenderName
}; };

View file

@ -10,7 +10,7 @@ class Pushover extends NotificationProvider {
let pushoverlink = "https://api.pushover.net/1/messages.json"; let pushoverlink = "https://api.pushover.net/1/messages.json";
let data = { let data = {
"message": "<b>Message</b>:" + msg, "message": msg,
"user": notification.pushoveruserkey, "user": notification.pushoveruserkey,
"token": notification.pushoverapptoken, "token": notification.pushoverapptoken,
"sound": notification.pushoversounds, "sound": notification.pushoversounds,
@ -24,6 +24,9 @@ class Pushover extends NotificationProvider {
if (notification.pushoverdevice) { if (notification.pushoverdevice) {
data.device = notification.pushoverdevice; data.device = notification.pushoverdevice;
} }
if (notification.pushoverttl) {
data.ttl = notification.pushoverttl;
}
try { try {
if (heartbeatJSON == null) { if (heartbeatJSON == null) {

View file

@ -21,6 +21,12 @@ class ServerChan extends NotificationProvider {
} }
} }
/**
* Get the formatted title for message
* @param {?Object} monitorJSON Monitor details (For Up/Down only)
* @param {?Object} heartbeatJSON Heartbeat details (For Up/Down only)
* @returns {string} Formatted title
*/
checkStatus(heartbeatJSON, monitorJSON) { checkStatus(heartbeatJSON, monitorJSON) {
let title = "UptimeKuma Message"; let title = "UptimeKuma Message";
if (heartbeatJSON != null && heartbeatJSON["status"] === UP) { if (heartbeatJSON != null && heartbeatJSON["status"] === UP) {

View file

@ -42,7 +42,7 @@ class Slack extends NotificationProvider {
const time = heartbeatJSON["time"]; const time = heartbeatJSON["time"];
const textMsg = "Uptime Kuma Alert"; const textMsg = "Uptime Kuma Alert";
let data = { let data = {
"text": monitorJSON ? textMsg + `: ${monitorJSON.name}` : textMsg, "text": `${textMsg}\n${msg}`,
"channel": notification.slackchannel, "channel": notification.slackchannel,
"username": notification.slackusername, "username": notification.slackusername,
"icon_emoji": notification.slackiconemo, "icon_emoji": notification.slackiconemo,

View file

@ -0,0 +1,113 @@
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
* @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 {string}
*/
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;

View file

@ -9,17 +9,27 @@ class Telegram extends NotificationProvider {
let okMsg = "Sent Successfully."; let okMsg = "Sent Successfully.";
try { try {
await axios.get(`https://api.telegram.org/bot${notification.telegramBotToken}/sendMessage`, { let params = {
params: {
chat_id: notification.telegramChatID, chat_id: notification.telegramChatID,
text: msg, text: msg,
}, disable_notification: notification.telegramSendSilently ?? false,
protect_content: notification.telegramProtectContent ?? false,
};
if (notification.telegramMessageThreadID) {
params.message_thread_id = notification.telegramMessageThreadID;
}
await axios.get(`https://api.telegram.org/bot${notification.telegramBotToken}/sendMessage`, {
params: params,
}); });
return okMsg; return okMsg;
} catch (error) { } catch (error) {
let msg = (error.response.data.description) ? error.response.data.description : "Error without description"; if (error.response && error.response.data && error.response.data.description) {
throw new Error(msg); throw new Error(error.response.data.description);
} else {
throw new Error(error.message);
}
} }
} }
} }

View file

@ -0,0 +1,41 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Twilio extends NotificationProvider {
name = "twilio";
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
let okMsg = "Sent Successfully.";
let accountSID = notification.twilioAccountSID;
let authToken = notification.twilioAuthToken;
try {
let config = {
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
"Authorization": "Basic " + Buffer.from(accountSID + ":" + authToken).toString("base64"),
}
};
let data = new URLSearchParams();
data.append("To", notification.twilioToNumber);
data.append("From", notification.twilioFromNumber);
data.append("Body", msg);
let url = "https://api.twilio.com/2010-04-01/Accounts/" + accountSID + "/Messages.json";
await axios.post(url, data, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Twilio;

View file

@ -23,7 +23,9 @@ const Mattermost = require("./notification-providers/mattermost");
const Ntfy = require("./notification-providers/ntfy"); const Ntfy = require("./notification-providers/ntfy");
const Octopush = require("./notification-providers/octopush"); const Octopush = require("./notification-providers/octopush");
const OneBot = require("./notification-providers/onebot"); const OneBot = require("./notification-providers/onebot");
const Opsgenie = require("./notification-providers/opsgenie");
const PagerDuty = require("./notification-providers/pagerduty"); const PagerDuty = require("./notification-providers/pagerduty");
const PagerTree = require("./notification-providers/pagertree");
const PromoSMS = require("./notification-providers/promosms"); const PromoSMS = require("./notification-providers/promosms");
const Pushbullet = require("./notification-providers/pushbullet"); const Pushbullet = require("./notification-providers/pushbullet");
const PushDeer = require("./notification-providers/pushdeer"); const PushDeer = require("./notification-providers/pushdeer");
@ -40,6 +42,8 @@ const Stackfield = require("./notification-providers/stackfield");
const Teams = require("./notification-providers/teams"); const Teams = require("./notification-providers/teams");
const TechulusPush = require("./notification-providers/techulus-push"); const TechulusPush = require("./notification-providers/techulus-push");
const Telegram = require("./notification-providers/telegram"); const Telegram = require("./notification-providers/telegram");
const Twilio = require("./notification-providers/twilio");
const Splunk = require("./notification-providers/splunk");
const Webhook = require("./notification-providers/webhook"); const Webhook = require("./notification-providers/webhook");
const WeCom = require("./notification-providers/wecom"); const WeCom = require("./notification-providers/wecom");
const GoAlert = require("./notification-providers/goalert"); const GoAlert = require("./notification-providers/goalert");
@ -81,7 +85,9 @@ class Notification {
new Ntfy(), new Ntfy(),
new Octopush(), new Octopush(),
new OneBot(), new OneBot(),
new Opsgenie(),
new PagerDuty(), new PagerDuty(),
new PagerTree(),
new PromoSMS(), new PromoSMS(),
new Pushbullet(), new Pushbullet(),
new PushDeer(), new PushDeer(),
@ -100,6 +106,8 @@ class Notification {
new Teams(), new Teams(),
new TechulusPush(), new TechulusPush(),
new Telegram(), new Telegram(),
new Twilio(),
new Splunk(),
new Webhook(), new Webhook(),
new WeCom(), new WeCom(),
new GoAlert(), new GoAlert(),

13
server/plugin.js Normal file
View file

@ -0,0 +1,13 @@
class Plugin {
async load() {
}
async unload() {
}
}
module.exports = {
Plugin,
};

256
server/plugins-manager.js Normal file
View file

@ -0,0 +1,256 @@
const fs = require("fs");
const { log } = require("../src/util");
const path = require("path");
const axios = require("axios");
const { Git } = require("./git");
const childProcess = require("child_process");
class PluginsManager {
static disable = false;
/**
* Plugin List
* @type {PluginWrapper[]}
*/
pluginList = [];
/**
* Plugins Dir
*/
pluginsDir;
server;
/**
*
* @param {UptimeKumaServer} server
*/
constructor(server) {
this.server = server;
if (!PluginsManager.disable) {
this.pluginsDir = "./data/plugins/";
if (! fs.existsSync(this.pluginsDir)) {
fs.mkdirSync(this.pluginsDir, { recursive: true });
}
log.debug("plugin", "Scanning plugin directory");
let list = fs.readdirSync(this.pluginsDir);
this.pluginList = [];
for (let item of list) {
this.loadPlugin(item);
}
} else {
log.warn("PLUGIN", "Skip scanning plugin directory");
}
}
/**
* Install a Plugin
*/
async loadPlugin(name) {
log.info("plugin", "Load " + name);
let plugin = new PluginWrapper(this.server, this.pluginsDir + name);
try {
await plugin.load();
this.pluginList.push(plugin);
} catch (e) {
log.error("plugin", "Failed to load plugin: " + this.pluginsDir + name);
log.error("plugin", "Reason: " + e.message);
}
}
/**
* Download a Plugin
* @param {string} repoURL Git repo url
* @param {string} name Directory name, also known as plugin unique name
*/
downloadPlugin(repoURL, name) {
if (fs.existsSync(this.pluginsDir + name)) {
log.info("plugin", "Plugin folder already exists? Removing...");
fs.rmSync(this.pluginsDir + name, {
recursive: true
});
}
log.info("plugin", "Installing plugin: " + name + " " + repoURL);
let result = Git.clone(repoURL, this.pluginsDir, name);
log.info("plugin", "Install result: " + result);
}
/**
* Remove a plugin
* @param {string} name
*/
async removePlugin(name) {
log.info("plugin", "Removing plugin: " + name);
for (let plugin of this.pluginList) {
if (plugin.info.name === name) {
await plugin.unload();
// Delete the plugin directory
fs.rmSync(this.pluginsDir + name, {
recursive: true
});
this.pluginList.splice(this.pluginList.indexOf(plugin), 1);
return;
}
}
log.warn("plugin", "Plugin not found: " + name);
throw new Error("Plugin not found: " + name);
}
/**
* TODO: Update a plugin
* Only available for plugins which were downloaded from the official list
* @param pluginID
*/
updatePlugin(pluginID) {
}
/**
* Get the plugin list from server + local installed plugin list
* Item will be merged if the `name` is the same.
* @returns {Promise<[]>}
*/
async fetchPluginList() {
let remotePluginList;
try {
const res = await axios.get("https://uptime.kuma.pet/c/plugins.json");
remotePluginList = res.data.pluginList;
} catch (e) {
log.error("plugin", "Failed to fetch plugin list: " + e.message);
remotePluginList = [];
}
for (let plugin of this.pluginList) {
let find = false;
// Try to merge
for (let remotePlugin of remotePluginList) {
if (remotePlugin.name === plugin.info.name) {
find = true;
remotePlugin.installed = true;
remotePlugin.name = plugin.info.name;
remotePlugin.fullName = plugin.info.fullName;
remotePlugin.description = plugin.info.description;
remotePlugin.version = plugin.info.version;
break;
}
}
// Local plugin
if (!find) {
plugin.info.local = true;
remotePluginList.push(plugin.info);
}
}
// Sort Installed first, then sort by name
return remotePluginList.sort((a, b) => {
if (a.installed === b.installed) {
if (a.fullName < b.fullName) {
return -1;
}
if (a.fullName > b.fullName) {
return 1;
}
return 0;
} else if (a.installed) {
return -1;
} else {
return 1;
}
});
}
}
class PluginWrapper {
server = undefined;
pluginDir = undefined;
/**
* Must be an `new-able` class.
* @type {function}
*/
pluginClass = undefined;
/**
*
* @type {Plugin}
*/
object = undefined;
info = {};
/**
*
* @param {UptimeKumaServer} server
* @param {string} pluginDir
*/
constructor(server, pluginDir) {
this.server = server;
this.pluginDir = pluginDir;
}
async load() {
let indexFile = this.pluginDir + "/index.js";
let packageJSON = this.pluginDir + "/package.json";
log.info("plugin", "Installing dependencies");
if (fs.existsSync(indexFile)) {
// Install dependencies
let result = childProcess.spawnSync("npm", [ "install" ], {
cwd: this.pluginDir,
env: {
...process.env,
PLAYWRIGHT_BROWSERS_PATH: "../../browsers", // Special handling for read-browser-monitor
}
});
if (result.stdout) {
log.info("plugin", "Install dependencies result: " + result.stdout.toString("utf-8"));
} else {
log.warn("plugin", "Install dependencies result: no output");
}
this.pluginClass = require(path.join(process.cwd(), indexFile));
let pluginClassType = typeof this.pluginClass;
if (pluginClassType === "function") {
this.object = new this.pluginClass(this.server);
await this.object.load();
} else {
throw new Error("Invalid plugin, it does not export a class");
}
if (fs.existsSync(packageJSON)) {
this.info = require(path.join(process.cwd(), packageJSON));
} else {
this.info.fullName = this.pluginDir;
this.info.name = "[unknown]";
this.info.version = "[unknown-version]";
}
this.info.installed = true;
log.info("plugin", `${this.info.fullName} v${this.info.version} loaded`);
}
}
async unload() {
await this.object.unload();
}
}
module.exports = {
PluginsManager,
PluginWrapper
};

View file

@ -99,6 +99,7 @@ class Prometheus {
} }
} }
/** Remove monitor from prometheus */
remove() { remove() {
try { try {
monitorCertDaysRemaining.remove(this.monitorLabelValues); monitorCertDaysRemaining.remove(this.monitorLabelValues);

View file

@ -132,6 +132,9 @@ class Proxy {
...httpAgentOptions, ...httpAgentOptions,
...httpsAgentOptions, ...httpsAgentOptions,
...proxyOptions, ...proxyOptions,
tls: {
rejectUnauthorized: httpsAgentOptions.rejectUnauthorized,
},
}); });
httpAgent = agent; httpAgent = agent;

View file

@ -54,6 +54,13 @@ const loginRateLimiter = new KumaRateLimiter({
errorMessage: "Too frequently, try again later." 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({ const twoFaRateLimiter = new KumaRateLimiter({
tokensPerInterval: 30, tokensPerInterval: 30,
interval: "minute", interval: "minute",
@ -63,5 +70,6 @@ const twoFaRateLimiter = new KumaRateLimiter({
module.exports = { module.exports = {
loginRateLimiter, loginRateLimiter,
apiRateLimiter,
twoFaRateLimiter, twoFaRateLimiter,
}; };

View file

@ -1,14 +1,16 @@
let express = require("express"); let express = require("express");
const { allowDevAllOrigin, allowAllOrigin, percentageToColor, filterAndJoin, send403 } = require("../util-server"); const { allowDevAllOrigin, allowAllOrigin, percentageToColor, filterAndJoin, sendHttpError } = require("../util-server");
const { R } = require("redbean-node"); const { R } = require("redbean-node");
const apicache = require("../modules/apicache"); const apicache = require("../modules/apicache");
const Monitor = require("../model/monitor"); const Monitor = require("../model/monitor");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const { UP, MAINTENANCE, DOWN, flipStatus, log } = require("../../src/util"); const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log } = require("../../src/util");
const StatusPage = require("../model/status_page"); const StatusPage = require("../model/status_page");
const { UptimeKumaServer } = require("../uptime-kuma-server"); const { UptimeKumaServer } = require("../uptime-kuma-server");
const { UptimeCacheList } = require("../uptime-cache-list");
const { makeBadge } = require("badge-maker"); const { makeBadge } = require("badge-maker");
const { badgeConstants } = require("../config"); const { badgeConstants } = require("../config");
const { Prometheus } = require("../prometheus");
let router = express.Router(); let router = express.Router();
@ -36,7 +38,7 @@ router.get("/api/push/:pushToken", async (request, response) => {
let pushToken = request.params.pushToken; let pushToken = request.params.pushToken;
let msg = request.query.msg || "OK"; let msg = request.query.msg || "OK";
let ping = request.query.ping || null; let ping = parseInt(request.query.ping) || null;
let statusString = request.query.status || "up"; let statusString = request.query.status || "up";
let status = (statusString === "up") ? UP : DOWN; let status = (statusString === "up") ? UP : DOWN;
@ -86,7 +88,9 @@ router.get("/api/push/:pushToken", async (request, response) => {
await R.store(bean); await R.store(bean);
io.to(monitor.user_id).emit("heartbeat", bean.toJSON()); io.to(monitor.user_id).emit("heartbeat", bean.toJSON());
UptimeCacheList.clearCache(monitor.id);
Monitor.sendStats(io, monitor.id, monitor.user_id); Monitor.sendStats(io, monitor.id, monitor.user_id);
new Prometheus(monitor).update(bean, undefined);
response.json({ response.json({
ok: true, ok: true,
@ -111,8 +115,12 @@ router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response
label, label,
upLabel = "Up", upLabel = "Up",
downLabel = "Down", downLabel = "Down",
pendingLabel = "Pending",
maintenanceLabel = "Maintenance",
upColor = badgeConstants.defaultUpColor, upColor = badgeConstants.defaultUpColor,
downColor = badgeConstants.defaultDownColor, downColor = badgeConstants.defaultDownColor,
pendingColor = badgeConstants.defaultPendingColor,
maintenanceColor = badgeConstants.defaultMaintenanceColor,
style = badgeConstants.defaultStyle, style = badgeConstants.defaultStyle,
value, // for demo purpose only value, // for demo purpose only
} = request.query; } = request.query;
@ -139,11 +147,34 @@ router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response
badgeValues.color = badgeConstants.naColor; badgeValues.color = badgeConstants.naColor;
} else { } else {
const heartbeat = await Monitor.getPreviousHeartbeat(requestedMonitorId); const heartbeat = await Monitor.getPreviousHeartbeat(requestedMonitorId);
const state = overrideValue !== undefined ? overrideValue : heartbeat.status === 1; const state = overrideValue !== undefined ? overrideValue : heartbeat.status;
badgeValues.label = label ? label : ""; if (label === undefined) {
badgeValues.color = state ? upColor : downColor; badgeValues.label = "Status";
badgeValues.message = label ?? state ? upLabel : downLabel; } 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 // build the svg based on given values
@ -152,7 +183,7 @@ router.get("/api/badge/:id/status", cache("5 minutes"), async (request, response
response.type("image/svg+xml"); response.type("image/svg+xml");
response.send(svg); response.send(svg);
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
} }
}); });
@ -189,7 +220,7 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
const badgeValues = { style }; const badgeValues = { style };
if (!publicMonitor) { if (!publicMonitor) {
// return a "N/A" badge in naColor (grey), if monitor is not public / not available / non exsitant // return a "N/A" badge in naColor (grey), if monitor is not public / not available / non existent
badgeValues.message = "N/A"; badgeValues.message = "N/A";
badgeValues.color = badgeConstants.naColor; badgeValues.color = badgeConstants.naColor;
} else { } else {
@ -199,15 +230,18 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
); );
// limit the displayed uptime percentage to four (two, when displayed as percent) decimal digits // limit the displayed uptime percentage to four (two, when displayed as percent) decimal digits
const cleanUptime = parseFloat(uptime.toPrecision(4)); const cleanUptime = (uptime * 100).toPrecision(4);
// use a given, custom color or calculate one based on the uptime value // use a given, custom color or calculate one based on the uptime value
badgeValues.color = color ?? percentageToColor(uptime); badgeValues.color = color ?? percentageToColor(uptime);
// use a given, custom labelColor or use the default badge label color (defined by badge-maker) // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
badgeValues.labelColor = labelColor ?? ""; badgeValues.labelColor = labelColor ?? "";
// build a lable string. If a custom label is given, override the default one (requestedDuration) // build a label string. If a custom label is given, override the default one (requestedDuration)
badgeValues.label = filterAndJoin([ labelPrefix, label ?? requestedDuration, labelSuffix ]); badgeValues.label = filterAndJoin([
badgeValues.message = filterAndJoin([ prefix, `${cleanUptime * 100}`, suffix ]); labelPrefix,
label ?? `Uptime (${requestedDuration}${labelSuffix})`,
]);
badgeValues.message = filterAndJoin([ prefix, cleanUptime, suffix ]);
} }
// build the SVG based on given values // build the SVG based on given values
@ -216,7 +250,7 @@ router.get("/api/badge/:id/uptime/:duration?", cache("5 minutes"), async (reques
response.type("image/svg+xml"); response.type("image/svg+xml");
response.send(svg); response.send(svg);
} catch (error) { } catch (error) {
send403(response, error.message); sendHttpError(response, error.message);
} }
}); });
@ -267,7 +301,7 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request,
// use a given, custom labelColor or use the default badge label color (defined by badge-maker) // use a given, custom labelColor or use the default badge label color (defined by badge-maker)
badgeValues.labelColor = labelColor ?? ""; badgeValues.labelColor = labelColor ?? "";
// build a lable string. If a custom label is given, override the default one (requestedDuration) // build a lable string. If a custom label is given, override the default one (requestedDuration)
badgeValues.label = filterAndJoin([ labelPrefix, label ?? requestedDuration, labelSuffix ]); badgeValues.label = filterAndJoin([ labelPrefix, label ?? `Avg. Ping (${requestedDuration}${labelSuffix})` ]);
badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]); badgeValues.message = filterAndJoin([ prefix, avgPing, suffix ]);
} }
@ -277,7 +311,240 @@ router.get("/api/badge/:id/ping/:duration?", cache("5 minutes"), async (request,
response.type("image/svg+xml"); response.type("image/svg+xml");
response.send(svg); response.send(svg);
} catch (error) { } catch (error) {
send403(response, error.message); 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 publicAvgPing = parseInt(await R.getCell(`
SELECT AVG(ping) FROM monitor_group, \`group\`, heartbeat
WHERE monitor_group.group_id = \`group\`.id
AND heartbeat.time > DATETIME('now', ? || ' hours')
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 = badgeConstants.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);
} }
}); });

Some files were not shown because too many files have changed in this diff Show more