Merge branch 'master' into websocket_test

This commit is contained in:
Louis Lam 2025-11-27 20:50:07 +08:00 committed by GitHub
commit ec93dd2116
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
265 changed files with 16477 additions and 4296 deletions

View File

@ -78,6 +78,7 @@ module.exports = {
"keyword-spacing": "warn",
"space-infix-ops": "error",
"arrow-spacing": "warn",
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-constant-condition": [ "error", {
"checkLoops": false,

View File

@ -3,7 +3,7 @@ name: ❓ Ask for help
description: |
Submit any question related to Uptime Kuma
#title: "[Help]"
labels: ["help", "P3-low"]
labels: ["help"]
body:
- type: markdown
attributes:

View File

@ -3,7 +3,7 @@ name: 🐛 Bug Report
description: |
Submit a bug report to help us improve
#title: "[Bug]"
labels: ["bug", "P2-medium"]
labels: ["bug"]
body:
- type: markdown
attributes:

View File

@ -3,28 +3,15 @@ name: 🚀 Feature Request
description: |
Submit a proposal for a new feature
# title: "[Feature]"
labels: ["feature-request", "P3-low"]
labels: ["feature-request"]
body:
- type: markdown
attributes:
value: |
## ❗Important Announcement
### 🚧 Temporary Delay in Feature Requests and Pull Request Reviews
**At this time, we may be slower to respond to new feature requests and review pull requests. Existing requests and PRs will remain in the backlog but may not be prioritized immediately.**
- **Reason**: Our current focus is on addressing bugs, improving system performance, and implementing essential updates. This will help stabilize the project and ensure smoother management.
- **Impact**: While no new feature requests or pull requests are being outright rejected, there may be significant delays in reviews. We encourage the community to help by reviewing PRs or assisting other users in the meantime.
- **What You Can Do**: If you're interested in contributing, reviewing open PRs by following our [Review Guidelines](https://github.com/louislam/uptime-kuma/blob/master/.github/REVIEW_GUIDELINES.md) or offering help to other users is greatly appreciated. All feature requests and PRs will be revisited once the suspension period is lifted.
We appreciate your patience and understanding as we continue to improve Uptime Kuma.
### 🚫 Please Avoid Unnecessary Pinging of Maintainers
**We kindly ask you to refrain from pinging maintainers unless absolutely necessary. Pings are reserved for critical/urgent pull requests that require immediate attention.**
**Why**: Reserving pings for urgent matters ensures maintainers can prioritize critical tasks effectively.
We kindly ask you to refrain from pinging maintainers unless absolutely necessary.
Pings are for critical/urgent pull requests that require immediate attention.
- type: textarea
id: related-issues
validations:

View File

@ -3,7 +3,7 @@ name: 🛡️ Security Issue
description: |
Notify Louis Lam about a security concern. Please do NOT include any sensitive details in this issue.
# title: "Security Issue"
labels: ["security", "P1-high"]
labels: ["security"]
assignees: [louislam]
body:
- type: markdown

View File

@ -1,44 +1,35 @@
**⚠️ Please Note: We do not accept all types of pull requests, and we want to ensure we dont waste your time. Before submitting, make sure you have read our pull request guidelines: [Pull Request Rules](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma)**
## ❗ Important Announcement
## ❗ Important Announcements
<details><summary>Click here for more details:</summary>
</p>
### 🚧 Temporary Delay in Feature Requests and Pull Request Reviews
**At this time, we may be slower to respond to new feature requests and review pull requests. Existing requests and PRs will remain in the backlog but may not be prioritized immediately.**
- **Reason**: Our current focus is on addressing bugs, improving system performance, and implementing essential updates. This will help stabilize the project and ensure smoother management.
- **Impact**: While no new feature requests or pull requests are being outright rejected, there may be significant delays in reviews. We encourage the community to help by reviewing PRs or assisting other users in the meantime.
- **What You Can Do**: If you're interested in contributing, reviewing open PRs by following our [Review Guidelines](https://github.com/louislam/uptime-kuma/blob/master/.github/REVIEW_GUIDELINES.md) or offering support to other users is greatly appreciated. All feature requests and PRs will be revisited once the suspension period is lifted.
We appreciate your patience and understanding as we continue to improve Uptime Kuma.
**⚠️ Please Note: We do not accept all types of pull requests, and we want to ensure we dont waste your time. Before submitting, make sure you have read our pull request guidelines: [Pull Request Rules](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma)**
### 🚫 Please Avoid Unnecessary Pinging of Maintainers
**We kindly ask you to refrain from pinging maintainers unless absolutely necessary. Pings are reserved for critical/urgent pull requests that require immediate attention.**
**Why**: Reserving pings for urgent matters ensures maintainers can prioritize critical tasks effectively.
We kindly ask you to refrain from pinging maintainers unless absolutely necessary. Pings are for critical/urgent pull requests that require immediate attention.
</p>
</details>
## 📋 Overview
Provide a clear summary of the purpose and scope of this pull request:
<!-- Provide a clear summary of the purpose and scope of this pull request:-->
- **What problem does this pull request address?**
- Please provide a detailed explanation here.
- **What features or functionality does this pull request introduce or enhance?**
- Please provide a detailed explanation here.
## 🔄 Changes
<!--
Please link any GitHub issues or tasks that this pull request addresses.
Use the appropriate issue numbers or links to enable auto-closing.
-->
### 🛠️ Type of change
- Relates to #issue-number
- Resolves #issue-number
## 🛠️ Type of change
<!-- Please select all options that apply -->
@ -52,23 +43,12 @@ Provide a clear summary of the purpose and scope of this pull request:
- [ ] 🔧 Other (please specify):
- Provide additional details here.
## 🔗 Related Issues
<!--
Please link any GitHub issues or tasks that this pull request addresses. Use the appropriate issue numbers or links.
**Note**: Include only issues directly related to this PR. Remove any irrelevant reference.
-->
- Relates to #issue-number
- Resolves #issue-number
- Fixes #issue-number
## 📄 Checklist *
## 📄 Checklist
<!-- Please select all options that apply -->
- [ ] 🔍 My code adheres to the style guidelines of this project.
- [ ] 🦿 I have indicated where (if any) I used an LLM for the contributions
- [ ] ✅ I ran ESLint and other code linters for modified files.
- [ ] 🛠️ I have reviewed and tested my code.
- [ ] 📝 I have commented my code, especially in hard-to-understand areas (e.g., using JSDoc for methods).
@ -82,10 +62,11 @@ Please link any GitHub issues or tasks that this pull request addresses. Use the
## 📷 Screenshots or Visual Changes
<!--
Please upload the image directly here by pasting it or dragging and dropping. Avoid using external image services as the image will be uploaded automatically.
If this pull request introduces visual changes, please provide the following details.
If not, remove this section.
Please upload the image directly here by pasting it or dragging and dropping.
Avoid using external image services as the image will be uploaded automatically.
-->
- **UI Modifications**: Highlight any changes made to the user interface.
@ -97,26 +78,3 @@ If not, remove this section.
| `DOWN` | ![Before](image-link) | ![After](image-link) |
| Certificate-expiry | ![Before](image-link) | ![After](image-link) |
| Testing | ![Before](image-link) | ![After](image-link) |
## Additional Context
Provide any relevant details to assist reviewers in understanding the changes.
<details><summary>Click here for more details:</summary>
</p>
**Key Considerations**:
- **Design decisions** Key choices or trade-offs made during development.
- **Alternative solutions** Approaches considered but not implemented, along with reasons.
- **Relevant links** Specifications, discussions, or resources that provide context.
- **Dependencies** Related pull requests or issues that must be resolved before merging.
- **Additional context** Any other details that may help reviewers understand the changes.
Provide details here
## 💬 Requested Feedback
<!-- If a part of our docs is unclear, you are unsure how to do something/.. this is where we would appreciate your feedback -->
- `Mention documents needing feedback here`

167
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,167 @@
# Copilot Instructions for Uptime Kuma
## Copilot's Goals/Tasks
- Check spelling
- Do not show "Pull Request Overview"
- You do not have to reply if there are no issues
## Repository Overview
**Uptime Kuma** is a self-hosted monitoring tool for HTTP(s), TCP, DNS, Docker, etc. Built with Vue 3 (frontend) and Node.js/Express (backend), using Socket.IO for real-time communication.
- **Languages**: JavaScript, Vue 3, TypeScript (limited), HTML, CSS/SCSS
- **Backend**: Node.js >= 20.4, Express.js, Socket.IO, SQLite
- **Frontend**: Vue 3, Vite, Bootstrap 5, Chart.js
- **Package Manager**: npm with `legacy-peer-deps=true` (.npmrc)
## Build & Validation Commands
### Prerequisites
- Node.js >= 20.4.0, npm >= 9.3, Git
### Essential Command Sequence
1. **Install Dependencies**:
```bash
npm ci # Use npm ci NOT npm install (~60-90 seconds)
```
2. **Linting** (required before committing):
```bash
npm run lint # Both linters (~15-30 seconds)
npm run lint:prod # For production (zero warnings)
```
3. **Build Frontend**:
```bash
npm run build # Takes ~90-120 seconds, builds to dist/
```
4. **Run Tests**:
```bash
npm run test-backend # Backend tests (~50-60 seconds)
npm test # All tests
```
### Development Workflow
```bash
npm run dev # Starts frontend (port 3000) and backend (port 3001)
```
## Project Architecture
### Directory Structure
```
/
├── server/ Backend source code
│ ├── model/ Database models (auto-mapped to tables)
│ ├── monitor-types/ Monitor type implementations
│ ├── notification-providers/ Notification integrations
│ ├── routers/ Express routers
│ ├── socket-handlers/ Socket.IO event handlers
│ ├── server.js Server entry point
│ └── uptime-kuma-server.js Main server logic
├── src/ Frontend source code (Vue 3 SPA)
│ ├── components/ Vue components
│ ├── pages/ Page components
│ ├── lang/ i18n translations
│ ├── router.js Vue Router configuration
│ └── main.js Frontend entry point
├── db/ Database related
│ ├── knex_migrations/ Knex migration files
│ └── kuma.db SQLite database (gitignored)
├── test/ Test files
│ ├── backend-test/ Backend unit tests
│ └── e2e/ Playwright E2E tests
├── config/ Build configuration
│ ├── vite.config.js Vite build config
│ └── playwright.config.js Playwright test config
├── dist/ Frontend build output (gitignored)
├── data/ App data directory (gitignored)
├── public/ Static frontend assets (dev only)
├── docker/ Docker build files
└── extra/ Utility scripts
```
### Key Configuration Files
- **package.json**: Scripts, dependencies, Node.js version requirement
- **.eslintrc.js**: ESLint rules (4 spaces, double quotes, unix line endings, JSDoc required)
- **.stylelintrc**: Stylelint rules (4 spaces indentation)
- **.editorconfig**: Editor settings (4 spaces, LF, UTF-8)
- **tsconfig-backend.json**: TypeScript config for backend (only src/util.ts)
- **.npmrc**: `legacy-peer-deps=true` (required for dependency resolution)
- **.gitignore**: Excludes node_modules, dist, data, tmp, private
### Code Style (strictly enforced by linters)
- 4 spaces indentation, double quotes, Unix line endings (LF), semicolons required
- **Naming**: JavaScript/TypeScript (camelCase), SQLite (snake_case), CSS/SCSS (kebab-case)
- JSDoc required for all functions/methods
## CI/CD Workflows
**auto-test.yml** (runs on PR/push to master/1.23.X):
- Linting, building, backend tests on multiple OS/Node versions (15 min timeout)
- E2E Playwright tests
**validate.yml**: Validates JSON/YAML files, language files, knex migrations
**PR Requirements**: All linters pass, tests pass, code follows style guidelines
## Common Issues
1. **npm install vs npm ci**: Always use `npm ci` for reproducible builds
2. **TypeScript errors**: `npm run tsc` shows 1400+ errors - ignore them, they don't affect builds
3. **Stylelint warnings**: Deprecation warnings are expected, ignore them
4. **Test failures**: Always run `npm run build` before running tests
5. **Port conflicts**: Dev server uses ports 3000 and 3001
6. **First run**: Server shows "db-config.json not found" - this is expected, starts setup wizard
## Translations
- Managed via Weblate. Add keys to `src/lang/en.json` only
- Don't include other languages in PRs
- Use `$t("key")` in Vue templates
## Database
- Primary: SQLite (also supports MariaDB/MySQL/PostgreSQL)
- Migrations in `db/knex_migrations/` using Knex.js
- Filename format validated by CI: `node ./extra/check-knex-filenames.mjs`
## Testing
- **Backend**: Node.js test runner, fast unit tests
- **E2E**: Playwright (requires `npx playwright install` first time)
- Test data in `data/playwright-test`
## Adding New Features
### New Notification Provider
Files to modify:
1. `server/notification-providers/PROVIDER_NAME.js` (backend logic)
2. `server/notification.js` (register provider)
3. `src/components/notifications/PROVIDER_NAME.vue` (frontend UI)
4. `src/components/notifications/index.js` (register frontend)
5. `src/components/NotificationDialog.vue` (add to list)
6. `src/lang/en.json` (add translation keys)
### New Monitor Type
Files to modify:
1. `server/monitor-types/MONITORING_TYPE.js` (backend logic)
2. `server/uptime-kuma-server.js` (register monitor type)
3. `src/pages/EditMonitor.vue` (frontend UI)
4. `src/lang/en.json` (add translation keys)
## Important Notes
1. **Trust these instructions** - based on testing. Search only if incomplete/incorrect
2. **Dependencies**: 5 known vulnerabilities (3 moderate, 2 high) - acknowledged, don't fix without discussion
3. **Git Branches**: `master` (v2 development), `1.23.X` (v1 maintenance)
4. **Node Version**: >= 20.4.0 required
5. **Socket.IO**: Most backend logic in `server/socket-handlers/`, not REST
6. **Never commit**: `data/`, `dist/`, `tmp/`, `private/`, `node_modules/`

View File

@ -21,16 +21,20 @@ jobs:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest, ARM64]
node: [ 18, 20 ]
os: [macos-latest, ubuntu-22.04, windows-latest, ARM64]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
node: [ 20, 24 ]
# Also test non-LTS, but only on Ubuntu.
include:
- os: ubuntu-22.04
node: 25
steps:
- run: git config --global core.autocrlf false # Mainly for Windows
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- run: npm install
@ -49,7 +53,7 @@ jobs:
strategy:
matrix:
os: [ ARMv7 ]
node: [ 18, 20 ]
node: [ 20, 22 ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
@ -57,7 +61,7 @@ jobs:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- run: npm ci --production
@ -70,7 +74,7 @@ jobs:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
- run: npm install
@ -84,7 +88,7 @@ jobs:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
- run: npm install

View File

@ -11,13 +11,13 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
node-version: [18]
node-version: [20]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

View File

@ -32,7 +32,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20

View File

@ -86,7 +86,7 @@ to review the appropriate one for your contribution.
PR:
- A text may not be currently localisable. In this case, **adding a new
language key** via `$t("languageKey")` might be nessesary
language key** via `$t("languageKey")` might be necessary
- language keys need to be **added to `en.json`** to be visible in weblate. If
this has not happened, a PR is appreciated.
- **Adding a new language** requires a new file see

View File

@ -17,7 +17,7 @@ Uptime Kuma is an easy-to-use self-hosted monitoring tool.
Try it!
Demo Server (Location: Frankfurt - Germany): https://demo.kuma.pet/start-demo
Demo Server (Location: Frankfurt - Germany): <https://demo.kuma.pet/start-demo>
It is a temporary live demo, all data will be deleted after 10 minutes. Sponsored by [Uptime Kuma Sponsors](https://github.com/louislam/uptime-kuma#%EF%B8%8F-sponsors).
@ -37,35 +37,44 @@ It is a temporary live demo, all data will be deleted after 10 minutes. Sponsore
## 🔧 How to Install
### 🐳 Docker
### 🐳 Docker Compose
```bash
docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:1
mkdir uptime-kuma
cd uptime-kuma
curl -o compose.yaml https://raw.githubusercontent.com/louislam/uptime-kuma/master/compose.yaml
docker compose up -d
```
Uptime Kuma is now running on <http://0.0.0.0:3001>.
Uptime Kuma is now running on all network interfaces (e.g. http://localhost:3001 or http://your-ip:3001).
> [!WARNING]
> File Systems like **NFS** (Network File System) are **NOT** supported. Please map to a local directory or volume.
> [!NOTE]
> If you want to limit exposure to localhost (without exposing port for other users or to use a [reverse proxy](https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy)), you can expose the port like this:
>
> ```bash
> docker run -d --restart=always -p 127.0.0.1:3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:1
> ```
### 🐳 Docker Command
```bash
docker run -d --restart=always -p 3001:3001 -v uptime-kuma:/app/data --name uptime-kuma louislam/uptime-kuma:2
```
Uptime Kuma is now running on all network interfaces (e.g. http://localhost:3001 or http://your-ip:3001).
If you want to limit exposure to localhost only:
```bash
docker run ... -p 127.0.0.1:3001:3001 ...
```
### 💪🏻 Non-Docker
Requirements:
- Platform
- ✅ Major Linux distros such as Debian, Ubuntu, CentOS, Fedora and ArchLinux etc.
- ✅ Major Linux distros such as Debian, Ubuntu, Fedora and ArchLinux etc.
- ✅ Windows 10 (x64), Windows Server 2012 R2 (x64) or higher
- ❌ FreeBSD / OpenBSD / NetBSD
- ❌ Replit / Heroku
- [Node.js](https://nodejs.org/en/download/) 18 / 20.4
- [npm](https://docs.npmjs.com/cli/) 9
- [Node.js](https://nodejs.org/en/download/) >= 20.4
- [Git](https://git-scm.com/downloads)
- [pm2](https://pm2.keymetrics.io/) - For running Uptime Kuma in the background
@ -84,8 +93,7 @@ npm install pm2 -g && pm2 install pm2-logrotate
# Start Server
pm2 start server/server.js --name uptime-kuma
```
Uptime Kuma is now running on http://localhost:3001
Uptime Kuma is now running on all network interfaces (e.g. http://localhost:3001 or http://your-ip:3001).
More useful PM2 Commands
@ -94,26 +102,26 @@ More useful PM2 Commands
pm2 monit
# If you want to add it to startup
pm2 save && pm2 startup
pm2 startup && pm2 save
```
### Advanced Installation
If you need more options or need to browse via a reverse proxy, please read:
https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install
<https://github.com/louislam/uptime-kuma/wiki/%F0%9F%94%A7-How-to-Install>
## 🆙 How to Update
Please read:
https://github.com/louislam/uptime-kuma/wiki/%F0%9F%86%99-How-to-Update
<https://github.com/louislam/uptime-kuma/wiki/%F0%9F%86%99-How-to-Update>
## 🆕 What's Next?
I will assign requests/issues to the next milestone.
https://github.com/louislam/uptime-kuma/milestones
<https://github.com/louislam/uptime-kuma/milestones>
## ❤️ Sponsors
@ -174,11 +182,11 @@ We DO NOT accept all types of pull requests and do not want to waste your time.
There are a lot of pull requests right now, but I don't have time to test them all.
If you want to help, you can check this:
https://github.com/louislam/uptime-kuma/wiki/Test-Pull-Requests
<https://github.com/louislam/uptime-kuma/wiki/Test-Pull-Requests>
### Test Beta Version
Check out the latest beta release here: https://github.com/louislam/uptime-kuma/releases
Check out the latest beta release here: <https://github.com/louislam/uptime-kuma/releases>
### Bug Reports / Feature Requests
@ -192,5 +200,3 @@ If you want to translate Uptime Kuma into your language, please visit [Weblate R
Feel free to correct the grammar in the documentation or code.
My mother language is not English and my grammar is not that great.

View File

@ -8,7 +8,8 @@
do not send a notification, I probably will miss it 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 public as it will cause
- Do not report any upstream dependency issues / scan result by any tools. It will be closed immediately without explanations. Unless you have PoC to prove that the upstream issue affected Uptime Kuma.
- Do not use the public issue tracker or discuss it in public as it will cause
more damage.
## Do you accept other 3rd-party bug bounty platforms?
@ -22,17 +23,21 @@ Advisories only. I will ignore all 3rd-party bug bounty platforms emails.
### Uptime Kuma Versions
You should use or upgrade to the latest version of Uptime Kuma. All `1.X.X`
versions are upgradable to the latest version.
You should use or upgrade to the latest version of Uptime Kuma.
All versions are upgradable to the latest version.
### Upgradable Docker Tags
| Tag | Supported |
| -------------- | ------------------ |
| 1 | :white_check_mark: |
| 1-debian | :white_check_mark: |
| latest | :white_check_mark: |
| debian | :white_check_mark: |
| 1-alpine | ⚠️ Deprecated |
| alpine | ⚠️ Deprecated |
| All other tags | ❌ |
| Tag | Supported |
| --------------- | ------------------------------------------------------------------------------------- |
| 2 | :white_check_mark: |
| 2-slim | :white_check_mark: |
| next | :white_check_mark: |
| next-slim | :white_check_mark: |
| 2-rootless | :white_check_mark: |
| 2-slim-rootless | :white_check_mark: |
| 1 | [⚠️ Deprecated](https://github.com/louislam/uptime-kuma/wiki/Migration-From-v1-To-v2) |
| 1-debian | [⚠️ Deprecated](https://github.com/louislam/uptime-kuma/wiki/Migration-From-v1-To-v2) |
| latest | [⚠️ Deprecated](https://github.com/louislam/uptime-kuma/wiki/Migration-From-v1-To-v2) |
| debian | [⚠️ Deprecated](https://github.com/louislam/uptime-kuma/wiki/Migration-From-v1-To-v2) |
| All other tags | ❌ |

View File

@ -1,9 +1,9 @@
services:
uptime-kuma:
image: louislam/uptime-kuma:1
image: louislam/uptime-kuma:2
restart: unless-stopped
volumes:
- ./data:/app/data
ports:
# <Host Port>:<Container Port>
- 3001:3001
restart: unless-stopped
- "3001:3001"

View File

@ -2,7 +2,7 @@ import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
import visualizer from "rollup-plugin-visualizer";
import viteCompression from "vite-plugin-compression";
import VueDevTools from "vite-plugin-vue-devtools";
import { VitePWA } from "vite-plugin-pwa";
const postCssScss = require("postcss-scss");
const postcssRTLCSS = require("postcss-rtlcss");
@ -31,7 +31,12 @@ export default defineConfig({
algorithm: "brotliCompress",
filter: viteCompressionFilter,
}),
VueDevTools(),
VitePWA({
registerType: null,
srcDir: "src",
filename: "serviceWorker.ts",
strategies: "injectManifest",
}),
],
css: {
postcss: {

View File

@ -0,0 +1,12 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.string("smtp_security").defaultTo(null);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("smtp_security");
});
};

View File

@ -0,0 +1,24 @@
/* SQL:
ALTER TABLE monitor ADD ping_count INTEGER default 1 not null;
ALTER TABLE monitor ADD ping_numeric BOOLEAN default true not null;
ALTER TABLE monitor ADD ping_per_request_timeout INTEGER default 2 not null;
*/
exports.up = function (knex) {
// Add new columns to table monitor
return knex.schema
.alterTable("monitor", function (table) {
table.integer("ping_count").defaultTo(1).notNullable();
table.boolean("ping_numeric").defaultTo(true).notNullable();
table.integer("ping_per_request_timeout").defaultTo(2).notNullable();
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.dropColumn("ping_count");
table.dropColumn("ping_numeric");
table.dropColumn("ping_per_request_timeout");
});
};

View File

@ -0,0 +1,13 @@
// Add column custom_url to monitor_group table
exports.up = function (knex) {
return knex.schema
.alterTable("monitor_group", function (table) {
table.text("custom_url", "text");
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor_group", function (table) {
table.dropColumn("custom_url");
});
};

View File

@ -0,0 +1,13 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.boolean("ip_family").defaultTo(null);
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.dropColumn("ip_family");
});
};

View File

@ -0,0 +1,12 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.string("manual_status").defaultTo(null);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.dropColumn("manual_status");
});
};

View File

@ -0,0 +1,34 @@
// Add column last_start_date to maintenance table
exports.up = async function (knex) {
await knex.schema
.alterTable("maintenance", function (table) {
table.datetime("last_start_date");
});
// Perform migration for recurring-interval strategy
const recurringMaintenances = await knex("maintenance").where({
strategy: "recurring-interval",
cron: "* * * * *"
}).select("id", "start_time");
// eslint-disable-next-line camelcase
const maintenanceUpdates = recurringMaintenances.map(async ({ start_time, id }) => {
// eslint-disable-next-line camelcase
const [ hourStr, minuteStr ] = start_time.split(":");
const hour = parseInt(hourStr, 10);
const minute = parseInt(minuteStr, 10);
const cron = `${minute} ${hour} * * *`;
await knex("maintenance")
.where({ id })
.update({ cron });
});
await Promise.all(maintenanceUpdates);
};
exports.down = function (knex) {
return knex.schema.alterTable("maintenance", function (table) {
table.dropColumn("last_start_date");
});
};

View File

@ -0,0 +1,13 @@
// Fix: Change manual_status column type to smallint
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.smallint("manual_status").alter();
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.string("manual_status").alter();
});
};

View File

@ -0,0 +1,12 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
table.string("oauth_audience").nullable().defaultTo(null);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("monitor", function (table) {
table.string("oauth_audience").alter();
});
};

View File

@ -0,0 +1,15 @@
exports.up = function (knex) {
// Add new column monitor.mqtt_websocket_path
return knex.schema
.alterTable("monitor", function (table) {
table.string("mqtt_websocket_path", 255).nullable();
});
};
exports.down = function (knex) {
// Drop column monitor.mqtt_websocket_path
return knex.schema
.alterTable("monitor", function (table) {
table.dropColumn("mqtt_websocket_path");
});
};

View File

@ -0,0 +1,16 @@
exports.up = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
// Fix ip_family, change to varchar instead of boolean
// possible values are "ipv4" and "ipv6"
table.string("ip_family", 4).defaultTo(null).alter();
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("monitor", function (table) {
// Rollback to boolean
table.boolean("ip_family").defaultTo(null).alter();
});
};

View File

@ -0,0 +1,27 @@
// Fix for #4315. Logically, setting it to 0 ping may not be correct, but it is better than throwing errors
exports.up = function (knex) {
return knex.schema
.alterTable("stat_daily", function (table) {
table.integer("ping").defaultTo(0).alter();
})
.alterTable("stat_hourly", function (table) {
table.integer("ping").defaultTo(0).alter();
})
.alterTable("stat_minutely", function (table) {
table.integer("ping").defaultTo(0).alter();
});
};
exports.down = function (knex) {
return knex.schema
.alterTable("stat_daily", function (table) {
table.integer("ping").alter();
})
.alterTable("stat_hourly", function (table) {
table.integer("ping").alter();
})
.alterTable("stat_minutely", function (table) {
table.integer("ping").alter();
});
};

View File

@ -0,0 +1,15 @@
exports.up = function (knex) {
// Add new column status_page.show_only_last_heartbeat
return knex.schema
.alterTable("status_page", function (table) {
table.boolean("show_only_last_heartbeat").notNullable().defaultTo(false);
});
};
exports.down = function (knex) {
// Drop column status_page.show_only_last_heartbeat
return knex.schema
.alterTable("status_page", function (table) {
table.dropColumn("show_only_last_heartbeat");
});
};

View File

@ -2,11 +2,17 @@
# 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
############################################
FROM golang:1.19-buster
FROM golang:1-buster
WORKDIR /app
ARG TARGETPLATFORM
COPY ./extra/ ./extra/
## Switch to archive.debian.org
RUN sed -i '/^deb/s/^/#/' /etc/apt/sources.list \
&& echo "deb http://archive.debian.org/debian buster main contrib non-free" | tee -a /etc/apt/sources.list \
&& echo "deb http://archive.debian.org/debian-security buster/updates main contrib non-free" | tee -a /etc/apt/sources.list \
&& echo "deb http://archive.debian.org/debian buster-updates main contrib non-free" | tee -a /etc/apt/sources.list
# Compile healthcheck.go
RUN apt update && \
apt --yes --no-install-recommends install curl && \

View File

@ -1,5 +1,5 @@
# Download Apprise deb package
FROM node:20-bookworm-slim AS download-apprise
FROM node:22-bookworm-slim AS download-apprise
WORKDIR /app
COPY ./extra/download-apprise.mjs ./download-apprise.mjs
RUN apt update && \
@ -9,7 +9,7 @@ RUN apt update && \
# Base Image (Slim)
# If the image changed, the second stage image should be changed too
FROM node:20-bookworm-slim AS base2-slim
FROM node:22-bookworm-slim AS base2-slim
ARG TARGETPLATFORM
# Specify --no-install-recommends to skip unused dependencies, make the base much smaller!
@ -47,9 +47,9 @@ RUN apt update && \
# Install cloudflared
RUN curl https://pkg.cloudflare.com/cloudflare-main.gpg --output /usr/share/keyrings/cloudflare-main.gpg && \
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bullseye main' | tee /etc/apt/sources.list.d/cloudflared.list && \
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' | tee /etc/apt/sources.list.d/cloudflared.list && \
apt update && \
apt install --yes --no-install-recommends -t stable cloudflared && \
apt install --yes --no-install-recommends cloudflared && \
cloudflared version && \
rm -rf /var/lib/apt/lists/* && \
apt --yes autoremove

View File

@ -79,6 +79,10 @@ USER node
RUN git config --global user.email "no-reply@no-reply.com"
RUN git config --global user.name "PR Tester"
RUN git clone https://github.com/louislam/uptime-kuma.git .
# Hide the warning when running in detached head state
RUN git config --global advice.detachedHead false
RUN npm ci
EXPOSE 3000 3001

View File

@ -24,9 +24,7 @@ if (! exists) {
// Also update package-lock.json
const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";
childProcess.spawnSync(npm, [ "install" ]);
commit(version);
tag(version);
} else {
console.log("version tag exists, please delete the tag or use another tag");
@ -54,19 +52,6 @@ function commit(version) {
console.log(res.stdout.toString().trim());
}
/**
* Create a tag with the specified version
* @param {string} version Tag to create
* @returns {void}
*/
function tag(version) {
let res = childProcess.spawnSync("git", [ "tag", version ]);
console.log(res.stdout.toString().trim());
res = childProcess.spawnSync("git", [ "push", "origin", version ]);
console.log(res.stdout.toString().trim());
}
/**
* Check if a tag exists for the specified version
* @param {string} version Version to check

View File

@ -1,33 +0,0 @@
const childProcess = require("child_process");
if (!process.env.UPTIME_KUMA_GH_REPO) {
console.error("Please set a repo to the environment variable 'UPTIME_KUMA_GH_REPO' (e.g. mhkarimi1383:goalert-notification)");
process.exit(1);
}
let inputArray = process.env.UPTIME_KUMA_GH_REPO.split(":");
if (inputArray.length !== 2) {
console.error("Invalid format. Please set a repo to the environment variable 'UPTIME_KUMA_GH_REPO' (e.g. mhkarimi1383:goalert-notification)");
}
let name = inputArray[0];
let branch = inputArray[1];
console.log("Checkout pr");
// Checkout the pr
let result = childProcess.spawnSync("git", [ "remote", "add", name, `https://github.com/${name}/uptime-kuma` ]);
console.log(result.stdout.toString());
console.error(result.stderr.toString());
result = childProcess.spawnSync("git", [ "fetch", name, branch ]);
console.log(result.stdout.toString());
console.error(result.stderr.toString());
result = childProcess.spawnSync("git", [ "checkout", `${name}/${branch}`, "--force" ]);
console.log(result.stdout.toString());
console.error(result.stderr.toString());

34
extra/checkout-pr.mjs Normal file
View File

@ -0,0 +1,34 @@
import childProcess from "child_process";
import { parsePrName } from "./kuma-pr/pr-lib.mjs";
let { name, branch } = parsePrName(process.env.UPTIME_KUMA_GH_REPO);
console.log(`Checking out PR from ${name}:${branch}`);
// Checkout the pr
let result = childProcess.spawnSync("git", [ "remote", "add", name, `https://github.com/${name}/uptime-kuma` ], {
stdio: "inherit"
});
if (result.status !== 0) {
console.error("Failed to add remote repository.");
process.exit(1);
}
result = childProcess.spawnSync("git", [ "fetch", name, branch ], {
stdio: "inherit"
});
if (result.status !== 0) {
console.error("Failed to fetch the branch.");
process.exit(1);
}
result = childProcess.spawnSync("git", [ "checkout", `${name}/${branch}`, "--force" ], {
stdio: "inherit"
});
if (result.status !== 0) {
console.error("Failed to checkout the branch.");
process.exit(1);
}

View File

@ -37,7 +37,7 @@ const github = require("@actions/github");
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
body: `@${username}: Hello! :wave:\n\nThis issue is being automatically closed because it does not follow the issue template. Please **DO NOT open blank issues and use our [issue-templates](https://github.com/louislam/uptime-kuma/issues/new/choose) instead**.\nBlank Issues do not contain the context nessesary for a good discussions.`
body: `@${username}: Hello! :wave:\n\nThis issue is being automatically closed because it does not follow the issue template. Please **DO NOT open blank issues and use our [issue-templates](https://github.com/louislam/uptime-kuma/issues/new/choose) instead**.\nBlank Issues do not contain the context necessary for a good discussions.`
});
// Close the issue

View File

@ -0,0 +1,201 @@
// Script to generate changelog
// Usage: node generate-changelog.mjs <previous-version-tag>
// GitHub CLI (gh command) is required
import * as childProcess from "child_process";
const ignoreList = [
"louislam",
"CommanderStorm",
"UptimeKumaBot",
"weblate",
"Copilot"
];
const mergeList = [
"Translations Update from Weblate",
"Update dependencies",
];
const template = `
LLM Task: Please help to put above PRs into the following sections based on their content. If a PR fits multiple sections, choose the most relevant one. If a PR doesn't fit any section, place it in "Others". If there are grammatical errors in the PR titles, please correct them. Don't change the PR numbers and authors, and keep the format. Output as markdown.
Changelog:
### 🆕 New Features
### 💇 Improvements
### 🐞 Bug Fixes
### Security Fixes
### 🦎 Translation Contributions
### Others
- Other small changes, code refactoring and comment/doc updates in this repo:
`;
await main();
/**
* Main Function
* @returns {Promise<void>}
*/
async function main() {
const previousVersion = process.argv[2];
if (!previousVersion) {
console.error("Please provide the previous version as the first argument.");
process.exit(1);
}
console.log(`Generating changelog since version ${previousVersion}...`);
try {
const prList = await getPullRequestList(previousVersion);
const list = [];
let i = 1;
for (const pr of prList) {
console.log(`Progress: ${i++}/${prList.length}`);
let authorSet = await getAuthorList(pr.number);
authorSet = await mainAuthorToFront(pr.author.login, authorSet);
if (mergeList.includes(pr.title)) {
// Check if it is already in the list
const existingItem = list.find(item => item.title === pr.title);
if (existingItem) {
existingItem.numbers.push(pr.number);
for (const author of authorSet) {
existingItem.authors.add(author);
// Sort the authors
existingItem.authors = new Set([ ...existingItem.authors ].sort((a, b) => a.localeCompare(b)));
}
continue;
}
}
const item = {
numbers: [ pr.number ],
title: pr.title,
authors: authorSet,
};
list.push(item);
}
for (const item of list) {
// Concat pr numbers into a string like #123 #456
const prPart = item.numbers.map(num => `#${num}`).join(" ");
// Concat authors into a string like @user1 @user2
let authorPart = [ ...item.authors ].map(author => `@${author}`).join(" ");
if (authorPart) {
authorPart = `(Thanks ${authorPart})`;
}
console.log(`- ${prPart} ${item.title} ${authorPart}`);
}
console.log(template);
} catch (e) {
console.error("Failed to get pull request list:", e);
process.exit(1);
}
}
/**
* @param {string} previousVersion Previous Version Tag
* @returns {Promise<object>} List of Pull Requests merged since previousVersion
*/
async function getPullRequestList(previousVersion) {
// Get the date of previousVersion in YYYY-MM-DD format from git
const previousVersionDate = childProcess.execSync(`git log -1 --format=%cd --date=short ${previousVersion}`).toString().trim();
if (!previousVersionDate) {
throw new Error(`Unable to find the date of version ${previousVersion}. Please make sure the version tag exists.`);
}
const ghProcess = childProcess.spawnSync("gh", [
"pr",
"list",
"--state",
"merged",
"--base",
"master",
"--search",
`merged:>=${previousVersionDate}`,
"--json",
"number,title,author",
"--limit",
"1000"
], {
encoding: "utf-8"
});
if (ghProcess.error) {
throw ghProcess.error;
}
if (ghProcess.status !== 0) {
throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`);
}
return JSON.parse(ghProcess.stdout);
}
/**
* @param {number} prID Pull Request ID
* @returns {Promise<Set<string>>} Set of Authors' GitHub Usernames
*/
async function getAuthorList(prID) {
const ghProcess = childProcess.spawnSync("gh", [
"pr",
"view",
prID,
"--json",
"commits"
], {
encoding: "utf-8"
});
if (ghProcess.error) {
throw ghProcess.error;
}
if (ghProcess.status !== 0) {
throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`);
}
const prInfo = JSON.parse(ghProcess.stdout);
const commits = prInfo.commits;
const set = new Set();
for (const commit of commits) {
for (const author of commit.authors) {
if (author.login && !ignoreList.includes(author.login)) {
set.add(author.login);
}
}
}
// Sort the set
return new Set([ ...set ].sort((a, b) => a.localeCompare(b)));
}
/**
* @param {string} mainAuthor Main Author
* @param {Set<string>} authorSet Set of Authors
* @returns {Set<string>} New Set with mainAuthor at the front
*/
async function mainAuthorToFront(mainAuthor, authorSet) {
if (ignoreList.includes(mainAuthor)) {
return authorSet;
}
return new Set([ mainAuthor, ...authorSet ]);
}

26
extra/kuma-pr/index.mjs Normal file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env node
import { spawn } from "child_process";
import { parsePrName } from "./pr-lib.mjs";
const prName = process.argv[2];
// Pre-check the prName here, so testers don't need to wait until the Docker image is pulled to see the error.
try {
parsePrName(prName);
} catch (error) {
console.error(error.message);
process.exit(1);
}
spawn("docker", [
"run",
"--rm",
"-it",
"-p", "3000:3000",
"-p", "3001:3001",
"--pull", "always",
"-e", `UPTIME_KUMA_GH_REPO=${prName}`,
"louislam/uptime-kuma:pr-test2"
], {
stdio: "inherit",
});

View File

@ -0,0 +1,8 @@
{
"name": "kuma-pr",
"version": "1.0.0",
"type": "module",
"bin": {
"kuma-pr": "./index.mjs"
}
}

39
extra/kuma-pr/pr-lib.mjs Normal file
View File

@ -0,0 +1,39 @@
/**
* Parse <name>:<branch> to an object.
* @param {string} prName <name>:<branch>
* @returns {object} An object with name and branch properties.
*/
export function parsePrName(prName) {
let name = "louislam";
let branch;
const errorMessage = "Please set a repo to the environment variable 'UPTIME_KUMA_GH_REPO' (e.g. mhkarimi1383:goalert-notification)";
if (!prName) {
throw new Error(errorMessage);
}
prName = prName.trim();
if (prName === "") {
throw new Error(errorMessage);
}
let inputArray = prName.split(":");
// Just realized that owner's prs are not prefixed with "louislam:"
if (inputArray.length === 1) {
branch = inputArray[0];
} else if (inputArray.length === 2) {
name = inputArray[0];
branch = inputArray[1];
} else {
throw new Error("Invalid format. The format is like this: mhkarimi1383:goalert-notification");
}
return {
name,
branch
};
}

View File

@ -1,44 +0,0 @@
// Generate on GitHub
const input = `
* Add Korean translation by @Alanimdeo in https://github.com/louislam/dockge/pull/86
`;
const template = `
### 🆕 New Features
### 💇 Improvements
### 🐞 Bug Fixes
### Security Fixes
### 🦎 Translation Contributions
### Others
- Other small changes, code refactoring and comment/doc updates in this repo:
`;
const lines = input.split("\n").filter((line) => line.trim() !== "");
for (const line of lines) {
// Split the last " by "
const usernamePullRequesURL = line.split(" by ").pop();
if (!usernamePullRequesURL) {
console.log("Unable to parse", line);
continue;
}
const [ username, pullRequestURL ] = usernamePullRequesURL.split(" in ");
const pullRequestID = "#" + pullRequestURL.split("/").pop();
let message = line.split(" by ").shift();
if (!message) {
console.log("Unable to parse", line);
continue;
}
message = message.split("* ").pop();
console.log("-", pullRequestID, message, `(Thanks ${username})`);
}
console.log(template);

View File

@ -8,7 +8,7 @@ import {
checkVersionFormat,
getRepoNames,
pressAnyKey,
execSync, uploadArtifacts,
execSync, uploadArtifacts, checkReleaseBranch,
} from "./lib.mjs";
import semver from "semver";
@ -23,6 +23,9 @@ if (!githubToken) {
process.exit(1);
}
// Check if the current branch is "release"
checkReleaseBranch();
// Check if the version is a valid semver
checkVersionFormat(version);

View File

@ -7,7 +7,7 @@ import {
checkTagExists,
checkVersionFormat,
getRepoNames,
pressAnyKey, execSync, uploadArtifacts
pressAnyKey, execSync, uploadArtifacts, checkReleaseBranch
} from "./lib.mjs";
const repoNames = getRepoNames();
@ -21,6 +21,9 @@ if (!githubToken) {
process.exit(1);
}
// Check if the current branch is "release"
checkReleaseBranch();
// Check if the version is a valid semver
checkVersionFormat(version);

View File

@ -249,3 +249,16 @@ export function execSync(cmd) {
console.info(`[DRY RUN] ${cmd}`);
}
}
/**
* Check if the current branch is "release"
* @returns {void}
*/
export function checkReleaseBranch() {
const res = childProcess.spawnSync("git", [ "rev-parse", "--abbrev-ref", "HEAD" ]);
const branch = res.stdout.toString().trim();
if (branch !== "release") {
console.error(`Current branch is ${branch}, please switch to "release" branch`);
process.exit(1);
}
}

View File

@ -27,10 +27,19 @@ if (! exists) {
// Also update package-lock.json
const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";
childProcess.spawnSync(npm, [ "install" ]);
const resultVersion = childProcess.spawnSync(npm, [ "--no-git-tag-version", "version", newVersion ], { shell: true });
if (resultVersion.error) {
console.error(resultVersion.error);
console.error("error npm version!");
process.exit(1);
}
const resultInstall = childProcess.spawnSync(npm, [ "install" ], { shell: true });
if (resultInstall.error) {
console.error(resultInstall.error);
console.error("error update package-lock!");
process.exit(1);
}
commit(newVersion);
tag(newVersion);
} else {
console.log("version exists");
@ -54,16 +63,6 @@ function commit(version) {
}
}
/**
* Create a tag with the specified version
* @param {string} version Tag to create
* @returns {void}
*/
function tag(version) {
let res = childProcess.spawnSync("git", [ "tag", version ]);
console.log(res.stdout.toString().trim());
}
/**
* Check if a tag exists for the specified version
* @param {string} version Version to check

6299
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,13 @@
{
"name": "uptime-kuma",
"version": "2.0.0-beta.2",
"version": "2.0.2",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/louislam/uptime-kuma.git"
},
"engines": {
"node": "18 || >= 20.4.0"
"node": ">= 20.4.0"
},
"scripts": {
"lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .",
@ -27,12 +27,14 @@
"build": "vite build --config ./config/vite.config.js",
"test": "npm run test-backend && npm run test-e2e",
"test-with-build": "npm run build && npm test",
"test-backend": "cross-env TEST_BACKEND=1 node --test test/backend-test",
"test-backend": "node test/test-backend.mjs",
"test-backend-22": "cross-env TEST_BACKEND=1 node --test \"test/backend-test/**/*.js\"",
"test-backend-20": "cross-env TEST_BACKEND=1 node --test test/backend-test",
"test-e2e": "playwright test --config ./config/playwright.config.js",
"test-e2e-ui": "playwright test --config ./config/playwright.config.js --ui --ui-port=51063",
"playwright-codegen": "playwright codegen localhost:3000 --save-storage=./private/e2e-auth.json",
"playwright-show-report": "playwright show-report ./private/playwright-report",
"tsc": "tsc",
"tsc": "tsc --project ./tsconfig-backend.json",
"vite-preview-dist": "vite preview --host --config ./config/vite.config.js",
"build-docker-base": "docker buildx build -f docker/debian-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base2 --target base2 . --push",
"build-docker-base-slim": "docker buildx build -f docker/debian-base.dockerfile --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:base2-slim --target base2-slim . --push",
@ -41,7 +43,7 @@
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push",
"upload-artifacts": "node extra/release/upload-artifacts.mjs",
"upload-artifacts-beta": "node extra/release/upload-artifacts-beta.mjs",
"setup": "git checkout 1.23.16 && npm ci --omit dev && npm run download-dist",
"setup": "git checkout 2.0.2 && npm ci --omit dev --no-audit && npm run download-dist",
"download-dist": "node extra/download-dist.js",
"mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js",
@ -57,14 +59,15 @@
"release-nightly": "node ./extra/release/nightly.mjs",
"git-remove-tag": "git tag -d",
"build-dist-and-restart": "npm run build && npm run start-server-dev",
"start-pr-test": "node extra/checkout-pr.js && npm install && npm run dev",
"start-pr-test": "node extra/checkout-pr.mjs && npm install && npm run dev",
"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",
"quick-run-nightly": "docker run --rm --env NODE_ENV=development -p 3001:3001 louislam/uptime-kuma:nightly2",
"start-dev-container": "cd docker && docker-compose -f docker-compose-dev.yml up --force-recreate",
"rebase-pr-to-1.23.X": "node extra/rebase-pr.js 1.23.X",
"reset-migrate-aggregate-table-state": "node extra/reset-migrate-aggregate-table-state.js"
"reset-migrate-aggregate-table-state": "node extra/reset-migrate-aggregate-table-state.js",
"generate-changelog": "node ./extra/generate-changelog.mjs"
},
"dependencies": {
"@grpc/grpc-js": "~1.8.22",
@ -81,7 +84,7 @@
"chroma-js": "~2.4.2",
"command-exists": "~1.2.9",
"compare-versions": "~3.6.0",
"compression": "~1.7.4",
"compression": "~1.8.1",
"country-flag-emoji-polyfill": "^0.1.8",
"croner": "~8.1.0",
"dayjs": "~1.11.5",
@ -99,6 +102,7 @@
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"iconv-lite": "~0.6.3",
"is-url": "^1.2.4",
"isomorphic-ws": "^5.0.0",
"jsesc": "~3.0.2",
"jsonata": "^2.0.3",
@ -117,7 +121,6 @@
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-radius-client": "~1.0.0",
"nodemailer": "~6.9.13",
"nostr-tools": "^2.10.4",
"notp": "~2.0.3",
@ -131,16 +134,20 @@
"promisify-child-process": "~4.1.2",
"protobufjs": "~7.2.4",
"qs": "~6.10.4",
"radius": "~1.1.4",
"node-radius-utils": "~1.2.0",
"redbean-node": "~0.3.0",
"redis": "~4.5.1",
"redis": "~5.9.0",
"semver": "~7.5.4",
"socket.io": "~4.8.0",
"socket.io-client": "~4.8.0",
"socks-proxy-agent": "~8.0.5",
"sqlstring": "~2.3.3",
"tar": "~6.2.1",
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",
"tough-cookie": "~4.1.3",
"web-push": "^3.6.7",
"ws": "^8.13.0"
},
"devDependencies": {
@ -155,6 +162,7 @@
"@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9",
"@types/node": "^20.8.6",
"@types/web-push": "^3.6.4",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"@vitejs/plugin-vue": "~5.0.1",
@ -193,7 +201,7 @@
"v-pagination-3": "~0.1.7",
"vite": "~5.4.15",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.0.15",
"vite-plugin-pwa": "^1.1.0",
"vue": "~3.4.2",
"vue-chartjs": "~5.2.0",
"vue-confirm-dialog": "~1.0.2",

View File

@ -1,6 +1,8 @@
{
"name": "Uptime Kuma",
"short_name": "Uptime Kuma",
"description": "An easy-to-use self-hosted monitoring tool.",
"theme_color": "#5cdd8b",
"start_url": "/",
"background_color": "#fff",
"display": "standalone",
@ -15,5 +17,72 @@
"sizes": "512x512",
"type": "image/png"
}
],
"shortcuts": [
{
"name": "Dashboard",
"short_name": "Dashboard",
"description": "View monitoring dashboard",
"url": "/dashboard",
"icons": [
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Add Monitor",
"short_name": "Add Monitor",
"description": "Add a new monitor",
"url": "/add",
"icons": [
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Monitor List",
"short_name": "List",
"description": "View all monitors",
"url": "/list",
"icons": [
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Settings",
"short_name": "Settings",
"description": "Open settings",
"url": "/settings",
"icons": [
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Maintenance",
"short_name": "Maintenance",
"description": "Manage maintenance windows",
"url": "/maintenance",
"icons": [
{
"src": "icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
]
}

View File

@ -18,15 +18,15 @@ exports.login = async function (username, password) {
return null;
}
let user = await R.findOne("user", " username = ? AND active = 1 ", [
username,
let user = await R.findOne("user", "TRIM(username) = ? AND active = 1 ", [
username.trim(),
]);
if (user && passwordHash.verify(password, user.password)) {
// Upgrade the hash to bcrypt
if (passwordHash.needRehash(user.password)) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
passwordHash.generate(password),
await passwordHash.generate(password),
user.id,
]);
}

View File

@ -1,4 +1,5 @@
const fs = require("fs");
const fsAsync = fs.promises;
const { R } = require("redbean-node");
const { setSetting, setting } = require("./util-server");
const { log, sleep } = require("../src/util");
@ -11,6 +12,7 @@ const { UptimeCalculator } = require("./uptime-calculator");
const dayjs = require("dayjs");
const { SimpleMigrationServer } = require("./utils/simple-migration-server");
const KumaColumnCompiler = require("./utils/knex/lib/dialects/mysql2/schema/mysql2-columncompiler");
const SqlString = require("sqlstring");
/**
* Database & App Data Folder
@ -18,7 +20,7 @@ const KumaColumnCompiler = require("./utils/knex/lib/dialects/mysql2/schema/mysq
class Database {
/**
* Boostrap database for SQLite
* Bootstrap database for SQLite
* @type {string}
*/
static templatePath = "./db/kuma.db";
@ -255,10 +257,6 @@ class Database {
}
};
} else if (dbConfig.type === "mariadb") {
if (!/^\w+$/.test(dbConfig.dbName)) {
throw Error("Invalid database name. A database name can only consist of letters, numbers and underscores");
}
const connection = await mysql.createConnection({
host: dbConfig.hostname,
port: dbConfig.port,
@ -266,7 +264,11 @@ class Database {
password: dbConfig.password,
});
await connection.execute("CREATE DATABASE IF NOT EXISTS " + dbConfig.dbName + " CHARACTER SET utf8mb4");
// Set to true, so for example "uptime.kuma", becomes `uptime.kuma`, not `uptime`.`kuma`
// Doc: https://github.com/mysqljs/sqlstring?tab=readme-ov-file#escaping-query-identifiers
const escapedDBName = SqlString.escapeId(dbConfig.dbName, true);
await connection.execute("CREATE DATABASE IF NOT EXISTS " + escapedDBName + " CHARACTER SET utf8mb4");
connection.end();
config = {
@ -707,12 +709,12 @@ class Database {
/**
* Get the size of the database (SQLite only)
* @returns {number} Size of database
* @returns {Promise<number>} Size of database
*/
static getSize() {
static async getSize() {
if (Database.dbConfig.type === "sqlite") {
log.debug("db", "Database.getSize()");
let stats = fs.statSync(Database.sqlitePath);
let stats = await fsAsync.stat(Database.sqlitePath);
log.debug("db", stats);
return stats.size;
}
@ -736,7 +738,7 @@ class Database {
if (Database.dbConfig.type === "sqlite") {
return "DATETIME('now', ? || ' hours')";
} else {
return "DATE_ADD(NOW(), INTERVAL ? HOUR)";
return "DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? HOUR)";
}
}

View File

@ -1,10 +1,10 @@
const axios = require("axios");
const { R } = require("redbean-node");
const https = require("https");
const fs = require("fs");
const fsAsync = require("fs").promises;
const path = require("path");
const Database = require("./database");
const { axiosAbortSignal } = require("./util-server");
const { axiosAbortSignal, fsExists } = require("./util-server");
class DockerHost {
@ -81,7 +81,7 @@ class DockerHost {
options.socketPath = dockerHost.dockerDaemon;
} else if (dockerHost.dockerType === "tcp") {
options.baseURL = DockerHost.patchDockerURL(dockerHost.dockerDaemon);
options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
options.httpsAgent = new https.Agent(await DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL));
}
try {
@ -141,9 +141,9 @@ class DockerHost {
* File names can also be overridden via 'DOCKER_TLS_FILE_NAME_(CA|KEY|CERT)'.
* @param {string} dockerType i.e. "tcp" or "socket"
* @param {string} url The docker host URL rewritten to https://
* @returns {object} HTTP agent options
* @returns {Promise<object>} HTTP agent options
*/
static getHttpsAgentOptions(dockerType, url) {
static async getHttpsAgentOptions(dockerType, url) {
let baseOptions = {
maxCachedSessions: 0,
rejectUnauthorized: true
@ -156,10 +156,10 @@ class DockerHost {
let certPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCert);
let keyPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameKey);
if (dockerType === "tcp" && fs.existsSync(caPath) && fs.existsSync(certPath) && fs.existsSync(keyPath)) {
let ca = fs.readFileSync(caPath);
let key = fs.readFileSync(keyPath);
let cert = fs.readFileSync(certPath);
if (dockerType === "tcp" && await fsExists(caPath) && await fsExists(certPath) && await fsExists(keyPath)) {
let ca = await fsAsync.readFile(caPath);
let key = await fsAsync.readFile(keyPath);
let cert = await fsAsync.readFile(certPath);
certOptions = {
ca,
key,

View File

@ -33,7 +33,7 @@ class Group extends BeanModel {
*/
async getMonitorList() {
return R.convertToBeans("monitor", await R.getAll(`
SELECT monitor.*, monitor_group.send_url FROM monitor, monitor_group
SELECT monitor.*, monitor_group.send_url, monitor_group.custom_url FROM monitor, monitor_group
WHERE monitor.id = monitor_group.monitor_id
AND group_id = ?
ORDER BY monitor_group.weight

View File

@ -158,12 +158,22 @@ class Maintenance extends BeanModel {
bean.active = obj.active;
if (obj.dateRange[0]) {
const parsedDate = new Date(obj.dateRange[0]);
if (isNaN(parsedDate.getTime()) || parsedDate.getFullYear() > 9999) {
throw new Error("Invalid start date");
}
bean.start_date = obj.dateRange[0];
} else {
bean.start_date = null;
}
if (obj.dateRange[1]) {
const parsedDate = new Date(obj.dateRange[1]);
if (isNaN(parsedDate.getTime()) || parsedDate.getFullYear() > 9999) {
throw new Error("Invalid end date");
}
bean.end_date = obj.dateRange[1];
} else {
bean.end_date = null;
@ -192,7 +202,7 @@ class Maintenance extends BeanModel {
* @returns {void}
*/
static validateCron(cron) {
let job = new Cron(cron, () => {});
let job = new Cron(cron, () => { });
job.stop();
}
@ -229,11 +239,13 @@ class Maintenance extends BeanModel {
apicache.clear();
});
} else if (this.cron != null) {
let current = dayjs();
// Here should be cron or recurring
try {
this.beanMeta.status = "scheduled";
let startEvent = (customDuration = 0) => {
let startEvent = async (customDuration = 0) => {
log.info("maintenance", "Maintenance id: " + this.id + " is under maintenance now");
this.beanMeta.status = "under-maintenance";
@ -248,6 +260,10 @@ class Maintenance extends BeanModel {
this.beanMeta.status = "scheduled";
UptimeKumaServer.getInstance().sendMaintenanceListByUserID(this.user_id);
}, duration);
// Set last start date to current time
this.last_start_date = current.toISOString();
await R.store(this);
};
// Create Cron
@ -256,11 +272,34 @@ class Maintenance extends BeanModel {
const startDate = dayjs(this.startDate);
const [ hour, minute ] = this.startTime.split(":");
const startDateTime = startDate.hour(hour).minute(minute);
// Fix #6118, since the startDateTime is optional, it will throw error if the date is null when using toISOString()
let startAt = undefined;
try {
startAt = startDateTime.toISOString();
} catch (_) {}
this.beanMeta.job = new Cron(this.cron, {
timezone: await this.getTimezone(),
interval: this.interval_day * 24 * 60 * 60,
startAt: startDateTime.toISOString(),
}, startEvent);
startAt,
}, () => {
if (!this.lastStartDate || this.interval_day === 1) {
return startEvent();
}
// If last start date is set, it means the maintenance has been started before
let lastStartDate = dayjs(this.lastStartDate)
.subtract(1.1, "hour"); // Subtract 1.1 hour to avoid issues with timezone differences
// Check if the interval is enough
if (current.diff(lastStartDate, "day") < this.interval_day) {
log.debug("maintenance", "Maintenance id: " + this.id + " is still in the window, skipping start event");
return;
}
log.debug("maintenance", "Maintenance id: " + this.id + " is not in the window, starting event");
return startEvent();
});
} else {
this.beanMeta.job = new Cron(this.cron, {
timezone: await this.getTimezone(),
@ -269,7 +308,6 @@ class Maintenance extends BeanModel {
// 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;
@ -413,8 +451,11 @@ class Maintenance extends BeanModel {
} else if (!this.strategy.startsWith("recurring-")) {
this.cron = "";
} else if (this.strategy === "recurring-interval") {
// For intervals, the pattern is calculated in the run function as the interval-option is set
this.cron = "* * * * *";
// For intervals, the pattern is used to check if the execution should be started
let array = this.start_time.split(":");
let hour = parseInt(array[0]);
let minute = parseInt(array[1]);
this.cron = `${minute} ${hour} * * *`;
this.duration = this.calcDuration();
log.debug("maintenance", "Cron: " + this.cron);
log.debug("maintenance", "Duration: " + this.duration);

View File

@ -2,10 +2,14 @@ const dayjs = require("dayjs");
const axios = require("axios");
const { Prometheus } = require("../prometheus");
const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND,
SQL_DATETIME_FORMAT, evaluateJsonQuery
SQL_DATETIME_FORMAT, evaluateJsonQuery,
PING_PACKET_SIZE_MIN, PING_PACKET_SIZE_MAX, PING_PACKET_SIZE_DEFAULT,
PING_GLOBAL_TIMEOUT_MIN, PING_GLOBAL_TIMEOUT_MAX, PING_GLOBAL_TIMEOUT_DEFAULT,
PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../../src/util");
const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
redisPingAsync, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery,
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal
} = require("../util-server");
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
@ -53,7 +57,7 @@ class Monitor extends BeanModel {
};
if (this.sendUrl) {
obj.url = this.url;
obj.url = this.customUrl ?? this.url;
}
if (showTags) {
@ -155,8 +159,15 @@ class Monitor extends BeanModel {
snmpOid: this.snmpOid,
jsonPathOperator: this.jsonPathOperator,
snmpVersion: this.snmpVersion,
smtpSecurity: this.smtpSecurity,
rabbitmqNodes: JSON.parse(this.rabbitmqNodes),
conditions: JSON.parse(this.conditions),
ipFamily: this.ipFamily,
// ping advanced options
ping_numeric: this.isPingNumeric(),
ping_count: this.ping_count,
ping_per_request_timeout: this.ping_per_request_timeout,
};
if (includeSensitiveData) {
@ -172,6 +183,7 @@ class Monitor extends BeanModel {
oauth_client_secret: this.oauth_client_secret,
oauth_token_url: this.oauth_token_url,
oauth_scopes: this.oauth_scopes,
oauth_audience: this.oauth_audience,
oauth_auth_method: this.oauth_auth_method,
pushToken: this.pushToken,
databaseConnectionString: this.databaseConnectionString,
@ -180,6 +192,7 @@ class Monitor extends BeanModel {
radiusSecret: this.radiusSecret,
mqttUsername: this.mqttUsername,
mqttPassword: this.mqttPassword,
mqttWebsocketPath: this.mqttWebsocketPath,
authWorkstation: this.authWorkstation,
authDomain: this.authDomain,
tlsCa: this.tlsCa,
@ -249,6 +262,14 @@ class Monitor extends BeanModel {
return Boolean(this.expiryNotification);
}
/**
* Check if ping should use numeric output only
* @returns {boolean} True if IP addresses will be output instead of symbolic hostnames
*/
isPingNumeric() {
return Boolean(this.ping_numeric);
}
/**
* Parse to boolean
* @returns {boolean} Should TLS errors be ignored?
@ -338,7 +359,7 @@ class Monitor extends BeanModel {
let previousBeat = null;
let retries = 0;
this.prometheus = new Prometheus(this);
this.prometheus = new Prometheus(this, await this.getTags());
const beat = async () => {
@ -390,39 +411,6 @@ class Monitor extends BeanModel {
if (await Monitor.isUnderMaintenance(this.id)) {
bean.msg = "Monitor under maintenance";
bean.status = MAINTENANCE;
} else if (this.type === "group") {
const children = await Monitor.getChildren(this.id);
if (children.length > 0) {
bean.status = UP;
bean.msg = "All children up and running";
for (const child of children) {
if (!child.active) {
// Ignore inactive childs
continue;
}
const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
// Only change state if the monitor is in worse conditions then the ones before
// lastBeat.status could be null
if (!lastBeat) {
bean.status = PENDING;
} else if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
bean.status = lastBeat.status;
} else if (bean.status === PENDING && lastBeat.status === DOWN) {
bean.status = lastBeat.status;
}
}
if (bean.status !== UP) {
bean.msg = "Child inaccessible";
}
} else {
// Set status pending if group is empty
bean.status = PENDING;
bean.msg = "Group empty";
}
} else if (this.type === "http" || this.type === "keyword" || this.type === "json-query") {
// Do not do any queries/high loading things before the "bean.ping"
let startTime = dayjs().valueOf();
@ -451,10 +439,26 @@ class Monitor extends BeanModel {
}
}
let agentFamily = undefined;
if (this.ipFamily === "ipv4") {
agentFamily = 4;
}
if (this.ipFamily === "ipv6") {
agentFamily = 6;
}
const httpsAgentOptions = {
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: !this.getIgnoreTls(),
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
autoSelectFamily: true,
...(agentFamily ? { family: agentFamily } : {})
};
const httpAgentOptions = {
maxCachedSessions: 0,
autoSelectFamily: true,
...(agentFamily ? { family: agentFamily } : {})
};
log.debug("monitor", `[${this.name}] Prepare Options for axios`);
@ -516,6 +520,7 @@ class Monitor extends BeanModel {
if (proxy && proxy.active) {
const { httpAgent, httpsAgent } = Proxy.createAgents(proxy, {
httpsAgentOptions: httpsAgentOptions,
httpAgentOptions: httpAgentOptions,
});
options.proxy = false;
@ -524,6 +529,10 @@ class Monitor extends BeanModel {
}
}
if (!options.httpAgent) {
options.httpAgent = new http.Agent(httpAgentOptions);
}
if (!options.httpsAgent) {
let jar = new CookieJar();
let httpsCookieAgentOptions = {
@ -579,7 +588,8 @@ class Monitor extends BeanModel {
}
}
if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID === this.id) {
// eslint-disable-next-line eqeqeq
if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID == this.id) {
log.info("monitor", res.data);
}
@ -621,13 +631,8 @@ class Monitor extends BeanModel {
}
} else if (this.type === "port") {
bean.ping = await tcping(this.hostname, this.port);
bean.msg = "";
bean.status = UP;
} else if (this.type === "ping") {
bean.ping = await ping(this.hostname, this.packetSize);
bean.ping = await ping(this.hostname, this.ping_count, "", this.ping_numeric, this.packetSize, this.timeout, this.ping_per_request_timeout);
bean.msg = "";
bean.status = UP;
} else if (this.type === "push") { // Type: Push
@ -699,7 +704,7 @@ class Monitor extends BeanModel {
bean.msg = res.data.response.servers[0].name;
try {
bean.ping = await ping(this.hostname, this.packetSize);
bean.ping = await ping(this.hostname, PING_COUNT_DEFAULT, "", true, this.packetSize, PING_GLOBAL_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT);
} catch (_) { }
} else {
throw new Error("Server not found on Steam");
@ -749,7 +754,7 @@ class Monitor extends BeanModel {
} else if (dockerHost._dockerType === "tcp") {
options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon);
options.httpsAgent = new https.Agent(
DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL)
await DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL)
);
}
@ -851,13 +856,6 @@ class Monitor extends BeanModel {
bean.msg = resp.code;
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "redis") {
let startTime = dayjs().valueOf();
bean.msg = await redisPingAsync(this.databaseConnectionString, !this.ignoreTls);
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type in UptimeKumaServer.monitorTypeList) {
let startTime = dayjs().valueOf();
const monitorType = UptimeKumaServer.monitorTypeList[this.type];
@ -1316,7 +1314,7 @@ class Monitor extends BeanModel {
/**
* Send a notification about a monitor
* @param {boolean} isFirstBeat Is this beat the first of this monitor?
* @param {Monitor} monitor The monitor to send a notificaton about
* @param {Monitor} monitor The monitor to send a notification about
* @param {Bean} bean Status information about monitor
* @returns {void}
*/
@ -1337,7 +1335,8 @@ class Monitor extends BeanModel {
try {
const heartbeatJSON = bean.toJSON();
const monitorData = [{ id: monitor.id,
active: monitor.active
active: monitor.active,
name: monitor.name
}];
const preloadData = await Monitor.preparePreloadData(monitorData);
// Prevent if the msg is undefined, notifications such as Discord cannot send out.
@ -1510,6 +1509,31 @@ class Monitor extends BeanModel {
if (this.interval < MIN_INTERVAL_SECOND) {
throw new Error(`Interval cannot be less than ${MIN_INTERVAL_SECOND} seconds`);
}
if (this.type === "ping") {
// ping parameters validation
if (this.packetSize && (this.packetSize < PING_PACKET_SIZE_MIN || this.packetSize > PING_PACKET_SIZE_MAX)) {
throw new Error(`Packet size must be between ${PING_PACKET_SIZE_MIN} and ${PING_PACKET_SIZE_MAX} (default: ${PING_PACKET_SIZE_DEFAULT})`);
}
if (this.ping_per_request_timeout && (this.ping_per_request_timeout < PING_PER_REQUEST_TIMEOUT_MIN || this.ping_per_request_timeout > PING_PER_REQUEST_TIMEOUT_MAX)) {
throw new Error(`Per-ping timeout must be between ${PING_PER_REQUEST_TIMEOUT_MIN} and ${PING_PER_REQUEST_TIMEOUT_MAX} seconds (default: ${PING_PER_REQUEST_TIMEOUT_DEFAULT})`);
}
if (this.ping_count && (this.ping_count < PING_COUNT_MIN || this.ping_count > PING_COUNT_MAX)) {
throw new Error(`Echo requests count must be between ${PING_COUNT_MIN} and ${PING_COUNT_MAX} (default: ${PING_COUNT_DEFAULT})`);
}
if (this.timeout) {
const pingGlobalTimeout = Math.round(Number(this.timeout));
if (pingGlobalTimeout < this.ping_per_request_timeout || pingGlobalTimeout < PING_GLOBAL_TIMEOUT_MIN || pingGlobalTimeout > PING_GLOBAL_TIMEOUT_MAX) {
throw new Error(`Timeout must be between ${PING_GLOBAL_TIMEOUT_MIN} and ${PING_GLOBAL_TIMEOUT_MAX} seconds (default: ${PING_GLOBAL_TIMEOUT_DEFAULT})`);
}
this.timeout = pingGlobalTimeout;
}
}
}
/**
@ -1635,7 +1659,7 @@ class Monitor extends BeanModel {
/**
* Gets all Children of the monitor
* @param {number} monitorID ID of monitor to get
* @returns {Promise<LooseObject<any>>} Children
* @returns {Promise<LooseObject<any>[]>} Children
*/
static async getChildren(monitorID) {
return await R.getAll(`
@ -1701,6 +1725,55 @@ class Monitor extends BeanModel {
]);
}
/**
* Delete a monitor from the system
* @param {number} monitorID ID of the monitor to delete
* @param {number} userID ID of the user who owns the monitor
* @returns {Promise<void>}
*/
static async deleteMonitor(monitorID, userID) {
const server = UptimeKumaServer.getInstance();
// Stop the monitor if it's running
if (monitorID in server.monitorList) {
await server.monitorList[monitorID].stop();
delete server.monitorList[monitorID];
}
// Delete from database
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
monitorID,
userID,
]);
}
/**
* Recursively delete a monitor and all its descendants
* @param {number} monitorID ID of the monitor to delete
* @param {number} userID ID of the user who owns the monitor
* @returns {Promise<void>}
*/
static async deleteMonitorRecursively(monitorID, userID) {
// Check if this monitor is a group
const monitor = await R.findOne("monitor", " id = ? AND user_id = ? ", [
monitorID,
userID,
]);
if (monitor && monitor.type === "group") {
// Get all children and delete them recursively
const children = await Monitor.getChildren(monitorID);
if (children && children.length > 0) {
for (const child of children) {
await Monitor.deleteMonitorRecursively(child.id, userID);
}
}
}
// Delete the monitor itself
await Monitor.deleteMonitor(monitorID, userID);
}
/**
* Checks recursive if parent (ancestors) are active
* @param {number} monitorID ID of the monitor to get
@ -1723,7 +1796,7 @@ class Monitor extends BeanModel {
*/
async makeOidcTokenClientCredentialsRequest() {
log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new token`);
const oAuthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method);
const oAuthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_audience, this.oauth_auth_method);
if (this.oauthAccessToken?.expires_at) {
log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken?.expires_at * 1000)}`);
} else {

View File

@ -120,8 +120,8 @@ class StatusPage extends BeanModel {
const head = $("head");
if (statusPage.googleAnalyticsTagId) {
let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId);
if (statusPage.google_analytics_tag_id) {
let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.google_analytics_tag_id);
head.append($(escapedGoogleAnalyticsScript));
}
@ -409,6 +409,7 @@ class StatusPage extends BeanModel {
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
showCertificateExpiry: !!this.show_certificate_expiry,
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat
};
}
@ -432,6 +433,7 @@ class StatusPage extends BeanModel {
showPoweredBy: !!this.show_powered_by,
googleAnalyticsId: this.google_analytics_tag_id,
showCertificateExpiry: !!this.show_certificate_expiry,
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat
};
}

View File

@ -14,7 +14,7 @@ class User extends BeanModel {
*/
static async resetPassword(userID, newPassword) {
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
passwordHash.generate(newPassword),
await passwordHash.generate(newPassword),
userID
]);
}
@ -25,7 +25,7 @@ class User extends BeanModel {
* @returns {Promise<void>}
*/
async resetPassword(newPassword) {
const hashedPassword = passwordHash.generate(newPassword);
const hashedPassword = await passwordHash.generate(newPassword);
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
hashedPassword,

View File

@ -82,7 +82,7 @@ function createNTLMv2Response(type2message, username, ntlmhash, nonce, targetNam
//reserved
buf.writeUInt32LE(0, 20);
//timestamp
//TODO: we are loosing precision here since js is not able to handle those large integers
//TODO: we are losing precision here since js is not able to handle those large integers
// maybe think about a different solution here
// 11644473600000 = diff between 1970 and 1601
var timestamp = ((Date.now() + 11644473600000) * 10000).toString(16);

View File

@ -89,6 +89,9 @@ function NtlmClient(credentials, AxiosConfig) {
switch (_b.label) {
case 0:
error = err.response;
// The header may look like this: `Negotiate, NTLM, Basic realm="itsahiddenrealm.example.net"`Add commentMore actions
// so extract the 'NTLM' part first
const ntlmheader = error.headers['www-authenticate'].split(',').find(_ => _.match(/ *NTLM/))?.trim() || '';
if (!(error && error.status === 401
&& error.headers['www-authenticate']
&& error.headers['www-authenticate'].includes('NTLM'))) return [3 /*break*/, 3];
@ -96,12 +99,12 @@ function NtlmClient(credentials, AxiosConfig) {
// include the Negotiate option when responding with the T2 message
// There is nore we could do to ensure we are processing correctly,
// but this is the easiest option for now
if (error.headers['www-authenticate'].length < 50) {
if (ntlmheader.length < 50) {
t1Msg = ntlm.createType1Message(credentials.workstation, credentials.domain);
error.config.headers["Authorization"] = t1Msg;
}
else {
t2Msg = ntlm.decodeType2Message((error.headers['www-authenticate'].match(/^NTLM\s+(.+?)(,|\s+|$)/) || [])[1]);
t2Msg = ntlm.decodeType2Message((ntlmheader.match(/^NTLM\s+(.+?)(,|\s+|$)/) || [])[1]);
t3Msg = ntlm.createType3Message(t2Msg, credentials.username, credentials.password, credentials.workstation, credentials.domain);
error.config.headers["X-retry"] = "false";
error.config.headers["Authorization"] = t3Msg;

View File

@ -0,0 +1,81 @@
const { UP, PENDING, DOWN } = require("../../src/util");
const { MonitorType } = require("./monitor-type");
const Monitor = require("../model/monitor");
class GroupMonitorType extends MonitorType {
name = "group";
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
const children = await Monitor.getChildren(monitor.id);
if (children.length === 0) {
// Set status pending if group is empty
heartbeat.status = PENDING;
heartbeat.msg = "Group empty";
return;
}
let worstStatus = UP;
const downChildren = [];
const pendingChildren = [];
for (const child of children) {
if (!child.active) {
// Ignore inactive (=paused) children
continue;
}
const label = child.name || `#${child.id}`;
const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
if (!lastBeat) {
if (worstStatus === UP) {
worstStatus = PENDING;
}
pendingChildren.push(label);
continue;
}
if (lastBeat.status === DOWN) {
worstStatus = DOWN;
downChildren.push(label);
} else if (lastBeat.status === PENDING) {
if (worstStatus !== DOWN) {
worstStatus = PENDING;
}
pendingChildren.push(label);
}
}
if (worstStatus === UP) {
heartbeat.status = UP;
heartbeat.msg = "All children up and running";
return;
}
if (worstStatus === PENDING) {
heartbeat.status = PENDING;
heartbeat.msg = `Pending child monitors: ${pendingChildren.join(", ")}`;
return;
}
heartbeat.status = DOWN;
let message = `Child monitors down: ${downChildren.join(", ")}`;
if (pendingChildren.length > 0) {
message += `; pending: ${pendingChildren.join(", ")}`;
}
// Throw to leverage the generic retry handling and notification flow
throw new Error(message);
}
}
module.exports = {
GroupMonitorType,
};

View File

@ -0,0 +1,36 @@
const { MonitorType } = require("./monitor-type");
const { UP, DOWN, PENDING } = require("../../src/util");
class ManualMonitorType extends MonitorType {
name = "Manual";
type = "manual";
description = "A monitor that allows manual control of the status";
supportsConditions = false;
conditionVariables = [];
/**
* @inheritdoc
*/
async check(monitor, heartbeat) {
if (monitor.manual_status !== null) {
heartbeat.status = monitor.manual_status;
switch (monitor.manual_status) {
case UP:
heartbeat.msg = "Up";
break;
case DOWN:
heartbeat.msg = "Down";
break;
default:
heartbeat.msg = "Pending";
}
} else {
heartbeat.status = PENDING;
heartbeat.msg = "Manual monitoring - No status set";
}
}
}
module.exports = {
ManualMonitorType
};

View File

@ -10,11 +10,12 @@ class MqttMonitorType extends MonitorType {
* @inheritdoc
*/
async check(monitor, heartbeat, server) {
const receivedMessage = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, {
const [ messageTopic, receivedMessage ] = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, {
port: monitor.port,
username: monitor.mqttUsername,
password: monitor.mqttPassword,
interval: monitor.interval,
websocketPath: monitor.mqttWebsocketPath,
});
if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") {
@ -24,7 +25,7 @@ class MqttMonitorType extends MonitorType {
if (monitor.mqttCheckType === "keyword") {
if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) {
heartbeat.msg = `Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`;
heartbeat.msg = `Topic: ${messageTopic}; Message: ${receivedMessage}`;
heartbeat.status = UP;
} else {
throw Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`);
@ -52,12 +53,12 @@ class MqttMonitorType extends MonitorType {
* @param {string} hostname Hostname / address of machine to test
* @param {string} topic MQTT topic
* @param {object} options MQTT options. Contains port, username,
* password and interval (interval defaults to 20)
* password, websocketPath and interval (interval defaults to 20)
* @returns {Promise<string>} Received MQTT message
*/
mqttAsync(hostname, topic, options = {}) {
return new Promise((resolve, reject) => {
const { port, username, password, interval = 20 } = options;
const { port, username, password, websocketPath, interval = 20 } = options;
// Adds MQTT protocol to the hostname if not already present
if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
@ -70,7 +71,15 @@ class MqttMonitorType extends MonitorType {
reject(new Error("Timeout, Message not received"));
}, interval * 1000 * 0.8);
const mqttUrl = `${hostname}:${port}`;
// Construct the URL based on protocol
let mqttUrl = `${hostname}:${port}`;
if (hostname.startsWith("ws://") || hostname.startsWith("wss://")) {
if (websocketPath && !websocketPath.startsWith("/")) {
mqttUrl = `${hostname}:${port}/${websocketPath || ""}`;
} else {
mqttUrl = `${hostname}:${port}${websocketPath || ""}`;
}
}
log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);
@ -101,11 +110,9 @@ class MqttMonitorType extends MonitorType {
});
client.on("message", (messageTopic, message) => {
if (messageTopic === topic) {
client.end();
clearTimeout(timeoutID);
resolve(message.toString("utf8"));
}
client.end();
clearTimeout(timeoutID);
resolve([ messageTopic, message.toString("utf8") ]);
});
});

View File

@ -2,13 +2,13 @@ const { MonitorType } = require("./monitor-type");
const { chromium } = require("playwright-core");
const { UP, log } = require("../../src/util");
const { Settings } = require("../settings");
const commandExistsSync = require("command-exists").sync;
const childProcess = require("child_process");
const path = require("path");
const Database = require("../database");
const jwt = require("jsonwebtoken");
const config = require("../config");
const { RemoteBrowser } = require("../remote-browser");
const { commandExists } = require("../util-server");
/**
* Cached instance of a browser
@ -122,7 +122,7 @@ async function prepareChromeExecutable(executablePath) {
executablePath = "/usr/bin/chromium";
// Install chromium in container via apt install
if ( !commandExistsSync(executablePath)) {
if (! await commandExists(executablePath)) {
await new Promise((resolve, reject) => {
log.info("Chromium", "Installing Chromium...");
let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk");
@ -146,7 +146,7 @@ async function prepareChromeExecutable(executablePath) {
}
} else {
executablePath = findChrome(allowedList);
executablePath = await findChrome(allowedList);
}
} else {
// User specified a path
@ -160,20 +160,20 @@ async function prepareChromeExecutable(executablePath) {
/**
* Find the chrome executable
* @param {any[]} executables Executables to search through
* @returns {any} Executable
* @throws Could not find executable
* @param {string[]} executables Executables to search through
* @returns {Promise<string>} Executable
* @throws {Error} Could not find executable
*/
function findChrome(executables) {
async function findChrome(executables) {
// Use the last working executable, so we don't have to search for it again
if (lastAutoDetectChromeExecutable) {
if (commandExistsSync(lastAutoDetectChromeExecutable)) {
if (await commandExists(lastAutoDetectChromeExecutable)) {
return lastAutoDetectChromeExecutable;
}
}
for (let executable of executables) {
if (commandExistsSync(executable)) {
if (await commandExists(executable)) {
lastAutoDetectChromeExecutable = executable;
return executable;
}

View File

@ -0,0 +1,57 @@
const { MonitorType } = require("./monitor-type");
const { UP } = require("../../src/util");
const redis = require("redis");
class RedisMonitorType extends MonitorType {
name = "redis";
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
heartbeat.msg = await this.redisPingAsync(monitor.databaseConnectionString, !monitor.ignoreTls);
heartbeat.status = UP;
}
/**
* Redis server ping
* @param {string} dsn The redis connection string
* @param {boolean} rejectUnauthorized If false, allows unverified server certificates.
* @returns {Promise<any>} Response from redis server
*/
redisPingAsync(dsn, rejectUnauthorized) {
return new Promise((resolve, reject) => {
const client = redis.createClient({
url: dsn,
socket: {
rejectUnauthorized
}
});
client.on("error", (err) => {
if (client.isOpen) {
client.disconnect();
}
reject(err);
});
client.connect().then(() => {
if (!client.isOpen) {
client.emit("error", new Error("connection isn't open"));
}
client.ping().then((res, err) => {
if (client.isOpen) {
client.disconnect();
}
if (err) {
reject(err);
} else {
resolve(res);
}
}).catch(error => reject(error));
});
});
}
}
module.exports = {
RedisMonitorType,
};

View File

@ -0,0 +1,35 @@
const { MonitorType } = require("./monitor-type");
const { UP } = require("../../src/util");
const nodemailer = require("nodemailer");
class SMTPMonitorType extends MonitorType {
name = "smtp";
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
let options = {
port: monitor.port || 25,
host: monitor.hostname,
secure: monitor.smtpSecurity === "secure", // use SMTPS (not STARTTLS)
ignoreTLS: monitor.smtpSecurity === "nostarttls", // don't use STARTTLS even if it's available
requireTLS: monitor.smtpSecurity === "starttls", // use STARTTLS or fail
};
let transporter = nodemailer.createTransport(options);
try {
await transporter.verify();
heartbeat.status = UP;
heartbeat.msg = "SMTP connection verifies successfully";
} catch (e) {
throw new Error(`SMTP connection doesn't verify: ${e}`);
} finally {
transporter.close();
}
}
}
module.exports = {
SMTPMonitorType,
};

View File

@ -31,7 +31,7 @@ class TailscalePing extends MonitorType {
timeout: timeout,
encoding: "utf8",
});
if (res.stderr && res.stderr.toString()) {
if (res.stderr && res.stderr.toString() && res.code !== 0) {
throw new Error(`Error in output: ${res.stderr.toString()}`);
}
if (res.stdout && res.stdout.toString()) {

158
server/monitor-types/tcp.js Normal file
View File

@ -0,0 +1,158 @@
const { MonitorType } = require("./monitor-type");
const { UP, DOWN, PING_GLOBAL_TIMEOUT_DEFAULT: TIMEOUT, log } = require("../../src/util");
const { checkCertificate } = require("../util-server");
const tls = require("tls");
const net = require("net");
const tcpp = require("tcp-ping");
/**
* Send TCP request to specified hostname and port
* @param {string} hostname Hostname / address of machine
* @param {number} port TCP port to test
* @returns {Promise<number>} Maximum time in ms rounded to nearest integer
*/
const tcping = (hostname, port) => {
return new Promise((resolve, reject) => {
tcpp.ping(
{
address: hostname,
port: port,
attempts: 1,
},
(err, data) => {
if (err) {
reject(err);
}
if (data.results.length >= 1 && data.results[0].err) {
reject(data.results[0].err);
}
resolve(Math.round(data.max));
}
);
});
};
class TCPMonitorType extends MonitorType {
name = "port";
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
try {
const resp = await tcping(monitor.hostname, monitor.port);
heartbeat.ping = resp;
heartbeat.msg = `${resp} ms`;
heartbeat.status = UP;
} catch {
heartbeat.status = DOWN;
heartbeat.msg = "Connection failed";
return;
}
let socket_;
const preTLS = () =>
new Promise((resolve, reject) => {
let timeout;
socket_ = net.connect(monitor.port, monitor.hostname);
const onTimeout = () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS connection timed out`);
reject("Connection timed out");
};
socket_.on("connect", () => {
log.debug(this.name, `[${monitor.name}] Pre-TLS connection: ${JSON.stringify(socket_)}`);
});
socket_.on("data", data => {
const response = data.toString();
const response_ = response.toLowerCase();
log.debug(this.name, `[${monitor.name}] Pre-TLS response: ${response}`);
switch (true) {
case response_.includes("start tls") || response_.includes("begin tls"):
timeout && clearTimeout(timeout);
resolve({ socket: socket_ });
break;
case response.startsWith("* OK") || response.match(/CAPABILITY.+STARTTLS/):
socket_.write("a001 STARTTLS\r\n");
break;
case response.startsWith("220") || response.includes("ESMTP"):
socket_.write(`EHLO ${monitor.hostname}\r\n`);
break;
case response.includes("250-STARTTLS"):
socket_.write("STARTTLS\r\n");
break;
default:
reject(`Unexpected response: ${response}`);
}
});
socket_.on("error", error => {
log.debug(this.name, `[${monitor.name}] ${error.toString()}`);
reject(error);
});
socket_.setTimeout(1000 * TIMEOUT, onTimeout);
timeout = setTimeout(onTimeout, 1000 * TIMEOUT);
});
const reuseSocket = monitor.smtpSecurity === "starttls" ? await preTLS() : {};
if ([ "secure", "starttls" ].includes(monitor.smtpSecurity) && monitor.isEnabledExpiryNotification()) {
let socket = null;
try {
const options = {
host: monitor.hostname,
port: monitor.port,
servername: monitor.hostname,
...reuseSocket,
};
const tlsInfoObject = await new Promise((resolve, reject) => {
socket = tls.connect(options);
socket.on("secureConnect", () => {
try {
const info = checkCertificate(socket);
resolve(info);
} catch (error) {
reject(error);
}
});
socket.on("error", error => {
reject(error);
});
socket.setTimeout(1000 * TIMEOUT, () => {
reject(new Error("Connection timed out"));
});
});
await monitor.handleTlsInfo(tlsInfoObject);
if (!tlsInfoObject.valid) {
heartbeat.status = DOWN;
heartbeat.msg = "Certificate is invalid";
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
heartbeat.status = DOWN;
heartbeat.msg = `TLS Connection failed: ${message}`;
} finally {
if (socket && !socket.destroyed) {
socket.end();
}
}
}
if (socket_ && !socket_.destroyed) {
socket_.end();
}
}
}
module.exports = {
TCPMonitorType,
};

View File

@ -17,12 +17,14 @@ class Elks extends NotificationProvider {
data.append("to", notification.elksToNumber );
data.append("message", msg);
const config = {
let config = {
headers: {
"Authorization": "Basic " + Buffer.from(`${notification.elksUsername}:${notification.elksAuthToken}`).toString("base64")
}
};
config = this.getAxiosConfigWithProxy(config);
await axios.post(url, data, config);
return okMsg;

View File

@ -0,0 +1,47 @@
const NotificationProvider = require("./notification-provider");
const { UP } = require("../../src/util");
const webpush = require("web-push");
const { setting } = require("../util-server");
class Webpush extends NotificationProvider {
name = "Webpush";
/**
* @inheritDoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
try {
const publicVapidKey = await setting("webpushPublicVapidKey");
const privateVapidKey = await setting("webpushPrivateVapidKey");
webpush.setVapidDetails("https://github.com/louislam/uptime-kuma", publicVapidKey, privateVapidKey);
if (heartbeatJSON === null && monitorJSON === null) {
// Test message
const data = JSON.stringify({
title: "TEST",
body: `Test Alert - ${msg}`
});
await webpush.sendNotification(notification.subscription, data);
return okMsg;
}
const data = JSON.stringify({
title: heartbeatJSON["status"] === UP ? "Monitor Up" : "Monitor DOWN",
body: heartbeatJSON["status"] === UP ? `${heartbeatJSON["name"]} is DOWN` : `${heartbeatJSON["name"]} is UP`
});
await webpush.sendNotification(notification.subscription, data);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Webpush;

View File

@ -30,6 +30,8 @@ class Alerta extends NotificationProvider {
type: "exceptionAlert",
};
config = this.getAxiosConfigWithProxy(config);
if (heartbeatJSON == null) {
let postData = Object.assign({
event: "msg",

View File

@ -41,7 +41,9 @@ class AlertNow extends NotificationProvider {
"event_id": eventId,
};
await axios.post(notification.alertNowWebhookURL, data);
let config = this.getAxiosConfigWithProxy({});
await axios.post(notification.alertNowWebhookURL, data, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);

View File

@ -72,6 +72,8 @@ class AliyunSMS extends NotificationProvider {
data: qs.stringify(params),
};
config = this.getAxiosConfigWithProxy(config);
let result = await axios(config);
if (result.data.Message === "OK") {
return true;

View File

@ -0,0 +1,34 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Bale extends NotificationProvider {
name = "bale";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
const url = "https://tapi.bale.ai";
try {
await axios.post(
`${url}/bot${notification.baleBotToken}/sendMessage`,
{
chat_id: notification.baleChatID,
text: msg
},
{
headers: {
"content-type": "application/json",
},
}
);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Bale;

View File

@ -96,20 +96,21 @@ class Bark extends NotificationProvider {
*/
async postNotification(notification, title, subtitle, endpoint) {
let result;
let config = this.getAxiosConfigWithProxy({});
if (notification.apiVersion === "v1" || notification.apiVersion == null) {
// url encode title and subtitle
title = encodeURIComponent(title);
subtitle = encodeURIComponent(subtitle);
const params = this.additionalParameters(notification);
result = await axios.get(`${endpoint}/${title}/${subtitle}${params}`);
result = await axios.get(`${endpoint}/${title}/${subtitle}${params}`, config);
} else {
result = await axios.post(`${endpoint}/push`, {
result = await axios.post(endpoint, {
title,
body: subtitle,
icon: barkNotificationAvatar,
sound: notification.barkSound || "telegraph", // default sound is telegraph
group: notification.barkGroup || "UptimeKuma", // default group is UptimeKuma
});
}, config);
}
this.checkResult(result);
if (result.statusText != null) {

View File

@ -19,7 +19,8 @@ class Bitrix24 extends NotificationProvider {
"ATTACH[BLOCKS][0][MESSAGE]": msg
};
await axios.get(`${notification.bitrix24WebhookURL}/im.notify.system.add.json`, { params });
let config = this.getAxiosConfigWithProxy({ params });
await axios.get(`${notification.bitrix24WebhookURL}/im.notify.system.add.json`, config);
return okMsg;
} catch (error) {

View File

@ -0,0 +1,63 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Brevo extends NotificationProvider {
name = "Brevo";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
try {
let config = {
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"api-key": notification.brevoApiKey,
},
};
config = this.getAxiosConfigWithProxy(config);
let to = [{ email: notification.brevoToEmail }];
let data = {
sender: {
email: notification.brevoFromEmail.trim(),
name: notification.brevoFromName || "Uptime Kuma"
},
to: to,
subject: notification.brevoSubject || "Notification from Your Uptime Kuma",
htmlContent: `<html><head></head><body><p>${msg.replace(/\n/g, "<br>")}</p></body></html>`
};
if (notification.brevoCcEmail) {
data.cc = notification.brevoCcEmail
.split(",")
.map((email) => ({ email: email.trim() }));
}
if (notification.brevoBccEmail) {
data.bcc = notification.brevoBccEmail
.split(",")
.map((email) => ({ email: email.trim() }));
}
let result = await axios.post(
"https://api.brevo.com/v3/smtp/email",
data,
config
);
if (result.status === 201) {
return okMsg;
} else {
throw new Error(`Unexpected status code: ${result.status}`);
}
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Brevo;

View File

@ -12,7 +12,8 @@ class CallMeBot extends NotificationProvider {
try {
const url = new URL(notification.callMeBotEndpoint);
url.searchParams.set("text", msg);
await axios.get(url.toString());
let config = this.getAxiosConfigWithProxy({});
await axios.get(url.toString(), config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);

View File

@ -22,7 +22,8 @@ class Cellsynt extends NotificationProvider {
}
};
try {
const resp = await axios.post("https://se-1.cellsynt.net/sms.php", null, data);
let config = this.getAxiosConfigWithProxy(data);
const resp = await axios.post("https://se-1.cellsynt.net/sms.php", null, config);
if (resp.data == null ) {
throw new Error("Could not connect to Cellsynt, please try again.");
} else if (resp.data.includes("Error:")) {

View File

@ -29,6 +29,7 @@ class ClickSendSMS extends NotificationProvider {
}
]
};
config = this.getAxiosConfigWithProxy(config);
let resp = await axios.post(url, data, config);
if (resp.data.data.messages[0].status !== "SUCCESS") {
let error = "Something gone wrong. Api returned " + resp.data.data.messages[0].status + ".";

View File

@ -11,17 +11,23 @@ class DingDing extends NotificationProvider {
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
const mentionAll = notification.mentioning === "everyone";
const mobileList = notification.mentioning === "specify-mobiles" ? notification.mobileList : [];
const userList = notification.mentioning === "specify-users" ? notification.userList : [];
const finalList = [ ...mobileList || [], ...userList || [] ];
const mentionStr = finalList.length > 0 ? "\n" : "" + finalList.map(item => `@${item}`).join(" ");
try {
if (heartbeatJSON != null) {
let params = {
msgtype: "markdown",
markdown: {
title: `[${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]}`,
text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n> ${heartbeatJSON["msg"]}\n> Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`,
text: `## [${this.statusToString(heartbeatJSON["status"])}] ${monitorJSON["name"]} \n> ${heartbeatJSON["msg"]}\n> Time (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}${mentionStr}`,
},
"at": {
"isAtAll": notification.mentioning === "everyone"
at: {
isAtAll: mentionAll,
atUserIds: userList,
atMobiles: mobileList
}
};
if (await this.sendToDingDing(notification, params)) {
@ -31,7 +37,12 @@ class DingDing extends NotificationProvider {
let params = {
msgtype: "text",
text: {
content: msg
content: `${msg}${mentionStr}`
},
at: {
isAtAll: mentionAll,
atUserIds: userList,
atMobiles: mobileList
}
};
if (await this.sendToDingDing(notification, params)) {
@ -60,6 +71,7 @@ class DingDing extends NotificationProvider {
url: `${notification.webHookUrl}&timestamp=${timestamp}&sign=${encodeURIComponent(this.sign(timestamp, notification.secretKey))}`,
data: JSON.stringify(params),
};
config = this.getAxiosConfigWithProxy(config);
let result = await axios(config);
if (result.data.errmsg === "ok") {

View File

@ -12,24 +12,36 @@ class Discord extends NotificationProvider {
const okMsg = "Sent Successfully.";
try {
let config = this.getAxiosConfigWithProxy({});
const discordDisplayName = notification.discordUsername || "Uptime Kuma";
const webhookUrl = new URL(notification.discordWebhookUrl);
if (notification.discordChannelType === "postToThread") {
webhookUrl.searchParams.append("thread_id", notification.threadId);
}
// Check if the webhook has an avatar
let webhookHasAvatar = true;
try {
const webhookInfo = await axios.get(webhookUrl.toString(), config);
webhookHasAvatar = !!webhookInfo.data.avatar;
} catch (e) {
// If we can't verify, we assume he has an avatar to avoid forcing the default avatar
webhookHasAvatar = true;
}
// If heartbeatJSON is null, assume we're testing.
if (heartbeatJSON == null) {
let discordtestdata = {
username: discordDisplayName,
content: msg,
};
if (!webhookHasAvatar) {
discordtestdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
if (notification.discordChannelType === "createNewForumPost") {
discordtestdata.thread_name = notification.postName;
}
await axios.post(webhookUrl.toString(), discordtestdata);
await axios.post(webhookUrl.toString(), discordtestdata, config);
return okMsg;
}
@ -46,10 +58,10 @@ class Discord extends NotificationProvider {
name: "Service Name",
value: monitorJSON["name"],
},
{
...(!notification.disableUrl ? [{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: this.extractAddress(monitorJSON),
},
}] : []),
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],
@ -61,6 +73,9 @@ class Discord extends NotificationProvider {
],
}],
};
if (!webhookHasAvatar) {
discorddowndata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
if (notification.discordChannelType === "createNewForumPost") {
discorddowndata.thread_name = notification.postName;
}
@ -68,7 +83,7 @@ class Discord extends NotificationProvider {
discorddowndata.content = notification.discordPrefixMessage;
}
await axios.post(webhookUrl.toString(), discorddowndata);
await axios.post(webhookUrl.toString(), discorddowndata, config);
return okMsg;
} else if (heartbeatJSON["status"] === UP) {
@ -83,10 +98,10 @@ class Discord extends NotificationProvider {
name: "Service Name",
value: monitorJSON["name"],
},
{
...(!notification.disableUrl ? [{
name: monitorJSON["type"] === "push" ? "Service Type" : "Service URL",
value: this.extractAddress(monitorJSON),
},
}] : []),
{
name: `Time (${heartbeatJSON["timezone"]})`,
value: heartbeatJSON["localDateTime"],
@ -98,6 +113,9 @@ class Discord extends NotificationProvider {
],
}],
};
if (!webhookHasAvatar) {
discordupdata.avatar_url = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png";
}
if (notification.discordChannelType === "createNewForumPost") {
discordupdata.thread_name = notification.postName;
@ -107,7 +125,7 @@ class Discord extends NotificationProvider {
discordupdata.content = notification.discordPrefixMessage;
}
await axios.post(webhookUrl.toString(), discordupdata);
await axios.post(webhookUrl.toString(), discordupdata, config);
return okMsg;
}
} catch (error) {

View File

@ -0,0 +1,40 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Evolution extends NotificationProvider {
name = "evolution";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
try {
let config = {
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"apikey": notification.evolutionAuthToken,
}
};
config = this.getAxiosConfigWithProxy(config);
let data = {
"number": notification.evolutionRecipient,
"text": msg,
};
let url = (notification.evolutionApiUrl || "https://evolapicloud.com/").replace(/([^/])\/+$/, "$1") + "/message/sendText/" + encodeURIComponent(notification.evolutionInstanceName);
await axios.post(url, data, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Evolution;

View File

@ -12,6 +12,7 @@ class Feishu extends NotificationProvider {
const okMsg = "Sent Successfully.";
try {
let config = this.getAxiosConfigWithProxy({});
if (heartbeatJSON == null) {
let testdata = {
msg_type: "text",
@ -19,7 +20,7 @@ class Feishu extends NotificationProvider {
text: msg,
},
};
await axios.post(notification.feishuWebHookUrl, testdata);
await axios.post(notification.feishuWebHookUrl, testdata, config);
return okMsg;
}
@ -49,7 +50,7 @@ class Feishu extends NotificationProvider {
]
}
};
await axios.post(notification.feishuWebHookUrl, downdata);
await axios.post(notification.feishuWebHookUrl, downdata, config);
return okMsg;
}
@ -79,7 +80,7 @@ class Feishu extends NotificationProvider {
]
}
};
await axios.post(notification.feishuWebHookUrl, updata);
await axios.post(notification.feishuWebHookUrl, updata, config);
return okMsg;
}
} catch (error) {

View File

@ -38,7 +38,7 @@ class FlashDuty extends NotificationProvider {
}
/**
* Generate a monitor url from the monitors infomation
* Generate a monitor url from the monitors information
* @param {object} monitorInfo Monitor details
* @returns {string|undefined} Monitor URL
*/
@ -73,13 +73,13 @@ class FlashDuty extends NotificationProvider {
}
const options = {
method: "POST",
url: "https://api.flashcat.cloud/event/push/alert/standard?integration_key=" + notification.flashdutyIntegrationKey,
url: notification.flashdutyIntegrationKey.startsWith("http") ? notification.flashdutyIntegrationKey : "https://api.flashcat.cloud/event/push/alert/standard?integration_key=" + notification.flashdutyIntegrationKey,
headers: { "Content-Type": "application/json" },
data: {
description: `[${title}] [${monitorInfo.name}] ${body}`,
title,
event_status: eventStatus || "Info",
alert_key: String(monitorInfo.id) || Math.random().toString(36).substring(7),
alert_key: monitorInfo.id ? String(monitorInfo.id) : Math.random().toString(36).substring(7),
labels,
}
};

View File

@ -11,10 +11,11 @@ class FreeMobile extends NotificationProvider {
const okMsg = "Sent Successfully.";
try {
let config = this.getAxiosConfigWithProxy({});
await axios.post(`https://smsapi.free-mobile.fr/sendmsg?msg=${encodeURIComponent(msg.replace("🔴", "⛔️"))}`, {
"user": notification.freemobileUser,
"pass": notification.freemobilePass,
});
}, config);
return okMsg;

View File

@ -24,6 +24,7 @@ class GoAlert extends NotificationProvider {
let config = {
headers: headers
};
config = this.getAxiosConfigWithProxy(config);
await axios.post(`${notification.goAlertBaseURL}/api/v2/generic/incoming?token=${notification.goAlertToken}`, data, config);
return okMsg;
} catch (error) {

View File

@ -12,8 +12,44 @@ class GoogleChat extends NotificationProvider {
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
// If Google Chat Webhook rate limit is reached, retry to configured max retries defaults to 3, delay between 60-180 seconds
const post = async (url, data, config) => {
let retries = notification.googleChatMaxRetries || 1; // Default to 1 retries
retries = (retries > 10) ? 10 : retries; // Enforce maximum retries in backend
while (retries > 0) {
try {
await axios.post(url, data, config);
return;
} catch (error) {
if (error.response && error.response.status === 429) {
retries--;
if (retries === 0) {
throw error;
}
const delay = 60000 + Math.random() * 120000;
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
};
try {
let config = this.getAxiosConfigWithProxy({});
// Google Chat message formatting: https://developers.google.com/chat/api/guides/message-formats/basic
if (notification.googleChatUseTemplate && notification.googleChatTemplate) {
// Send message using template
const renderedText = await this.renderTemplate(
notification.googleChatTemplate,
msg,
monitorJSON,
heartbeatJSON
);
const data = { "text": renderedText };
await post(notification.googleChatWebhookURL, data, config);
return okMsg;
}
let chatHeader = {
title: "Uptime Kuma Alert",
@ -83,12 +119,11 @@ class GoogleChat extends NotificationProvider {
],
};
await axios.post(notification.googleChatWebhookURL, data);
await post(notification.googleChatWebhookURL, data, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}

View File

@ -31,8 +31,7 @@ class Gorush extends NotificationProvider {
}
]
};
let config = {};
let config = this.getAxiosConfigWithProxy({});
await axios.post(`${notification.gorushServerURL}/api/push`, data, config);
return okMsg;
} catch (error) {

View File

@ -11,6 +11,7 @@ class Gotify extends NotificationProvider {
const okMsg = "Sent Successfully.";
try {
let config = this.getAxiosConfigWithProxy({});
if (notification.gotifyserverurl && notification.gotifyserverurl.endsWith("/")) {
notification.gotifyserverurl = notification.gotifyserverurl.slice(0, -1);
}
@ -18,7 +19,7 @@ class Gotify extends NotificationProvider {
"message": msg,
"priority": notification.gotifyPriority || 8,
"title": "Uptime-Kuma",
});
}, config);
return okMsg;

View File

@ -16,13 +16,14 @@ class GrafanaOncall extends NotificationProvider {
}
try {
let config = this.getAxiosConfigWithProxy({});
if (heartbeatJSON === null) {
let grafanaupdata = {
title: "General notification",
message: msg,
state: "alerting",
};
await axios.post(notification.GrafanaOncallURL, grafanaupdata);
await axios.post(notification.GrafanaOncallURL, grafanaupdata, config);
return okMsg;
} else if (heartbeatJSON["status"] === DOWN) {
let grafanadowndata = {
@ -30,7 +31,7 @@ class GrafanaOncall extends NotificationProvider {
message: heartbeatJSON["msg"],
state: "alerting",
};
await axios.post(notification.GrafanaOncallURL, grafanadowndata);
await axios.post(notification.GrafanaOncallURL, grafanadowndata, config);
return okMsg;
} else if (heartbeatJSON["status"] === UP) {
let grafanaupdata = {
@ -38,7 +39,7 @@ class GrafanaOncall extends NotificationProvider {
message: heartbeatJSON["msg"],
state: "ok",
};
await axios.post(notification.GrafanaOncallURL, grafanaupdata);
await axios.post(notification.GrafanaOncallURL, grafanaupdata, config);
return okMsg;
}
} catch (error) {

View File

@ -14,6 +14,7 @@ class GtxMessaging extends NotificationProvider {
const text = msg.replaceAll("🔴 ", "").replaceAll("✅ ", "");
try {
let config = this.getAxiosConfigWithProxy({});
const data = new URLSearchParams();
data.append("from", notification.gtxMessagingFrom.trim());
data.append("to", notification.gtxMessagingTo.trim());
@ -21,7 +22,7 @@ class GtxMessaging extends NotificationProvider {
const url = `https://rest.gtx-messaging.net/smsc/sendsms/${notification.gtxMessagingApiKey}/json`;
await axios.post(url, data);
await axios.post(url, data, config);
return okMsg;
} catch (error) {

View File

@ -18,7 +18,7 @@ class HeiiOnCall extends NotificationProvider {
payload["url"] = baseURL + getMonitorRelativeURL(monitorJSON.id);
}
const config = {
let config = {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
@ -28,6 +28,7 @@ class HeiiOnCall extends NotificationProvider {
const heiiUrl = `https://heiioncall.com/triggers/${notification.heiiOnCallTriggerId}/`;
// docs https://heiioncall.com/docs#manual-triggers
try {
config = this.getAxiosConfigWithProxy(config);
if (!heartbeatJSON) {
// Testing or general notification like certificate expiry
payload["msg"] = msg;

View File

@ -15,6 +15,13 @@ class HomeAssistant extends NotificationProvider {
const notificationService = notification?.notificationService || defaultNotificationService;
try {
let config = {
headers: {
Authorization: `Bearer ${notification.longLivedAccessToken}`,
"Content-Type": "application/json",
},
};
config = this.getAxiosConfigWithProxy(config);
await axios.post(
`${notification.homeAssistantUrl.trim().replace(/\/*$/, "")}/api/services/notify/${notificationService}`,
{
@ -26,14 +33,7 @@ class HomeAssistant extends NotificationProvider {
channel: "Uptime Kuma",
icon_url: "https://github.com/louislam/uptime-kuma/blob/master/public/icon.png?raw=true",
} }),
},
{
headers: {
Authorization: `Bearer ${notification.longLivedAccessToken}`,
"Content-Type": "application/json",
},
}
);
}, config);
return okMsg;
} catch (error) {

View File

@ -31,6 +31,8 @@ class Keep extends NotificationProvider {
let webhookURL = url + "/alerts/event/uptimekuma";
config = this.getAxiosConfigWithProxy(config);
await axios.post(webhookURL, data, config);
return okMsg;
} catch (error) {

View File

@ -22,6 +22,7 @@ class Kook extends NotificationProvider {
},
};
try {
config = this.getAxiosConfigWithProxy(config);
await axios.post(url, data, config);
return okMsg;

View File

@ -19,6 +19,7 @@ class Line extends NotificationProvider {
"Authorization": "Bearer " + notification.lineChannelAccessToken
}
};
config = this.getAxiosConfigWithProxy(config);
if (heartbeatJSON == null) {
let testMessage = {
"to": notification.lineUserID,

View File

@ -20,6 +20,7 @@ class LineNotify extends NotificationProvider {
"Authorization": "Bearer " + notification.lineNotifyAccessToken
}
};
config = this.getAxiosConfigWithProxy(config);
if (heartbeatJSON == null) {
let testMessage = {
"message": msg,

View File

@ -13,13 +13,14 @@ class LunaSea extends NotificationProvider {
const url = "https://notify.lunasea.app/v1";
try {
let config = this.getAxiosConfigWithProxy({});
const target = this.getTarget(notification);
if (heartbeatJSON == null) {
let testdata = {
"title": "Uptime Kuma Alert",
"body": msg,
};
await axios.post(`${url}/custom/${target}`, testdata);
await axios.post(`${url}/custom/${target}`, testdata, config);
return okMsg;
}
@ -30,7 +31,7 @@ class LunaSea extends NotificationProvider {
heartbeatJSON["msg"] +
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
};
await axios.post(`${url}/custom/${target}`, downdata);
await axios.post(`${url}/custom/${target}`, downdata, config);
return okMsg;
}
@ -41,7 +42,7 @@ class LunaSea extends NotificationProvider {
heartbeatJSON["msg"] +
`\nTime (${heartbeatJSON["timezone"]}): ${heartbeatJSON["localDateTime"]}`
};
await axios.post(`${url}/custom/${target}`, updata);
await axios.post(`${url}/custom/${target}`, updata, config);
return okMsg;
}

View File

@ -37,6 +37,7 @@ class Matrix extends NotificationProvider {
"body": msg
};
config = this.getAxiosConfigWithProxy(config);
await axios.put(`${notification.homeserverUrl}/_matrix/client/r0/rooms/${roomId}/send/m.room.message/${randomString}`, data, config);
return okMsg;
} catch (error) {

View File

@ -12,6 +12,7 @@ class Mattermost extends NotificationProvider {
const okMsg = "Sent Successfully.";
try {
let config = this.getAxiosConfigWithProxy({});
const mattermostUserName = notification.mattermostusername || "Uptime Kuma";
// If heartbeatJSON is null, assume non monitoring notification (Certificate warning) or testing.
if (heartbeatJSON == null) {
@ -19,7 +20,7 @@ class Mattermost extends NotificationProvider {
username: mattermostUserName,
text: msg,
};
await axios.post(notification.mattermostWebhookUrl, mattermostTestData);
await axios.post(notification.mattermostWebhookUrl, mattermostTestData, config);
return okMsg;
}
@ -79,13 +80,11 @@ class Mattermost extends NotificationProvider {
fallback:
"Your " +
monitorJSON.pathName +
monitorJSON.name +
" service went " +
statusText,
color: color,
title:
monitorJSON.pathName +
monitorJSON.name +
" service went " +
statusText,
title_link: monitorJSON.url,
@ -100,7 +99,7 @@ class Mattermost extends NotificationProvider {
},
],
};
await axios.post(notification.mattermostWebhookUrl, mattermostdata);
await axios.post(notification.mattermostWebhookUrl, mattermostdata, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);

View File

@ -0,0 +1,66 @@
const { UP, DOWN } = require("../../src/util");
const Crypto = require("crypto");
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class NextcloudTalk extends NotificationProvider {
name = "nextcloudtalk";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
// See documentation at https://nextcloud-talk.readthedocs.io/en/latest/bots/#sending-a-chat-message
const okMsg = "Sent Successfully.";
// Create a random string
const talkRandom = encodeURIComponent(
Crypto
.randomBytes(64)
.toString("hex")
.slice(0, 64)
);
// Create the signature over random and message
const talkSignature = Crypto
.createHmac("sha256", Buffer.from(notification.botSecret, "utf8"))
.update(Buffer.from(`${talkRandom}${msg}`, "utf8"))
.digest("hex");
let silentUp = (heartbeatJSON?.status === UP && notification.sendSilentUp);
let silentDown = (heartbeatJSON?.status === DOWN && notification.sendSilentDown);
let silent = (silentUp || silentDown);
let url = `${notification.host}/ocs/v2.php/apps/spreed/api/v1/bot/${notification.conversationToken}/message`;
let config = this.getAxiosConfigWithProxy({});
const data = {
message: msg,
silent
};
const options = {
...config,
headers: {
"X-Nextcloud-Talk-Bot-Random": talkRandom,
"X-Nextcloud-Talk-Bot-Signature": talkSignature,
"OCS-APIRequest": true,
}
};
try {
let result = await axios.post(url, data, options);
if (result?.status === 201) {
return okMsg;
}
throw new Error("Nextcloud Talk Error " + (result?.status ?? "Unknown"));
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = NextcloudTalk;

View File

@ -0,0 +1,54 @@
const { getMonitorRelativeURL, UP } = require("../../src/util");
const { setting } = require("../util-server");
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Notifery extends NotificationProvider {
name = "notifery";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
const url = "https://api.notifery.com/event";
let data = {
title: notification.notiferyTitle || "Uptime Kuma Alert",
message: msg,
};
if (notification.notiferyGroup) {
data.group = notification.notiferyGroup;
}
// Link to the monitor
const baseURL = await setting("primaryBaseURL");
if (baseURL && monitorJSON) {
data.message += `\n\nMonitor: ${baseURL}${getMonitorRelativeURL(monitorJSON.id)}`;
}
if (heartbeatJSON) {
data.code = heartbeatJSON.status === UP ? 0 : 1;
if (heartbeatJSON.ping) {
data.duration = heartbeatJSON.ping;
}
}
try {
const headers = {
"Content-Type": "application/json",
"x-api-key": notification.notiferyApiKey,
};
let config = this.getAxiosConfigWithProxy({ headers });
await axios.post(url, data, config);
return okMsg;
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Notifery;

View File

@ -1,5 +1,7 @@
const { Liquid } = require("liquidjs");
const { DOWN } = require("../../src/util");
const { HttpProxyAgent } = require("http-proxy-agent");
const { HttpsProxyAgent } = require("https-proxy-agent");
class NotificationProvider {
@ -61,7 +63,11 @@ class NotificationProvider {
* @returns {Promise<string>} rendered template
*/
async renderTemplate(template, msg, monitorJSON, heartbeatJSON) {
const engine = new Liquid();
const engine = new Liquid({
root: "./no-such-directory-uptime-kuma",
relativeReference: false,
dynamicPartials: false,
});
const parsedTpl = engine.parse(template);
// Let's start with dummy values to simplify code
@ -115,6 +121,30 @@ class NotificationProvider {
throw new Error(msg);
}
/**
* Returns axios config with proxy agent if proxy env is set.
* @param {object} axiosConfig - Axios config containing params
* @returns {object} Axios config
*/
getAxiosConfigWithProxy(axiosConfig = {}) {
const proxyEnv = process.env.notification_proxy || process.env.NOTIFICATION_PROXY;
if (proxyEnv) {
const proxyUrl = new URL(proxyEnv);
if (proxyUrl.protocol === "http:") {
axiosConfig.httpAgent = new HttpProxyAgent(proxyEnv);
axiosConfig.httpsAgent = new HttpsProxyAgent(proxyEnv);
} else if (proxyUrl.protocol === "https:") {
const agent = new HttpsProxyAgent(proxyEnv);
axiosConfig.httpAgent = agent;
axiosConfig.httpsAgent = agent;
}
axiosConfig.proxy = false;
}
return axiosConfig;
}
}
module.exports = NotificationProvider;

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