mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 08:42:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d652049af | ||
|
|
7af2a956d2 | ||
|
|
45b23a04c9 | ||
|
|
1a7c5990ae | ||
|
|
9133dac3d0 | ||
|
|
2709166a5b | ||
|
|
fcb5284d57 | ||
|
|
92d511253f | ||
|
|
9874c4d339 | ||
|
|
86e97c66b9 | ||
|
|
f2afed643e | ||
|
|
f502e3df76 | ||
|
|
7491da96e6 | ||
|
|
0b887689f0 | ||
|
|
9cce96c80c | ||
|
|
2711d27c03 | ||
|
|
db3f31aba5 | ||
|
|
29a7e3607f | ||
|
|
d7f6c2920d | ||
|
|
6bff7e86f6 | ||
|
|
a7a78a316e | ||
|
|
17ff74854a | ||
|
|
a101601cf4 | ||
|
|
1904192f55 | ||
|
|
7fd49ab55c | ||
|
|
3d7138ea30 | ||
|
|
835f8f17ec | ||
|
|
488c1afcab | ||
|
|
1fd59efe6c | ||
|
|
9cf0366aca | ||
|
|
15d7480547 | ||
|
|
819c55d691 | ||
|
|
67b4c5ebd8 | ||
|
|
4bd9fda728 | ||
|
|
33b8308b3f | ||
|
|
57cf9c243f | ||
|
|
f497a1e48b | ||
|
|
f23fa95a56 | ||
|
|
8e8033bb35 | ||
|
|
71b0903b0e | ||
|
|
d0eefe2dd2 | ||
|
|
2c547bab68 | ||
|
|
2dc04b5ecf | ||
|
|
e2dae1ec7b | ||
|
|
59d923e168 | ||
|
|
51844c27f1 | ||
|
|
53171affca | ||
|
|
d31d12e59f | ||
|
|
3715f2b3f9 | ||
|
|
cc3d54bff4 | ||
|
|
ef376a88b8 | ||
|
|
e639238f0e | ||
|
|
cf8241a77a | ||
|
|
eb87a4fc33 | ||
|
|
0727fb4343 | ||
|
|
d55d759fec | ||
|
|
aad279ce55 | ||
|
|
e9b1a6b2cd | ||
|
|
bd5a6934e1 | ||
|
|
2f04008453 | ||
|
|
fbaa410fd4 | ||
|
|
1d4f6f2814 | ||
|
|
ad2f5160fb | ||
|
|
d284405310 | ||
|
|
ff172ccbf4 | ||
|
|
14635948b6 | ||
|
|
012fd1dcc2 | ||
|
|
94fa5862b1 | ||
|
|
e7ec6c71f7 | ||
|
|
59b187b2a6 | ||
|
|
5e7cb0f9f1 | ||
|
|
dfe51b0dc5 | ||
|
|
7a4dd4bf66 | ||
|
|
0b34beea59 | ||
|
|
90ee9bc731 | ||
|
|
046f38e7c4 | ||
|
|
eb733cea45 | ||
|
|
eb68f0c629 | ||
|
|
1cbcac4a00 | ||
|
|
2d6d58edab | ||
|
|
c01fadbb48 | ||
|
|
41ef2e239d | ||
|
|
7176180343 | ||
|
|
0b9a01c8ac | ||
|
|
50ef62a64a | ||
|
|
384fa4bbe1 | ||
|
|
10f84a8052 | ||
|
|
53bd83af8b | ||
|
|
53577e21e7 | ||
|
|
46c0cb9bfb | ||
|
|
ac8ee8f9fa | ||
|
|
787e8bdd80 | ||
|
|
83a39e3143 | ||
|
|
5e2ed79929 | ||
|
|
c8f0ba926d | ||
|
|
17e0e14aef | ||
|
|
731602a80d | ||
|
|
37f0e449de | ||
|
|
56dd512d3c | ||
|
|
16f11b052a | ||
|
|
06bae7efcb | ||
|
|
39b017620f | ||
|
|
45e8a3a530 | ||
|
|
8dd64a9fa3 | ||
|
|
c1e8b0779f | ||
|
|
d16daf1862 | ||
|
|
491b092ac1 | ||
|
|
e2304c6b1b | ||
|
|
ea52f61a2c | ||
|
|
3db6edcc3d | ||
|
|
2045dd3f30 | ||
|
|
7a37c93a02 | ||
|
|
1d49300f8c | ||
|
|
f5641df0d9 | ||
|
|
b9c1e58703 | ||
|
|
22b9fada55 | ||
|
|
d34b286c4f | ||
|
|
6dab3c8450 | ||
|
|
32dd5c9414 | ||
|
|
2f09d28b94 | ||
|
|
77ce900bbf | ||
|
|
0675634b27 | ||
|
|
74db186ab3 | ||
|
|
1a5bc31b9b | ||
|
|
f6a94db702 | ||
|
|
6213ce7556 | ||
|
|
ff91e548b8 | ||
|
|
6d59070184 | ||
|
|
c8126e551f | ||
|
|
589eae0d3f | ||
|
|
8e25bc9ce3 | ||
|
|
9aa4dcc206 | ||
|
|
b2b5966699 | ||
|
|
e9c7e0031c | ||
|
|
1e3d29ffdb | ||
|
|
84670ac231 | ||
|
|
6b17c5c498 | ||
|
|
eed9820ce6 | ||
|
|
858d914ae3 | ||
|
|
be7c3812db | ||
|
|
44c65f1eb8 | ||
|
|
dd742c1038 | ||
|
|
d19b64ec1d | ||
|
|
6ea22e3241 | ||
|
|
ea99966e7a | ||
|
|
fa13d4d1f9 | ||
|
|
15e4f583e0 | ||
|
|
3fbbc7bdce | ||
|
|
5fcd1d313c | ||
|
|
967b7ab8a7 | ||
|
|
d113dde3cc | ||
|
|
60c24a5ee1 | ||
|
|
d5b8982763 |
No files matched your search
@@ -0,0 +1,109 @@
|
||||
name: Auto Merge Dev to Main
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize] # No longer need 'closed' trigger
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
# Job to enable Auto-Merge for the PR
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for open PRs from dev to main
|
||||
if: github.head_ref == 'dev' && github.base_ref == 'main'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Required for git history
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
- name: Get PR number
|
||||
id: get-pr
|
||||
run: |
|
||||
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for conflicts
|
||||
id: check-conflicts
|
||||
run: |
|
||||
# Ensure local branches are up-to-date
|
||||
git fetch origin main
|
||||
git fetch origin dev
|
||||
|
||||
# Use merge-base to find the common ancestor and check for conflicts
|
||||
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
|
||||
echo "has_conflicts=true" >> $GITHUB_OUTPUT
|
||||
echo "❌ Merge conflicts detected"
|
||||
else
|
||||
echo "has_conflicts=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ No merge conflicts"
|
||||
fi
|
||||
|
||||
- name: Add conflict comment
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "❌ **Merge Conflict Detection**
|
||||
|
||||
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
|
||||
|
||||
Steps to resolve conflicts:
|
||||
1. \`git checkout dev\`
|
||||
2. \`git merge main\`
|
||||
3. Resolve conflicts
|
||||
4. \`git commit\`
|
||||
5. \`git push origin dev\`"
|
||||
exit 1 # Exit to prevent further steps
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.check-conflicts.outputs.has_conflicts == 'false'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Enable auto-merge using a merge commit
|
||||
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--auto \
|
||||
--merge \
|
||||
--delete-branch=false
|
||||
|
||||
# Add success comment
|
||||
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
|
||||
--body "✅ **Auto-merge Enabled**
|
||||
|
||||
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
|
||||
|
||||
Merge method: Merge commit
|
||||
Branch retention: dev branch will be preserved"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
|
||||
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,9 +1,13 @@
|
||||
name: Build Binaries on Main Push
|
||||
name: Build Binaries on Main Push and PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Cleanup Old Packages
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Run every Sunday at midnight
|
||||
#schedule:
|
||||
# - cron: '0 0 * * 0' # Run every Sunday at midnight
|
||||
# 会清理掉正常的镜像
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
@@ -20,23 +21,41 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
|
||||
});
|
||||
// 分页获取所有包版本
|
||||
const allVersions = [];
|
||||
let page = 1;
|
||||
let fetched;
|
||||
const packageName = process.env.GITHUB_REPOSITORY.split('/')[1];
|
||||
do {
|
||||
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: packageName,
|
||||
page,
|
||||
per_page: 100
|
||||
});
|
||||
fetched = packages.length;
|
||||
allVersions.push(...packages);
|
||||
page++;
|
||||
} while (fetched === 100);
|
||||
|
||||
// Identify versions with only a single SHA tag (format: sha-<7 hexadecimal characters>)
|
||||
const shaOnlyVersions = packages.filter(pkg => {
|
||||
// 筛选仅有 SHA/sha256 标签或无标签的版本
|
||||
const shaOrNoTagVersions = allVersions.filter(pkg => {
|
||||
const tags = pkg.metadata.container.tags;
|
||||
return tags.length === 1 && tags[0].match(/^sha-[0-9a-f]{7}$/);
|
||||
return (
|
||||
(tags.length === 1 && (
|
||||
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
|
||||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
|
||||
)) ||
|
||||
tags.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
// Delete the SHA-only versions
|
||||
for (const version of shaOnlyVersions) {
|
||||
// 删除这些版本
|
||||
for (const version of shaOrNoTagVersions) {
|
||||
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
|
||||
package_type: 'container',
|
||||
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
|
||||
package_name: packageName,
|
||||
package_version_id: version.id
|
||||
});
|
||||
console.log(`Deleted package version ${version.id} with SHA tag ${version.metadata.container.tags[0]}`);
|
||||
}
|
||||
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Publish Docker Image
|
||||
name: Publish Docker Image on Main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
||||
@@ -96,3 +96,5 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
provenance: false
|
||||
@@ -7,6 +7,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
__debug*
|
||||
|
||||
# local development data
|
||||
komari.db
|
||||
|
||||
@@ -6,6 +6,8 @@ WORKDIR /app
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
RUN chmod +x /app/komari
|
||||
|
||||
@@ -4,27 +4,42 @@
|
||||
|
||||

|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md)
|
||||
|
||||
[文档](https://komari-monitor.github.io/komari-document/)
|
||||
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
|
||||
|
||||
## 特性
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
|
||||
|
||||
## 快速开始
|
||||
## Features
|
||||
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
|
||||
- **Self-hosted**: Complete control over data privacy, easy to deploy.
|
||||
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
|
||||
|
||||
### 依赖
|
||||
- Docker(快速部署)
|
||||
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
|
||||
## Quick Start
|
||||
|
||||
### Docker 部署
|
||||
1. 创建数据目录:
|
||||
### 0. One-click Deployment with Cloud Hosting
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- Claw Run - USD 1.5/month
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
|
||||
### 1. Use the One-click Install Script
|
||||
Suitable for distributions using systemd (Ubuntu, Debian...).
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker Deployment
|
||||
1. Create a data directory:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. 运行 Docker 容器:
|
||||
2. Run the Docker container:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
@@ -32,65 +47,73 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. 查看默认账号和密码:
|
||||
3. View the default username and password:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
|
||||
4. Access `http://<your_server_ip>:25774` in your browser.
|
||||
|
||||
> [!NOTE]
|
||||
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
|
||||
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
|
||||
### 二进制文件部署
|
||||
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
|
||||
2. 运行 Komari:
|
||||
### 3. Binary File Deployment
|
||||
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
|
||||
2. Run Komari:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
|
||||
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
|
||||
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
|
||||
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
|
||||
> [!NOTE]
|
||||
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
|
||||
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
|
||||
|
||||
|
||||
### 手工构建
|
||||
1. 构建前端静态文件:
|
||||
### Manual Build
|
||||
|
||||
#### Dependencies
|
||||
|
||||
- Go 1.18+ and Node.js 20+ (for manual build)
|
||||
|
||||
1. Build the frontend static files:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. 构建后端:
|
||||
2. Build the backend:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
|
||||
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 运行:
|
||||
4. Run:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
默认监听 `25774` 端口,访问 `http://localhost:25774`。
|
||||
The default listening port is `25774`. Access `http://localhost:25774`.
|
||||
|
||||
## 前端开发指南
|
||||
[Komari 主题开发指南 | Komari](https://komari-monitor.github.io/komari-document/dev/theme.html)
|
||||
## Frontend Development Guide
|
||||
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
## 客户端 Agent 开发指南
|
||||
[Komari Agent 信息上报与事件处理文档](https://komari-monitor.github.io/komari-document/dev/agent.html)
|
||||
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## 贡献
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
## Client Agent Development Guide
|
||||
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 鸣谢
|
||||
## Contributing
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
## Acknowledgements
|
||||
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
- 感谢我自己能这么闲
|
||||
- 提交PR、制作主题的各位开发者
|
||||
- Thanks to myself for being so idle
|
||||
- All the developers who submitted PRs and created themes
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
|
||||
@@ -10,7 +10,14 @@ import (
|
||||
|
||||
func AdminAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// session-based authentication
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
@@ -119,3 +120,14 @@ func GetVersion(c *gin.Context) {
|
||||
"hash": utils.VersionHash,
|
||||
})
|
||||
}
|
||||
|
||||
func isApiKeyValid(apiKey string) bool {
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
|
||||
return false
|
||||
}
|
||||
return apiKey == "Bearer "+cfg.ApiKey
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
publicPaths = []string{
|
||||
"/ping",
|
||||
"/api/public",
|
||||
"/api/login",
|
||||
"/api/me",
|
||||
"/api/oauth",
|
||||
"/api/oauth_callback",
|
||||
"/api/version",
|
||||
"/api/recent",
|
||||
"/api/admin", // 由AdminAuthMiddleware处理
|
||||
"/api/clients/", // 由TokenAuthMiddleware处理
|
||||
}
|
||||
)
|
||||
|
||||
func PrivateSiteMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 如果是公开的路径,直接放行
|
||||
for _, path := range publicPaths {
|
||||
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
// 如果不是 /api,直接放行
|
||||
if len(c.Request.URL.Path) < 4 || c.Request.URL.Path[:4] != "/api" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 验证私有, 如果不是私有站点,直接放行
|
||||
if !conf.PrivateSite {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// 如果是私有站点,检查是否有 session
|
||||
session, err := c.Cookie("session_token")
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
_, err = accounts.GetSession(session)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ import (
|
||||
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
|
||||
func TokenAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// API key authentication
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if isApiKeyValid(apiKey) {
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
var token string
|
||||
|
||||
// Step 1: Check query parameter for token
|
||||
|
||||
@@ -68,6 +68,7 @@ func RemoveClient(c *gin.Context) {
|
||||
user_uuid, _ := c.Get("uuid")
|
||||
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
ws.DeleteConnectedClients(uuid)
|
||||
ws.DeleteLatestReport(uuid)
|
||||
}
|
||||
|
||||
|
||||
+110
-13
@@ -14,18 +14,92 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
)
|
||||
|
||||
// copyDatabaseToTemp 安全地复制数据库文件到临时目录
|
||||
// 对于SQLite数据库,会使用BACKUP命令确保数据一致性
|
||||
func copyDatabaseToTemp(dbFilePath, tempDir string) (string, error) {
|
||||
if dbFilePath == "" {
|
||||
return "", fmt.Errorf("database file path is empty")
|
||||
}
|
||||
|
||||
// 检查数据库文件是否存在
|
||||
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("database file does not exist: %s", dbFilePath)
|
||||
}
|
||||
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
tempDbPath := filepath.Join(tempDir, dbFileName)
|
||||
|
||||
// 如果是SQLite数据库,使用SQLite的BACKUP命令来确保一致性
|
||||
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
// 获取底层的SQLite连接
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get underlying database connection: %v", err)
|
||||
}
|
||||
|
||||
// 使用SQLite的BACKUP API来创建一致的备份
|
||||
backupSQL := fmt.Sprintf("BACKUP DATABASE main TO '%s'", tempDbPath)
|
||||
_, err = sqlDB.Exec(backupSQL)
|
||||
if err != nil {
|
||||
// 如果BACKUP命令失败,回退到文件复制方法
|
||||
log.Printf("SQLite BACKUP command failed, falling back to file copy: %v", err)
|
||||
return copyFileToTemp(dbFilePath, tempDbPath)
|
||||
}
|
||||
|
||||
log.Printf("Database backed up using SQLite BACKUP to: %s", tempDbPath)
|
||||
return tempDbPath, nil
|
||||
} else {
|
||||
// 对于非SQLite数据库,直接复制文件
|
||||
return copyFileToTemp(dbFilePath, tempDbPath)
|
||||
}
|
||||
}
|
||||
|
||||
// copyFileToTemp 简单的文件复制到临时目录
|
||||
func copyFileToTemp(srcPath, destPath string) (string, error) {
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open source file: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create destination file: %v", err)
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
_, err = io.Copy(dest, src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to copy file: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Database file copied to: %s", destPath)
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
|
||||
func DownloadBackup(c *gin.Context) {
|
||||
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
|
||||
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
|
||||
c.Writer.Header().Set("Content-Type", "application/zip")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
|
||||
|
||||
zipWriter := zip.NewWriter(c.Writer)
|
||||
defer zipWriter.Close()
|
||||
|
||||
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
|
||||
// 创建临时目录用于存放数据库备份
|
||||
tempDir, err := os.MkdirTemp("", "komari-backup-*")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // 清理临时目录
|
||||
|
||||
err = filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,27 +170,32 @@ func DownloadBackup(c *gin.Context) {
|
||||
|
||||
// 如果数据库文件的绝对路径不是以 ./data 目录的绝对路径开头,则单独添加它
|
||||
if !strings.HasPrefix(dbAbsPath, dataAbsPath) {
|
||||
dbFileName := filepath.Base(dbFilePath)
|
||||
dbInfo, err := os.Stat(dbFilePath)
|
||||
// 使用安全的数据库复制方法
|
||||
tempDbPath, err := copyDatabaseToTemp(dbFilePath, tempDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
|
||||
return // 数据库不存在是正常情况,直接返回
|
||||
} else {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
|
||||
return
|
||||
}
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !dbInfo.IsDir() {
|
||||
file, err := os.Open(dbFilePath)
|
||||
} else {
|
||||
// 将临时数据库文件添加到zip
|
||||
dbInfo, err := os.Stat(tempDbPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating temp database file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(tempDbPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening temp database file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: dbFileName,
|
||||
Name: filepath.Base(dbFilePath), // 使用原始数据库文件名
|
||||
Method: zip.Deflate,
|
||||
Modified: dbInfo.ModTime(),
|
||||
})
|
||||
@@ -135,5 +214,23 @@ func DownloadBackup(c *gin.Context) {
|
||||
log.Printf("Database file '%s' is within './data', skipping explicit addition.\n", dbFilePath)
|
||||
}
|
||||
|
||||
// 添加备份标记文件
|
||||
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format(time.RFC3339)
|
||||
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: "komari-backup-markup",
|
||||
Method: zip.Deflate,
|
||||
Modified: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = markupWriter.Write([]byte(markupContent))
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// zipWriter.Close() 由 defer 自动调用
|
||||
}
|
||||
+32
-12
@@ -2,11 +2,13 @@ package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
@@ -21,16 +23,25 @@ func Exec(c *gin.Context) {
|
||||
Clients []string `json:"clients" binding:"required"`
|
||||
}
|
||||
var onlineClients []string
|
||||
var offlineClients []string
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
for uuid := range ws.GetConnectedClients() {
|
||||
if contain(req.Clients, uuid) {
|
||||
// for uuid := range ws.GetConnectedClients() {
|
||||
// if contain(req.Clients, uuid) {
|
||||
// onlineClients = append(onlineClients, uuid)
|
||||
// }
|
||||
// // else {
|
||||
// // api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
// // return
|
||||
// // }
|
||||
// }
|
||||
for _, uuid := range req.Clients {
|
||||
if client := ws.GetConnectedClients()[uuid]; client != nil {
|
||||
onlineClients = append(onlineClients, uuid)
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client not connected: "+uuid)
|
||||
return
|
||||
offlineClients = append(offlineClients, uuid)
|
||||
}
|
||||
}
|
||||
if len(onlineClients) == 0 {
|
||||
@@ -38,7 +49,7 @@ func Exec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
taskId := utils.GenerateRandomString(16)
|
||||
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
|
||||
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
|
||||
api.RespondError(c, 500, "Failed to create task: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -55,7 +66,10 @@ func Exec(c *gin.Context) {
|
||||
payload, _ := json.Marshal(send)
|
||||
client := ws.GetConnectedClients()[uuid]
|
||||
if client != nil {
|
||||
client.WriteMessage(websocket.TextMessage, payload)
|
||||
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
api.RespondError(c, 400, "Client connection is broke: "+uuid)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
api.RespondError(c, 400, "Client connection is null: "+uuid)
|
||||
return
|
||||
@@ -67,12 +81,18 @@ func Exec(c *gin.Context) {
|
||||
"task_id": taskId,
|
||||
"clients": onlineClients,
|
||||
})
|
||||
}
|
||||
func contain(clients []string, uuid string) bool {
|
||||
for _, client := range clients {
|
||||
if client == uuid {
|
||||
return true
|
||||
if len(offlineClients) > 0 {
|
||||
for _, uuid := range offlineClients {
|
||||
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// func contain(clients []string, uuid string) bool {
|
||||
// for _, client := range clients {
|
||||
// if client == uuid {
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
@@ -0,0 +1,63 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
func GetMessageSenderProvider(c *gin.Context) {
|
||||
provider := c.Query("provider")
|
||||
if provider != "" {
|
||||
// 如果指定了provider,返回单个提供者的配置
|
||||
config, err := database.GetMessageSenderConfigByName(provider)
|
||||
if err != nil {
|
||||
api.RespondError(c, 404, "Provider not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
// 否则返回所有提供者的配置项模板
|
||||
providers := factory.GetSenderConfigs()
|
||||
if len(providers) == 0 {
|
||||
api.RespondError(c, 404, "No message sender providers found")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, providers)
|
||||
}
|
||||
|
||||
func SetMessageSenderProvider(c *gin.Context) {
|
||||
var senderConfig models.MessageSenderProvider
|
||||
if err := c.ShouldBindJSON(&senderConfig); err != nil {
|
||||
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if senderConfig.Name == "" {
|
||||
api.RespondError(c, 400, "Provider name is required")
|
||||
return
|
||||
}
|
||||
_, exists := factory.GetConstructor(senderConfig.Name)
|
||||
if !exists {
|
||||
api.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
|
||||
return
|
||||
}
|
||||
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
|
||||
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
// 正在使用,重载
|
||||
if cfg.NotificationMethod == senderConfig.Name {
|
||||
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
|
||||
}
|
||||
@@ -3,7 +3,12 @@ package admin
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
)
|
||||
|
||||
func BindingExternalAccount(c *gin.Context) {
|
||||
@@ -33,3 +38,55 @@ func UnbindExternalAccount(c *gin.Context) {
|
||||
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func GetOidcProvider(c *gin.Context) {
|
||||
provider := c.Query("provider")
|
||||
if provider != "" {
|
||||
// 如果指定了provider,返回单个提供者的配置
|
||||
config, err := database.GetOidcConfigByName(provider)
|
||||
if err != nil {
|
||||
api.RespondError(c, 404, "Provider not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
// 否则返回所有提供者的配置
|
||||
providers := factory.GetProviderConfigs()
|
||||
if len(providers) == 0 {
|
||||
api.RespondError(c, 404, "No OIDC providers found")
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, providers)
|
||||
}
|
||||
|
||||
func SetOidcProvider(c *gin.Context) {
|
||||
var oidcConfig models.OidcProvider
|
||||
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
|
||||
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if oidcConfig.Name == "" {
|
||||
api.RespondError(c, 400, "Provider name is required")
|
||||
return
|
||||
}
|
||||
_, exists := factory.GetConstructor(oidcConfig.Name)
|
||||
if !exists {
|
||||
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
|
||||
return
|
||||
}
|
||||
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
|
||||
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
// 正在使用,重载
|
||||
if cfg.OAuthProvider == oidcConfig.Name {
|
||||
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package admin
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
@@ -281,3 +284,336 @@ func isValidThemeShort(short string) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// downloadThemeFromURL 从URL下载主题文件
|
||||
func downloadThemeFromURL(url string) ([]byte, error) {
|
||||
// 发送HTTP GET请求
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载主题文件失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载主题文件失败,HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取主题文件内容失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("下载的主题文件为空")
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// getGitHubReleaseDownloadURL 从GitHub API获取最新release的下载链接
|
||||
// 该函数通过GitHub API获取指定仓库最新release的资源下载链接
|
||||
// 参考API: https://api.github.com/repos/{owner}/{repo}/releases/latest
|
||||
// 参数:
|
||||
// - owner: GitHub仓库所有者
|
||||
// - repo: GitHub仓库名称
|
||||
//
|
||||
// 返回:
|
||||
// - 最新release的第一个资源的下载链接
|
||||
// - 错误信息(如果有)
|
||||
func getGitHubReleaseDownloadURL(owner, repo string) (string, error) {
|
||||
if owner == "" || repo == "" {
|
||||
return "", errors.New("GitHub仓库所有者和仓库名称不能为空")
|
||||
}
|
||||
|
||||
// 构建GitHub API URL
|
||||
// 使用GitHub API获取最新release信息
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||
|
||||
// 发送HTTP GET请求
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取GitHub release信息失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("获取GitHub release信息失败,HTTP状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析JSON响应
|
||||
// GitHub API返回的JSON包含assets数组,每个asset包含browser_download_url字段
|
||||
var releaseInfo struct {
|
||||
Assets []struct {
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
|
||||
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查是否有可下载的资源
|
||||
if len(releaseInfo.Assets) == 0 {
|
||||
return "", errors.New("GitHub release中没有可下载的资源")
|
||||
}
|
||||
|
||||
// 返回第一个资源的下载链接
|
||||
// 相当于shell命令: curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url"
|
||||
return releaseInfo.Assets[0].BrowserDownloadURL, nil
|
||||
}
|
||||
|
||||
// isGitHubRepoURL 检查URL是否是GitHub仓库地址
|
||||
// 支持的格式:
|
||||
// - https://github.com/owner/repo
|
||||
// - https://github.com/owner/repo.git
|
||||
// - https://www.github.com/owner/repo
|
||||
// - http://github.com/owner/repo
|
||||
// 返回:
|
||||
// - 是否是GitHub仓库URL
|
||||
// - 仓库所有者
|
||||
// - 仓库名称
|
||||
func isGitHubRepoURL(urlStr string) (bool, string, string) {
|
||||
if urlStr == "" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 检查URL是否包含github.com
|
||||
if !strings.Contains(strings.ToLower(urlStr), "github.com") {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 解析URL
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 检查主机名是否是github.com或www.github.com
|
||||
hostname := strings.ToLower(parsedURL.Host)
|
||||
if hostname != "github.com" && hostname != "www.github.com" {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// 解析路径部分,提取owner和repo
|
||||
// 路径格式应该是 /owner/repo 或 /owner/repo.git
|
||||
path := strings.TrimPrefix(parsedURL.Path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 2 {
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
owner := parts[0]
|
||||
repo := parts[1]
|
||||
|
||||
// 如果repo以.git结尾,去掉这个后缀
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
|
||||
return true, owner, repo
|
||||
}
|
||||
|
||||
// UpdateTheme 更新主题
|
||||
// 支持四种更新方式:
|
||||
// 1. 使用主题原有URL下载更新
|
||||
// 2. 提供新的直接下载URL进行更新
|
||||
// 3. 提供GitHub仓库信息,从最新release下载更新
|
||||
// 4. 如果主题URL是GitHub仓库地址,自动获取最新release
|
||||
func UpdateTheme(c *gin.Context) {
|
||||
var req struct {
|
||||
Short string `json:"short" binding:"required"` // 主题短名称
|
||||
URL string `json:"url"` // 新的URL地址(可选)
|
||||
GitOwner string `json:"git_owner"` // GitHub仓库所有者(可选)
|
||||
GitRepo string `json:"git_repo"` // GitHub仓库名称(可选)
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查主题是否存在
|
||||
themeDir := filepath.Join("./data/theme", req.Short)
|
||||
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
|
||||
|
||||
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
|
||||
api.RespondError(c, http.StatusNotFound, "主题不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 加载现有主题配置
|
||||
themeInfo, err := loadThemeConfig(themeConfigPath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 方式1和方式4: 尝试从原始URL下载主题
|
||||
// 如果原始URL是GitHub仓库地址,则自动获取最新release
|
||||
var themeData []byte
|
||||
// 不保存下载链接,更新后由主题覆盖
|
||||
//var downloadURL string
|
||||
// var err2 error
|
||||
|
||||
if themeInfo.URL != "" {
|
||||
// 检查原始URL是否是GitHub仓库地址
|
||||
// 例如: https://github.com/owner/repo
|
||||
isGitHub, owner, repo := isGitHubRepoURL(themeInfo.URL)
|
||||
if isGitHub {
|
||||
// 方式4: 如果原始URL是GitHub仓库地址,自动获取最新release
|
||||
// 这是本次需求的核心功能:当主题文件中现有的url地址如果是github仓库的路径,则直接引用该url地址去下载最新的release
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err == nil {
|
||||
// 使用获取到的GitHub release下载链接下载主题
|
||||
themeData, _ = downloadThemeFromURL(gitHubURL)
|
||||
//if err2 == nil {
|
||||
// 注意:这里我们保存的是release的下载链接,而不是GitHub仓库地址
|
||||
// 这样做是为了在下载成功后,将这个具体的release下载链接保存到主题配置中
|
||||
// 但在下次更新时,我们仍然会检测到这是一个GitHub仓库,并获取最新的release
|
||||
// downloadURL = gitHubURL
|
||||
//}
|
||||
}
|
||||
} else {
|
||||
// 原始URL不是GitHub仓库地址,直接尝试下载(方式1)
|
||||
themeData, _ = downloadThemeFromURL(themeInfo.URL)
|
||||
//if err2 == nil {
|
||||
// downloadURL = themeInfo.URL
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果原始URL下载失败,尝试其他方式下载
|
||||
if themeData == nil || len(themeData) == 0 {
|
||||
// 方式3: 如果提供了GitHub仓库信息,尝试从GitHub最新release下载
|
||||
// 这种方式允许用户只需提供owner和repo信息,系统会自动获取最新release的下载链接
|
||||
if req.GitOwner != "" && req.GitRepo != "" {
|
||||
// 从GitHub API获取下载链接
|
||||
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存下载链接,稍后更新到主题配置中
|
||||
// downloadURL = gitHubURL
|
||||
} else if req.URL != "" {
|
||||
// 方式2: 如果提供了新URL,尝试从新URL下载
|
||||
// 检查新URL是否是GitHub仓库地址
|
||||
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
|
||||
if isGitHub {
|
||||
// 如果新URL是GitHub仓库地址,获取最新release
|
||||
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
|
||||
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 使用获取到的链接下载主题
|
||||
themeData, err = downloadThemeFromURL(gitHubURL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
|
||||
// 这是一个重要的设计决策:我们保存的是GitHub仓库URL,而不是具体的release下载链接
|
||||
// 这样在下次更新时,系统会再次检测到这是GitHub仓库,并自动获取最新的release
|
||||
// downloadURL = req.URL
|
||||
} else {
|
||||
// 新URL不是GitHub仓库地址,直接尝试下载
|
||||
themeData, err = downloadThemeFromURL(req.URL)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
// downloadURL = req.URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有成功下载主题数据
|
||||
if themeData == nil || len(themeData) == 0 {
|
||||
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
|
||||
return
|
||||
}
|
||||
|
||||
// 到这里,我们已经成功获取了主题数据,可能是通过以下四种方式之一:
|
||||
// 1. 原始URL直接下载
|
||||
// 2. 原始URL是GitHub仓库,自动获取最新release下载
|
||||
// 3. 用户提供的新URL下载
|
||||
// 4. 用户提供的GitHub仓库信息,获取最新release下载
|
||||
|
||||
// 临时文件名
|
||||
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
|
||||
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.Remove(tempFile)
|
||||
|
||||
// 解压ZIP文件并验证
|
||||
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 如果下载URL与原始URL不同,更新主题配置中的URL
|
||||
// if downloadURL != themeInfo.URL {
|
||||
// updatedThemeInfo.URL = downloadURL
|
||||
|
||||
// // 更新主题配置文件
|
||||
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
|
||||
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
|
||||
// if err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
|
||||
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
|
||||
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
|
||||
}
|
||||
|
||||
func UpdateThemeSettings(c *gin.Context) {
|
||||
theme := c.Query("theme")
|
||||
if theme == "" || theme == "default" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
|
||||
return
|
||||
}
|
||||
|
||||
var req map[string]any
|
||||
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
data, err := json.Marshal(&req)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var themeCfg models.ThemeConfiguration
|
||||
db.Where("short = ?", theme).
|
||||
Assign(models.ThemeConfiguration{Short: theme, Data: string(data)}).
|
||||
FirstOrCreate(&themeCfg)
|
||||
api.RespondSuccess(c, nil)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
)
|
||||
|
||||
// 只有一个备份恢复操作在进行
|
||||
var restoreMutex sync.Mutex
|
||||
|
||||
// UploadBackup 用于接收上传的备份文件并将其内容恢复到原始位置
|
||||
func UploadBackup(c *gin.Context) {
|
||||
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
|
||||
if !restoreMutex.TryLock() {
|
||||
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
|
||||
return
|
||||
}
|
||||
defer restoreMutex.Unlock()
|
||||
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("backup")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查文件是否为zip格式
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
||||
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
|
||||
return
|
||||
}
|
||||
|
||||
// 创建临时文件保存上传的zip
|
||||
tempFile, err := os.CreateTemp("", "backup-*.zip")
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
|
||||
return
|
||||
}
|
||||
tempFilePath := tempFile.Name()
|
||||
defer os.Remove(tempFilePath) // 确保临时文件最终被删除
|
||||
|
||||
// 将上传的文件内容复制到临时文件
|
||||
_, err = io.Copy(tempFile, file)
|
||||
if err != nil {
|
||||
tempFile.Close()
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
|
||||
return
|
||||
}
|
||||
tempFile.Close() // 关闭文件以便后续操作
|
||||
|
||||
// 打开zip文件准备解压
|
||||
zipReader, err := zip.OpenReader(tempFilePath)
|
||||
if err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
|
||||
return
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
// 检查是否包含备份标记文件
|
||||
hasMarkupFile := false
|
||||
for _, zipFile := range zipReader.File {
|
||||
if zipFile.Name == "komari-backup-markup" {
|
||||
hasMarkupFile = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMarkupFile {
|
||||
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
|
||||
return
|
||||
}
|
||||
|
||||
// 确保data目录存在
|
||||
if err := os.MkdirAll("./data", 0755); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取数据库文件名
|
||||
dbFileName := filepath.Base(flags.DatabaseFile)
|
||||
|
||||
// 解压文件
|
||||
for _, zipFile := range zipReader.File {
|
||||
// 检查文件路径是否安全(防止路径遍历攻击)
|
||||
filePath := zipFile.Name
|
||||
if strings.Contains(filePath, "..") {
|
||||
log.Printf("Potentially unsafe path in zip: %s, skipping", filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过备份标记文件
|
||||
if filePath == "komari-backup-markup" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 确定目标路径
|
||||
var destPath string
|
||||
if filePath == dbFileName {
|
||||
// 如果是数据库文件,恢复到数据库文件路径
|
||||
destPath = flags.DatabaseFile
|
||||
} else {
|
||||
// 其他文件恢复到data目录
|
||||
destPath = filepath.Join("./data", filePath)
|
||||
}
|
||||
|
||||
// 如果是目录,创建目录
|
||||
if zipFile.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||
log.Printf("Error creating directory %s: %v", destPath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 确保目标文件的目录存在
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
log.Printf("Error creating directory %s: %v", destDir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 打开zip中的文件
|
||||
srcFile, err := zipFile.Open()
|
||||
if err != nil {
|
||||
log.Printf("Error opening file from zip %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建目标文件
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
srcFile.Close()
|
||||
log.Printf("Error creating file %s: %v", destPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 复制内容
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
srcFile.Close()
|
||||
destFile.Close()
|
||||
if err != nil {
|
||||
log.Printf("Error extracting file %s: %v", destPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 保持原始文件的修改时间
|
||||
if err := os.Chtimes(destPath, zipFile.Modified, zipFile.Modified); err != nil {
|
||||
log.Printf("Error setting file time for %s: %v", destPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "success",
|
||||
"message": "Backup restored successfully. The service will restart shortly.",
|
||||
})
|
||||
|
||||
go func() {
|
||||
log.Println("Backup restored, restarting service in 2 seconds...")
|
||||
time.Sleep(2 * time.Second)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
func RegisterClient(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.AutoDiscoveryKey == "" ||
|
||||
len(cfg.AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+cfg.AutoDiscoveryKey != auth {
|
||||
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
name = utils.GenerateRandomString(8)
|
||||
}
|
||||
name = "Auto-" + name
|
||||
uuid, token, err := clients.CreateClientWithName(name)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to create client: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
|
||||
}
|
||||
+76
-55
@@ -22,6 +22,12 @@ import (
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
const (
|
||||
// 如果超过这个时间没有收到任何消息,则认为连接已死
|
||||
// 因为目前server没有存agent的信息上报间隔。只有写一个默认的
|
||||
readWait = 11 * time.Second
|
||||
)
|
||||
|
||||
func UploadReport(c *gin.Context) {
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
@@ -51,7 +57,7 @@ func UploadReport(c *gin.Context) {
|
||||
}
|
||||
// Update report with method and token
|
||||
|
||||
ws.GetLatestReport()[report.UUID] = &report
|
||||
ws.SetLatestReport(report.UUID, &report)
|
||||
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
|
||||
c.JSON(200, gin.H{"status": "success"})
|
||||
@@ -65,7 +71,7 @@ func WebSocketReport(c *gin.Context) {
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
@@ -109,72 +115,87 @@ func WebSocketReport(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许一个客户端的连接
|
||||
if _, exists := ws.GetConnectedClients()[uuid]; exists {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
|
||||
return
|
||||
// 接受新连接,并处理旧连接
|
||||
if oldConn, exists := ws.GetConnectedClients()[uuid]; exists {
|
||||
log.Printf("Client %s is reconnecting. Closing the old connection.", uuid)
|
||||
|
||||
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
|
||||
go oldConn.Close()
|
||||
}
|
||||
ws.SetConnectedClients(uuid, conn)
|
||||
go notifier.OnlineNotification(uuid)
|
||||
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
|
||||
go notifier.OnlineNotification(uuid, conn.ID)
|
||||
defer func() {
|
||||
ws.DeleteConnectedClients(uuid)
|
||||
notifier.OfflineNotification(uuid)
|
||||
ws.DeleteClientConditionally(uuid, conn)
|
||||
notifier.OfflineNotification(uuid, conn.ID)
|
||||
}()
|
||||
|
||||
// 首先处理第一次ws conn收到的消息
|
||||
processMessage(conn, message, uuid)
|
||||
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(readWait))
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("Client %s connection error: %v", uuid, err)
|
||||
}
|
||||
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
|
||||
}
|
||||
processMessage(conn, message, uuid)
|
||||
}
|
||||
}
|
||||
|
||||
type MessageType struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
var msgType MessageType
|
||||
err = json.Unmarshal(message, &msgType)
|
||||
// 将消息处理逻辑提取到一个函数中,方便复用
|
||||
func processMessage(conn *ws.SafeConn, message []byte, uuid string) {
|
||||
type MessageType struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
var msgType MessageType
|
||||
err := json.Unmarshal(message, &msgType)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
switch msgType.Type {
|
||||
case "", "report":
|
||||
report := common.Report{}
|
||||
err = json.Unmarshal(message, &report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
|
||||
continue
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
|
||||
return
|
||||
}
|
||||
switch msgType.Type {
|
||||
case "", "report":
|
||||
report := common.Report{}
|
||||
err = json.Unmarshal(message, &report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
|
||||
continue
|
||||
}
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
|
||||
continue
|
||||
}
|
||||
ws.SetLatestReport(uuid, &report)
|
||||
case "ping_result":
|
||||
var reqBody struct {
|
||||
PingTaskID uint `json:"task_id"`
|
||||
PingResult int `json:"value"`
|
||||
PingType string `json:"ping_type"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
}
|
||||
err = json.Unmarshal(message, &reqBody)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
|
||||
continue
|
||||
}
|
||||
pingResult := models.PingRecord{
|
||||
Client: uuid,
|
||||
TaskId: reqBody.PingTaskID,
|
||||
Value: reqBody.PingResult,
|
||||
Time: models.FromTime(reqBody.FinishedAt),
|
||||
}
|
||||
tasks.SavePingRecord(pingResult)
|
||||
default:
|
||||
log.Printf("Unknown message type: %s", msgType.Type)
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
|
||||
report.UpdatedAt = time.Now()
|
||||
err = SaveClientReport(uuid, report)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
ws.SetLatestReport(uuid, &report)
|
||||
case "ping_result":
|
||||
var reqBody struct {
|
||||
PingTaskID uint `json:"task_id"`
|
||||
PingResult int `json:"value"`
|
||||
PingType string `json:"ping_type"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
}
|
||||
err = json.Unmarshal(message, &reqBody)
|
||||
if err != nil {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
|
||||
return
|
||||
}
|
||||
pingResult := models.PingRecord{
|
||||
Client: uuid,
|
||||
TaskId: reqBody.PingTaskID,
|
||||
Value: reqBody.PingResult,
|
||||
Time: models.FromTime(reqBody.FinishedAt),
|
||||
}
|
||||
tasks.SavePingRecord(pingResult)
|
||||
default:
|
||||
log.Printf("Unknown message type: %s", msgType.Type)
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
@@ -26,7 +27,7 @@ func TaskResult(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
|
||||
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, models.FromTime(req.FinishedAt)); err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func EstablishConnection(c *gin.Context) {
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // TODO: 检查源站
|
||||
return true // 被控
|
||||
},
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
|
||||
@@ -10,6 +10,18 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getClientIPType(ip net.IP) int {
|
||||
// 0:ipv4 1:ipv6 -1:错误的输入
|
||||
if ip == nil {
|
||||
return -1
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func UploadBasicInfo(c *gin.Context) {
|
||||
var cbi = map[string]interface{}{}
|
||||
if err := c.ShouldBindJSON(&cbi); err != nil {
|
||||
@@ -26,6 +38,28 @@ func UploadBasicInfo(c *gin.Context) {
|
||||
|
||||
cbi["uuid"] = uuid
|
||||
|
||||
if (func() bool {
|
||||
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
|
||||
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})() {
|
||||
ipStr := c.ClientIP()
|
||||
ip := net.ParseIP(ipStr)
|
||||
ipType := getClientIPType(ip)
|
||||
|
||||
switch ipType {
|
||||
case 0:
|
||||
cbi["ipv4"] = ip
|
||||
case 1:
|
||||
cbi["ipv6"] = ip
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
|
||||
@@ -2,14 +2,43 @@ package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetClientRecentRecords(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
if uuid == "" {
|
||||
RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err := accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
hiddenMap := map[string]bool{}
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
|
||||
if hiddenMap[uuid] {
|
||||
RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
records, _ := Records.Get(uuid)
|
||||
RespondSuccess(c, records)
|
||||
}
|
||||
+9
-7
@@ -48,13 +48,15 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
// 2FA
|
||||
user, _ := accounts.GetUserByUUID(uuid)
|
||||
if user.TwoFactor != "" && data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
if user.TwoFactor != "" { // 开启了2FA
|
||||
if data.TwoFa == "" {
|
||||
RespondError(c, http.StatusUnauthorized, "2FA code is required")
|
||||
return
|
||||
}
|
||||
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
|
||||
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Create session
|
||||
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
|
||||
|
||||
+17
-4
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
)
|
||||
|
||||
@@ -11,16 +12,28 @@ func GetNodesInformation(c *gin.Context) {
|
||||
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
|
||||
return
|
||||
}
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err = accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
count := len(clientList)
|
||||
// 公开信息不展示IP地址,私有备注不展示
|
||||
for i := 0; i < count; i++ {
|
||||
// 过滤掉 Hidden 的客户端,并清理需要隐藏的字段
|
||||
j := 0
|
||||
for i := 0; i < len(clientList); i++ {
|
||||
if clientList[i].Hidden && !isLogin { // 不返回 Hidden 客户端
|
||||
continue
|
||||
}
|
||||
clientList[i].IPv4 = ""
|
||||
clientList[i].IPv6 = ""
|
||||
clientList[i].Remark = ""
|
||||
clientList[i].Remark = "" // 私有备注不展示
|
||||
clientList[i].Version = ""
|
||||
clientList[i].Token = ""
|
||||
clientList[j] = clientList[i]
|
||||
j++
|
||||
}
|
||||
clientList = clientList[:j]
|
||||
|
||||
RespondSuccess(c, clientList)
|
||||
}
|
||||
+30
-16
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ func OAuth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
authURL, state := oauth.GetOAuthUrl()
|
||||
authURL, state := oauth.CurrentProvider().GetAuthorizationURL(utils.GetCallbackURL(c))
|
||||
|
||||
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
|
||||
|
||||
@@ -30,26 +31,39 @@ func OAuthCallback(c *gin.Context) {
|
||||
// 验证state防止CSRF攻击
|
||||
state, _ := c.Cookie("oauth_state")
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
|
||||
if state == "" || state != c.Query("state") {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
|
||||
return
|
||||
|
||||
// 获取当前OAuth提供商名称
|
||||
providerName := oauth.CurrentProvider().GetName()
|
||||
|
||||
// 对于QQ登录,由于是通过QQ聚合登录平台中转,state可能会不匹配
|
||||
// 但我们仍然需要验证state的存在性(不能是空的)
|
||||
if providerName == "qq" {
|
||||
if state == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 对于其他提供商,严格验证state匹配
|
||||
if state == "" || state != c.Query("state") {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取code
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
|
||||
return
|
||||
queries := make(map[string]string)
|
||||
for key, values := range c.Request.URL.Query() {
|
||||
if len(values) > 0 {
|
||||
queries[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
githubUser, err := oauth.OAuthCallback(code, state)
|
||||
oidcUser, err := oauth.CurrentProvider().OnCallback(c.Request.Context(), state, queries, utils.GetCallbackURL(c))
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用GitHub ID作为SSO ID
|
||||
sso_id := fmt.Sprintf("github_%d", githubUser.ID)
|
||||
// ID作为SSO ID
|
||||
sso_id := fmt.Sprintf("%s_%s", oauth.CurrentProvider().GetName(), oidcUser.UserId)
|
||||
|
||||
// 如果cookie中有binding_external_account,说明是绑定外部账号
|
||||
// 否则是登录
|
||||
@@ -78,7 +92,7 @@ func OAuthCallback(c *gin.Context) {
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{
|
||||
"status": "error",
|
||||
"message": "please log in and bind your GitHub account first.",
|
||||
"message": "please log in and bind your external account first.",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -93,5 +107,5 @@ func OAuthCallback(c *gin.Context) {
|
||||
// 设置cookie并返回
|
||||
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
|
||||
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
|
||||
c.Redirect(302, "/manage")
|
||||
}
|
||||
c.Redirect(302, "/admin")
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
@@ -11,6 +19,70 @@ func GetPublicSettings(c *gin.Context) {
|
||||
RespondError(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
tc := models.ThemeConfiguration{}
|
||||
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
|
||||
if err != nil {
|
||||
tc.Data = "{}"
|
||||
}
|
||||
tc_data := gin.H{}
|
||||
err = json.Unmarshal([]byte(tc.Data), &tc_data)
|
||||
if err != nil {
|
||||
log.Printf("%v", err)
|
||||
}
|
||||
// Try to load theme declaration file and merge defaults for managed configuration
|
||||
// Theme declarations live in ./data/theme/<short>/komari-theme.json
|
||||
if cst.Theme != "" && cst.Theme != "default" {
|
||||
themeConfigPath := filepath.Join("./data/theme", cst.Theme, "komari-theme.json")
|
||||
if _, err := os.Stat(themeConfigPath); err == nil {
|
||||
b, err := os.ReadFile(themeConfigPath)
|
||||
if err == nil {
|
||||
var themeDecl struct {
|
||||
Configuration struct {
|
||||
Type string `json:"type"`
|
||||
Data []models.ManagedThemeConfigurationItem `json:"data"`
|
||||
} `json:"configuration"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &themeDecl); err == nil {
|
||||
if themeDecl.Configuration.Type == "managed" {
|
||||
for _, item := range themeDecl.Configuration.Data {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
// missing
|
||||
if _, exists := tc_data[item.Key]; !exists {
|
||||
var def any = item.Default
|
||||
// select
|
||||
if item.Type == "select" {
|
||||
if def == nil || def == "" {
|
||||
if item.Options != "" {
|
||||
opts := strings.Split(item.Options, ",")
|
||||
if len(opts) > 0 {
|
||||
def = strings.TrimSpace(opts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// number->0, string->"", switch->false
|
||||
if def == nil {
|
||||
switch item.Type {
|
||||
case "number":
|
||||
def = 0
|
||||
case "switch":
|
||||
def = false
|
||||
default:
|
||||
def = ""
|
||||
}
|
||||
}
|
||||
tc_data[item.Key] = def
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return public settings including CORS
|
||||
RespondSuccess(c, gin.H{
|
||||
"sitename": cst.Sitename,
|
||||
@@ -18,11 +90,15 @@ func GetPublicSettings(c *gin.Context) {
|
||||
"custom_head": cst.CustomHead,
|
||||
"custom_body": cst.CustomBody,
|
||||
"oauth_enable": cst.OAuthEnabled,
|
||||
"oauth_provider": cst.OAuthProvider,
|
||||
"disable_password_login": cst.DisablePasswordLogin,
|
||||
"allow_cors": cst.AllowCors,
|
||||
"record_enabled": cst.RecordEnabled,
|
||||
"record_preserve_time": cst.RecordPreserveTime,
|
||||
"ping_record_preserve_time": cst.PingRecordPreserveTime,
|
||||
"private_site": cst.PrivateSite,
|
||||
"theme": cst.Theme,
|
||||
"theme_settings": tc_data,
|
||||
})
|
||||
|
||||
}
|
||||
+102
-20
@@ -1,17 +1,46 @@
|
||||
package record
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
records "github.com/komari-monitor/komari/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
)
|
||||
|
||||
func GetRecordsByUUID(c *gin.Context) {
|
||||
uuid := c.Query("uuid")
|
||||
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err := accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
hiddenMap := map[string]bool{}
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
|
||||
if hiddenMap[uuid] {
|
||||
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hours := c.Query("hours")
|
||||
if uuid == "" {
|
||||
api.RespondError(c, 400, "UUID is required")
|
||||
@@ -41,12 +70,48 @@ func GetRecordsByUUID(c *gin.Context) {
|
||||
// GET query: uuid string, hours int
|
||||
func GetPingRecords(c *gin.Context) {
|
||||
uuid := c.Query("uuid")
|
||||
hours := c.Query("hours")
|
||||
|
||||
if uuid == "" {
|
||||
api.RespondError(c, 400, "UUID is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err := accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
hiddenMap := map[string]bool{}
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
|
||||
if hiddenMap[uuid] {
|
||||
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hours := c.Query("hours")
|
||||
type RecordsResp struct {
|
||||
TaskId uint `json:"task_id"`
|
||||
Time string `json:"time"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
type Resp struct {
|
||||
Count int `json:"count"`
|
||||
Records []RecordsResp `json:"records"`
|
||||
Tasks []gin.H `json:"tasks,omitempty"` // 任务列表
|
||||
}
|
||||
|
||||
if hours == "" {
|
||||
hours = "4"
|
||||
}
|
||||
@@ -63,12 +128,19 @@ func GetPingRecords(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"records": records,
|
||||
"count": len(records),
|
||||
response := &Resp{
|
||||
Count: len(records),
|
||||
Records: []RecordsResp{},
|
||||
}
|
||||
for _, r := range records {
|
||||
response.Records = append(response.Records, RecordsResp{
|
||||
TaskId: r.TaskId,
|
||||
Time: r.Time.ToTime().Format(time.RFC3339),
|
||||
Value: r.Value,
|
||||
})
|
||||
}
|
||||
|
||||
if len(records) > 0 {
|
||||
if len(records) >= 0 {
|
||||
// 获取当前属于该 uuid 的 pingTasks
|
||||
pingTasks, err := tasks.GetAllPingTasks()
|
||||
if err != nil {
|
||||
@@ -84,26 +156,36 @@ func GetPingRecords(c *gin.Context) {
|
||||
tasksList := make([]gin.H, 0, len(pingTasks))
|
||||
for _, t := range pingTasks {
|
||||
// 只保留分配给该 uuid 的任务
|
||||
found := false
|
||||
for _, client := range t.Clients {
|
||||
if client == uuid {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
found := slices.Contains(t.Clients, uuid)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
// 只返回有记录的任务
|
||||
if _, ok := taskIdSet[t.Id]; ok {
|
||||
tasksList = append(tasksList, gin.H{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"interval": t.Interval,
|
||||
})
|
||||
|
||||
// 计算该任务的丢包率
|
||||
totalCount := 0
|
||||
lossCount := 0
|
||||
for _, r := range records {
|
||||
if r.TaskId == t.Id {
|
||||
totalCount++
|
||||
if r.Value < 0 {
|
||||
lossCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lossRate float64 = 0
|
||||
if totalCount > 0 {
|
||||
lossRate = float64(lossCount) / float64(totalCount) * 100
|
||||
}
|
||||
|
||||
tasksList = append(tasksList, gin.H{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"interval": t.Interval,
|
||||
"loss": lossRate,
|
||||
})
|
||||
}
|
||||
response["tasks"] = tasksList
|
||||
response.Tasks = tasksList
|
||||
}
|
||||
|
||||
api.RespondSuccess(c, response)
|
||||
|
||||
+1
-3
@@ -30,9 +30,7 @@ func RequestTerminal(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
CheckOrigin: ws.CheckOrigin,
|
||||
}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
func GetClients(c *gin.Context) {
|
||||
// 升级到ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if cfg.AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 请求
|
||||
for {
|
||||
var resp struct {
|
||||
Online []string `json:"online"` // 已建立连接的客户端uuid列表
|
||||
Data map[string]*common.Report `json:"data"` // 最后上报的数据
|
||||
}
|
||||
resp.Online = []string{}
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
//log.Println("Error reading message:", err)
|
||||
return
|
||||
}
|
||||
message := string(data)
|
||||
|
||||
uuID := ""
|
||||
if message != "get" { // 非请求全部内容
|
||||
if strings.HasPrefix(message, "get ") {
|
||||
uuID = strings.TrimSpace(strings.TrimPrefix(message, "get "))
|
||||
} else {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid message"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 登录状态检查
|
||||
isLogin := false
|
||||
session, _ := c.Cookie("session_token")
|
||||
_, err = accounts.GetUserBySession(session)
|
||||
if err == nil {
|
||||
isLogin = true
|
||||
}
|
||||
// 仅在未登录时需要 Hidden 信息做过滤
|
||||
hiddenMap := map[string]bool{}
|
||||
if !isLogin {
|
||||
var hiddenClients []models.Client
|
||||
db := dbcore.GetDBInstance()
|
||||
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
|
||||
for _, cli := range hiddenClients {
|
||||
hiddenMap[cli.UUID] = true
|
||||
}
|
||||
}
|
||||
// 已建立连接的客户端uuid列表
|
||||
for key := range ws.GetConnectedClients() {
|
||||
if uuID != "" { // 请求特定服务器信息
|
||||
if !(!isLogin && hiddenMap[key]) && key == uuID { // 未登录且 Hidden 或 不符合请求的特定服务器 -> 跳过
|
||||
resp.Online = append(resp.Online, key)
|
||||
}
|
||||
} else {
|
||||
if !(!isLogin && hiddenMap[key]) { // 未登录且 Hidden -> 跳过
|
||||
resp.Online = append(resp.Online, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 清除UUID,简化报告单
|
||||
resp.Data = ws.GetLatestReport()
|
||||
if !isLogin { // 未登录过滤 Hidden 的节点报告
|
||||
for uuid := range resp.Data {
|
||||
if hiddenMap[uuid] {
|
||||
delete(resp.Data, uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uuID != "" { // 过滤不符合请求的特定服务器
|
||||
for key := range resp.Data {
|
||||
if key != uuID {
|
||||
delete(resp.Data, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, report := range resp.Data { // 不暴露 uuid
|
||||
report.UUID = ""
|
||||
if report.CPU.Usage == 0 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
}
|
||||
|
||||
err = conn.WriteJSON(gin.H{"status": "success", "data": resp})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+46
-9
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/api/record"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
@@ -35,7 +37,7 @@ import (
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -72,10 +74,34 @@ func RunServer() {
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go geoip.InitGeoIp()
|
||||
go DoScheduledWork()
|
||||
go messageSender.Initialize()
|
||||
// oidcInit
|
||||
go oauth.Initialize()
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if event.New.OAuthProvider != event.Old.OAuthProvider {
|
||||
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get OIDC provider config: %v", err)
|
||||
} else {
|
||||
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
|
||||
}
|
||||
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
|
||||
if err != nil {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
|
||||
}
|
||||
}
|
||||
if event.New.NotificationMethod != event.Old.NotificationMethod {
|
||||
messageSender.Initialize()
|
||||
}
|
||||
|
||||
})
|
||||
// 初始化 cloudflared
|
||||
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
|
||||
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
|
||||
if err != nil {
|
||||
@@ -86,10 +112,7 @@ func RunServer() {
|
||||
r := gin.Default()
|
||||
|
||||
// 动态 CORS 中间件
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
DynamicCorsEnabled = event.New.AllowCors
|
||||
@@ -116,6 +139,8 @@ func RunServer() {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(api.PrivateSiteMiddleware())
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
@@ -129,7 +154,7 @@ func RunServer() {
|
||||
// #region 公开路由
|
||||
r.POST("/api/login", api.Login)
|
||||
r.GET("/api/me", api.GetMe)
|
||||
r.GET("/api/clients", ws.GetClients)
|
||||
r.GET("/api/clients", api.GetClients)
|
||||
r.GET("/api/nodes", api.GetNodesInformation)
|
||||
r.GET("/api/public", api.GetPublicSettings)
|
||||
r.GET("/api/oauth", api.OAuth)
|
||||
@@ -141,6 +166,7 @@ func RunServer() {
|
||||
r.GET("/api/records/load", record.GetRecordsByUUID)
|
||||
r.GET("/api/records/ping", record.GetPingRecords)
|
||||
// #region Agent
|
||||
r.POST("/api/clients/register", client.RegisterClient)
|
||||
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
|
||||
{
|
||||
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
|
||||
@@ -153,6 +179,7 @@ func RunServer() {
|
||||
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
|
||||
{
|
||||
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
|
||||
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
|
||||
// test
|
||||
testGroup := adminAuthrized.Group("/test")
|
||||
{
|
||||
@@ -178,9 +205,15 @@ func RunServer() {
|
||||
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
|
||||
}
|
||||
// settings
|
||||
adminAuthrized.GET("/settings", admin.GetSettings)
|
||||
adminAuthrized.POST("/settings", admin.EditSettings)
|
||||
|
||||
settingsGroup := adminAuthrized.Group("/settings")
|
||||
{
|
||||
settingsGroup.GET("/", admin.GetSettings)
|
||||
settingsGroup.POST("/", admin.EditSettings)
|
||||
settingsGroup.POST("/oidc", admin.SetOidcProvider)
|
||||
settingsGroup.GET("/oidc", admin.GetOidcProvider)
|
||||
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
|
||||
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
|
||||
}
|
||||
// themes
|
||||
themeGroup := adminAuthrized.Group("/theme")
|
||||
{
|
||||
@@ -188,6 +221,8 @@ func RunServer() {
|
||||
themeGroup.GET("/list", admin.ListThemes)
|
||||
themeGroup.POST("/delete", admin.DeleteTheme)
|
||||
themeGroup.GET("/set", admin.SetTheme)
|
||||
themeGroup.POST("/update", admin.UpdateTheme)
|
||||
themeGroup.POST("/settings", admin.UpdateThemeSettings)
|
||||
}
|
||||
// clients
|
||||
clientGroup := adminAuthrized.Group("/client")
|
||||
@@ -345,6 +380,8 @@ func DoScheduledWork() {
|
||||
records.DeleteAll()
|
||||
tasks.DeleteAllPingRecords()
|
||||
}
|
||||
// 每分钟检查一次流量提醒
|
||||
go notifier.CheckTraffic()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,16 @@ package accounts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
// GetAllSessions 获取所有会话
|
||||
@@ -33,6 +38,16 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
LoginMethod: login_method,
|
||||
LatestOnline: models.FromTime(time.Now()),
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
if cfg.LoginNotification {
|
||||
ipAddr := net.ParseIP(ip)
|
||||
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
|
||||
loc := "unknown"
|
||||
if ipinfo != nil {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
messageSender.SendTextMessage(fmt.Sprintf("From: %s,(%s)\nMethod: %s\nUser Agent: %s\n\n%s", ip, loc, login_method, userAgent, time.Now().Format(time.RFC3339)), "New Login on Komari")
|
||||
}
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
@@ -47,7 +48,7 @@ func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
|
||||
if cbi.Arch != "" {
|
||||
updates["arch"] = cbi.Arch
|
||||
}
|
||||
if cbi.CpuCores != 0 {
|
||||
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
|
||||
updates["cpu_cores"] = cbi.CpuCores
|
||||
}
|
||||
if cbi.OS != "" {
|
||||
@@ -120,6 +121,36 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
|
||||
update["updated_at"] = time.Now()
|
||||
|
||||
checkInt64 := func(name string, val float64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
verify := func(update map[string]interface{}) error {
|
||||
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
|
||||
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
|
||||
}
|
||||
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := verify(update); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package clients
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/common"
|
||||
@@ -51,10 +52,98 @@ func ParseReport(data map[string]interface{}) (report common.Report, err error)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report common.Report) error {
|
||||
// 防止输入不合理范围
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
}
|
||||
|
||||
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
|
||||
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
|
||||
}
|
||||
|
||||
checkFloat64 := func(name string, val float64) error {
|
||||
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
|
||||
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [float64] 防止数据溢出
|
||||
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkInt64 := func(name string, val int64) error {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%s must be non-negative, got %d", name, val)
|
||||
}
|
||||
if val > math.MaxInt64-1 {
|
||||
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// [int64] 防止数据溢出
|
||||
// Ram 验证
|
||||
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Swap 验证
|
||||
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Disk 验证
|
||||
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Network 验证
|
||||
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
|
||||
return err
|
||||
}
|
||||
// 拒绝所有负数Int
|
||||
if report.Process < 0 {
|
||||
return fmt.Errorf("Process must be non-negative: %d", report.Process)
|
||||
}
|
||||
if report.Connections.TCP < 0 {
|
||||
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
|
||||
}
|
||||
if report.Connections.UDP < 0 {
|
||||
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveClientReport 保存客户端报告到 Record 表
|
||||
func SaveClientReport(clientUUID string, report common.Report) (err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
if err := ReportVerify(report); err != nil {
|
||||
return fmt.Errorf("failed to save Record: %v", err)
|
||||
}
|
||||
|
||||
Record := models.Record{
|
||||
Client: clientUUID,
|
||||
Time: models.FromTime(time.Now()),
|
||||
|
||||
+10
-22
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
@@ -10,7 +11,15 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
func init() {
|
||||
mu = sync.Mutex{}
|
||||
}
|
||||
|
||||
func Get() (models.Config, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.Config
|
||||
if err := db.First(&config).Error; err != nil {
|
||||
@@ -23,9 +32,6 @@ func Get() (models.Config, error) {
|
||||
OAuthEnabled: false,
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "ip-api",
|
||||
EmailPort: 587,
|
||||
EmailUseSSL: true,
|
||||
EmailReceiver: "", // Default empty
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
@@ -45,26 +51,8 @@ func Save(cst models.Config) error {
|
||||
// Only one records
|
||||
cst.ID = 1
|
||||
cst.UpdatedAt = models.FromTime(time.Now())
|
||||
// Do not update CreatedAt
|
||||
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
|
||||
Select("sitename",
|
||||
"description",
|
||||
"allow_cors",
|
||||
"geo_ip_enabled",
|
||||
"geo_ip_provider",
|
||||
"o_auth_client_id",
|
||||
"o_auth_client_secret",
|
||||
"o_auth_enabled",
|
||||
"custom_head",
|
||||
"email_enabled",
|
||||
"email_host",
|
||||
"email_port",
|
||||
"email_username",
|
||||
"email_password",
|
||||
"email_sender",
|
||||
"email_use_ssl",
|
||||
"email_receiver",
|
||||
"updated_at").
|
||||
Select("*").
|
||||
Updates(cst).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dbcore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -79,6 +80,127 @@ func MergeDatabase(db *gorm.DB) {
|
||||
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
|
||||
db.Migrator().DropTable(&models.LoadNotification{})
|
||||
}
|
||||
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
|
||||
log.Println("[>1.0.2] Merge OidcProvider table....")
|
||||
var config struct {
|
||||
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
|
||||
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
|
||||
log.Println("Failed to get config for OIDC provider migration:", err)
|
||||
}
|
||||
db.AutoMigrate(&models.OidcProvider{})
|
||||
j, err := json.Marshal(&map[string]string{
|
||||
"client_id": config.OAuthClientID,
|
||||
"client_secret": config.OAuthClientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal OIDC provider config:", err)
|
||||
return
|
||||
}
|
||||
db.Save(&models.OidcProvider{
|
||||
Name: "github",
|
||||
Addition: string(j),
|
||||
})
|
||||
db.AutoMigrate(&models.Config{})
|
||||
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
|
||||
}
|
||||
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
|
||||
log.Println("[>1.0.2] Migrate MessageSender configuration....")
|
||||
var config struct {
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
|
||||
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
|
||||
EmailPort int `json:"email_port" gorm:"type:int"`
|
||||
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"`
|
||||
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"`
|
||||
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"`
|
||||
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
|
||||
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
|
||||
}
|
||||
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
|
||||
log.Println("Failed to get config for MessageSender migration:", err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(&models.MessageSenderProvider{})
|
||||
|
||||
// 迁移Telegram配置
|
||||
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
|
||||
telegramConfig := map[string]interface{}{
|
||||
"bot_token": config.TelegramBotToken,
|
||||
"chat_id": config.TelegramChatID,
|
||||
"endpoint": config.TelegramEndpoint,
|
||||
}
|
||||
if telegramConfig["endpoint"] == "" {
|
||||
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
|
||||
}
|
||||
telegramConfigJSON, err := json.Marshal(telegramConfig)
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal Telegram config:", err)
|
||||
} else {
|
||||
db.Save(&models.MessageSenderProvider{
|
||||
Name: "telegram",
|
||||
Addition: string(telegramConfigJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移Email配置
|
||||
if config.NotificationMethod == "email" && config.EmailHost != "" {
|
||||
emailConfig := map[string]interface{}{
|
||||
"host": config.EmailHost,
|
||||
"port": config.EmailPort,
|
||||
"username": config.EmailUsername,
|
||||
"password": config.EmailPassword,
|
||||
"sender": config.EmailSender,
|
||||
"receiver": config.EmailReceiver,
|
||||
"use_ssl": config.EmailUseSSL,
|
||||
}
|
||||
emailConfigJSON, err := json.Marshal(emailConfig)
|
||||
if err != nil {
|
||||
log.Println("Failed to marshal Email config:", err)
|
||||
} else {
|
||||
db.Save(&models.MessageSenderProvider{
|
||||
Name: "email",
|
||||
Addition: string(emailConfigJSON),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除旧的配置字段
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_host")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_port")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_username")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_password")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_sender")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
|
||||
}
|
||||
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
|
||||
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -140,6 +262,9 @@ func GetDBInstance() *gorm.DB {
|
||||
&models.OfflineNotification{},
|
||||
&models.PingRecord{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create tables: %v", err)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetAllMessageSenderConfigs() []models.MessageSenderProvider {
|
||||
db := dbcore.GetDBInstance()
|
||||
var result []models.MessageSenderProvider
|
||||
if err := db.Find(&result).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func GetMessageSenderConfigByName(name string) (*models.MessageSenderProvider, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.MessageSenderProvider
|
||||
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func SaveMessageSenderConfig(config *models.MessageSenderProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Save(config).Error
|
||||
}
|
||||
+71
-96
@@ -10,137 +10,112 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// UTCTime 是一个自定义类型,用于在 GORM 中处理 time.Time。
|
||||
// 它强制所有时间都以指定时区(或 UTC)的字符串格式存取。
|
||||
type UTCTime time.Time
|
||||
// LocalTime is a custom time type for GORM to handle time.Time correctly.
|
||||
// It ensures that all times are stored and retrieved based on the application's configured timezone.
|
||||
type LocalTime time.Time
|
||||
|
||||
// Value 实现 driver.Valuer 接口,将 UTCTime 转换为数据库可以识别的 TEXT 类型。
|
||||
// 所有时间在存储到数据库前,都转换为 UTC 并以标准格式存储。
|
||||
func (t UTCTime) Value() (driver.Value, error) {
|
||||
// Value implements the driver.Valuer interface.
|
||||
// It converts UTCTime to a TEXT format that the database understands.
|
||||
// The time is formatted as a string in the application's local timezone, without timezone information.
|
||||
func (t LocalTime) Value() (driver.Value, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
return nil, nil // GORM 默认会将零值时间存储为 NULL,这里保持一致
|
||||
return nil, nil
|
||||
}
|
||||
// 将时间转换为应用程序指定的时区,并格式化为不带时区信息的字符串。
|
||||
// 这样数据库中存储的是 'YYYY-MM-DD HH:MM:SS.NNNNNNN' 格式。
|
||||
// 注意:之前你遇到的格式是 "2025-06-14 23:51:17.3022328+08:00"
|
||||
// 这种格式带有微秒和时区偏移。为了兼容旧数据读取,我们这里也用精确格式。
|
||||
// 但存储时,为了避免时区混乱,建议统一为某个特定时区的纯时间戳字符串。
|
||||
// 这里我建议统一存储为 UTC 时间的精确字符串。
|
||||
// "2006-01-02 15:04:05.0000000" 这个布局对应不带时区后缀的纳秒精度。
|
||||
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
|
||||
}
|
||||
|
||||
// Scan 实现 sql.Scanner 接口,将数据库中的 TEXT 值扫描到 UTCTime。
|
||||
// 所有时间字符串在读取时,都会被尝试解析为指定时区(或 UTC)的 time.Time 对象。
|
||||
func (t *UTCTime) Scan(v interface{}) error {
|
||||
// Scan implements the sql.Scanner interface.
|
||||
// It scans a value from the database into a UTCTime object.
|
||||
func (t *LocalTime) Scan(v interface{}) error {
|
||||
if v == nil {
|
||||
*t = UTCTime(time.Time{})
|
||||
*t = LocalTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
loc := GetAppLocation()
|
||||
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
// If the driver already parsed it to time.Time, use it directly.
|
||||
// Then convert to the application's desired location.
|
||||
*t = UTCTime(val.In(GetAppLocation()))
|
||||
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
|
||||
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
|
||||
// the date and clock values in the application's actual timezone.
|
||||
year, month, day := val.Date()
|
||||
hour, min, sec := val.Clock()
|
||||
nanosec := val.Nanosecond()
|
||||
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
|
||||
return nil
|
||||
case []byte:
|
||||
// If it's still a byte slice (string), parse it.
|
||||
timeStr := strings.TrimSpace(string(val))
|
||||
if timeStr == "" {
|
||||
*t = UTCTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsedTime time.Time
|
||||
var err error
|
||||
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.0000000-07:00", // Your observed precise format with timezone
|
||||
"2006-01-02 15:04:05-07:00", // With seconds and timezone
|
||||
time.RFC3339Nano, // RFC3339 with nanoseconds
|
||||
time.RFC3339, // RFC3339
|
||||
"2006-01-02 15:04:05.0000000", // Nanoseconds, no timezone
|
||||
"2006-01-02 15:04:05", // Standard, no timezone
|
||||
"2006-01-02", // Date only
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
// time.Parse handles strings that include timezone info correctly.
|
||||
parsedTime, err = time.Parse(layout, timeStr)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析时间字符串 '%s' 为 UTCTime: %w", timeStr, err)
|
||||
}
|
||||
|
||||
*t = UTCTime(parsedTime.In(GetAppLocation()))
|
||||
return nil
|
||||
return t.parseTime(string(val), loc)
|
||||
case string:
|
||||
return t.parseTime(val, loc)
|
||||
default:
|
||||
return fmt.Errorf("UTCTime scan source was not []byte or time.Time: %T (%v)", v, v)
|
||||
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler interface.
|
||||
// This method defines how UTCTime should be serialized to JSON.
|
||||
func (t UTCTime) MarshalJSON() ([]byte, error) {
|
||||
// parseTime handles parsing a string into UTCTime.
|
||||
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
|
||||
timeStr = strings.TrimSpace(timeStr)
|
||||
if timeStr == "" {
|
||||
*t = LocalTime(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano, time.RFC3339,
|
||||
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
|
||||
*t = LocalTime(parsedTime)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface.
|
||||
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
|
||||
func (t LocalTime) MarshalJSON() ([]byte, error) {
|
||||
if time.Time(t).IsZero() {
|
||||
// 如果是零值时间,返回 JSON null。
|
||||
// 这样 API 返回的就是 "field": null,而不是 "field": {}。
|
||||
return []byte("null"), nil
|
||||
}
|
||||
// 将时间转换为应用程序指定的时区,并格式化为 JSON 字符串。
|
||||
// 使用双引号包裹,符合 JSON 字符串规范。
|
||||
// 同样使用之前定义的精确格式,便于客户端解析。
|
||||
formattedTime := time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000-07:00")
|
||||
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
|
||||
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
|
||||
}
|
||||
|
||||
// ToTime 将 UTCTime 转换为 Go 的 time.Time 类型。
|
||||
func (t UTCTime) ToTime() time.Time {
|
||||
return time.Time(t)
|
||||
}
|
||||
// ToTime converts UTCTime to Go's native time.Time type.
|
||||
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
|
||||
|
||||
// FromTime 将 Go 的 time.Time 类型转换为 UTCTime。
|
||||
func FromTime(t time.Time) UTCTime {
|
||||
return UTCTime(t)
|
||||
}
|
||||
// FromTime converts Go's native time.Time type to UTCTime.
|
||||
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
|
||||
|
||||
// Now returns the current time in the application's configured location.
|
||||
func Now() UTCTime {
|
||||
return UTCTime(time.Now().In(GetAppLocation()))
|
||||
}
|
||||
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
|
||||
|
||||
// AppLocation 用于存储应用程序全局使用的时区。
|
||||
// 首次获取后会缓存,避免重复加载。
|
||||
var (
|
||||
appLocation *time.Location
|
||||
locationOnce sync.Once
|
||||
defaultLocation = time.UTC // 默认时区设为 UTC
|
||||
appLocation *time.Location
|
||||
locationOnce sync.Once
|
||||
)
|
||||
|
||||
// GetAppLocation 获取应用程序的全局时区。
|
||||
// 优先从环境变量 "TZ" 获取,否则使用默认的 UTC。
|
||||
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
|
||||
func GetAppLocation() *time.Location {
|
||||
locationOnce.Do(func() {
|
||||
tz := os.Getenv("TZ")
|
||||
if tz != "" {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load timezone from environment variable TZ='%s', using default UTC. Error: %v\n", tz, err)
|
||||
appLocation = defaultLocation
|
||||
} else {
|
||||
appLocation = loc
|
||||
log.Printf("Info: Application timezone set from environment variable TZ='%s' (%s).\n", tz, appLocation.String())
|
||||
}
|
||||
} else {
|
||||
appLocation = defaultLocation
|
||||
log.Printf("Info: Environment variable TZ not set, using default UTC timezone (%s).\n", appLocation.String())
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
time.Local = appLocation // 设置全局时区
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
|
||||
appLocation = time.UTC
|
||||
} else {
|
||||
appLocation = loc
|
||||
}
|
||||
time.Local = appLocation
|
||||
log.Printf("Application timezone is set to '%s'.", appLocation.String())
|
||||
})
|
||||
return appLocation
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package models
|
||||
|
||||
type Clipboard struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
+17
-26
@@ -1,44 +1,35 @@
|
||||
package models
|
||||
|
||||
type Config struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
|
||||
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
|
||||
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
|
||||
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
|
||||
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
|
||||
// GeoIP 配置
|
||||
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
|
||||
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
|
||||
// OAuth 配置
|
||||
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
|
||||
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
|
||||
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
|
||||
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
|
||||
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
|
||||
// 自定义美化
|
||||
CustomHead string `json:"custom_head" gorm:"type:longtext"`
|
||||
CustomBody string `json:"custom_body" gorm:"type:longtext"`
|
||||
// Telegram Bot 配置
|
||||
// TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
|
||||
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
|
||||
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
|
||||
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
|
||||
// Email 配置
|
||||
EmailHost string `json:"email_host" gorm:"type:varchar(255)"` // 邮件服务器地址
|
||||
EmailPort int `json:"email_port" gorm:"default:587"` // 邮件服务器端口,默认587
|
||||
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"` // 邮箱用户名
|
||||
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"` // 邮箱密码
|
||||
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"` // 邮箱发送者地址
|
||||
EmailUseSSL bool `json:"email_use_ssl" gorm:"default:true"` // 是否使用 SSL/TLS 连接,默认 true
|
||||
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"` // 邮件接收者地址
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
|
||||
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
|
||||
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
|
||||
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
|
||||
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
|
||||
// Record
|
||||
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
|
||||
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
|
||||
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
|
||||
CreatedAt UTCTime
|
||||
UpdatedAt UTCTime
|
||||
CreatedAt LocalTime
|
||||
UpdatedAt LocalTime
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package models
|
||||
|
||||
type Log struct {
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);"`
|
||||
Message string `json:"message" gorm:"type:text;not null"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
|
||||
Time UTCTime `json:"time" gorm:"autoCreateTime;not null"`
|
||||
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36);"`
|
||||
Message string `json:"message" gorm:"type:text;not null"`
|
||||
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
|
||||
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type MessageSenderProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
+63
-59
@@ -8,34 +8,38 @@ import (
|
||||
|
||||
// Client represents a registered client device
|
||||
type Client struct {
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt UTCTime `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
Tags string `json:"tags" gorm:"type:text"` // split by ';'
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
|
||||
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
|
||||
Name string `json:"name" gorm:"type:varchar(100)"`
|
||||
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
|
||||
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(50)"`
|
||||
CpuCores int `json:"cpu_cores" gorm:"type:int"`
|
||||
OS string `json:"os" gorm:"type:varchar(100)"`
|
||||
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
|
||||
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
|
||||
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
|
||||
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
|
||||
Region string `json:"region" gorm:"type:varchar(100)"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
|
||||
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
|
||||
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
|
||||
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
|
||||
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
|
||||
Group string `json:"group" gorm:"type:varchar(100)"`
|
||||
Tags string `json:"tags" gorm:"type:text"` // split by ';'
|
||||
Hidden bool `json:"hidden" gorm:"default:false"`
|
||||
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
|
||||
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an authenticated user
|
||||
@@ -47,45 +51,45 @@ type User struct {
|
||||
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
|
||||
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
|
||||
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UpdatedAt UTCTime `json:"updated_at"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
UpdatedAt LocalTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Session manages user sessions
|
||||
type Session struct {
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36)"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
|
||||
UserAgent string `json:"user_agent" gorm:"type:text"`
|
||||
Ip string `json:"ip" gorm:"type:varchar(100)"`
|
||||
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
|
||||
LatestOnline UTCTime `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
|
||||
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
|
||||
Expires UTCTime `json:"expires" gorm:"not null"`
|
||||
CreatedAt UTCTime `json:"created_at"`
|
||||
UUID string `json:"uuid" gorm:"type:varchar(36)"`
|
||||
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
|
||||
UserAgent string `json:"user_agent" gorm:"type:text"`
|
||||
Ip string `json:"ip" gorm:"type:varchar(100)"`
|
||||
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
|
||||
LatestOnline LocalTime `json:"latest_online" gorm:"type:timestamp"`
|
||||
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
|
||||
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
|
||||
Expires LocalTime `json:"expires" gorm:"not null"`
|
||||
CreatedAt LocalTime `json:"created_at"`
|
||||
}
|
||||
|
||||
// Record logs client metrics over time
|
||||
type Record struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"`
|
||||
Time UTCTime `json:"time" gorm:"index"`
|
||||
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
|
||||
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
|
||||
Ram int64 `json:"ram" gorm:"type:bigint"`
|
||||
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
|
||||
Swap int64 `json:"swap" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
|
||||
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
|
||||
Disk int64 `json:"disk" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
NetIn int64 `json:"net_in" gorm:"type:bigint"`
|
||||
NetOut int64 `json:"net_out" gorm:"type:bigint"`
|
||||
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
|
||||
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
Client string `json:"client" gorm:"type:varchar(36);index"`
|
||||
Time LocalTime `json:"time" gorm:"index"`
|
||||
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
|
||||
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
|
||||
Ram int64 `json:"ram" gorm:"type:bigint"`
|
||||
RamTotal int64 `json:"ram_total" gorm:"type:bigint"`
|
||||
Swap int64 `json:"swap" gorm:"type:bigint"`
|
||||
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
|
||||
Load float32 `json:"load" gorm:"type:decimal(5,2)"`
|
||||
Temp float32 `json:"temp" gorm:"type:decimal(5,2)"`
|
||||
Disk int64 `json:"disk" gorm:"type:bigint"`
|
||||
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
|
||||
NetIn int64 `json:"net_in" gorm:"type:bigint"`
|
||||
NetOut int64 `json:"net_out" gorm:"type:bigint"`
|
||||
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
|
||||
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
|
||||
Process int `json:"process"`
|
||||
Connections int `json:"connections"`
|
||||
ConnectionsUdp int `json:"connections_udp"`
|
||||
}
|
||||
|
||||
// StringArray represents a slice of strings stored as JSON in the database
|
||||
|
||||
@@ -6,8 +6,8 @@ type OfflineNotification struct {
|
||||
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
|
||||
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
|
||||
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
|
||||
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
@@ -19,5 +19,5 @@ type LoadNotification struct {
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified UTCTime `json:"last_notified"` // 上次通知时间
|
||||
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type OidcProvider struct {
|
||||
Name string `json:"name" gorm:"primaryKey;unique;not null"`
|
||||
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package models
|
||||
|
||||
type PingRecord struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
TaskId uint `json:"task_id" gorm:"not null;index"`
|
||||
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
|
||||
Time UTCTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
TaskId uint `json:"task_id" gorm:"not null;index"`
|
||||
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
|
||||
Time LocalTime `json:"time" gorm:"index;not null"`
|
||||
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
|
||||
}
|
||||
|
||||
type PingTask struct {
|
||||
|
||||
@@ -8,11 +8,11 @@ type Task struct {
|
||||
}
|
||||
|
||||
type TaskResult struct {
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
|
||||
Client string `json:"client" gorm:"type:varchar(36)"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
Result string `json:"result" gorm:"type:longtext"`
|
||||
ExitCode *int `json:"exit_code" gorm:"type:int"`
|
||||
FinishedAt *UTCTime `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt UTCTime `json:"created_at" gorm:"type:timestamp"`
|
||||
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
|
||||
Client string `json:"client" gorm:"type:varchar(36)"`
|
||||
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
|
||||
Result string `json:"result" gorm:"type:longtext"`
|
||||
ExitCode *int `json:"exit_code" gorm:"type:int"`
|
||||
FinishedAt *LocalTime `json:"finished_at" gorm:"type:timestamp"`
|
||||
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
|
||||
}
|
||||
@@ -2,11 +2,34 @@ package models
|
||||
|
||||
// Theme represents a komari theme information
|
||||
type Theme struct {
|
||||
Name string `json:"name"` // 主题名称
|
||||
Short string `json:"short"` // 短名称,用作文件夹名
|
||||
Description string `json:"description"` // 主题描述
|
||||
Version string `json:"version"` // 版本号
|
||||
Author string `json:"author"` // 作者
|
||||
URL string `json:"url"` // 主题URL
|
||||
Preview string `json:"preview"` // 预览图片相对路径
|
||||
Name string `json:"name"` // 主题名称
|
||||
Short string `json:"short"` // 短名称,用作文件夹名
|
||||
Description string `json:"description"` // 主题描述
|
||||
Version string `json:"version"` // 版本号
|
||||
Author string `json:"author"` // 作者
|
||||
URL string `json:"url"` // 主题URL
|
||||
Preview string `json:"preview"` // 预览图片相对路径
|
||||
Configuration Configuration `json:"configuration"` // 声明配置项
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name string `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
}
|
||||
|
||||
type ManagedThemeConfigurationItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type"` // string number select switch title
|
||||
Options string `json:"options"`
|
||||
Default any `json:"default"`
|
||||
Help string `json:"help"`
|
||||
}
|
||||
|
||||
type ThemeConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetAllOidcConfigs() []models.OidcProvider {
|
||||
db := dbcore.GetDBInstance()
|
||||
var result []models.OidcProvider
|
||||
if err := db.Find(&result).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.OidcProvider
|
||||
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func SaveOidcConfig(config *models.OidcProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Save(config).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func DeleteTaskByTaskId(taskId string) error {
|
||||
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
|
||||
}
|
||||
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
|
||||
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
|
||||
return dbcore.GetDBInstance().
|
||||
Model(&models.TaskResult{}).
|
||||
Where("task_id = ? AND client = ?", taskId, clientId).
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
|
||||
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
|
||||
|
||||
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
|
||||
|
||||
## 特徴
|
||||
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
|
||||
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
|
||||
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### 0. クラウドホスティングによるワンクリック展開
|
||||
- Rainyun - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- Claw Run - USD 1.5/月
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
|
||||
### 1. ワンクリックインストールスクリプトを使用する
|
||||
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker展開
|
||||
1. データディレクトリを作成します:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. Dockerコンテナを実行します:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. デフォルトのユーザー名とパスワードを表示します:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
|
||||
|
||||
> [!NOTE]
|
||||
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
|
||||
|
||||
### 3. バイナリファイル展開
|
||||
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
|
||||
2. Komariを実行します:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
|
||||
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
|
||||
|
||||
> [!NOTE]
|
||||
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
|
||||
|
||||
|
||||
### 手動ビルド
|
||||
|
||||
#### 依存関係
|
||||
|
||||
- Go 1.18+ および Node.js 20+(手動ビルド用)
|
||||
|
||||
1. フロントエンドの静的ファイルをビルドします:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. バックエンドをビルドします:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 実行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
|
||||
|
||||
## フロントエンド開発ガイド
|
||||
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## クライアントエージェント開発ガイド
|
||||
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 貢献
|
||||
IssueやPull Requestを歓迎します!
|
||||
|
||||
## 謝辞
|
||||
- こんなに暇でいられる自分に感謝
|
||||
- PRを提出し、テーマを作成してくれたすべての開発者
|
||||
|
||||
## Star履歴
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
@@ -0,0 +1,118 @@
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
|
||||
Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單、高效的伺服器性能監控解決方案。它支援透過 Web 介面查看伺服器狀態,並透過輕量級 Agent 收集數據。
|
||||
|
||||
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
|
||||
- **自託管**:完全掌控數據隱私,部署簡單。
|
||||
- **Web 介面**:直觀的監控儀表盤,易於使用。
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 0. 容器雲一鍵部署
|
||||
|
||||
- 雨雲雲應用 - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- Claw Run - USD 1.5/月
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
|
||||
### 1. 使用一鍵安裝腳本
|
||||
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker 部署
|
||||
1. 建立資料目錄:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. 執行 Docker 容器:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. 查看預設帳號和密碼:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. 在瀏覽器中存取 `http://<your_server_ip>:25774`。
|
||||
|
||||
> [!NOTE]
|
||||
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
|
||||
|
||||
### 3. 二進位檔案部署
|
||||
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
|
||||
2. 執行 Komari:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
|
||||
4. 預設帳號和密碼可在啟動日誌中查看,或透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 設定。
|
||||
|
||||
> [!NOTE]
|
||||
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
|
||||
|
||||
|
||||
### 手工建置
|
||||
|
||||
#### 依賴
|
||||
|
||||
- Go 1.18+ 和 Node.js 20+(手工建置)
|
||||
|
||||
1. 建置前端靜態檔案:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. 建置後端:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 執行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
|
||||
|
||||
## 前端開發指南
|
||||
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## 客戶端 Agent 開發指南
|
||||
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 貢獻
|
||||
歡迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 鳴謝
|
||||
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
- 感謝我自己能這麼閒
|
||||
- 提交PR、製作主題的各位開發者
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 0. 容器云一键部署
|
||||
|
||||
- 雨云云应用 - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- Claw Run - USD 1.5/月
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
|
||||
### 1. 使用一键安装脚本
|
||||
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
|
||||
chmod +x install-komari.sh
|
||||
sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker 部署
|
||||
1. 创建数据目录:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
```
|
||||
2. 运行 Docker 容器:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 25774:25774 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--name komari \
|
||||
ghcr.io/komari-monitor/komari:latest
|
||||
```
|
||||
3. 查看默认账号和密码:
|
||||
```bash
|
||||
docker logs komari
|
||||
```
|
||||
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
|
||||
|
||||
> [!NOTE]
|
||||
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
|
||||
|
||||
### 3. 二进制文件部署
|
||||
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
|
||||
2. 运行 Komari:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
|
||||
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
|
||||
|
||||
> [!NOTE]
|
||||
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
|
||||
|
||||
|
||||
### 手工构建
|
||||
|
||||
#### 依赖
|
||||
|
||||
- Go 1.18+ 和 Node.js 20+(手工构建)
|
||||
|
||||
1. 构建前端静态文件:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
2. 构建后端:
|
||||
```bash
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 运行:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
默认监听 `25774` 端口,访问 `http://localhost:25774`。
|
||||
|
||||
## 前端开发指南
|
||||
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## 客户端 Agent 开发指南
|
||||
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
|
||||
|
||||
## 贡献
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 鸣谢
|
||||
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
|
||||
- 感谢我自己能这么闲
|
||||
- 提交PR、制作主题的各位开发者
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
+27
-7
@@ -23,11 +23,14 @@ log_step() {
|
||||
echo -e "${YELLOW}$1${NC}"
|
||||
}
|
||||
|
||||
|
||||
# Global variables
|
||||
INSTALL_DIR="/opt/komari"
|
||||
DATA_DIR="/opt/komari/data"
|
||||
DATA_DIR="/opt/komari"
|
||||
SERVICE_NAME="komari"
|
||||
BINARY_PATH="$INSTALL_DIR/komari"
|
||||
DEFAULT_PORT="25774"
|
||||
LISTEN_PORT=""
|
||||
|
||||
# Show banner
|
||||
show_banner() {
|
||||
@@ -119,6 +122,21 @@ install_binary() {
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
# 监听端口输入,校验范围 1-65535
|
||||
while true; do
|
||||
read -p "请输入监听端口 [默认: $DEFAULT_PORT]: " input_port
|
||||
if [[ -z "$input_port" ]]; then
|
||||
LISTEN_PORT="$DEFAULT_PORT"
|
||||
break
|
||||
elif [[ "$input_port" =~ ^[0-9]+$ ]] && (( input_port >= 1 && input_port <= 65535 )); then
|
||||
LISTEN_PORT="$input_port"
|
||||
break
|
||||
else
|
||||
log_error "端口号无效,请输入 1-65535 之间的数字。"
|
||||
fi
|
||||
done
|
||||
|
||||
install_dependencies
|
||||
|
||||
local arch=$(detect_arch)
|
||||
@@ -147,13 +165,13 @@ install_binary() {
|
||||
if ! check_systemd; then
|
||||
log_step "警告:未检测到 systemd,跳过服务创建。"
|
||||
log_step "您可以从命令行手动运行 Komari:"
|
||||
log_step " $BINARY_PATH server -l 0.0.0.0:25774"
|
||||
log_step " $BINARY_PATH server -l 0.0.0.0:$LISTEN_PORT"
|
||||
echo
|
||||
log_success "安装完成!"
|
||||
return
|
||||
fi
|
||||
|
||||
create_systemd_service
|
||||
create_systemd_service "$LISTEN_PORT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable ${SERVICE_NAME}.service
|
||||
@@ -168,7 +186,7 @@ install_binary() {
|
||||
if [ -z "$password" ]; then
|
||||
log_error "未能获取初始密码,请检查日志"
|
||||
fi
|
||||
show_access_info "$password"
|
||||
show_access_info "$password" "$LISTEN_PORT"
|
||||
else
|
||||
log_error "Komari 服务启动失败"
|
||||
log_info "查看日志: journalctl -u ${SERVICE_NAME} -f"
|
||||
@@ -178,6 +196,7 @@ install_binary() {
|
||||
|
||||
# Create systemd service file
|
||||
create_systemd_service() {
|
||||
local port="$1"
|
||||
log_step "创建 systemd 服务..."
|
||||
|
||||
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
@@ -188,7 +207,7 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${BINARY_PATH} server -l 0.0.0.0:25774
|
||||
ExecStart=${BINARY_PATH} server -l 0.0.0.0:${port}
|
||||
WorkingDirectory=${DATA_DIR}
|
||||
Restart=always
|
||||
User=root
|
||||
@@ -203,13 +222,14 @@ EOF
|
||||
# Show access information
|
||||
show_access_info() {
|
||||
local password=$1
|
||||
local port=${2:-$DEFAULT_PORT}
|
||||
echo
|
||||
log_success "安装完成!"
|
||||
echo
|
||||
log_info "访问信息:"
|
||||
log_info " URL: http://$(hostname -I | awk '{print $1}'):25774"
|
||||
log_info " URL: http://$(hostname -I | awk '{print $1}'):${port}"
|
||||
if [ -n "$password" ]; then
|
||||
log_info "初始登录信息: $password"
|
||||
log_info "初始登录信息(仅显示一次): $password"
|
||||
fi
|
||||
echo
|
||||
log_info "服务管理命令:"
|
||||
|
||||
+62
-45
@@ -103,32 +103,32 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
|
||||
}
|
||||
c.Data(http.StatusOK, "image/x-icon", data)
|
||||
})
|
||||
r.GET("/manifest.json", func(c *gin.Context) {
|
||||
cfg, err := config.Get()
|
||||
if cfg.Theme == "default" || cfg.Theme == "" || err != nil {
|
||||
// 使用默认主题的manifest.json
|
||||
f, err := DistFS.Open("manifest.json")
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", data)
|
||||
} else {
|
||||
// 使用自定义主题的manifest.json
|
||||
themePath := filepath.Join("./data/theme", cfg.Theme, "dist", "manifest.json")
|
||||
if _, err := os.Stat(themePath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Manifest not found"})
|
||||
} else {
|
||||
c.File(themePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
// r.GET("/manifest.json", func(c *gin.Context) {
|
||||
// cfg, err := config.Get()
|
||||
// if cfg.Theme == "default" || cfg.Theme == "" || err != nil {
|
||||
// // 使用默认主题的manifest.json
|
||||
// f, err := DistFS.Open("manifest.json")
|
||||
// if err != nil {
|
||||
// c.Status(http.StatusNotFound)
|
||||
// return
|
||||
// }
|
||||
// defer f.Close()
|
||||
// data, err := io.ReadAll(f)
|
||||
// if err != nil {
|
||||
// c.Status(http.StatusInternalServerError)
|
||||
// return
|
||||
// }
|
||||
// c.Data(http.StatusOK, "application/json", data)
|
||||
// } else {
|
||||
// // 使用自定义主题的manifest.json
|
||||
// themePath := filepath.Join("./data/theme", cfg.Theme, "dist", "manifest.json")
|
||||
// if _, err := os.Stat(themePath); os.IsNotExist(err) {
|
||||
// c.JSON(http.StatusNotFound, gin.H{"error": "Manifest not found"})
|
||||
// } else {
|
||||
// c.File(themePath)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// Serve theme files from data/theme directory (for theme previews and static assets)
|
||||
r.Static("/themes", "./data/theme")
|
||||
@@ -159,26 +159,43 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
|
||||
// serveFromEmbedded 从嵌入的文件系统服务文件
|
||||
func serveFromEmbedded(c *gin.Context, path string) {
|
||||
// 处理静态资源文件夹
|
||||
folders := []string{"assets", "images", "streamer", "static"}
|
||||
for _, folder := range folders {
|
||||
if strings.HasPrefix(path, "/"+folder+"/") {
|
||||
c.Header("Cache-Control", "public, max-age=15552000")
|
||||
sub, err := fs.Sub(DistFS, folder)
|
||||
if err == nil {
|
||||
relativePath := strings.TrimPrefix(path, "/"+folder+"/")
|
||||
file, err := sub.Open(relativePath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err == nil {
|
||||
// 设置正确的Content-Type
|
||||
contentType := getContentType(path)
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
return
|
||||
}
|
||||
}
|
||||
// folders := []string{"assets", "images", "streamer", "static"}
|
||||
// for _, folder := range folders {
|
||||
// if strings.HasPrefix(path, "/"+folder+"/") {
|
||||
// c.Header("Cache-Control", "public, max-age=15552000")
|
||||
// sub, err := fs.Sub(DistFS, folder)
|
||||
// if err == nil {
|
||||
// relativePath := strings.TrimPrefix(path, "/"+folder+"/")
|
||||
// file, err := sub.Open(relativePath)
|
||||
// if err == nil {
|
||||
// defer file.Close()
|
||||
// data, err := io.ReadAll(file)
|
||||
// if err == nil {
|
||||
// // 设置正确的Content-Type
|
||||
// contentType := getContentType(path)
|
||||
// c.Header("Content-Type", contentType)
|
||||
// c.Data(http.StatusOK, contentType, data)
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// 如果文件存在,直接返回文件内容
|
||||
cleanPath := strings.TrimPrefix(path, "/")
|
||||
file, err := DistFS.Open(cleanPath)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err == nil {
|
||||
contentType := getContentType(path)
|
||||
c.Header("Content-Type", contentType)
|
||||
// 静态资源设置缓存
|
||||
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
|
||||
c.Header("Cache-Control", "public, max-age=15552000")
|
||||
}
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Frontend Build Instructions / 前端构建说明 / フロントエンド構築手順
|
||||
|
||||
## English
|
||||
|
||||
### Frontend Repository
|
||||
- **Frontend project repository**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### Build Requirements
|
||||
1. Clone the frontend repository and build the static files
|
||||
2. Copy the generated static files to the `dist` folder in the Komari backend project root directory
|
||||
3. Ensure the `dist` folder contains `index.html`
|
||||
|
||||
### Important Note
|
||||
⚠️ **The projects under Akizon77's personal repository are no longer maintained. Please use the projects under the organization (komari-monitor).**
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 前端项目仓库
|
||||
- **前端项目地址**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### 构建要求
|
||||
1. 克隆前端仓库并构建静态文件
|
||||
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `dist` 文件夹
|
||||
3. 确保 `dist` 文件夹内包含 `index.html`
|
||||
|
||||
### 重要提醒
|
||||
⚠️ **Akizon77 个人仓库的项目已经不再使用,请使用组织(komari-monitor)下的项目。**
|
||||
|
||||
---
|
||||
|
||||
## 日本語
|
||||
|
||||
### フロントエンドプロジェクトリポジトリ
|
||||
- **フロントエンドプロジェクトアドレス**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### ビルド要件
|
||||
1. フロントエンドリポジトリをクローンして静的ファイルをビルドする
|
||||
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `dist` フォルダーにコピーする
|
||||
3. `dist` フォルダー内に `index.html` が含まれていることを確認する
|
||||
|
||||
### 重要な注意事項
|
||||
⚠️ **Akizon77 の個人リポジトリのプロジェクトは使用されなくなりました。組織(komari-monitor)下のプロジェクトを使用してください。**
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup / 快速设置 / クイックセットアップ
|
||||
|
||||
```bash
|
||||
# Clone frontend repository / 克隆前端仓库 / フロントエンドリポジトリをクローン
|
||||
git clone https://github.com/komari-monitor/komari-web
|
||||
cd komari-web
|
||||
|
||||
# Install dependencies and build / 安装依赖并构建 / 依存関係をインストールしてビルド
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Copy dist folder to Komari backend project / 将 dist 文件夹复制到 Komari 后端项目 / dist フォルダーを Komari バックエンドプロジェクトにコピー
|
||||
cp -r dist /path/to/komari/public/
|
||||
```
|
||||
@@ -1,39 +0,0 @@
|
||||
# komari 待办清单
|
||||
|
||||
## 通用
|
||||
|
||||
- [ ] 低带宽模式
|
||||
|
||||
- [x] Web终端
|
||||
|
||||
- [x] 一键部署脚本
|
||||
|
||||
- [x] 远程命令执行
|
||||
|
||||
## Serser
|
||||
|
||||
- [x] Docker 部署
|
||||
|
||||
- [ ] Web前端
|
||||
|
||||
- [x] Web前端嵌入二进制
|
||||
|
||||
- [x] 命令行指令 chpasswd 强制更换密码
|
||||
|
||||
- [x] Github单点登录、绑定、解绑
|
||||
|
||||
- [x] 2FA
|
||||
|
||||
- [ ] 负载通知
|
||||
|
||||
- [x] 离线通知
|
||||
|
||||
- [ ] DDNS
|
||||
|
||||
- [x] Agent 备注
|
||||
|
||||
## Agent
|
||||
|
||||
- [x] 自动更新
|
||||
|
||||
- [x] 慢速模式(调整interval)
|
||||
@@ -0,0 +1,31 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// https://github.com/labstack/echo/blob/98ca08e7dd64075b858e758d6693bf9799340756/context.go#L275-L294
|
||||
func GetScheme(c *gin.Context) string {
|
||||
// Can't use `r.Request.URL.Scheme`
|
||||
// See: https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0
|
||||
if c.Request.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if scheme := c.Request.Header.Get("X-Forwarded-Proto"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
if scheme := c.Request.Header.Get("X-Forwarded-Protocol"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
if ssl := c.Request.Header.Get("X-Forwarded-Ssl"); ssl == "on" {
|
||||
return "https"
|
||||
}
|
||||
if scheme := c.Request.Header.Get("X-Url-Scheme"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func GetCallbackURL(c *gin.Context) string {
|
||||
scheme := GetScheme(c)
|
||||
host := c.Request.Host
|
||||
return scheme + "://" + host + "/api/oauth_callback"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package item
|
||||
|
||||
import "reflect"
|
||||
|
||||
type Item struct {
|
||||
Name string `json:"name"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type"`
|
||||
Options string `json:"options"`
|
||||
Default string `json:"default"`
|
||||
Help string `json:"help"`
|
||||
}
|
||||
|
||||
func Parse(v any) []Item {
|
||||
val := reflect.ValueOf(v)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
var items []Item
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
typ := field.Tag.Get("type")
|
||||
if typ != "option" {
|
||||
typ = field.Type.Name()
|
||||
}
|
||||
item := Item{
|
||||
Name: field.Tag.Get("json"),
|
||||
Required: field.Tag.Get("required") == "true",
|
||||
Type: typ,
|
||||
Options: field.Tag.Get("options"),
|
||||
Default: field.Tag.Get("default"),
|
||||
Help: field.Tag.Get("help"),
|
||||
}
|
||||
if item.Type == "" {
|
||||
item.Type = "string"
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package messageSender
|
||||
|
||||
import (
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/email"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
|
||||
)
|
||||
|
||||
func All() {
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package bark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type BarkSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
func (b *BarkSender) GetName() string {
|
||||
return "bark"
|
||||
}
|
||||
|
||||
func (b *BarkSender) GetConfiguration() factory.Configuration {
|
||||
return &b.Addition
|
||||
}
|
||||
|
||||
func (b *BarkSender) Init() error {
|
||||
// 验证 ServerURL 格式
|
||||
if b.Addition.ServerURL != "" {
|
||||
if _, err := url.Parse(b.Addition.ServerURL); err != nil {
|
||||
return fmt.Errorf("invalid server URL: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BarkSender) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
if b.Addition.DeviceKey == "" {
|
||||
return fmt.Errorf("device key is required")
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
return fmt.Errorf("message is empty")
|
||||
}
|
||||
|
||||
// 准备请求数据
|
||||
payload := map[string]interface{}{
|
||||
"body": message,
|
||||
"device_key": b.Addition.DeviceKey,
|
||||
}
|
||||
|
||||
// 如果有标题,添加标题
|
||||
if title != "" {
|
||||
payload["title"] = title
|
||||
}
|
||||
|
||||
// 添加可选参数
|
||||
if b.Addition.Icon != "" {
|
||||
payload["icon"] = b.Addition.Icon
|
||||
}
|
||||
|
||||
if b.Addition.Level != "" {
|
||||
payload["level"] = b.Addition.Level
|
||||
}
|
||||
|
||||
// 序列化 JSON
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
// 构建请求 URL
|
||||
serverURL := b.Addition.ServerURL
|
||||
if serverURL == "" {
|
||||
serverURL = "https://api.day.app"
|
||||
}
|
||||
|
||||
// 确保 URL 以正确的格式结尾
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
requestURL := fmt.Sprintf("%s/push", serverURL)
|
||||
|
||||
// 发送 HTTP POST 请求
|
||||
resp, err := http.Post(requestURL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bark API returned non-OK status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
// 如果解析失败,认为发送成功(某些 Bark 服务器可能返回纯文本)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查 Bark API 响应
|
||||
if result.Code != 200 {
|
||||
return fmt.Errorf("bark API error (code: %d): %s", result.Code, result.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &BarkSender{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*BarkSender)(nil)
|
||||
@@ -0,0 +1,18 @@
|
||||
package bark
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
ServerURL string `json:"server_url" required:"true" default:"https://api.day.app" help:"Bark server URL, e.g., https://api.day.app or your self-hosted server address"`
|
||||
DeviceKey string `json:"device_key" required:"true" help:"Your Bark device key, which can be found in the Bark App"`
|
||||
Icon string `json:"icon" help:"Push notification icon, supports URL or system icon name"`
|
||||
Level string `json:"level" type:"option" default:"timeSensitive" options:"active,timeSensitive,passive,critical" help:"Push notification level: active, timeSensitive (default), passive, critical"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &BarkSender{}
|
||||
})
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package messageSender
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
)
|
||||
|
||||
// EmailMessageSender implements the MessageSender interface for Email.
|
||||
type EmailMessageSender struct{}
|
||||
|
||||
// SendTextMessage sends a text message via Email (SMTP).
|
||||
// The title is used as the email subject.
|
||||
func (e *EmailMessageSender) SendTextMessage(message, title string) error {
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
if conf.EmailHost == "" || conf.EmailSender == "" || conf.EmailUsername == "" || conf.EmailPassword == "" || conf.EmailReceiver == "" {
|
||||
return fmt.Errorf("email sending is not fully configured")
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth(
|
||||
"",
|
||||
conf.EmailUsername,
|
||||
conf.EmailPassword,
|
||||
conf.EmailHost,
|
||||
)
|
||||
|
||||
msg := []byte("To: " + conf.EmailReceiver + "\r\n" +
|
||||
"From: " + conf.EmailSender + "\r\n" +
|
||||
"Subject: " + title + "\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" +
|
||||
message)
|
||||
|
||||
addr := conf.EmailHost + ":" + strconv.Itoa(conf.EmailPort)
|
||||
|
||||
if conf.EmailUseSSL {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial SMTP server: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err = c.StartTLS(&tls.Config{ServerName: conf.EmailHost}); err != nil {
|
||||
return fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
// Set the sender and recipient
|
||||
if err = c.Mail(conf.EmailSender); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
if err = c.Rcpt(conf.EmailReceiver); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
} else {
|
||||
// Send without SSL/TLS (less secure)
|
||||
return smtp.SendMail(
|
||||
addr,
|
||||
auth,
|
||||
conf.EmailSender,
|
||||
[]string{conf.EmailReceiver},
|
||||
msg,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type EmailSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
func (e *EmailSender) GetName() string {
|
||||
return "email"
|
||||
}
|
||||
|
||||
func (e *EmailSender) GetConfiguration() factory.Configuration {
|
||||
return &e.Addition
|
||||
}
|
||||
|
||||
func (e *EmailSender) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailSender) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
|
||||
if e.Addition.Host == "" || e.Addition.Sender == "" || e.Addition.Username == "" || e.Addition.Password == "" || e.Addition.Receiver == "" {
|
||||
return fmt.Errorf("email sending is not fully configured")
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth(
|
||||
"",
|
||||
e.Addition.Username,
|
||||
e.Addition.Password,
|
||||
e.Addition.Host,
|
||||
)
|
||||
|
||||
msg := []byte("To: " + e.Addition.Receiver + "\r\n" +
|
||||
"From: " + e.Addition.Sender + "\r\n" +
|
||||
"Subject: " + title + "\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" +
|
||||
message)
|
||||
|
||||
addr := e.Addition.Host + ":" + strconv.Itoa(e.Addition.Port)
|
||||
|
||||
if e.Addition.UseSSL {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial SMTP server: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err = c.StartTLS(&tls.Config{ServerName: e.Addition.Host}); err != nil {
|
||||
return fmt.Errorf("failed to start TLS: %w", err)
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
// Set the sender and recipient
|
||||
if err = c.Mail(e.Addition.Sender); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
if err = c.Rcpt(e.Addition.Receiver); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
} else {
|
||||
// Send without SSL/TLS (less secure)
|
||||
return smtp.SendMail(
|
||||
addr,
|
||||
auth,
|
||||
e.Addition.Sender,
|
||||
[]string{e.Addition.Receiver},
|
||||
msg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*EmailSender)(nil)
|
||||
@@ -0,0 +1,21 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
Host string `json:"host" required:"true"`
|
||||
Port int `json:"port" required:"true" default:"587"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Sender string `json:"sender"`
|
||||
Receiver string `json:"receiver"`
|
||||
UseSSL bool `json:"use_ssl" default:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &EmailSender{}
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package messageSender
|
||||
|
||||
import "fmt"
|
||||
|
||||
type EmptyProvider struct{}
|
||||
|
||||
func (e *EmptyProvider) SendTextMessage(message, title string) error {
|
||||
return fmt.Errorf("you are using an empty message sender, please check your configuration")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package empty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
}
|
||||
|
||||
type EmptyProvider struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
func (e *EmptyProvider) GetName() string {
|
||||
return "empty"
|
||||
}
|
||||
|
||||
func (e *EmptyProvider) GetConfiguration() factory.Configuration {
|
||||
return &e.Addition
|
||||
}
|
||||
|
||||
func (e *EmptyProvider) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmptyProvider) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmptyProvider) SendTextMessage(message, title string) error {
|
||||
return fmt.Errorf("you are using an empty message sender, please check your configuration")
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &EmptyProvider{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*EmptyProvider)(nil)
|
||||
@@ -0,0 +1,60 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/item"
|
||||
)
|
||||
|
||||
var (
|
||||
senders = make(map[string]IMessageSender)
|
||||
senderConstructor = make(map[string]MessageSenderConstructor)
|
||||
sendersAdditionalItems = make(map[string][]item.Item)
|
||||
)
|
||||
|
||||
func RegisterMessageSender(constructor MessageSenderConstructor) {
|
||||
sender := constructor()
|
||||
senderConstructor[sender.GetName()] = constructor
|
||||
if sender == nil {
|
||||
panic("Message sender constructor returned nil")
|
||||
}
|
||||
if _, exists := senders[sender.GetName()]; exists {
|
||||
log.Println("Message sender already registered: " + sender.GetName())
|
||||
}
|
||||
senders[sender.GetName()] = sender
|
||||
|
||||
// 使用反射来提取提供程序的配置字段
|
||||
config := sender.GetConfiguration()
|
||||
items := item.Parse(config)
|
||||
|
||||
sendersAdditionalItems[sender.GetName()] = items
|
||||
}
|
||||
|
||||
func GetSenderConfigs() map[string][]item.Item {
|
||||
return sendersAdditionalItems
|
||||
}
|
||||
|
||||
func GetAllMessageSenders() map[string]IMessageSender {
|
||||
return senders
|
||||
}
|
||||
|
||||
func GetConstructor(name string) (MessageSenderConstructor, bool) {
|
||||
constructor, exists := senderConstructor[name]
|
||||
return constructor, exists
|
||||
}
|
||||
|
||||
func GetAllMessageSenderNames() []string {
|
||||
names := make([]string, 0, len(senders))
|
||||
for name := range senders {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func Initialize() {
|
||||
for _, sender := range senders {
|
||||
if err := sender.Init(); err != nil {
|
||||
log.Printf("Failed to initialize message sender %s: %v", sender.GetName(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package factory
|
||||
|
||||
type IMessageSender interface {
|
||||
GetName() string
|
||||
// 请务必返回 &Configuration{} 的指针
|
||||
GetConfiguration() Configuration
|
||||
SendTextMessage(message, title string) error
|
||||
Init() error
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
type Configuration interface{}
|
||||
|
||||
type MessageSenderConstructor func() IMessageSender
|
||||
@@ -0,0 +1,52 @@
|
||||
package messageSender
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
func LoadProvider(name string, addition string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
constructor, exists := factory.GetConstructor(name)
|
||||
if !exists {
|
||||
return fmt.Errorf("message sender provider not found: %s", name)
|
||||
}
|
||||
|
||||
provider := constructor()
|
||||
err := json.Unmarshal([]byte(addition), provider.GetConfiguration())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config for provider %s: %w", name, err)
|
||||
}
|
||||
provider.Init()
|
||||
if currentProvider != nil {
|
||||
currentProvider.Destroy()
|
||||
}
|
||||
currentProvider = provider
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetProviderConfiguration(name string) (map[string]interface{}, error) {
|
||||
constructor, exists := factory.GetConstructor(name)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("message sender provider not found: %s", name)
|
||||
}
|
||||
|
||||
provider := constructor()
|
||||
config := provider.GetConfiguration()
|
||||
|
||||
// 将配置转换为map
|
||||
configBytes, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal configuration: %w", err)
|
||||
}
|
||||
|
||||
var configMap map[string]interface{}
|
||||
if err := json.Unmarshal(configBytes, &configMap); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal configuration: %w", err)
|
||||
}
|
||||
|
||||
return configMap, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package messageSender
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
func Test(t *testing.T) {
|
||||
senders := factory.GetAllMessageSenders()
|
||||
if len(senders) == 0 {
|
||||
t.Error("No message senders found")
|
||||
return
|
||||
}
|
||||
cfg := factory.GetSenderConfigs()
|
||||
if len(cfg) == 0 {
|
||||
t.Error("No sender configs found")
|
||||
return
|
||||
}
|
||||
LoadProvider("email", `{"host":"smtp.example.com","port":587,"username":"user","password":"pass"}`)
|
||||
cp := CurrentProvider
|
||||
if cp() == nil {
|
||||
t.Error("Current provider is nil")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,76 @@
|
||||
package messageSender
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
var CurrentProvider MessageSender
|
||||
var (
|
||||
currentProvider factory.IMessageSender
|
||||
mu = sync.Mutex{}
|
||||
once = sync.Once{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
func CurrentProvider() factory.IMessageSender {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return currentProvider
|
||||
}
|
||||
|
||||
func Initialize() {
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
go func() {
|
||||
once.Do(func() {
|
||||
all := factory.GetAllMessageSenders()
|
||||
for _, provider := range all {
|
||||
if _, err := database.GetMessageSenderConfigByName(provider.GetName()); err == nil {
|
||||
continue
|
||||
}
|
||||
// 如果数据库中没有该提供者的配置,则保存默认配置
|
||||
config := provider.GetConfiguration()
|
||||
configBytes, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
|
||||
return
|
||||
}
|
||||
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
|
||||
Name: provider.GetName(),
|
||||
Addition: string(configBytes),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
cfg, _ := config.Get()
|
||||
|
||||
if cfg.NotificationMethod == "" || cfg.NotificationMethod == "none" {
|
||||
LoadProvider("empty", "{}")
|
||||
return
|
||||
}
|
||||
|
||||
switch cfg.NotificationMethod {
|
||||
case "telegram":
|
||||
CurrentProvider = &TelegramMessageSender{}
|
||||
case "email":
|
||||
CurrentProvider = &EmailMessageSender{}
|
||||
case "none", "":
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
default:
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
// 尝试从数据库加载配置
|
||||
senderConfig, err := database.GetMessageSenderConfigByName(cfg.NotificationMethod)
|
||||
if err != nil {
|
||||
// 如果没有找到配置,使用empty provider
|
||||
LoadProvider("empty", "{}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type MessageSender interface {
|
||||
SendTextMessage(message, title string) error
|
||||
LoadProvider(cfg.NotificationMethod, senderConfig.Addition)
|
||||
}
|
||||
|
||||
func SendTextMessage(message string, title string) error {
|
||||
if CurrentProvider() == nil {
|
||||
return fmt.Errorf("message sender provider is not initialized")
|
||||
}
|
||||
var err error
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
@@ -44,7 +80,7 @@ func SendTextMessage(message string, title string) error {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
err = CurrentProvider.SendTextMessage(message, title)
|
||||
err = CurrentProvider().SendTextMessage(message, title)
|
||||
if err == nil {
|
||||
auditlog.Log("", "", "Message sent: "+title, "info")
|
||||
return nil
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package messageSender
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
)
|
||||
|
||||
// TelegramMessageSender implements the MessageSender interface for Telegram.
|
||||
type TelegramMessageSender struct{}
|
||||
|
||||
// SendTextMessage sends a text message via Telegram API.
|
||||
// The title is prepended to the message.
|
||||
func (t *TelegramMessageSender) SendTextMessage(message, title string) error {
|
||||
fullMessage := message
|
||||
if title != "" {
|
||||
fullMessage = fmt.Sprintf("<b>%s</b>\n%s", title, message)
|
||||
}
|
||||
|
||||
var err error
|
||||
conf, err := config.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if fullMessage == "" {
|
||||
return errors.New("message is empty")
|
||||
}
|
||||
|
||||
endpoint := conf.TelegramEndpoint + conf.TelegramBotToken + "/sendMessage"
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("chat_id", conf.TelegramChatID)
|
||||
data.Set("text", fullMessage)
|
||||
data.Set("parse_mode", "HTML")
|
||||
|
||||
resp, err := http.PostForm(endpoint, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Ok bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !result.Ok {
|
||||
return fmt.Errorf("telegram API error: %s", result.Description)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package telegram
|
||||
|
||||
type Addition struct {
|
||||
BotToken string `json:"bot_token" required:"true"`
|
||||
ChatID string `json:"chat_id" required:"true"`
|
||||
MessageThreadID string `json:"message_thread_id" help:"Optional. Unique identifier of a message thread to which the message belongs; for supergroups only"`
|
||||
Endpoint string `json:"endpoint" required:"true" default:"https://api.telegram.org/bot" help:"Telegram API endpoint, default is https://api.telegram.org/bot"`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type TelegramSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
func (t *TelegramSender) GetName() string {
|
||||
return "telegram"
|
||||
}
|
||||
|
||||
func (t *TelegramSender) GetConfiguration() factory.Configuration {
|
||||
return &t.Addition
|
||||
}
|
||||
|
||||
func (t *TelegramSender) Init() error {
|
||||
// 初始化逻辑,如果需要的话
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TelegramSender) Destroy() error {
|
||||
// 清理逻辑,如果需要的话
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TelegramSender) SendTextMessage(message, title string) error {
|
||||
fullMessage := message
|
||||
if title != "" {
|
||||
fullMessage = fmt.Sprintf("<b>%s</b>\n%s", title, message)
|
||||
}
|
||||
|
||||
if fullMessage == "" {
|
||||
return errors.New("message is empty")
|
||||
}
|
||||
|
||||
endpoint := t.Addition.Endpoint + t.Addition.BotToken + "/sendMessage"
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("chat_id", t.Addition.ChatID)
|
||||
data.Set("text", fullMessage)
|
||||
data.Set("parse_mode", "HTML")
|
||||
|
||||
// Add message_thread_id if provided
|
||||
if t.Addition.MessageThreadID != "" {
|
||||
data.Set("message_thread_id", t.Addition.MessageThreadID)
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(endpoint, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram API returned non-OK status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Ok bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !result.Ok {
|
||||
return fmt.Errorf("telegram API error: %s", result.Description)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &TelegramSender{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*TelegramSender)(nil)
|
||||
@@ -0,0 +1,21 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
URL string `json:"url" required:"true"`
|
||||
Method string `json:"method" default:"GET" type:"option" options:"POST,GET"`
|
||||
ContentType string `json:"content_type" default:"application/json"`
|
||||
Headers string `json:"headers" help:"HTTP headers in JSON format"`
|
||||
Body string `json:"body" default:"{\"message\":\"{{message}}\",\"title\":\"{{title}}\"}"` // 默认使用message和title字段
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &WebhookSender{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type WebhookSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
func (w *WebhookSender) GetName() string {
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
func (w *WebhookSender) GetConfiguration() factory.Configuration {
|
||||
return &w.Addition
|
||||
}
|
||||
|
||||
func (w *WebhookSender) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) SendTextMessage(message, title string) error {
|
||||
if w.Addition.URL == "" {
|
||||
return fmt.Errorf("webhook URL is not configured")
|
||||
}
|
||||
|
||||
method := strings.ToUpper(w.Addition.Method)
|
||||
if method == "" {
|
||||
method = "GET" // 默认使用 GET
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second, // 固定30秒超时
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
switch method {
|
||||
case "POST":
|
||||
req, err = w.createPOSTRequest(message, title)
|
||||
case "GET":
|
||||
req, err = w.createGETRequest(message, title)
|
||||
default:
|
||||
return fmt.Errorf("unsupported HTTP method: %s", method)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
// 解析并设置自定义头部
|
||||
if w.Addition.Headers != "" {
|
||||
var headers map[string]string
|
||||
if err := json.Unmarshal([]byte(w.Addition.Headers), &headers); err == nil {
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置基本认证
|
||||
if w.Addition.Username != "" && w.Addition.Password != "" {
|
||||
req.SetBasicAuth(w.Addition.Username, w.Addition.Password)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send webhook request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webhook request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
|
||||
// 替换模板中的占位符
|
||||
body := w.replaceTemplate(w.Addition.Body, message, title)
|
||||
|
||||
contentType := w.Addition.ContentType
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (w *WebhookSender) createGETRequest(message, title string) (*http.Request, error) {
|
||||
URL := w.replaceTemplate(w.Addition.URL, message, title)
|
||||
u, err := url.Parse(URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符
|
||||
func (w *WebhookSender) replaceTemplate(template, message, title string) string {
|
||||
result := template
|
||||
result = strings.ReplaceAll(result, "{{message}}", message)
|
||||
result = strings.ReplaceAll(result, "{{title}}", title)
|
||||
return result
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type notificationState struct {
|
||||
mu sync.Mutex // 互斥锁,保护该客户端状态
|
||||
pendingOfflineSince time.Time // 客户端离线的时间。为零值表示客户端在线或已发送离线通知。
|
||||
isFirstConnection bool // 标记是否为首次上线连接。
|
||||
isConnExist bool // 标记是否存在连接
|
||||
connectionID int64 // 连接ID,用于区分不同的连接会话,防止竞态条件
|
||||
}
|
||||
|
||||
// clientStates 使用 sync.Map 实现对客户端状态的并发访问。
|
||||
@@ -52,7 +54,7 @@ func getOrInitState(clientID string) *notificationState {
|
||||
}
|
||||
|
||||
// OfflineNotification 在启用通知且未在宽限期内发送的情况下,发送客户端离线通知。
|
||||
func OfflineNotification(clientID string) {
|
||||
func OfflineNotification(clientID string, endedConnectionID int64) {
|
||||
client, err := clients.GetClientByUUID(clientID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -73,7 +75,8 @@ func OfflineNotification(clientID string) {
|
||||
|
||||
state.mu.Lock()
|
||||
// 如果已处于待通知状态,则不做处理。
|
||||
if !state.pendingOfflineSince.IsZero() {
|
||||
// 只有当离线事件来自当前的连接会话时,我们才认为它有效。
|
||||
if !state.pendingOfflineSince.IsZero() || state.connectionID != endedConnectionID {
|
||||
state.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -82,7 +85,7 @@ func OfflineNotification(clientID string) {
|
||||
state.mu.Unlock()
|
||||
|
||||
// 新建协程,等待宽限期后判断是否需要发送通知。
|
||||
go func(startTime time.Time) {
|
||||
go func(startTime time.Time, expectedConnectionID int64) {
|
||||
time.Sleep(gracePeriod)
|
||||
|
||||
state.mu.Lock()
|
||||
@@ -90,13 +93,16 @@ func OfflineNotification(clientID string) {
|
||||
|
||||
// 检查离线状态是否仍为本次协程启动时的状态。
|
||||
// 若为零值,说明客户端已重连。
|
||||
// 若时间不同,说明客户端重连后又断开,已启动新计时。
|
||||
if state.pendingOfflineSince.IsZero() || !state.pendingOfflineSince.Equal(startTime) {
|
||||
// 当前的 connectionID 是否还是我们触发离线时的那个ID。如果不是,说明客户端重连过,本次离线通知已失效。
|
||||
if state.pendingOfflineSince.IsZero() || state.connectionID != expectedConnectionID {
|
||||
log.Println("%s is reconnected new connID: %d, old connID: %d", clientID, state.connectionID, expectedConnectionID)
|
||||
return
|
||||
}
|
||||
|
||||
// 即将发送通知,重置待通知状态。
|
||||
// 需要多一个boolean 是因为pendingOfflineSince在offline睡眠后才修改,可能导致online判断不对
|
||||
state.pendingOfflineSince = time.Time{}
|
||||
state.isConnExist = false
|
||||
|
||||
// Send notification
|
||||
message := fmt.Sprintf("🔴%s is offline", client.Name)
|
||||
@@ -111,11 +117,11 @@ func OfflineNotification(clientID string) {
|
||||
if err := db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).Update("last_notified", now).Error; err != nil {
|
||||
log.Printf("Failed to update last_notified for client %s: %v", clientID, err)
|
||||
}
|
||||
}(now)
|
||||
}(now, endedConnectionID)
|
||||
}
|
||||
|
||||
// OnlineNotification 在启用通知的情况下,发送客户端上线通知。
|
||||
func OnlineNotification(clientID string) {
|
||||
func OnlineNotification(clientID string, connectionID int64) {
|
||||
client, err := clients.GetClientByUUID(clientID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -131,12 +137,14 @@ func OnlineNotification(clientID string) {
|
||||
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
state.connectionID = connectionID
|
||||
|
||||
// 规则1:首次连接不通知。
|
||||
if state.isFirstConnection {
|
||||
state.isFirstConnection = false
|
||||
// 同时清除任何待离线状态(如服务器重启时客户端本已离线)
|
||||
state.pendingOfflineSince = time.Time{}
|
||||
state.isConnExist = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -145,12 +153,21 @@ func OnlineNotification(clientID string) {
|
||||
// 上线时总是清除待离线状态。
|
||||
state.pendingOfflineSince = time.Time{}
|
||||
|
||||
// 规则4:宽限期内重连,不通知。
|
||||
// 规则2:宽限期内重连,不通知。
|
||||
if wasPending {
|
||||
return
|
||||
}
|
||||
|
||||
// 规则3:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
|
||||
// 规则3: 没断开后重连, 不通知
|
||||
// 为了解决OfflineNotify中不是全程加锁
|
||||
if state.isConnExist {
|
||||
log.Println("%s has connection exist: %d", clientID, connectionID)
|
||||
return
|
||||
} else {
|
||||
state.isConnExist = true
|
||||
}
|
||||
|
||||
// 规则4:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
|
||||
message := fmt.Sprintf("🟢%s is online", client.Name)
|
||||
go func(msg string) {
|
||||
if err := messageSender.SendTextMessage(msg, "Komari Online Notification"); err != nil {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
cache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
// trafficCache 用于记录每个客户端已触发的阈值步进,避免重复提醒
|
||||
// key: "traffic:"+clientUUID, value: int 步进百分比(例如 80, 85, 90 ... 100)
|
||||
var trafficCache = cache.New(30*24*time.Hour, time.Hour) // 30天缓存,1小时清理
|
||||
|
||||
// CheckTraffic 检查各客户端流量使用情况,并在达到阈值和每+5%时提醒一次;100%时额外提醒一次
|
||||
// 由外部协程每分钟调用一次
|
||||
func CheckTraffic() {
|
||||
// 获取最新上报与客户端配置
|
||||
reports := ws.GetLatestReport()
|
||||
if len(reports) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.TrafficLimitPercentage <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 起始阈值:例如 80%,非5的倍数则从上取整到最近的5的倍数,例如 83->85
|
||||
startThreshold := cfg.TrafficLimitPercentage
|
||||
if startThreshold < 0 {
|
||||
startThreshold = 0
|
||||
}
|
||||
baseStep := int(math.Ceil(startThreshold/5.0) * 5.0)
|
||||
if baseStep > 100 {
|
||||
baseStep = 100
|
||||
}
|
||||
|
||||
allClients, err := clients.GetAllClientBasicInfo()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, c := range allClients {
|
||||
if c.TrafficLimit <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
r, ok := reports[c.UUID]
|
||||
if !ok || r == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算不同类型的使用值
|
||||
used := computeUsedByType(strings.ToLower(c.TrafficLimitType), r.Network.TotalUp, r.Network.TotalDown)
|
||||
if used <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pct := float64(used) / float64(c.TrafficLimit) * 100.0
|
||||
if pct < startThreshold {
|
||||
continue
|
||||
}
|
||||
|
||||
// 当前所在阈值步进(5%的倍数)
|
||||
curStep := int(math.Floor(pct/5.0) * 5.0)
|
||||
if curStep < baseStep {
|
||||
curStep = baseStep
|
||||
}
|
||||
// if curStep > 100 {
|
||||
// curStep = 100
|
||||
// }
|
||||
|
||||
key := "traffic:" + c.UUID
|
||||
last, _ := trafficCache.Get(key)
|
||||
lastStep, _ := last.(int)
|
||||
|
||||
if curStep > lastStep { // 只在进入新步进时提醒一次
|
||||
trafficCache.SetDefault(key, curStep)
|
||||
|
||||
title := "Komari Traffic Alert"
|
||||
msg := fmt.Sprintf("%s traffic used %d%% (%s / %s), type=%s", c.Name, curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
|
||||
// 发送通知(内部会检查 NotificationEnabled)
|
||||
_ = messageSender.SendTextMessage(msg, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeUsedByType(t string, up, down int64) int64 {
|
||||
switch t {
|
||||
case "up":
|
||||
return up
|
||||
case "down":
|
||||
return down
|
||||
case "sum":
|
||||
return up + down
|
||||
case "min":
|
||||
if up < down {
|
||||
return up
|
||||
}
|
||||
return down
|
||||
case "max":
|
||||
fallthrough
|
||||
default:
|
||||
if up > down {
|
||||
return up
|
||||
}
|
||||
return down
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
// KMGTPE
|
||||
prefixes := []string{"K", "M", "G", "T", "P", "E"}
|
||||
if exp >= len(prefixes) {
|
||||
exp = len(prefixes) - 1
|
||||
}
|
||||
return fmt.Sprintf("%.2f %sB", float64(b)/float64(div), prefixes[exp])
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/generic"
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/github"
|
||||
_ "github.com/komari-monitor/komari/utils/oauth/qq"
|
||||
)
|
||||
|
||||
func All() {
|
||||
//empty function to ensure all OIDC providers are registered
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/item"
|
||||
)
|
||||
|
||||
var (
|
||||
providers = make(map[string]IOidcProvider)
|
||||
providerConstructor = make(map[string]OidcConstructor)
|
||||
providersAdditionalItems = make(map[string][]item.Item)
|
||||
)
|
||||
|
||||
func RegisterOidcProvider(constructor OidcConstructor) {
|
||||
provider := constructor()
|
||||
providerConstructor[provider.GetName()] = constructor
|
||||
if provider == nil {
|
||||
panic("OIDC provider constructor returned nil")
|
||||
}
|
||||
if _, exists := providers[provider.GetName()]; exists {
|
||||
log.Println("OIDC provider already registered: " + provider.GetName())
|
||||
}
|
||||
providers[provider.GetName()] = provider
|
||||
|
||||
// 使用反射来提取提供程序的配置字段
|
||||
config := provider.GetConfiguration()
|
||||
items := item.Parse(config)
|
||||
providersAdditionalItems[provider.GetName()] = items
|
||||
}
|
||||
|
||||
func GetProviderConfigs() map[string][]item.Item {
|
||||
return providersAdditionalItems
|
||||
}
|
||||
|
||||
func GetAllOidcProviders() map[string]IOidcProvider {
|
||||
return providers
|
||||
}
|
||||
|
||||
func GetConstructor(name string) (OidcConstructor, bool) {
|
||||
constructor, exists := providerConstructor[name]
|
||||
return constructor, exists
|
||||
}
|
||||
|
||||
func GetAllOidcProviderNames() []string {
|
||||
names := make([]string, 0, len(providers))
|
||||
for name := range providers {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func Initialize() {
|
||||
for _, provider := range providers {
|
||||
if err := provider.Init(); err != nil {
|
||||
log.Printf("Failed to initialize OIDC provider %s: %v", provider.GetName(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package factory
|
||||
|
||||
import "context"
|
||||
|
||||
type IOidcProvider interface {
|
||||
GetName() string
|
||||
// 请务必返回 &Configuration{} 的指针
|
||||
GetConfiguration() Configuration
|
||||
// 获取授权URL和状态
|
||||
GetAuthorizationURL(redirectURI string) (string, string)
|
||||
OnCallback(ctx context.Context, state string, query map[string]string, callbackURI string) (OidcCallback, error)
|
||||
Init() error
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
type OidcCallback struct {
|
||||
UserId string
|
||||
}
|
||||
|
||||
type Configuration interface{}
|
||||
|
||||
type OidcConstructor func() IOidcProvider
|
||||
@@ -0,0 +1,33 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
)
|
||||
|
||||
// Test function
|
||||
func TestRegisterAndGetProviderConfigs(t *testing.T) {
|
||||
All()
|
||||
configs := factory.GetProviderConfigs()
|
||||
if len(configs) == 0 {
|
||||
t.Error("Expected non-empty provider configs, got empty")
|
||||
}
|
||||
providers := factory.GetAllOidcProviders()
|
||||
if len(providers) == 0 {
|
||||
t.Error("Expected non-empty OIDC providers, got empty")
|
||||
}
|
||||
names := factory.GetAllOidcProviderNames()
|
||||
if len(names) == 0 {
|
||||
t.Error("Expected non-empty OIDC provider names, got empty")
|
||||
}
|
||||
|
||||
//err := LoadProvider("github", `{"client_id":"test_id","client_secret":"test_secret"}`)
|
||||
//if err != nil {
|
||||
// t.Errorf("Failed to load provider: %v", err)
|
||||
//}
|
||||
cfg := CurrentProvider().GetConfiguration()
|
||||
if cfg == nil {
|
||||
t.Error("Expected non-nil configuration for 'github' provider, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package generic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
func (g *Generic) GetName() string {
|
||||
return "generic"
|
||||
}
|
||||
func (g *Generic) GetConfiguration() factory.Configuration {
|
||||
return &g.Addition
|
||||
}
|
||||
|
||||
func (g *Generic) GetAuthorizationURL(redirectURI string) (string, string) {
|
||||
state := utils.GenerateRandomString(16)
|
||||
|
||||
// 构建GitHub OAuth授权URL
|
||||
authURL := fmt.Sprintf(
|
||||
"%s?client_id=%s&state=%s&scope=%s&redirect_uri=%s&response_type=code",
|
||||
g.Addition.AuthURL,
|
||||
url.QueryEscape(g.Addition.ClientId),
|
||||
url.QueryEscape(state),
|
||||
url.QueryEscape(g.Addition.Scope),
|
||||
url.QueryEscape(redirectURI),
|
||||
)
|
||||
g.stateCache.Set(state, true, cache.DefaultExpiration)
|
||||
return authURL, state
|
||||
}
|
||||
func (g *Generic) OnCallback(ctx context.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
|
||||
code := query["code"]
|
||||
|
||||
// 验证state防止CSRF攻击
|
||||
if g.stateCache == nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
|
||||
}
|
||||
if _, ok := g.stateCache.Get(state); !ok {
|
||||
return factory.OidcCallback{}, fmt.Errorf("invalid state")
|
||||
}
|
||||
if state == "" {
|
||||
return factory.OidcCallback{}, fmt.Errorf("invalid state")
|
||||
}
|
||||
|
||||
// 获取code
|
||||
if code == "" {
|
||||
return factory.OidcCallback{}, fmt.Errorf("no code provided")
|
||||
}
|
||||
|
||||
// 获取访问令牌
|
||||
data := url.Values{
|
||||
"client_id": {g.Addition.ClientId},
|
||||
"client_secret": {g.Addition.ClientSecret},
|
||||
"code": {code},
|
||||
"redirect_uri": {callbackURI},
|
||||
"grant_type": {"authorization_code"},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", g.Addition.TokenURL, strings.NewReader(data.Encode()))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
userReq, _ := http.NewRequest("GET", g.Addition.UserInfoURL, nil)
|
||||
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
|
||||
userReq.Header.Set("Accept", "application/json")
|
||||
|
||||
userResp, err := http.DefaultClient.Do(userReq)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
|
||||
}
|
||||
defer userResp.Body.Close()
|
||||
|
||||
var user map[string]interface{}
|
||||
if err := json.NewDecoder(userResp.Body).Decode(&user); err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse user info response: %v", err)
|
||||
}
|
||||
|
||||
userId, ok := user[g.Addition.UserIDField]
|
||||
if !ok {
|
||||
return factory.OidcCallback{}, fmt.Errorf("user id field '%s' not found in user info response", g.Addition.UserIDField)
|
||||
}
|
||||
|
||||
return factory.OidcCallback{UserId: fmt.Sprintf("%v", userId)}, nil
|
||||
}
|
||||
func (g *Generic) Init() error {
|
||||
g.stateCache = cache.New(time.Minute*5, time.Minute*10)
|
||||
return nil
|
||||
}
|
||||
func (g *Generic) Destroy() error {
|
||||
g.stateCache.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ factory.IOidcProvider = (*Generic)(nil)
|
||||
@@ -0,0 +1,27 @@
|
||||
package generic
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
func init() {
|
||||
factory.RegisterOidcProvider(func() factory.IOidcProvider {
|
||||
return &Generic{}
|
||||
})
|
||||
}
|
||||
|
||||
type Generic struct {
|
||||
Addition
|
||||
stateCache *cache.Cache // 用于存储state和用户信息的映射
|
||||
}
|
||||
|
||||
type Addition struct {
|
||||
ClientId string `json:"client_id" required:"true"`
|
||||
ClientSecret string `json:"client_secret" required:"true"`
|
||||
AuthURL string `json:"auth_url" required:"true"`
|
||||
TokenURL string `json:"token_url" required:"true"`
|
||||
UserInfoURL string `json:"user_info_url" required:"true"`
|
||||
Scope string `json:"scope"`
|
||||
UserIDField string `json:"user_id_field" required:"true"`
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
type GitHubUser struct {
|
||||
ID int `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// 获取OAuth的URL、state !!请务必验证state防止CSRF攻击
|
||||
func GetOAuthUrl() (string, string) {
|
||||
cfg, _ := config.Get()
|
||||
|
||||
state := utils.GenerateRandomString(16)
|
||||
|
||||
// 构建GitHub OAuth授权URL
|
||||
authURL := fmt.Sprintf(
|
||||
"https://github.com/login/oauth/authorize?client_id=%s&state=%s&scope=user:email",
|
||||
url.QueryEscape(cfg.OAuthClientID),
|
||||
url.QueryEscape(state),
|
||||
)
|
||||
|
||||
return authURL, state
|
||||
}
|
||||
|
||||
func OAuthCallback(code, state string) (GitHubUser, error) {
|
||||
cfg, _ := config.Get()
|
||||
|
||||
// 验证state防止CSRF攻击
|
||||
// state, _ := c.Cookie("oauth_state")
|
||||
if state == "" {
|
||||
return GitHubUser{}, fmt.Errorf("invalid state")
|
||||
}
|
||||
|
||||
// 获取code
|
||||
//code := c.Query("code")
|
||||
if code == "" {
|
||||
return GitHubUser{}, fmt.Errorf("no code provided")
|
||||
}
|
||||
|
||||
// 获取访问令牌
|
||||
tokenURL := "https://github.com/login/oauth/access_token"
|
||||
data := url.Values{
|
||||
"client_id": {cfg.OAuthClientID},
|
||||
"client_secret": {cfg.OAuthClientSecret},
|
||||
"code": {code},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", tokenURL, nil)
|
||||
req.URL.RawQuery = data.Encode()
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return GitHubUser{}, fmt.Errorf("failed to get access token: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return GitHubUser{}, fmt.Errorf("failed to parse access token response: %v", err)
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
userReq, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
|
||||
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
|
||||
userReq.Header.Set("Accept", "application/json")
|
||||
|
||||
userResp, err := http.DefaultClient.Do(userReq)
|
||||
if err != nil {
|
||||
return GitHubUser{}, fmt.Errorf("failed to get user info: %v", err)
|
||||
}
|
||||
defer userResp.Body.Close()
|
||||
|
||||
var githubUser GitHubUser
|
||||
if err := json.NewDecoder(userResp.Body).Decode(&githubUser); err != nil {
|
||||
return GitHubUser{}, fmt.Errorf("failed to parse user info response: %v", err)
|
||||
}
|
||||
|
||||
return githubUser, nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
}
|
||||
|
||||
func (g *Github) GetName() string {
|
||||
return "github"
|
||||
}
|
||||
func (g *Github) GetConfiguration() factory.Configuration {
|
||||
return &g.Addition
|
||||
}
|
||||
|
||||
func (g *Github) GetAuthorizationURL(_ string) (string, string) {
|
||||
state := utils.GenerateRandomString(16)
|
||||
|
||||
// 构建GitHub OAuth授权URL
|
||||
authURL := fmt.Sprintf(
|
||||
"https://github.com/login/oauth/authorize?client_id=%s&state=%s&scope=user:email",
|
||||
url.QueryEscape(g.Addition.ClientId),
|
||||
url.QueryEscape(state),
|
||||
)
|
||||
g.stateCache.Set(state, true, cache.NoExpiration)
|
||||
return authURL, state
|
||||
}
|
||||
func (g *Github) OnCallback(ctx context.Context, state string, query map[string]string, _ string) (factory.OidcCallback, error) {
|
||||
code := query["code"]
|
||||
|
||||
// 验证state防止CSRF攻击
|
||||
// state, _ := c.Cookie("oauth_state")
|
||||
if g.stateCache == nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
|
||||
}
|
||||
if _, ok := g.stateCache.Get(state); !ok {
|
||||
return factory.OidcCallback{}, fmt.Errorf("invalid state")
|
||||
}
|
||||
if state == "" {
|
||||
return factory.OidcCallback{}, fmt.Errorf("invalid state")
|
||||
}
|
||||
|
||||
// 获取code
|
||||
//code := c.Query("code")
|
||||
if code == "" {
|
||||
return factory.OidcCallback{}, fmt.Errorf("no code provided")
|
||||
}
|
||||
|
||||
// 获取访问令牌
|
||||
tokenURL := "https://github.com/login/oauth/access_token"
|
||||
data := url.Values{
|
||||
"client_id": {g.Addition.ClientId},
|
||||
"client_secret": {g.Addition.ClientSecret},
|
||||
"code": {code},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("POST", tokenURL, nil)
|
||||
req.URL.RawQuery = data.Encode()
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
userReq, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
|
||||
userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
|
||||
userReq.Header.Set("Accept", "application/json")
|
||||
|
||||
userResp, err := http.DefaultClient.Do(userReq)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
|
||||
}
|
||||
defer userResp.Body.Close()
|
||||
|
||||
var githubUser GitHubUser
|
||||
if err := json.NewDecoder(userResp.Body).Decode(&githubUser); err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse user info response: %v", err)
|
||||
}
|
||||
|
||||
return factory.OidcCallback{UserId: fmt.Sprintf("%d", githubUser.ID)}, nil
|
||||
}
|
||||
func (g *Github) Init() error {
|
||||
g.stateCache = cache.New(time.Minute*5, time.Minute*10)
|
||||
return nil
|
||||
}
|
||||
func (g *Github) Destroy() error {
|
||||
g.stateCache.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ factory.IOidcProvider = (*Github)(nil)
|
||||
@@ -0,0 +1,28 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
func init() {
|
||||
factory.RegisterOidcProvider(func() factory.IOidcProvider {
|
||||
return &Github{}
|
||||
})
|
||||
}
|
||||
|
||||
type Github struct {
|
||||
Addition
|
||||
stateCache *cache.Cache // 用于存储state和用户信息的映射
|
||||
}
|
||||
|
||||
type Addition struct {
|
||||
ClientId string `json:"client_id" required:"true"`
|
||||
ClientSecret string `json:"client_secret" required:"true"`
|
||||
}
|
||||
|
||||
type GitHubUser struct {
|
||||
ID int `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
)
|
||||
|
||||
var (
|
||||
currentProvider factory.IOidcProvider
|
||||
mu = sync.Mutex{}
|
||||
once = sync.Once{}
|
||||
)
|
||||
|
||||
func CurrentProvider() factory.IOidcProvider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return currentProvider
|
||||
}
|
||||
|
||||
func LoadProvider(name string, configJson string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if currentProvider != nil {
|
||||
if err := currentProvider.Destroy(); err != nil {
|
||||
log.Printf("Failed to destroy provider %s: %v", currentProvider.GetName(), err)
|
||||
}
|
||||
}
|
||||
constructor, exists := factory.GetConstructor(name)
|
||||
if !exists {
|
||||
return fmt.Errorf("provider %s not found", name)
|
||||
}
|
||||
currentProvider = constructor()
|
||||
if err := json.Unmarshal([]byte(configJson), currentProvider.GetConfiguration()); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal config for provider %s: %w", name, err)
|
||||
}
|
||||
err := currentProvider.Init()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize provider %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Initialize() error {
|
||||
once.Do(func() {
|
||||
all := factory.GetAllOidcProviders()
|
||||
for _, provider := range all {
|
||||
if _, err := database.GetOidcConfigByName(provider.GetName()); err == nil {
|
||||
continue
|
||||
}
|
||||
// 如果数据库中没有该提供者的配置,则保存默认配置
|
||||
config := provider.GetConfiguration()
|
||||
configBytes, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
|
||||
return
|
||||
}
|
||||
if err := database.SaveOidcConfig(&models.OidcProvider{
|
||||
Name: provider.GetName(),
|
||||
Addition: string(configBytes),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
cfg, _ := config.Get()
|
||||
if cfg.OAuthProvider == "" || cfg.OAuthProvider == "none" {
|
||||
LoadProvider("empty", "{}")
|
||||
return nil
|
||||
}
|
||||
provider, err := database.GetOidcConfigByName(cfg.OAuthProvider)
|
||||
if err != nil {
|
||||
// 如果没有找到配置,使用empty provider
|
||||
LoadProvider("empty", "{}")
|
||||
return nil
|
||||
}
|
||||
err = LoadProvider(provider.Name, provider.Addition)
|
||||
if err != nil {
|
||||
log.Printf("Failed to load OIDC provider %s: %v", provider.Name, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
func init() {
|
||||
factory.RegisterOidcProvider(func() factory.IOidcProvider {
|
||||
return &QQ{}
|
||||
})
|
||||
}
|
||||
|
||||
type QQ struct {
|
||||
Addition
|
||||
stateCache *cache.Cache // 用于存储state和用户信息的映射
|
||||
}
|
||||
|
||||
type Addition struct {
|
||||
AggregationURL string `json:"aggregation_url" required:"true" default:"https://login.qjqq.cn"` // 聚合登录地址
|
||||
AppId string `json:"app_id" required:"true"`
|
||||
AppKey string `json:"app_key" required:"true"`
|
||||
LoginType string `json:"login_type" required:"true"` // 登录方式,如qq, google等
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package qq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/oauth/factory"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
func (q *QQ) GetName() string {
|
||||
return "qq"
|
||||
}
|
||||
|
||||
func (q *QQ) GetConfiguration() factory.Configuration {
|
||||
return &q.Addition
|
||||
}
|
||||
|
||||
func (q *QQ) GetAuthorizationURL(redirectURI string) (string, string) {
|
||||
state := utils.GenerateRandomString(16)
|
||||
|
||||
// 构建请求QQ聚合登录平台的URL
|
||||
requestURL := fmt.Sprintf(
|
||||
"%s/connect.php?act=login&appid=%s&appkey=%s&type=%s&redirect_uri=%s",
|
||||
q.Addition.AggregationURL,
|
||||
url.QueryEscape(q.Addition.AppId),
|
||||
url.QueryEscape(q.Addition.AppKey),
|
||||
url.QueryEscape(q.Addition.LoginType),
|
||||
url.QueryEscape(redirectURI),
|
||||
)
|
||||
|
||||
// 向聚合登录平台发送请求
|
||||
resp, err := http.Get(requestURL)
|
||||
if err != nil {
|
||||
// 如果请求失败,返回错误信息
|
||||
return "", state
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应内容
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", state
|
||||
}
|
||||
|
||||
// 解析响应JSON
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", state
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if result.Code != 0 {
|
||||
return "", state
|
||||
}
|
||||
|
||||
q.stateCache.Set(state, true, cache.DefaultExpiration)
|
||||
return result.URL, state
|
||||
}
|
||||
|
||||
// OnCallback 处理QQ OAuth回调
|
||||
// 例如:http://localhost:25774/api/oauth_callback?type=qq&code=XXXXXXXXXXXXXXXX
|
||||
// 然后我们使用code参数向聚合登录平台请求用户信息
|
||||
func (q *QQ) OnCallback(ctx context.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
|
||||
// 根据文档,回调地址会附带type和code参数
|
||||
code := query["code"]
|
||||
loginType := query["type"]
|
||||
|
||||
// 如果回调中没有type参数,则使用配置中的LoginType
|
||||
if loginType == "" {
|
||||
loginType = q.Addition.LoginType
|
||||
}
|
||||
|
||||
// 验证state防止CSRF攻击
|
||||
if q.stateCache == nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
|
||||
}
|
||||
if _, ok := q.stateCache.Get(state); !ok {
|
||||
return factory.OidcCallback{}, fmt.Errorf("invalid state")
|
||||
}
|
||||
if state == "" {
|
||||
return factory.OidcCallback{}, fmt.Errorf("invalid state")
|
||||
}
|
||||
|
||||
// 检查是否提供了Authorization Code
|
||||
if code == "" {
|
||||
return factory.OidcCallback{}, fmt.Errorf("no authorization code provided")
|
||||
}
|
||||
|
||||
// 通过Authorization Code获取用户信息
|
||||
// 根据文档,请求URL应为: {AggregationURL}/connect.php?act=callback&appid={appid}&appkey={appkey}&type={登录方式}&code={code}
|
||||
callbackURL := fmt.Sprintf(
|
||||
"%s/connect.php?act=callback&appid=%s&appkey=%s&type=%s&code=%s",
|
||||
q.Addition.AggregationURL,
|
||||
url.QueryEscape(q.Addition.AppId),
|
||||
url.QueryEscape(q.Addition.AppKey),
|
||||
url.QueryEscape(loginType),
|
||||
url.QueryEscape(code),
|
||||
)
|
||||
|
||||
resp, err := http.Get(callbackURL)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查HTTP响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return factory.OidcCallback{}, fmt.Errorf("HTTP request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Type string `json:"type"`
|
||||
SocialUid string `json:"social_uid"`
|
||||
AccessToken string `json:"access_token"`
|
||||
FaceImg string `json:"faceimg"`
|
||||
Nickname string `json:"nickname"`
|
||||
Gender string `json:"gender"`
|
||||
Location string `json:"location"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return factory.OidcCallback{}, fmt.Errorf("failed to parse callback response: %v, response body: %s", err, string(body))
|
||||
}
|
||||
|
||||
// 检查返回状态码
|
||||
if result.Code != 0 {
|
||||
return factory.OidcCallback{}, fmt.Errorf("QQ login callback failed with code %d: %s", result.Code, result.Msg)
|
||||
}
|
||||
|
||||
// 检查是否返回了用户唯一标识
|
||||
if result.SocialUid == "" {
|
||||
return factory.OidcCallback{}, fmt.Errorf("empty social_uid returned, full response: %s", string(body))
|
||||
}
|
||||
|
||||
// 返回用户唯一标识
|
||||
return factory.OidcCallback{UserId: result.SocialUid}, nil
|
||||
}
|
||||
|
||||
func (q *QQ) Init() error {
|
||||
q.stateCache = cache.New(time.Minute*5, time.Minute*10)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QQ) Destroy() error {
|
||||
q.stateCache.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ factory.IOidcProvider = (*QQ)(nil)
|
||||
+57
-38
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,16 +11,13 @@ import (
|
||||
|
||||
// PingTaskManager 管理定时器和任务
|
||||
type PingTaskManager struct {
|
||||
mu sync.Mutex
|
||||
tickers map[int]*time.Ticker
|
||||
tasks map[int][]models.PingTask
|
||||
stopChan chan struct{}
|
||||
mu sync.Mutex
|
||||
cancelFunc context.CancelFunc
|
||||
tasks map[int][]models.PingTask
|
||||
}
|
||||
|
||||
var manager = &PingTaskManager{
|
||||
tickers: make(map[int]*time.Ticker),
|
||||
tasks: make(map[int][]models.PingTask),
|
||||
stopChan: make(chan struct{}),
|
||||
tasks: make(map[int][]models.PingTask),
|
||||
}
|
||||
|
||||
// Reload 重载时间表
|
||||
@@ -27,60 +25,81 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 停止所有现有定时器
|
||||
for _, ticker := range m.tickers {
|
||||
ticker.Stop()
|
||||
if m.cancelFunc != nil {
|
||||
m.cancelFunc()
|
||||
}
|
||||
m.tickers = make(map[int]*time.Ticker)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.cancelFunc = cancel
|
||||
|
||||
m.tasks = make(map[int][]models.PingTask)
|
||||
|
||||
// 按Interval分组任务
|
||||
taskGroups := make(map[int][]models.PingTask)
|
||||
for _, task := range pingTasks {
|
||||
if task.Interval <= 0 {
|
||||
continue
|
||||
}
|
||||
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
|
||||
}
|
||||
|
||||
// 为每个唯一的Interval创建定时器
|
||||
// 为每个唯一的Interval创建协程
|
||||
for interval, tasks := range taskGroups {
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
m.tickers[interval] = ticker
|
||||
m.tasks[interval] = tasks
|
||||
|
||||
go func(ticker *time.Ticker, tasks []models.PingTask) {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
for _, task := range tasks {
|
||||
go executePingTask(task)
|
||||
}
|
||||
case <-m.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}(ticker, tasks)
|
||||
go m.runPreciseLoop(ctx, time.Duration(interval)*time.Second, tasks)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *PingTaskManager) runPreciseLoop(ctx context.Context, interval time.Duration, tasks []models.PingTask) {
|
||||
// Start the first timer.
|
||||
timer := time.NewTimer(interval)
|
||||
|
||||
// This will be the reference point for all future ticks.
|
||||
// By adding the interval to this time, we avoid accumulating execution delays.
|
||||
nextTick := time.Now().Add(interval)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
onlineClients := ws.GetConnectedClients()
|
||||
for _, task := range tasks {
|
||||
go executePingTask(ctx, task, onlineClients)
|
||||
}
|
||||
|
||||
nextTick = nextTick.Add(interval)
|
||||
timer.Reset(time.Until(nextTick))
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executePingTask 执行单个PingTask
|
||||
func executePingTask(task models.PingTask) {
|
||||
clients := ws.GetConnectedClients()
|
||||
func executePingTask(ctx context.Context, task models.PingTask, onlineClients map[string]*ws.SafeConn) {
|
||||
var message struct {
|
||||
TaskID uint `json:"ping_task_id"`
|
||||
Message string `json:"message"`
|
||||
Type string `json:"ping_type"`
|
||||
Target string `json:"ping_target"`
|
||||
}
|
||||
|
||||
message.Message = "ping"
|
||||
message.TaskID = task.Id
|
||||
message.Type = task.Type
|
||||
message.Target = task.Target
|
||||
|
||||
for _, clientUUID := range task.Clients {
|
||||
if conn, exists := clients[clientUUID]; exists {
|
||||
if conn == nil {
|
||||
continue
|
||||
}
|
||||
message.Message = "ping"
|
||||
message.TaskID = task.Id
|
||||
message.Type = task.Type
|
||||
message.Target = task.Target
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Context was canceled, stop sending pings.
|
||||
return
|
||||
default:
|
||||
// Context is still active, continue.
|
||||
}
|
||||
|
||||
if conn, exists := onlineClients[clientUUID]; exists && conn != nil {
|
||||
if err := conn.WriteJSON(message); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ func CheckAndAutoRenewal(client models.Client) {
|
||||
} else if billingCycle >= 1080 && billingCycle <= 1150 {
|
||||
// 三年计费 - 加3年
|
||||
newExpireTime = baseTime.AddDate(3, 0, 0)
|
||||
} else if billingCycle >= 1800 && billingCycle <= 1850 {
|
||||
// 五年计费 - 加5年
|
||||
newExpireTime = baseTime.AddDate(5, 0, 0)
|
||||
} else {
|
||||
// 其他情况,直接加上账单周期天数
|
||||
newExpireTime = baseTime.AddDate(0, 0, billingCycle)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetClients(c *gin.Context) {
|
||||
// 升级到ws
|
||||
if !websocket.IsWebSocketUpgrade(c.Request) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
// Upgrade the HTTP connection to a WebSocket connection
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 请求
|
||||
for {
|
||||
var resp struct {
|
||||
Online []string `json:"online"` // 已建立连接的客户端uuid列表
|
||||
Data map[string]*common.Report `json:"data"` // 最后上报的数据
|
||||
}
|
||||
resp.Online = []string{}
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
//log.Println("Error reading message:", err)
|
||||
return
|
||||
}
|
||||
message := string(data)
|
||||
|
||||
if message != "get" {
|
||||
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid message"})
|
||||
continue
|
||||
}
|
||||
// 已建立连接的客户端uuid列表
|
||||
for key := range GetConnectedClients() {
|
||||
resp.Online = append(resp.Online, key)
|
||||
}
|
||||
// 清除UUID,简化报告单
|
||||
resp.Data = GetLatestReport()
|
||||
for _, report := range resp.Data {
|
||||
report.UUID = ""
|
||||
}
|
||||
for _, report := range resp.Data {
|
||||
if report.CPU.Usage == 0 {
|
||||
report.CPU.Usage = 0.01
|
||||
}
|
||||
}
|
||||
err = conn.WriteJSON(gin.H{"status": "success", "data": resp})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,12 +29,19 @@ func SetConnectedClients(uuid string, conn *SafeConn) {
|
||||
defer mu.Unlock()
|
||||
connectedClients[uuid] = conn
|
||||
}
|
||||
func DeleteClientConditionally(uuid string, connToRemove *SafeConn) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// 检查当前 map 里的 conn 是否就是要删除的这一个
|
||||
if currentConn, exists := connectedClients[uuid]; exists && currentConn == connToRemove {
|
||||
delete(connectedClients, uuid)
|
||||
}
|
||||
}
|
||||
func DeleteConnectedClients(uuid string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if conn, exists := connectedClients[uuid]; exists {
|
||||
conn.Close()
|
||||
}
|
||||
// 只从 map 中删除,不再负责关闭连接
|
||||
delete(connectedClients, uuid)
|
||||
}
|
||||
func GetLatestReport() map[string]*common.Report {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CheckOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
// 显式关闭校验
|
||||
if strings.EqualFold(os.Getenv("KOMARI_WS_DISABLE_ORIGIN"), "true") {
|
||||
return true
|
||||
}
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
host := r.Host
|
||||
originUrl, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return originUrl.Host == host
|
||||
}
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
type SafeConn struct {
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
ID int64
|
||||
}
|
||||
|
||||
func NewSafeConn(conn *websocket.Conn) *SafeConn {
|
||||
return &SafeConn{
|
||||
conn: conn,
|
||||
mu: sync.Mutex{},
|
||||
ID: time.Now().UnixNano(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user