mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 17:00:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb1eed1ccb | ||
|
|
c34bc8da69 | ||
|
|
249c4376f1 | ||
|
|
d36bde7d94 | ||
|
|
493d2a2707 | ||
|
|
93a9fbe5cc | ||
|
|
b070f33bf9 | ||
|
|
ee5480dba7 | ||
|
|
2e9ef3d5ba | ||
|
|
1061b84087 | ||
|
|
0dc80f7696 | ||
|
|
212e3c689b | ||
|
|
488ca451ae | ||
|
|
f50c167b37 | ||
|
|
4e91d62d48 | ||
|
|
a1b0b1e177 | ||
|
|
147dd2011a | ||
|
|
d24b71d4af | ||
|
|
483e3a94e9 | ||
|
|
e612f5ed8f | ||
|
|
78f3c8fd4b | ||
|
|
5a4e073d63 | ||
|
|
22cbfecfa9 | ||
|
|
d1b059fc2f | ||
|
|
cf5c410aa6 | ||
|
|
9c46673a1a | ||
|
|
45c27ae981 | ||
|
|
b62fb1f708 | ||
|
|
579c196643 | ||
|
|
e8da6e213a | ||
|
|
d9426e43a9 | ||
|
|
aad0274d56 | ||
|
|
d65f7ca924 | ||
|
|
a4d95640a5 | ||
|
|
adda3a2488 | ||
|
|
34654c0969 | ||
|
|
b708f9817d |
No files matched your search
@@ -70,8 +70,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -107,4 +114,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
@@ -12,13 +12,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
uses: actions/checkout@v4
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p public/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
|
||||
|
||||
# mkdir -p public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
run: |
|
||||
git clone https://github.com/komari-monitor/komari-web web
|
||||
@@ -26,8 +26,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -33,8 +33,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -36,8 +36,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -73,8 +73,15 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
mkdir -p public/dist
|
||||
cp -r web/dist/* public/dist/
|
||||
mkdir -p public/defaultTheme/dist
|
||||
rm -rf public/defaultTheme/dist/*
|
||||
cp -r web/dist/* public/defaultTheme/dist/
|
||||
cp -f web/komari-theme.json public/defaultTheme/
|
||||
if [ -f web/preview.png ]; then cp -f web/preview.png public/defaultTheme/; fi
|
||||
if [ -f web/perview.png ]; then cp -f web/perview.png public/defaultTheme/; fi
|
||||
# Compatibility: ensure both filenames exist if only one is present
|
||||
if [ -f public/defaultTheme/preview.png ] && [ ! -f public/defaultTheme/perview.png ]; then cp -f public/defaultTheme/preview.png public/defaultTheme/perview.png; fi
|
||||
if [ -f public/defaultTheme/perview.png ] && [ ! -f public/defaultTheme/preview.png ]; then cp -f public/defaultTheme/perview.png public/defaultTheme/preview.png; fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
CoreOS Project
|
||||
Copyright 2014 CoreOS, Inc
|
||||
|
||||
This product includes software developed at CoreOS, Inc.
|
||||
(http://www.coreos.com/).
|
||||
|
||||
|
||||
otp
|
||||
Copyright (c) 2014, Paul Querna
|
||||
|
||||
This product includes software developed by
|
||||
Paul Querna (http://paul.querna.org/).
|
||||
|
||||
|
||||
Copyright 2014 gRPC authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,6 +1,7 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

|
||||
[](https://deepwiki.com/komari-monitor/komari)
|
||||
|
||||

|
||||
|
||||
@@ -8,9 +9,10 @@
|
||||
|
||||
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.
|
||||
|
||||
[Documentation](https://komari-document.pages.dev/) | [Telegram Group](https://t.me/komari_monitor)
|
||||
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [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.
|
||||
@@ -18,16 +20,15 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
|
||||
## Quick Start
|
||||
|
||||
### 0. One-click Deployment with Cloud Hosting
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
- Rainyun - CNY 4.5/month
|
||||
|
||||
- Claw Run - USD 1.5/month
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 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
|
||||
@@ -35,6 +36,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker Deployment
|
||||
|
||||
1. Create a data directory:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -57,6 +59,7 @@ sudo ./install-komari.sh
|
||||
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
|
||||
|
||||
### 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
|
||||
@@ -68,7 +71,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
|
||||
|
||||
|
||||
### Manual Build
|
||||
|
||||
#### Dependencies
|
||||
@@ -87,25 +89,26 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
|
||||
```bash
|
||||
Copy the static files generated in step 1 to the `/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/public/defaultTheme`.
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. Run:
|
||||
3. Run:
|
||||
```bash
|
||||
./komari server -l 0.0.0.0:25774
|
||||
```
|
||||
The default listening port is `25774`. Access `http://localhost:25774`.
|
||||
|
||||
## Frontend Development Guide
|
||||
|
||||
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
|
||||
|
||||
[Translate Komari on Crowdin](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
|
||||
|
||||
## 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
|
||||
@@ -116,7 +119,7 @@ Issues and Pull Requests are welcome!
|
||||
|
||||
### 🚀 Sponsored by SharonNetworks
|
||||
|
||||
[](https://sharon.io)
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
@@ -129,4 +132,3 @@ All the developers who submitted PRs and created themes
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
|
||||
+5
-4
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
@@ -146,12 +146,13 @@ func GetVersion(c *gin.Context) {
|
||||
}
|
||||
|
||||
func isApiKeyValid(apiKey string) bool {
|
||||
cfg, err := config.Get()
|
||||
apiKeyConfig, err := config.GetAs[string](config.ApiKeyKey, "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
|
||||
|
||||
if apiKeyConfig == "" || len(apiKeyConfig) < 12 {
|
||||
return false
|
||||
}
|
||||
return apiKey == "Bearer "+cfg.ApiKey
|
||||
return apiKey == "Bearer "+apiKeyConfig
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -45,14 +45,14 @@ func PrivateSiteMiddleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
conf, err := config.Get()
|
||||
PrivateSite, err := config.GetAs[bool](config.PrivateSiteKey, false)
|
||||
if err != nil {
|
||||
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 验证私有, 如果不是私有站点,直接放行
|
||||
if !conf.PrivateSite {
|
||||
if !PrivateSite {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package admin
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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"
|
||||
@@ -50,9 +50,10 @@ func SetMessageSenderProvider(c *gin.Context) {
|
||||
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
|
||||
|
||||
// 正在使用,重载
|
||||
if cfg.NotificationMethod == senderConfig.Name {
|
||||
if 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())
|
||||
|
||||
+3
-3
@@ -3,9 +3,9 @@ package admin
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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"
|
||||
@@ -80,9 +80,9 @@ func SetOidcProvider(c *gin.Context) {
|
||||
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
OAuthProvider, _ := config.GetAs[string](config.OAuthProviderKey, "github")
|
||||
// 正在使用,重载
|
||||
if cfg.OAuthProvider == oidcConfig.Name {
|
||||
if OAuthProvider == oidcConfig.Name {
|
||||
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
|
||||
|
||||
+5
-23
@@ -1,12 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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/database/records"
|
||||
"github.com/komari-monitor/komari/database/tasks"
|
||||
|
||||
@@ -15,20 +12,10 @@ import (
|
||||
|
||||
// GetSettings 获取自定义配置
|
||||
func GetSettings(c *gin.Context) {
|
||||
cst, err := config.Get()
|
||||
cst, err := config.GetAll()
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
//override
|
||||
cst = models.Config{Sitename: "Komari"}
|
||||
cst.ID = 1
|
||||
config.Save(cst)
|
||||
api.RespondSuccess(c, cst)
|
||||
return
|
||||
}
|
||||
c.JSON(500, gin.H{
|
||||
"status": "error",
|
||||
"message": "Internal Server Error: " + err.Error(),
|
||||
})
|
||||
api.RespondError(c, 500, "Failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
api.RespondSuccess(c, cst)
|
||||
}
|
||||
@@ -41,8 +28,7 @@ func EditSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg["id"] = 1 // Only one record
|
||||
if err := config.Update(cfg); err != nil {
|
||||
if err := config.SetMany(cfg); err != nil {
|
||||
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -50,10 +36,6 @@ func EditSettings(c *gin.Context) {
|
||||
uuid, _ := c.Get("uuid")
|
||||
message := "update settings: "
|
||||
for key := range cfg {
|
||||
ignoredKeys := []string{"id", "updated_at"}
|
||||
if contains(ignoredKeys, key) {
|
||||
continue
|
||||
}
|
||||
message += key + ", "
|
||||
}
|
||||
if len(message) > 2 {
|
||||
|
||||
@@ -5,7 +5,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/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
@@ -32,12 +32,12 @@ func TestGeoIp(c *gin.Context) {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
}
|
||||
conf, err := config.Get()
|
||||
cfg, err := config.GetAs[bool](config.GeoIpEnabledKey, false)
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
if !cfg {
|
||||
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
+14
-9
@@ -14,9 +14,10 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/public"
|
||||
)
|
||||
|
||||
// UploadTheme 上传主题
|
||||
@@ -69,6 +70,15 @@ func ListThemes(c *gin.Context) {
|
||||
}
|
||||
|
||||
var themes []models.Theme
|
||||
defaultTheme, err := public.PublicFS.ReadFile("defaultTheme/komari-theme.json")
|
||||
if err == nil {
|
||||
dt := models.Theme{}
|
||||
err := json.Unmarshal(defaultTheme, &dt)
|
||||
if err == nil {
|
||||
themes = append(themes, dt)
|
||||
}
|
||||
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
themeConfigPath := filepath.Join(dataDir, entry.Name(), "komari-theme.json")
|
||||
@@ -133,12 +143,7 @@ func SetTheme(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
updateData := map[string]interface{}{
|
||||
"theme": themeName,
|
||||
}
|
||||
|
||||
if err := config.Update(updateData); err != nil {
|
||||
if err := config.Set("theme", themeName); err != nil {
|
||||
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -591,8 +596,8 @@ func UpdateTheme(c *gin.Context) {
|
||||
|
||||
func UpdateThemeSettings(c *gin.Context) {
|
||||
theme := c.Query("theme")
|
||||
if theme == "" || theme == "default" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
|
||||
if theme == "" {
|
||||
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package client
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
@@ -14,15 +14,14 @@ func RegisterClient(c *gin.Context) {
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
}
|
||||
cfg, err := config.Get()
|
||||
AutoDiscoveryKey, err := config.GetAs[string](config.AutoDiscoveryKeyKey, "")
|
||||
if err != nil {
|
||||
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
|
||||
api.RespondError(c, 500, "Failed to get AutoDiscovery Key: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.AutoDiscoveryKey == "" ||
|
||||
len(cfg.AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+cfg.AutoDiscoveryKey != auth {
|
||||
if AutoDiscoveryKey == "" ||
|
||||
len(AutoDiscoveryKey) < 12 ||
|
||||
"Bearer "+AutoDiscoveryKey != auth {
|
||||
|
||||
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
|
||||
return
|
||||
|
||||
@@ -3,8 +3,8 @@ package client
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -60,7 +60,7 @@ func UploadBasicInfo(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if cfg, err := config.GetAs[bool](config.GeoIpEnabledKey); err == nil && cfg {
|
||||
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
|
||||
ip4 := net.ParseIP(ipv4)
|
||||
ip4_record, _ := geoip.GetGeoInfo(ip4)
|
||||
|
||||
+54
-8
@@ -11,22 +11,64 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/rpc"
|
||||
"github.com/komari-monitor/komari/ws"
|
||||
)
|
||||
|
||||
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
|
||||
// group: 调用者的权限分组 (guest/client/admin)
|
||||
// method: RPC 方法名 (如 "common:getNodes")
|
||||
// params: 方法参数
|
||||
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
|
||||
// 检查私有站点
|
||||
PrivateSite, _ := config.GetAs[bool](config.PrivateSiteKey)
|
||||
|
||||
if PrivateSite && group == "guest" {
|
||||
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
|
||||
}
|
||||
|
||||
// 解析方法命名空间
|
||||
fc := strings.Split(method, ":")
|
||||
namespace := "common"
|
||||
if len(fc) >= 2 {
|
||||
namespace = fc[0]
|
||||
}
|
||||
|
||||
// 权限检查
|
||||
allowed := false
|
||||
switch namespace {
|
||||
case "guest", "", "rpc", "common":
|
||||
allowed = true
|
||||
case "client":
|
||||
if group == "client" || group == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
case "admin":
|
||||
if group == "admin" {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
|
||||
}
|
||||
|
||||
// 构建 context meta
|
||||
meta := &rpc.ContextMeta{Permission: group}
|
||||
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
|
||||
}
|
||||
|
||||
// Json Rpc2 over websocket, /api/rpc2
|
||||
func OnRpcRequest(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
AllowCors, _ := config.GetAs[bool](config.AllowCorsKey)
|
||||
|
||||
// GET -> WebSocket
|
||||
if c.Request.Method == http.MethodGet {
|
||||
_conn, err := ws.UpgradeRequest(c, func(r *http.Request) bool {
|
||||
if cfg.AllowCors {
|
||||
if AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
@@ -36,7 +78,7 @@ func OnRpcRequest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
|
||||
return
|
||||
}
|
||||
permissionGroup := detectPermissionGroup(c, cfg)
|
||||
permissionGroup := detectPermissionGroup(c)
|
||||
meta := buildContextMeta(c, permissionGroup)
|
||||
defer conn.Close()
|
||||
for {
|
||||
@@ -77,7 +119,7 @@ func OnRpcRequest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, jerr.Response())
|
||||
return
|
||||
}
|
||||
permissionGroup := detectPermissionGroup(c, cfg)
|
||||
permissionGroup := detectPermissionGroup(c)
|
||||
meta := buildContextMeta(c, permissionGroup)
|
||||
// 批量
|
||||
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
|
||||
@@ -118,7 +160,7 @@ func OnRpcRequest(c *gin.Context) {
|
||||
}
|
||||
|
||||
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
|
||||
func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
|
||||
func detectPermissionGroup(c *gin.Context) string {
|
||||
permissionGroup := "guest"
|
||||
token := c.Query("Authorization")
|
||||
if _, err := clients.GetClientUUIDByToken(token); err == nil {
|
||||
@@ -130,7 +172,11 @@ func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
|
||||
}
|
||||
}
|
||||
apiKey := c.GetHeader("Authorization")
|
||||
if apiKey == "Bearer "+cfg.ApiKey {
|
||||
if len(apiKey) <= len("Bearer ") {
|
||||
return permissionGroup
|
||||
}
|
||||
cfg, _ := config.GetAs[string](config.ApiKeyKey, "")
|
||||
if len(cfg) > 8 && apiKey == "Bearer "+cfg {
|
||||
permissionGroup = "admin"
|
||||
}
|
||||
return permissionGroup
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
"github.com/komari-monitor/komari/api"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"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/database/tasks"
|
||||
@@ -229,7 +229,7 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
|
||||
}
|
||||
meta := rpc.MetaFromContext(ctx)
|
||||
|
||||
cfg, _ := config.Get()
|
||||
SendIpAddrToGuest, _ := config.GetAs[bool](config.SendIpAddrToGuestKey)
|
||||
if meta.Permission != "admin" {
|
||||
// 过滤 Hidden 节点并隐藏敏感字段
|
||||
filtered := make([]models.Client, 0, len(cinfo))
|
||||
@@ -237,7 +237,7 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
|
||||
if node.Hidden { // 非 admin 不显示隐藏节点
|
||||
continue
|
||||
}
|
||||
if cfg.SendIpAddrToGuest {
|
||||
if SendIpAddrToGuest {
|
||||
if node.IPv4 != "" {
|
||||
node.IPv4 = strings.Split(node.IPv4, ".")[0] + ".*.*.*"
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/accounts"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -19,8 +19,8 @@ type LoginRequest struct {
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
conf, _ := config.Get()
|
||||
if conf.DisablePasswordLogin {
|
||||
DisablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
|
||||
if DisablePasswordLogin {
|
||||
RespondError(c, http.StatusForbidden, "Password login is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,17 +5,17 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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"
|
||||
)
|
||||
|
||||
// /api/oauth
|
||||
func OAuth(c *gin.Context) {
|
||||
cfg, _ := config.Get()
|
||||
if !cfg.OAuthEnabled {
|
||||
OAuthEnabled, _ := config.GetAs[bool](config.OAuthEnabledKey, false)
|
||||
if !OAuthEnabled {
|
||||
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
+1616
File diff suppressed because it is too large.
Load diff
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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"
|
||||
@@ -20,10 +20,10 @@ func GetClients(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
|
||||
return
|
||||
}
|
||||
cfg, _ := config.Get()
|
||||
AllowCors, _ := config.GetAs[bool](config.AllowCorsKey, false)
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
if cfg.AllowCors {
|
||||
if AllowCors {
|
||||
return true
|
||||
}
|
||||
return ws.CheckOrigin(r)
|
||||
|
||||
+3
-3
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
apiClient "github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/auditlog"
|
||||
"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/geoip"
|
||||
@@ -370,7 +370,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
|
||||
if in != nil && in.Ip != nil {
|
||||
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
|
||||
updates["ipv4"] = v4
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
|
||||
if ip := net.ParseIP(v4); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
@@ -381,7 +381,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
|
||||
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
|
||||
updates["ipv6"] = v6
|
||||
if iso == "" { // 优先使用 v4 的国家码
|
||||
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
|
||||
if GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
|
||||
if ip := net.ParseIP(v6); ip != nil {
|
||||
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
|
||||
iso = gi.ISOCode
|
||||
|
||||
+27
-21
@@ -21,13 +21,15 @@ import (
|
||||
"github.com/komari-monitor/komari/api/admin/update"
|
||||
"github.com/komari-monitor/komari/api/client"
|
||||
"github.com/komari-monitor/komari/api/jsonRpc"
|
||||
public_api "github.com/komari-monitor/komari/api/public"
|
||||
"github.com/komari-monitor/komari/api/record"
|
||||
"github.com/komari-monitor/komari/api/task"
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
d_notification "github.com/komari-monitor/komari/database/notification"
|
||||
@@ -37,6 +39,7 @@ import (
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"github.com/komari-monitor/komari/utils/cloudflared"
|
||||
"github.com/komari-monitor/komari/utils/geoip"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"github.com/komari-monitor/komari/utils/oauth"
|
||||
@@ -76,7 +79,7 @@ func RunServer() {
|
||||
if utils.VersionHash != "unknown" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
conf, err := config.Get()
|
||||
conf, err := config.GetManyAs[config.Legacy]()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -96,8 +99,11 @@ func RunServer() {
|
||||
}
|
||||
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
if event.New.OAuthProvider != event.Old.OAuthProvider {
|
||||
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
|
||||
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
|
||||
if t == "" || t == "none" {
|
||||
t = "github"
|
||||
}
|
||||
oidcProvider, err := database.GetOidcConfigByName(t)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get OIDC provider config: %v", err)
|
||||
} else {
|
||||
@@ -108,12 +114,11 @@ func RunServer() {
|
||||
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
|
||||
}
|
||||
}
|
||||
if event.New.NotificationMethod != event.Old.NotificationMethod {
|
||||
messageSender.Initialize()
|
||||
}
|
||||
if event.New.NezhaCompatEnabled != event.Old.NezhaCompatEnabled {
|
||||
if event.New.NezhaCompatEnabled {
|
||||
if err := StartNezhaCompat(event.New.NezhaCompatListen); err != nil {
|
||||
|
||||
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
|
||||
if t {
|
||||
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
|
||||
if err := StartNezhaCompat(l); err != nil {
|
||||
log.Printf("start Nezha compat server error: %v", err)
|
||||
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
|
||||
}
|
||||
@@ -134,19 +139,25 @@ func RunServer() {
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r := gin.New()
|
||||
r.Use(logutil.GinLogger())
|
||||
r.Use(logutil.GinRecovery())
|
||||
|
||||
// 动态 CORS 中间件
|
||||
|
||||
DynamicCorsEnabled = conf.AllowCors
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
DynamicCorsEnabled = event.New.AllowCors
|
||||
if event.New.GeoIpProvider != event.Old.GeoIpProvider {
|
||||
if ok, t := config.IsChangedT[bool](event, config.AllowCorsKey); ok {
|
||||
DynamicCorsEnabled = t
|
||||
}
|
||||
if event.IsChanged(config.GeoIpProviderKey) {
|
||||
go geoip.InitGeoIp()
|
||||
}
|
||||
if event.New.NotificationMethod != event.Old.NotificationMethod {
|
||||
|
||||
if event.IsChanged(config.NotificationMethodKey) {
|
||||
go messageSender.Initialize()
|
||||
}
|
||||
|
||||
})
|
||||
r.Use(func(c *gin.Context) {
|
||||
if DynamicCorsEnabled {
|
||||
@@ -193,7 +204,7 @@ func RunServer() {
|
||||
r.GET("/api/task/ping", task.GetPublicPingTasks)
|
||||
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
|
||||
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
|
||||
|
||||
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
|
||||
// #region Agent
|
||||
r.POST("/api/clients/register", client.RegisterClient)
|
||||
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
|
||||
@@ -334,11 +345,6 @@ func RunServer() {
|
||||
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
|
||||
r.NoRoute(handlers...)
|
||||
})
|
||||
// #region 静态文件服务
|
||||
public.UpdateIndex(conf)
|
||||
config.Subscribe(func(event config.ConfigEvent) {
|
||||
public.UpdateIndex(event.New)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: flags.Listen,
|
||||
@@ -394,9 +400,9 @@ func DoScheduledWork() {
|
||||
minute := time.NewTicker(60 * time.Second)
|
||||
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
|
||||
records.CompactRecord()
|
||||
cfg, _ := config.Get()
|
||||
go notifier.CheckExpireScheduledWork()
|
||||
for {
|
||||
cfg, _ := config.GetManyAs[config.Legacy]()
|
||||
select {
|
||||
case <-ticker.C:
|
||||
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
Key string `gorm:"primaryKey;column:key;type:text"`
|
||||
Value string `gorm:"column:value;type:text"` // 存 JSON 字符串
|
||||
}
|
||||
|
||||
func (ConfigItem) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
SetDb = func(gdb *gorm.DB) {
|
||||
db = gdb
|
||||
migrateInPlace()
|
||||
}
|
||||
)
|
||||
|
||||
func migrateInPlace() {
|
||||
if db.Migrator().HasTable("configs") && db.Migrator().HasColumn(&Legacy{}, "Sitename") {
|
||||
slog.Info("[>1.1.4] Moving legacy config data...")
|
||||
|
||||
var oldData Legacy
|
||||
if err := db.Order("id desc").First(&oldData).Error; err != nil {
|
||||
db.Migrator().DropTable("configs")
|
||||
} else {
|
||||
var newRows []ConfigItem
|
||||
val := reflect.ValueOf(oldData)
|
||||
typ := reflect.TypeOf(oldData)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("json")
|
||||
key := strings.Split(tag, ",")[0]
|
||||
|
||||
// 过滤 id 和无用字段
|
||||
if key == "" || key == "-" || key == "id" {
|
||||
continue
|
||||
}
|
||||
|
||||
valInterface := val.Field(i).Interface()
|
||||
jsonBytes, _ := json.Marshal(valInterface)
|
||||
|
||||
newRows = append(newRows, ConfigItem{
|
||||
Key: key,
|
||||
Value: string(jsonBytes),
|
||||
})
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().DropTable("configs"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&ConfigItem{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(newRows) > 0 {
|
||||
return tx.Create(&newRows).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
panic("failed " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
db.AutoMigrate(&ConfigItem{})
|
||||
}
|
||||
|
||||
// Get 获取原始值 (反序列化为 interface{})
|
||||
func Get(key string, defaul ...any) (any, error) {
|
||||
var item ConfigItem
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
v := defaul[0]
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var val any
|
||||
if err := json.Unmarshal([]byte(item.Value), &val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetAs 获取并转换为指定类型 (泛型)
|
||||
func GetAs[T any](key string, defaul ...any) (T, error) {
|
||||
var t T
|
||||
var item ConfigItem
|
||||
|
||||
err := db.First(&item, "key = ?", key).Error
|
||||
if err != nil {
|
||||
if len(defaul) > 0 {
|
||||
v := defaul[0].(T)
|
||||
err = Set(key, v)
|
||||
return v, err
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(item.Value), &t)
|
||||
return t, err
|
||||
}
|
||||
|
||||
// key[defaults]
|
||||
func GetMany(keys map[string]any) (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
keyList := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
keyList = append(keyList, k)
|
||||
}
|
||||
if len(keyList) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if err := db.Where("key IN ?", keyList).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
foundKeys := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
foundKeys[item.Key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fill in defaults for missing keys
|
||||
for k, def := range keys {
|
||||
if _, found := foundKeys[k]; !found {
|
||||
if def != nil {
|
||||
result[k] = def
|
||||
_ = Set(k, def) // Ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
|
||||
func GetManyAs[T any]() (*T, error) {
|
||||
var t T
|
||||
val := reflect.ValueOf(&t).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
tagToFieldMap := make(map[string]int) // Key -> FieldIndex
|
||||
keys := make([]string, 0)
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
tag := field.Tag.Get("json")
|
||||
if tag != "" && tag != "-" {
|
||||
keys = append(keys, tag)
|
||||
tagToFieldMap[tag] = i
|
||||
}
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
var items []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
fieldIndex, ok := tagToFieldMap[item.Key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldVal := val.Field(fieldIndex)
|
||||
if !fieldVal.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
target := reflect.New(fieldVal.Type()).Interface()
|
||||
|
||||
if err := json.Unmarshal([]byte(item.Value), target); err == nil {
|
||||
fieldVal.Set(reflect.ValueOf(target).Elem())
|
||||
}
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func GetAll() (map[string]any, error) {
|
||||
var items []ConfigItem
|
||||
result := make(map[string]any)
|
||||
if err := db.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
|
||||
result[item.Key] = parsed
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Set 设置单个配置
|
||||
func Set(key string, value any) error {
|
||||
oldVal := map[string]any{}
|
||||
{
|
||||
var oldItem ConfigItem
|
||||
if err := db.First(&oldItem, "key = ?", key).Error; err == nil {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oldItem.Value), &parsed); err == nil {
|
||||
oldVal[key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
item := ConfigItem{
|
||||
Key: key,
|
||||
Value: string(bytes),
|
||||
}
|
||||
|
||||
err = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&item).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newVal := map[string]any{key: value}
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMany 将结构体保存为多个配置项
|
||||
func SetManyAs[T any](config T) error {
|
||||
val := reflect.ValueOf(config)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
var items []ConfigItem
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
fieldType := typ.Field(i)
|
||||
tag := fieldType.Tag.Get("json")
|
||||
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := val.Field(i).Interface()
|
||||
|
||||
bytes, err := json.Marshal(fieldValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal field %s failed: %w", fieldType.Name, err)
|
||||
}
|
||||
|
||||
items = append(items, ConfigItem{
|
||||
Key: tag,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetMany(cst map[string]any) error {
|
||||
var items []ConfigItem
|
||||
for k, v := range cst {
|
||||
bytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal key %s failed: %w", k, err)
|
||||
}
|
||||
items = append(items, ConfigItem{
|
||||
Key: k,
|
||||
Value: string(bytes),
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(items))
|
||||
newVal := make(map[string]any, len(items))
|
||||
for _, it := range items {
|
||||
keys = append(keys, it.Key)
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
|
||||
newVal[it.Key] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
oldVal := map[string]any{}
|
||||
if len(keys) > 0 {
|
||||
var oldItems []ConfigItem
|
||||
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
|
||||
for _, oi := range oldItems {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
|
||||
oldVal[oi.Key] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).Create(&items).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishEvent(oldVal, newVal)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConfigEvent struct {
|
||||
Old map[string]any // Old models.Config
|
||||
New map[string]any // New models.Config
|
||||
}
|
||||
|
||||
func (e ConfigEvent) IsChanged(key string) bool {
|
||||
oldVal, oldOk := e.Old[key]
|
||||
newVal, newOk := e.New[key]
|
||||
if !oldOk && !newOk {
|
||||
return false
|
||||
}
|
||||
if oldOk != newOk {
|
||||
return true
|
||||
}
|
||||
return !reflect.DeepEqual(oldVal, newVal)
|
||||
}
|
||||
|
||||
func IsChangedT[T any](e ConfigEvent, key string) (bool, T) {
|
||||
changed := e.IsChanged(key)
|
||||
var zero T
|
||||
|
||||
val, ok := e.New[key]
|
||||
if !ok {
|
||||
val, ok = e.Old[key]
|
||||
if !ok {
|
||||
return changed, zero
|
||||
}
|
||||
}
|
||||
if val == nil {
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// Fast path: direct assertion.
|
||||
if cast, ok := val.(T); ok {
|
||||
return changed, cast
|
||||
}
|
||||
|
||||
// Try reflection-based conversion (covers numeric conversions, etc.).
|
||||
targetType := reflect.TypeOf((*T)(nil)).Elem()
|
||||
v := reflect.ValueOf(val)
|
||||
if v.IsValid() {
|
||||
if v.Type().AssignableTo(targetType) {
|
||||
return changed, v.Interface().(T)
|
||||
}
|
||||
if v.Type().ConvertibleTo(targetType) {
|
||||
converted := v.Convert(targetType)
|
||||
return changed, converted.Interface().(T)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: JSON roundtrip for map/struct and other loosely typed values.
|
||||
if b, err := json.Marshal(val); err == nil {
|
||||
var out T
|
||||
if err := json.Unmarshal(b, &out); err == nil {
|
||||
return changed, out
|
||||
}
|
||||
}
|
||||
|
||||
return changed, zero
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal map[string]any) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
for _, sub := range subscribers {
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type Legacy 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'
|
||||
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:''"` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
|
||||
// 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
|
||||
// Nezha 兼容(Agent gRPC)
|
||||
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
|
||||
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
|
||||
// OAuth 配置
|
||||
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"`
|
||||
// 通知
|
||||
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
|
||||
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
|
||||
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
|
||||
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天
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
SitenameKey = "sitename"
|
||||
DescriptionKey = "description"
|
||||
AllowCorsKey = "allow_cors"
|
||||
ThemeKey = "theme"
|
||||
PrivateSiteKey = "private_site"
|
||||
ApiKeyKey = "api_key"
|
||||
AutoDiscoveryKeyKey = "auto_discovery_key"
|
||||
ScriptDomainKey = "script_domain"
|
||||
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
|
||||
EulaAcceptedKey = "eula_accepted"
|
||||
GeoIpEnabledKey = "geo_ip_enabled"
|
||||
GeoIpProviderKey = "geo_ip_provider"
|
||||
NezhaCompatEnabledKey = "nezha_compat_enabled"
|
||||
NezhaCompatListenKey = "nezha_compat_listen"
|
||||
OAuthEnabledKey = "o_auth_enabled"
|
||||
OAuthProviderKey = "o_auth_provider"
|
||||
DisablePasswordLoginKey = "disable_password_login"
|
||||
CustomHeadKey = "custom_head"
|
||||
CustomBodyKey = "custom_body"
|
||||
NotificationEnabledKey = "notification_enabled"
|
||||
NotificationMethodKey = "notification_method"
|
||||
NotificationTemplateKey = "notification_template"
|
||||
ExpireNotificationEnabledKey = "expire_notification_enabled"
|
||||
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
|
||||
LoginNotificationKey = "login_notification"
|
||||
TrafficLimitPercentageKey = "traffic_limit_percentage"
|
||||
RecordEnabledKey = "record_enabled"
|
||||
RecordPreserveTimeKey = "record_preserve_time"
|
||||
PingRecordPreserveTimeKey = "ping_record_preserve_time"
|
||||
UpdatedAtKey = "updated_at"
|
||||
)
|
||||
|
||||
func (Legacy) TableName() string {
|
||||
return "configs"
|
||||
}
|
||||
|
||||
// Decrepted
|
||||
/*
|
||||
func Update(cst map[string]interface{}) error {
|
||||
oldConfig, _ := GetManyAs[Legacy]()
|
||||
// Proceed with update
|
||||
cst["updated_at"] = time.Now().Unix()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
// 没绑定账号也不能禁用
|
||||
if newDisablePasswordLogin {
|
||||
usr := &models.User{}
|
||||
if err := Db.Model(&models.User{}).First(usr).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve user"))
|
||||
}
|
||||
if usr.SSOID == "" {
|
||||
return errors.New("cannot disable password login when no SSO-bound account exists")
|
||||
}
|
||||
}
|
||||
err := Db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
//publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
@@ -39,21 +39,23 @@ 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
|
||||
go func() {
|
||||
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
|
||||
if LoginNotification {
|
||||
ipAddr := net.ParseIP(ip)
|
||||
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
|
||||
loc := "unknown"
|
||||
if ipinfo != nil && ipinfo.Name != "" {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
Emoji: "🔑",
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"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 {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
config = models.Config{
|
||||
ID: 1,
|
||||
Sitename: "Komari",
|
||||
Description: "Komari Monitor, a simple server monitoring tool.",
|
||||
AllowCors: false,
|
||||
OAuthEnabled: false,
|
||||
GeoIpEnabled: true,
|
||||
GeoIpProvider: "ipinfo",
|
||||
NezhaCompatEnabled: false,
|
||||
NezhaCompatListen: "",
|
||||
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
|
||||
UpdatedAt: models.FromTime(time.Now()),
|
||||
CreatedAt: models.FromTime(time.Now()),
|
||||
}
|
||||
if err := db.Create(&config).Error; err != nil {
|
||||
log.Fatal("Failed to create default config:", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func Save(cst models.Config) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Only one records
|
||||
cst.ID = 1
|
||||
cst.UpdatedAt = models.FromTime(time.Now())
|
||||
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
|
||||
Select("*").
|
||||
Updates(cst).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newConfig, _ := Get()
|
||||
publishEvent(oldConfig, newConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Update(cst map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
oldConfig, _ := Get()
|
||||
// Proceed with update
|
||||
cst["id"] = 1
|
||||
cst["updated_at"] = time.Now()
|
||||
delete(cst, "created_at")
|
||||
delete(cst, "CreatedAt")
|
||||
|
||||
// 至少有一种登录方式启用
|
||||
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
|
||||
newOAuthEnabled := oldConfig.OAuthEnabled
|
||||
if val, exists := cst["disable_password_login"]; exists {
|
||||
newDisablePasswordLogin = val.(bool)
|
||||
}
|
||||
if val, exists := cst["o_auth_enabled"]; exists {
|
||||
newOAuthEnabled = val.(bool)
|
||||
}
|
||||
if newDisablePasswordLogin && !newOAuthEnabled {
|
||||
return errors.New("at least one login method must be enabled (password/oauth)")
|
||||
}
|
||||
// 没绑定账号也不能禁用
|
||||
if newDisablePasswordLogin {
|
||||
usr := &models.User{}
|
||||
if err := db.Model(&models.User{}).First(usr).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve user"))
|
||||
}
|
||||
if usr.SSOID == "" {
|
||||
return errors.New("cannot disable password login when no SSO-bound account exists")
|
||||
}
|
||||
}
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to update configuration"))
|
||||
}
|
||||
newConfig := &models.Config{}
|
||||
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
|
||||
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
|
||||
}
|
||||
publishEvent(oldConfig, *newConfig)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// ConfigEvent represents a configuration change event
|
||||
type ConfigEvent struct {
|
||||
Old models.Config
|
||||
New models.Config
|
||||
}
|
||||
|
||||
// ConfigSubscriber handles config events
|
||||
type ConfigSubscriber func(event ConfigEvent)
|
||||
|
||||
var (
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers []ConfigSubscriber
|
||||
)
|
||||
|
||||
// Subscribe registers a subscriber for all config events.
|
||||
func Subscribe(subscriber ConfigSubscriber) {
|
||||
subscribersMu.Lock()
|
||||
defer subscribersMu.Unlock()
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
|
||||
// publishEvent notifies all subscribers of a config change.
|
||||
func publishEvent(oldVal, newVal models.Config) {
|
||||
subscribersMu.RLock()
|
||||
defer subscribersMu.RUnlock()
|
||||
event := ConfigEvent{Old: oldVal, New: newVal}
|
||||
for _, sub := range subscribers {
|
||||
go sub(event)
|
||||
}
|
||||
}
|
||||
+21
-24
@@ -14,12 +14,11 @@ import (
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/common"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/driver/mysql"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
|
||||
@@ -361,6 +360,7 @@ var (
|
||||
|
||||
func GetDBInstance() *gorm.DB {
|
||||
once.Do(func() {
|
||||
|
||||
var err error
|
||||
|
||||
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
|
||||
@@ -408,13 +408,7 @@ func GetDBInstance() *gorm.DB {
|
||||
}()
|
||||
|
||||
logConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}
|
||||
|
||||
if utils.VersionHash == "unknown" {
|
||||
logConfig = &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
}
|
||||
Logger: logutil.NewGormLogger(),
|
||||
}
|
||||
|
||||
// 根据数据库类型选择不同的连接方式
|
||||
@@ -431,22 +425,24 @@ func GetDBInstance() *gorm.DB {
|
||||
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
|
||||
}
|
||||
instance.Exec("VACUUM;")
|
||||
case "mysql":
|
||||
// MySQL 连接
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
|
||||
flags.DatabaseUser,
|
||||
flags.DatabasePass,
|
||||
flags.DatabaseHost,
|
||||
flags.DatabasePort,
|
||||
flags.DatabaseName)
|
||||
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to MySQL database: %v", err)
|
||||
}
|
||||
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
// case "mysql":
|
||||
// // MySQL 连接
|
||||
// dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
|
||||
// flags.DatabaseUser,
|
||||
// flags.DatabasePass,
|
||||
// flags.DatabaseHost,
|
||||
// flags.DatabasePort,
|
||||
// flags.DatabaseName)
|
||||
// instance, err = gorm.Open(mysql.Open(dsn), logConfig)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to connect to MySQL database: %v", err)
|
||||
// }
|
||||
// log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
|
||||
default:
|
||||
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
|
||||
}
|
||||
config.SetDb(instance)
|
||||
MergeDatabase(instance)
|
||||
// 自动迁移模型
|
||||
err = instance.AutoMigrate(
|
||||
@@ -454,7 +450,7 @@ func GetDBInstance() *gorm.DB {
|
||||
&models.Client{},
|
||||
&models.Record{},
|
||||
&models.GPURecord{},
|
||||
&models.Config{},
|
||||
// &models.Config{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
@@ -495,5 +491,6 @@ func GetDBInstance() *gorm.DB {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type Config struct {
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
|
||||
// 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
|
||||
|
||||
@@ -15,18 +15,18 @@ type Theme struct {
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed
|
||||
Icon string `json:"icon"` // 图标
|
||||
Name string `json:"name"`
|
||||
Name any `json:"name"`
|
||||
Data any `json:"data"` // 配置数据
|
||||
}
|
||||
|
||||
type ManagedThemeConfigurationItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Name any `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"`
|
||||
Help any `json:"help"`
|
||||
}
|
||||
|
||||
type ThemeConfiguration struct {
|
||||
|
||||
+18
-17
@@ -41,9 +41,9 @@ func DeleteAll() error {
|
||||
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var records []models.GPURecord
|
||||
|
||||
|
||||
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
|
||||
|
||||
|
||||
var recentRecords []models.GPURecord
|
||||
recentStart := start
|
||||
if end.After(fourHoursAgo) {
|
||||
@@ -57,7 +57,7 @@ func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.G
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var longTermRecords []models.GPURecord
|
||||
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
|
||||
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
|
||||
@@ -176,6 +176,7 @@ func CompactRecord() error {
|
||||
if err := db.Exec("VACUUM").Error; err != nil {
|
||||
log.Printf("Error vacuuming database: %v", err)
|
||||
}
|
||||
db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
}
|
||||
//log.Printf("Record compaction completed")
|
||||
return nil
|
||||
@@ -363,17 +364,17 @@ func migrateOldRecords(db *gorm.DB) error {
|
||||
// migrateGPURecords 压缩GPU记录数据
|
||||
func migrateGPURecords(db *gorm.DB) error {
|
||||
fourHoursAgo := time.Now().Add(-4 * time.Hour)
|
||||
|
||||
|
||||
// 查询超过4小时的GPU记录
|
||||
var gpuRecords []models.GPURecord
|
||||
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
if len(gpuRecords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// 按Client + DeviceIndex + 15分钟时间窗口分组
|
||||
type gpuGroupKey struct {
|
||||
Client string
|
||||
@@ -381,16 +382,16 @@ func migrateGPURecords(db *gorm.DB) error {
|
||||
TimeSlot time.Time
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
|
||||
type gpuGroupData struct {
|
||||
MemTotal []int64
|
||||
MemUsed []int64
|
||||
Utilization []float32
|
||||
Temperature []int
|
||||
}
|
||||
|
||||
|
||||
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
|
||||
|
||||
|
||||
for _, record := range gpuRecords {
|
||||
key := gpuGroupKey{
|
||||
Client: record.Client,
|
||||
@@ -398,18 +399,18 @@ func migrateGPURecords(db *gorm.DB) error {
|
||||
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
|
||||
DeviceName: record.DeviceName,
|
||||
}
|
||||
|
||||
|
||||
if _, ok := groupedGPUs[key]; !ok {
|
||||
groupedGPUs[key] = &gpuGroupData{}
|
||||
}
|
||||
|
||||
|
||||
data := groupedGPUs[key]
|
||||
data.MemTotal = append(data.MemTotal, record.MemTotal)
|
||||
data.MemUsed = append(data.MemUsed, record.MemUsed)
|
||||
data.Utilization = append(data.Utilization, record.Utilization)
|
||||
data.Temperature = append(data.Temperature, record.Temperature)
|
||||
}
|
||||
|
||||
|
||||
// 百分位数计算函数 (复用传统Record压缩逻辑)
|
||||
getPercentile := func(values []float64, percentile float64) float64 {
|
||||
if len(values) == 0 {
|
||||
@@ -457,7 +458,7 @@ func migrateGPURecords(db *gorm.DB) error {
|
||||
}
|
||||
return float32(getPercentile(floats, percentile))
|
||||
}
|
||||
|
||||
|
||||
// 保持与传统Record压缩的一致性
|
||||
high_percentile := 0.7
|
||||
|
||||
@@ -480,10 +481,10 @@ func migrateGPURecords(db *gorm.DB) error {
|
||||
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
|
||||
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
|
||||
}
|
||||
|
||||
|
||||
if existingCount > 0 {
|
||||
// 更新已存在记录
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
|
||||
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -494,12 +495,12 @@ func migrateGPURecords(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 删除已压缩的原始GPU数据
|
||||
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
+67
-2
@@ -8,16 +8,81 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetPublicInfo() (any, error) {
|
||||
cst, err := config.Get()
|
||||
cstPtr, err := config.GetManyAs[models.Config]()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cst := *cstPtr
|
||||
|
||||
all, allErr := config.GetAll()
|
||||
hasKey := func(k string) bool {
|
||||
if allErr != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := all[k]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Apply defaults only when a key is missing.
|
||||
if !hasKey("sitename") {
|
||||
cst.Sitename = "Komari"
|
||||
}
|
||||
if !hasKey("description") {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
if !hasKey("theme") {
|
||||
cst.Theme = "default"
|
||||
}
|
||||
if !hasKey("o_auth_provider") {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
if !hasKey("geo_ip_enabled") {
|
||||
cst.GeoIpEnabled = true
|
||||
}
|
||||
if !hasKey("geo_ip_provider") {
|
||||
cst.GeoIpProvider = "ip-api"
|
||||
}
|
||||
if !hasKey("notification_method") {
|
||||
cst.NotificationMethod = "none"
|
||||
}
|
||||
if !hasKey("record_enabled") {
|
||||
cst.RecordEnabled = true
|
||||
}
|
||||
if !hasKey("record_preserve_time") {
|
||||
cst.RecordPreserveTime = 720
|
||||
}
|
||||
if !hasKey("ping_record_preserve_time") {
|
||||
cst.PingRecordPreserveTime = 24
|
||||
}
|
||||
|
||||
// Fallback defaults if we couldn't enumerate keys.
|
||||
if allErr != nil {
|
||||
if cst.Sitename == "" {
|
||||
cst.Sitename = "Komari"
|
||||
}
|
||||
if cst.Description == "" {
|
||||
cst.Description = "Komari Monitor, a simple server monitoring tool."
|
||||
}
|
||||
if cst.Theme == "" {
|
||||
cst.Theme = "default"
|
||||
}
|
||||
if cst.OAuthProvider == "" {
|
||||
cst.OAuthProvider = "github"
|
||||
}
|
||||
if cst.RecordPreserveTime == 0 {
|
||||
cst.RecordPreserveTime = 720
|
||||
}
|
||||
if cst.PingRecordPreserveTime == 0 {
|
||||
cst.PingRecordPreserveTime = 24
|
||||
}
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
tc := models.ThemeConfiguration{}
|
||||
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
|
||||
|
||||
+15
-11
@@ -1,5 +1,5 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
@@ -9,6 +9,7 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
|
||||
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
|
||||
|
||||
## 特徴
|
||||
|
||||
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
|
||||
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
|
||||
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
|
||||
@@ -16,16 +17,15 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
|
||||
## クイックスタート
|
||||
|
||||
### 0. クラウドホスティングによるワンクリック展開
|
||||
- Rainyun - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
- Rainyun - CNY 4.5/月
|
||||
|
||||
- Claw Run - USD 1.5/月
|
||||
|
||||
[](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Dkomari)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 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
|
||||
@@ -33,6 +33,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker展開
|
||||
|
||||
1. データディレクトリを作成します:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -55,6 +56,7 @@ sudo ./install-komari.sh
|
||||
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
|
||||
|
||||
### 3. バイナリファイル展開
|
||||
|
||||
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
|
||||
2. Komariを実行します:
|
||||
```bash
|
||||
@@ -66,7 +68,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
|
||||
|
||||
|
||||
### 手動ビルド
|
||||
|
||||
#### 依存関係
|
||||
@@ -85,25 +86,28 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
|
||||
```bash
|
||||
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/public/defaultTheme` にコピーします。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 実行:
|
||||
3. 実行:
|
||||
```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を歓迎します!
|
||||
|
||||
## 謝辞
|
||||
@@ -114,7 +118,7 @@ IssueやPull Requestを歓迎します!
|
||||
|
||||
### 🚀 SharonNetworks スポンサー
|
||||
|
||||
[](https://sharon.io)
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
|
||||
|
||||
|
||||
+15
-13
@@ -1,5 +1,5 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
@@ -9,6 +9,7 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
|
||||
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
|
||||
- **自託管**:完全掌控數據隱私,部署簡單。
|
||||
- **Web 介面**:直觀的監控儀表盤,易於使用。
|
||||
@@ -17,16 +18,14 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
|
||||
|
||||
### 0. 容器雲一鍵部署
|
||||
|
||||
- 雨雲雲應用 - CNY 4.5/月
|
||||
- 雨雲雲應用 - 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)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 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
|
||||
@@ -34,6 +33,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker 部署
|
||||
|
||||
1. 建立資料目錄:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -56,6 +56,7 @@ sudo ./install-komari.sh
|
||||
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
|
||||
|
||||
### 3. 二進位檔案部署
|
||||
|
||||
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
|
||||
2. 執行 Komari:
|
||||
```bash
|
||||
@@ -67,7 +68,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
|
||||
|
||||
|
||||
### 手工建置
|
||||
|
||||
#### 依賴
|
||||
@@ -86,25 +86,28 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
|
||||
```bash
|
||||
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/public/defaultTheme`。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 執行:
|
||||
3. 執行:
|
||||
```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!
|
||||
|
||||
## 鳴謝
|
||||
@@ -115,7 +118,7 @@ sudo ./install-komari.sh
|
||||
|
||||
### 🚀 由 SharonNetworks 贊助
|
||||
|
||||
[](https://sharon.io)
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 為您的業務起飛保駕護航!
|
||||
|
||||
@@ -130,4 +133,3 @@ SharonNetworks 為您的業務起飛保駕護航!
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
|
||||
+16
-13
@@ -1,14 +1,15 @@
|
||||
# Komari
|
||||
|
||||
# Komari
|
||||

|
||||
|
||||

|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
[文档](https://komari-document.pages.dev/) | [Telegram 群组](https://t.me/komari_monitor)
|
||||
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
@@ -17,16 +18,14 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
|
||||
|
||||
### 0. 容器云一键部署
|
||||
|
||||
- 雨云云应用 - CNY 4.5/月
|
||||
- 雨云云应用 - 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)
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
### 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
|
||||
@@ -34,6 +33,7 @@ sudo ./install-komari.sh
|
||||
```
|
||||
|
||||
### 2. Docker 部署
|
||||
|
||||
1. 创建数据目录:
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
@@ -56,6 +56,7 @@ sudo ./install-komari.sh
|
||||
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
|
||||
|
||||
### 3. 二进制文件部署
|
||||
|
||||
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
|
||||
2. 运行 Komari:
|
||||
```bash
|
||||
@@ -67,7 +68,6 @@ sudo ./install-komari.sh
|
||||
> [!NOTE]
|
||||
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
|
||||
|
||||
|
||||
### 手工构建
|
||||
|
||||
#### 依赖
|
||||
@@ -86,25 +86,28 @@ sudo ./install-komari.sh
|
||||
git clone https://github.com/komari-monitor/komari
|
||||
cd komari
|
||||
```
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
|
||||
```bash
|
||||
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/public/defaultTheme`。
|
||||
```bash
|
||||
go build -o komari
|
||||
```
|
||||
4. 运行:
|
||||
3. 运行:
|
||||
```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!
|
||||
|
||||
## 鸣谢
|
||||
@@ -115,7 +118,7 @@ sudo ./install-komari.sh
|
||||
|
||||
### 🚀 由 SharonNetworks 赞助
|
||||
|
||||
[](https://sharon.io)
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
module github.com/komari-monitor/komari
|
||||
|
||||
go 1.23.2
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -12,6 +16,7 @@ require (
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/image v0.35.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
@@ -35,20 +40,23 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.27 // indirect
|
||||
@@ -63,7 +71,7 @@ require (
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
@@ -17,6 +19,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
@@ -37,6 +45,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
@@ -47,6 +57,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -93,7 +105,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -133,6 +144,8 @@ golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
@@ -140,8 +153,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
@@ -155,6 +168,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -2,12 +2,21 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
logutil "github.com/komari-monitor/komari/utils/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if utils.VersionHash == "unknown" {
|
||||
logutil.SetupGlobalLogger(slog.LevelDebug)
|
||||
} else {
|
||||
logutil.SetupGlobalLogger(slog.LevelInfo)
|
||||
}
|
||||
|
||||
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
|
||||
|
||||
cmd.Execute()
|
||||
}
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
dist/*
|
||||
dist/*
|
||||
defaultTheme/*
|
||||
+201
-267
@@ -2,308 +2,242 @@ package public
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
//go:embed defaultTheme
|
||||
var PublicFS embed.FS
|
||||
|
||||
var DistFS fs.FS
|
||||
var RawIndexFile string
|
||||
// 常量定义
|
||||
const (
|
||||
DataDir = "./data"
|
||||
ThemesDir = "theme"
|
||||
FaviconFile = "favicon.ico"
|
||||
DefaultTheme = "default"
|
||||
|
||||
var IndexFile string
|
||||
// 主题内部结构定义
|
||||
DistDir = "dist" // 静态资源存放目录
|
||||
IndexFile = "index.html" // 相对于 DistDir
|
||||
)
|
||||
|
||||
func initIndex() {
|
||||
err := os.MkdirAll("./data/theme", 0755)
|
||||
if err != nil {
|
||||
log.Println("Failed to create theme directory:", err)
|
||||
return
|
||||
}
|
||||
dist, err := fs.Sub(PublicFS, "dist")
|
||||
if err != nil {
|
||||
log.Println("Failed to create dist subdirectory:", err)
|
||||
}
|
||||
DistFS = dist
|
||||
|
||||
indexFile, err := dist.Open("index.html")
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Println("index.html not exist, you may forget to put dist of frontend to public/dist")
|
||||
}
|
||||
log.Println("Failed to open index.html:", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = indexFile.Close()
|
||||
}()
|
||||
index, err := io.ReadAll(indexFile)
|
||||
if err != nil {
|
||||
log.Println("Failed to read index.html:", err)
|
||||
}
|
||||
RawIndexFile = string(index)
|
||||
}
|
||||
func UpdateIndex(cfg models.Config) {
|
||||
IndexFile = applyCustomizations(RawIndexFile, cfg)
|
||||
func init() {
|
||||
_ = os.MkdirAll("./data/theme", 0755)
|
||||
}
|
||||
|
||||
// applyCustomizations 应用自定义内容到HTML字符串
|
||||
func applyCustomizations(htmlContent string, cfg models.Config) string {
|
||||
var titleReplacement string
|
||||
if cfg.Sitename == "Komari" {
|
||||
titleReplacement = "<title>Komari Monitor</title>"
|
||||
} else {
|
||||
titleReplacement = fmt.Sprintf("<title>%s - Komari Monitor</title>", html.EscapeString(cfg.Sitename))
|
||||
// isSafePath 验证路径是否在指定的基础目录内,防止路径穿透攻击
|
||||
func isSafePath(basePath, targetPath string) bool {
|
||||
// 获取基础目录的绝对路径
|
||||
absBase, err := filepath.Abs(basePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
replaceMap := map[string]string{
|
||||
"<title>Komari Monitor</title>": titleReplacement,
|
||||
"A simple server monitor tool.": cfg.Description,
|
||||
"</head>": cfg.CustomHead + "</head>",
|
||||
"</body>": cfg.CustomBody + "</body>",
|
||||
// 清理目标路径,移除 ../ 等
|
||||
cleanTarget := filepath.Clean(targetPath)
|
||||
|
||||
// 拼接完整路径
|
||||
fullPath := filepath.Join(absBase, cleanTarget)
|
||||
|
||||
// 获取绝对路径
|
||||
absTarget, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := htmlContent
|
||||
for k, v := range replaceMap {
|
||||
updated = strings.Replace(updated, k, v, -1)
|
||||
// 检查目标路径是否以基础路径开头
|
||||
// 使用 filepath.Rel 更可靠地检查路径关系
|
||||
rel, err := filepath.Rel(absBase, absTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return updated
|
||||
|
||||
// 如果相对路径以 .. 开头,说明目标在基础目录之外
|
||||
return !strings.HasPrefix(rel, "..") && rel != ".."
|
||||
}
|
||||
|
||||
// Static 注册静态资源和 SPA 路由处理
|
||||
func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
|
||||
initIndex()
|
||||
// 初始化嵌入式文件系统,指向 defaultTheme 根目录
|
||||
// 假设 defaultTheme 内部结构也是: dist/, theme.json 等
|
||||
defaultThemeFS, err := fs.Sub(PublicFS, "defaultTheme")
|
||||
if err != nil {
|
||||
panic("you may forget to put dist of frontend to public/defaultTheme/dist")
|
||||
}
|
||||
|
||||
// Serve favicon.ico: use local file if exists, fallback to embedded
|
||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||
if _, err := os.Stat("data/favicon.ico"); err == nil {
|
||||
c.File("data/favicon.ico")
|
||||
getConfig := func() map[string]any {
|
||||
cfg, _ := config.GetMany(map[string]any{
|
||||
config.DescriptionKey: "A simple server monitor tool.",
|
||||
config.CustomHeadKey: "",
|
||||
config.CustomBodyKey: "",
|
||||
config.SitenameKey: "Komari Monitor",
|
||||
config.ThemeKey: DefaultTheme,
|
||||
})
|
||||
return cfg
|
||||
}
|
||||
|
||||
// 核心逻辑:获取文件内容
|
||||
// filePath: 相对于主题根目录的路径 (例如 "theme.json" 或 "dist/assets/a.js")
|
||||
// 返回: content, contentType, exists
|
||||
getFileContent := func(themeID string, relativePath string) ([]byte, string, bool) {
|
||||
cleanPath := strings.TrimPrefix(relativePath, "/")
|
||||
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
|
||||
if themeID != DefaultTheme {
|
||||
if strings.Contains(themeID, "..") || strings.Contains(themeID, "/") || strings.Contains(themeID, "\\") {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
themeBasePath := filepath.Join(DataDir, ThemesDir, themeID)
|
||||
|
||||
if !isSafePath(themeBasePath, cleanPath) {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
localPath := filepath.Join(themeBasePath, cleanPath)
|
||||
// 检查文件是否存在且不是目录
|
||||
if info, err := os.Stat(localPath); err == nil && !info.IsDir() {
|
||||
content, err := os.ReadFile(localPath)
|
||||
if err == nil {
|
||||
return content, mime.TypeByExtension(filepath.Ext(localPath)), true
|
||||
}
|
||||
}
|
||||
// 本地文件不存在,或读取失败 -> 继续向下回退
|
||||
}
|
||||
|
||||
// 2. 尝试从嵌入式 defaultTheme/{cleanPath} 读取
|
||||
// fs.ReadFile 处理 embed 路径时使用 "/"
|
||||
embedPath := filepath.ToSlash(cleanPath)
|
||||
|
||||
if strings.Contains(embedPath, "..") {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
if content, err := fs.ReadFile(defaultThemeFS, embedPath); err == nil {
|
||||
return content, mime.TypeByExtension(filepath.Ext(embedPath)), true
|
||||
}
|
||||
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// 核心逻辑:渲染 Index.html
|
||||
serveIndex := func(c *gin.Context) {
|
||||
reqPath := c.Request.URL.Path
|
||||
cfg := getConfig()
|
||||
|
||||
currentTheme := cfg[config.ThemeKey].(string)
|
||||
shouldReplace := true
|
||||
|
||||
// 特殊页面:强制使用 default 主题,且不进行内容替换
|
||||
if strings.HasPrefix(reqPath, "/admin") || strings.HasPrefix(reqPath, "/terminal") {
|
||||
currentTheme = DefaultTheme
|
||||
shouldReplace = false
|
||||
}
|
||||
|
||||
// 获取 dist/index.html (相对于主题根目录)
|
||||
targetFile := path.Join(DistDir, IndexFile)
|
||||
content, _, exists := getFileContent(currentTheme, targetFile)
|
||||
|
||||
if !exists {
|
||||
c.String(http.StatusNotFound, "Index file missing (checked %s/dist/index.html and default).", currentTheme)
|
||||
return
|
||||
}
|
||||
f, err := DistFS.Open("favicon.ico")
|
||||
if err != nil {
|
||||
|
||||
// 如果不替换,直接返回原始内容
|
||||
if !shouldReplace {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行 HTML 内容替换
|
||||
htmlStr := string(content)
|
||||
replacer := strings.NewReplacer(
|
||||
"<title>Komari Monitor</title>", "<title>"+cfg[config.SitenameKey].(string)+"</title>",
|
||||
"A simple server monitor tool.", cfg[config.DescriptionKey].(string),
|
||||
"</head>", cfg[config.CustomHeadKey].(string)+"</head>",
|
||||
"</body>", cfg[config.CustomBodyKey].(string)+"</body>",
|
||||
)
|
||||
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(replacer.Replace(htmlStr)))
|
||||
}
|
||||
|
||||
// ================= 路由定义 =================
|
||||
|
||||
// 1. Favicon 优先策略
|
||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||
// 优先:./data/favicon.ico
|
||||
localFavicon := filepath.Join(DataDir, FaviconFile)
|
||||
if _, err := os.Stat(localFavicon); err == nil {
|
||||
c.File(localFavicon)
|
||||
return
|
||||
}
|
||||
|
||||
// 其次:当前主题的 dist/favicon.ico 或 theme_root/favicon.ico ?
|
||||
// 通常构建后的资源在 dist 中,这里假设优先找 dist 内的,如果你的 favicon 在根目录,去掉 DistDir 拼接即可
|
||||
cfg := getConfig()
|
||||
themeFaviconPath := path.Join(DistDir, FaviconFile)
|
||||
content, mimeType, exists := getFileContent(cfg[config.ThemeKey].(string), themeFaviconPath)
|
||||
if exists {
|
||||
c.Data(http.StatusOK, mimeType, content)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
|
||||
// 2. 静态资源路由 /themes/:id/*path
|
||||
// 允许访问 /themes/MyTheme/theme.json 和 /themes/MyTheme/dist/assets/a.js
|
||||
r.GET("/themes/:id/*path", func(c *gin.Context) {
|
||||
themeID := c.Param("id")
|
||||
// c.Param("path") 包含了开头的 /,getFileContent 会处理
|
||||
filePath := c.Param("path")
|
||||
|
||||
content, mimeType, exists := getFileContent(themeID, filePath)
|
||||
if exists {
|
||||
c.Data(http.StatusOK, mimeType, content)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
|
||||
// 3. SPA 路由 (noRoute)
|
||||
noRoute(func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodGet {
|
||||
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, "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)
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
|
||||
// Serve theme files from data/theme directory (for theme previews and static assets)
|
||||
r.Static("/themes", "./data/theme")
|
||||
reqPath := c.Request.URL.Path
|
||||
cfg := getConfig()
|
||||
currentTheme := cfg[config.ThemeKey].(string)
|
||||
|
||||
// 使用传入的noRoute函数来处理未匹配的路由
|
||||
noRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
// SPA 静态资源回退
|
||||
distPath := path.Join(DistDir, reqPath)
|
||||
|
||||
// 对于admin页面,直接使用embedded文件
|
||||
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
|
||||
serveFromEmbedded(c, path)
|
||||
content, mimeType, exists := getFileContent(currentTheme, distPath)
|
||||
if exists {
|
||||
c.Data(http.StatusOK, mimeType, content)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前主题配置
|
||||
cfg, err := config.Get()
|
||||
if err != nil || cfg.Theme == "default" || cfg.Theme == "" {
|
||||
// 使用默认主题(embedded文件)
|
||||
serveFromEmbedded(c, path)
|
||||
return
|
||||
}
|
||||
// 如果资源不存在,且路径包含扩展名 (如 .js, .css, .png),则返回 404
|
||||
// 避免将 index.html 作为 js 文件返回导致 "Failed to fetch dynamically imported module"
|
||||
//ext := filepath.Ext(reqPath)
|
||||
//if ext != "" && ext != ".html" {
|
||||
// c.Status(http.StatusNotFound)
|
||||
// return
|
||||
//}
|
||||
|
||||
// 使用自定义主题
|
||||
serveFromTheme(c, path, cfg.Theme)
|
||||
// 路由 (如 /dashboard, /settings) -> 返回 index.html
|
||||
serveIndex(c)
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// 如果文件存在,直接返回文件内容
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 处理HTML页面
|
||||
if c.Request.Method != "GET" && c.Request.Method != "POST" {
|
||||
c.Status(405)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Status(200)
|
||||
|
||||
if strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/terminal") {
|
||||
c.Writer.WriteString(RawIndexFile)
|
||||
} else {
|
||||
c.Writer.WriteString(IndexFile)
|
||||
}
|
||||
|
||||
c.Writer.Flush()
|
||||
c.Writer.WriteHeaderNow()
|
||||
}
|
||||
|
||||
// serveFromTheme 从主题目录服务文件
|
||||
func serveFromTheme(c *gin.Context, path string, themeName string) {
|
||||
themeDir := filepath.Join("./data/theme", themeName, "dist")
|
||||
|
||||
// 构建完整的文件路径
|
||||
var filePath string
|
||||
if path == "/" || path == "" {
|
||||
filePath = filepath.Join(themeDir, "index.html")
|
||||
} else {
|
||||
filePath = filepath.Join(themeDir, strings.TrimPrefix(path, "/"))
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
// 如果文件不存在,尝试serve index.html(用于SPA路由)
|
||||
if !strings.Contains(path, ".") {
|
||||
indexPath := filepath.Join(themeDir, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
serveThemeIndexWithCustomizations(c, indexPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 回退到默认主题
|
||||
serveFromEmbedded(c, path)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置缓存头
|
||||
if strings.Contains(path, "/assets/") || strings.Contains(path, "/static/") {
|
||||
c.Header("Cache-Control", "public, max-age=15552000")
|
||||
}
|
||||
|
||||
// 如果是index.html文件,需要处理自定义内容
|
||||
if strings.HasSuffix(filePath, "index.html") {
|
||||
serveThemeIndexWithCustomizations(c, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
// serveThemeIndexWithCustomizations 服务主题的index.html并应用自定义内容
|
||||
func serveThemeIndexWithCustomizations(c *gin.Context, indexPath string) {
|
||||
// 读取主题的index.html文件
|
||||
data, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取配置以应用自定义内容
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
// 如果获取配置失败,直接返回原始文件
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Data(http.StatusOK, "text/html", data)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用通用的自定义内容应用函数
|
||||
content := applyCustomizations(string(data), cfg)
|
||||
|
||||
c.Header("Content-Type", "text/html")
|
||||
c.Data(http.StatusOK, "text/html", []byte(content))
|
||||
}
|
||||
|
||||
// getContentType 根据文件扩展名返回Content-Type
|
||||
func getContentType(path string) string {
|
||||
if strings.HasSuffix(path, ".css") {
|
||||
return "text/css"
|
||||
} else if strings.HasSuffix(path, ".js") {
|
||||
return "application/javascript"
|
||||
} else if strings.HasSuffix(path, ".png") {
|
||||
return "image/png"
|
||||
} else if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
|
||||
return "image/jpeg"
|
||||
} else if strings.HasSuffix(path, ".gif") {
|
||||
return "image/gif"
|
||||
} else if strings.HasSuffix(path, ".svg") {
|
||||
return "image/svg+xml"
|
||||
} else if strings.HasSuffix(path, ".ico") {
|
||||
return "image/x-icon"
|
||||
} else if strings.HasSuffix(path, ".woff") {
|
||||
return "font/woff"
|
||||
} else if strings.HasSuffix(path, ".woff2") {
|
||||
return "font/woff2"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
+15
-6
@@ -3,14 +3,17 @@
|
||||
## 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`
|
||||
2. Copy the generated static files to the `defaultTheme` folder in the Komari backend project root directory
|
||||
3. Ensure the `defaultTheme/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).**
|
||||
|
||||
---
|
||||
@@ -18,14 +21,17 @@
|
||||
## 中文
|
||||
|
||||
### 前端项目仓库
|
||||
|
||||
- **前端项目地址**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### 构建要求
|
||||
|
||||
1. 克隆前端仓库并构建静态文件
|
||||
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `dist` 文件夹
|
||||
2. 将生成的静态文件复制到 Komari 后端项目根目录下的 `defaultTheme` 文件夹
|
||||
3. 确保 `dist` 文件夹内包含 `index.html`
|
||||
|
||||
### 重要提醒
|
||||
|
||||
⚠️ **Akizon77 个人仓库的项目已经不再使用,请使用组织(komari-monitor)下的项目。**
|
||||
|
||||
---
|
||||
@@ -33,14 +39,17 @@
|
||||
## 日本語
|
||||
|
||||
### フロントエンドプロジェクトリポジトリ
|
||||
|
||||
- **フロントエンドプロジェクトアドレス**: https://github.com/komari-monitor/komari-web
|
||||
|
||||
### ビルド要件
|
||||
|
||||
1. フロントエンドリポジトリをクローンして静的ファイルをビルドする
|
||||
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `dist` フォルダーにコピーする
|
||||
2. 生成された静的ファイルを Komari バックエンドプロジェクトのルートディレクトリ下の `defaultTheme` フォルダーにコピーする
|
||||
3. `dist` フォルダー内に `index.html` が含まれていることを確認する
|
||||
|
||||
### 重要な注意事項
|
||||
|
||||
⚠️ **Akizon77 の個人リポジトリのプロジェクトは使用されなくなりました。組織(komari-monitor)下のプロジェクトを使用してください。**
|
||||
|
||||
---
|
||||
@@ -57,5 +66,5 @@ npm install
|
||||
npm run build
|
||||
|
||||
# Copy dist folder to Komari backend project / 将 dist 文件夹复制到 Komari 后端项目 / dist フォルダーを Komari バックエンドプロジェクトにコピー
|
||||
cp -r dist /path/to/komari/public/
|
||||
```
|
||||
cp -r dist /path/to/komari/public/defaultTheme
|
||||
```
|
||||
+20
-17
@@ -1,13 +1,13 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
@@ -52,60 +52,63 @@ func GetRegionUnicodeEmoji(isoCode string) string {
|
||||
}
|
||||
|
||||
func InitGeoIp() {
|
||||
conf, err := config.Get()
|
||||
conf, err := config.GetMany(map[string]any{
|
||||
config.GeoIpEnabledKey: true,
|
||||
config.GeoIpProviderKey: "ipinfo",
|
||||
})
|
||||
if err != nil {
|
||||
panic("Failed to get configuration for GeoIP: " + err.Error())
|
||||
}
|
||||
if !conf.GeoIpEnabled {
|
||||
if !conf[config.GeoIpEnabledKey].(bool) {
|
||||
return
|
||||
}
|
||||
switch conf.GeoIpProvider {
|
||||
switch conf[config.GeoIpProviderKey].(string) {
|
||||
case "mmdb":
|
||||
NewCurrentProvider, err := NewMaxMindGeoIPService()
|
||||
if err != nil {
|
||||
log.Printf("Failed to initialize MaxMind GeoIP service: " + err.Error())
|
||||
slog.Error("Failed to initialize MaxMind GeoIP service: " + err.Error())
|
||||
}
|
||||
if NewCurrentProvider != nil {
|
||||
CurrentProvider = NewCurrentProvider
|
||||
} else {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
log.Println("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
|
||||
slog.Error("Failed to initialize MaxMind GeoIP service, using EmptyProvider instead.")
|
||||
}
|
||||
case "ip-api":
|
||||
NewCurrentProvider, err := NewIPAPIService()
|
||||
if err != nil {
|
||||
log.Printf("Failed to initialize ip-api service: " + err.Error())
|
||||
slog.Error("Failed to initialize ip-api service: " + err.Error())
|
||||
}
|
||||
if NewCurrentProvider != nil {
|
||||
CurrentProvider = NewCurrentProvider
|
||||
log.Println("Using ip-api.com as GeoIP provider.")
|
||||
slog.Info("Using ip-api.com as GeoIP provider.")
|
||||
} else {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
log.Println("Failed to initialize ip-api service, using EmptyProvider instead.")
|
||||
slog.Info("Failed to initialize ip-api service, using EmptyProvider instead.")
|
||||
}
|
||||
case "geojs":
|
||||
NewCurrentProvider, err := NewGeoJSService()
|
||||
if err != nil {
|
||||
log.Printf("Failed to initialize GeoJS service: " + err.Error())
|
||||
slog.Error("Failed to initialize GeoJS service: " + err.Error())
|
||||
}
|
||||
if NewCurrentProvider != nil {
|
||||
CurrentProvider = NewCurrentProvider
|
||||
log.Println("Using geojs.io as GeoIP provider.")
|
||||
slog.Info("Using geojs.io as GeoIP provider.")
|
||||
} else {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
log.Println("Failed to initialize GeoJS service, using EmptyProvider instead.")
|
||||
slog.Info("Failed to initialize GeoJS service, using EmptyProvider instead.")
|
||||
}
|
||||
case "ipinfo":
|
||||
NewCurrentProvider, err := NewIPInfoService()
|
||||
if err != nil {
|
||||
log.Printf("Failed to initialize IPInfo service: " + err.Error())
|
||||
slog.Error("Failed to initialize IPInfo service: " + err.Error())
|
||||
}
|
||||
if NewCurrentProvider != nil {
|
||||
CurrentProvider = NewCurrentProvider
|
||||
log.Println("Using ipinfo.io as GeoIP provider.")
|
||||
slog.Info("Using ipinfo.io as GeoIP provider.")
|
||||
} else {
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
log.Println("Failed to initialize IPInfo service, using EmptyProvider instead.")
|
||||
slog.Info("Failed to initialize IPInfo service, using EmptyProvider instead.")
|
||||
}
|
||||
default:
|
||||
CurrentProvider = &EmptyProvider{}
|
||||
@@ -133,7 +136,7 @@ func UpdateDatabase() error {
|
||||
err := CurrentProvider.UpdateDatabase()
|
||||
if err == nil {
|
||||
geoCache.Flush()
|
||||
log.Println("GeoIP cache cleared due to database update.")
|
||||
slog.Info("GeoIP cache cleared due to database update.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
+13
-1
@@ -17,11 +17,13 @@ func Parse(v any) []Item {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
var allowTypes = []string{"option", "richtext"}
|
||||
|
||||
var items []Item
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
typ := field.Tag.Get("type")
|
||||
if typ != "option" {
|
||||
if !contains(allowTypes, typ) {
|
||||
typ = field.Type.Name()
|
||||
}
|
||||
item := Item{
|
||||
@@ -39,3 +41,13 @@ func Parse(v any) []Item {
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// contains reports whether s is present in slice.
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GinLogger 返回一个 gin.HandlerFunc,用于记录 HTTP 请求
|
||||
func GinLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
// 计算延迟
|
||||
latency := time.Since(start)
|
||||
|
||||
// 获取状态码
|
||||
statusCode := c.Writer.Status()
|
||||
|
||||
statusCodeColored := func(code int) string {
|
||||
if code >= 500 {
|
||||
return Red("%d", code)
|
||||
} else if code >= 400 {
|
||||
return Yellow("%d", code)
|
||||
} else if code >= 300 {
|
||||
return Cyan("%d", code)
|
||||
} else {
|
||||
return Green("%d", code)
|
||||
}
|
||||
}(statusCode)
|
||||
|
||||
msg := fmt.Sprintf("%s %s %s", statusCodeColored, c.Request.Method, path)
|
||||
if query != "" {
|
||||
msg += "?" + query
|
||||
}
|
||||
msg += fmt.Sprintf(" | %s | %s", c.ClientIP(), latency)
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
msg += " | " + c.Errors.String()
|
||||
}
|
||||
|
||||
handler := slog.Default().Handler()
|
||||
var level slog.Level
|
||||
if statusCode >= 500 {
|
||||
level = slog.LevelError
|
||||
} else {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
r := slog.NewRecord(time.Now(), level, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GIN")) // 添加分组标识
|
||||
handler.Handle(c.Request.Context(), r)
|
||||
}
|
||||
}
|
||||
|
||||
// GinRecovery 返回一个 gin.HandlerFunc,用于恢复 panic
|
||||
func GinRecovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
msg := fmt.Sprintf("panic recovered: %v | %s %s", err, c.Request.Method, c.Request.URL.Path)
|
||||
handler := slog.Default().Handler()
|
||||
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GIN"))
|
||||
handler.Handle(c.Request.Context(), r)
|
||||
c.AbortWithStatus(500)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
komari_utils "github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/utils"
|
||||
)
|
||||
|
||||
// GormLogger 实现 gorm.io/gorm/logger.Interface
|
||||
type GormLogger struct {
|
||||
SlowThreshold time.Duration
|
||||
IgnoreRecordNotFoundError bool
|
||||
LogLevel gormlogger.LogLevel
|
||||
}
|
||||
|
||||
// NewGormLogger 创建 GORM logger
|
||||
func NewGormLogger() *GormLogger {
|
||||
return &GormLogger{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
LogLevel: func(hash string) gormlogger.LogLevel {
|
||||
if hash == "unknown" {
|
||||
return gormlogger.Info
|
||||
}
|
||||
return gormlogger.Silent
|
||||
}(komari_utils.VersionHash),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
|
||||
newlogger := *l
|
||||
newlogger.LogLevel = level
|
||||
return &newlogger
|
||||
}
|
||||
|
||||
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.LogLevel >= gormlogger.Info {
|
||||
slog.InfoContext(ctx, fmt.Sprintf(msg, data...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.LogLevel >= gormlogger.Warn {
|
||||
slog.WarnContext(ctx, fmt.Sprintf(msg, data...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.LogLevel >= gormlogger.Error {
|
||||
slog.ErrorContext(ctx, fmt.Sprintf(msg, data...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
if l.LogLevel <= gormlogger.Silent {
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(begin)
|
||||
sql, rows := fc()
|
||||
|
||||
fileWithLineNum := utils.FileWithLineNum()
|
||||
|
||||
handler := slog.Default().Handler()
|
||||
|
||||
switch {
|
||||
case err != nil && l.LogLevel >= gormlogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | ERROR: %v %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, err, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now(), slog.LevelError, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormlogger.Warn:
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s | SLOW QUERY %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now(), slog.LevelWarn, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
case l.LogLevel >= gormlogger.Info:
|
||||
msg := fmt.Sprintf("[%.3fms] [rows:%d] %s %s",
|
||||
float64(elapsed.Nanoseconds())/1e6, rows, sql, Gray("(%s)", fileWithLineNum))
|
||||
r := slog.NewRecord(time.Now(), slog.LevelDebug, msg, 0)
|
||||
r.AddAttrs(slog.String("_group", "GORM"))
|
||||
handler.Handle(ctx, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
stdlog "log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Green(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[32m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Yellow(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[33m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Red(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[31m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Blue(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[34m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Cyan(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[36m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func Gray(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[90m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
func White(format string, v ...interface{}) string {
|
||||
return fmt.Sprintf("\033[37m"+format+"\033[0m", v...)
|
||||
}
|
||||
|
||||
type LogHandler struct {
|
||||
w io.Writer
|
||||
level slog.Level
|
||||
group string
|
||||
}
|
||||
|
||||
func NewHandler(w io.Writer, level slog.Level) *LogHandler {
|
||||
return &LogHandler{w: w, level: level, group: ""}
|
||||
}
|
||||
|
||||
func (h *LogHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level
|
||||
}
|
||||
|
||||
func (h *LogHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
var file string
|
||||
var line int
|
||||
if r.PC != 0 {
|
||||
fs := runtime.CallersFrames([]uintptr{r.PC})
|
||||
f, _ := fs.Next()
|
||||
file = f.File
|
||||
line = f.Line
|
||||
}
|
||||
|
||||
timeStr := r.Time.Format("2006/01/02 15:04:05")
|
||||
|
||||
// 检查是否有分组信息(从 attributes 中获取)
|
||||
group := h.group
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if a.Key == "_group" {
|
||||
group = a.Value.String()
|
||||
return false // 停止迭代
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
levelStr := ""
|
||||
switch r.Level {
|
||||
case slog.LevelDebug:
|
||||
if group != "" {
|
||||
levelStr = Cyan("[DEBUG/%s]", group)
|
||||
} else {
|
||||
levelStr = Cyan("[DEBUG]")
|
||||
}
|
||||
case slog.LevelInfo:
|
||||
if group != "" {
|
||||
levelStr = Green(fmt.Sprintf("[INFO/%s]", group))
|
||||
} else {
|
||||
levelStr = Green("[INFO]")
|
||||
}
|
||||
case slog.LevelWarn:
|
||||
if group != "" {
|
||||
levelStr = Yellow(fmt.Sprintf("[WARN/%s]", group))
|
||||
} else {
|
||||
levelStr = Yellow("[WARN]")
|
||||
}
|
||||
case slog.LevelError:
|
||||
if group != "" {
|
||||
levelStr = Red(fmt.Sprintf("[ERROR/%s]", group))
|
||||
} else {
|
||||
levelStr = Red("[ERROR]")
|
||||
}
|
||||
}
|
||||
|
||||
var msg string
|
||||
if file != "" {
|
||||
msg = fmt.Sprintf("%s %s %s %s",
|
||||
timeStr,
|
||||
levelStr,
|
||||
r.Message,
|
||||
Gray(fmt.Sprintf("(%s:%d)", file, line)))
|
||||
} else {
|
||||
msg = fmt.Sprintf("%s %s %s",
|
||||
timeStr,
|
||||
levelStr,
|
||||
r.Message)
|
||||
}
|
||||
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if a.Key != "_group" { // 跳过内部使用的 _group 属性
|
||||
msg += fmt.Sprintf(" %s=%s", Cyan(a.Key), Yellow(fmt.Sprintf("%v", a.Value)))
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
msg += "\n"
|
||||
_, err := h.w.Write([]byte(msg))
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *LogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *LogHandler) WithGroup(name string) slog.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
// SetupGlobalLogger 设置全局标准库 log 使用 slog handler
|
||||
func SetupGlobalLogger(level slog.Level) {
|
||||
handler := NewHandler(os.Stdout, level)
|
||||
logger := slog.New(handler)
|
||||
|
||||
// 设置 slog 默认 logger
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// 设置标准库 log 使用 slog
|
||||
stdlog.SetOutput(os.Stdout)
|
||||
stdlog.SetFlags(0) // 清除默认标志
|
||||
stdlog.SetPrefix("")
|
||||
|
||||
// 替换标准库 log 的输出为 slog handler
|
||||
stdlog.SetOutput(&writerAdapter{handler: handler, level: level})
|
||||
}
|
||||
|
||||
// writerAdapter 将标准库 log 的输出适配到 slog
|
||||
type writerAdapter struct {
|
||||
handler slog.Handler
|
||||
level slog.Level
|
||||
}
|
||||
|
||||
func (w *writerAdapter) Write(p []byte) (n int, err error) {
|
||||
msg := string(p)
|
||||
if len(msg) > 0 && msg[len(msg)-1] == '\n' {
|
||||
msg = msg[:len(msg)-1]
|
||||
}
|
||||
|
||||
var pcs [1]uintptr
|
||||
runtime.Callers(4, pcs[:]) // skip [Callers, Write, log.Output, log.Printf/etc]
|
||||
|
||||
r := slog.NewRecord(time.Now(), w.level, msg, pcs[0])
|
||||
return len(p), w.handler.Handle(context.Background(), r)
|
||||
}
|
||||
|
||||
// GetWriter 返回一个 io.Writer,可以用于 Gin 等框架
|
||||
func GetWriter() io.Writer {
|
||||
handler := slog.Default().Handler()
|
||||
return &writerAdapter{handler: handler, level: slog.LevelInfo}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ 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/serverchan3"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/serverchanturbo"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/javascript"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
|
||||
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
if serverURL == "" {
|
||||
serverURL = "https://api.day.app"
|
||||
}
|
||||
|
||||
|
||||
// 确保 URL 以正确的格式结尾
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
requestURL := fmt.Sprintf("%s/push", serverURL)
|
||||
@@ -96,8 +96,8 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -114,11 +114,5 @@ func (b *BarkSender) SendTextMessage(message, title string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &BarkSender{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*BarkSender)(nil)
|
||||
@@ -19,6 +19,59 @@ type EmailSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
// loginAuth 是一个更宽松的 SMTP 认证实现,支持更多 SMTP 服务器
|
||||
// 它不会严格验证服务器主机名,从而兼容微软邮箱、网易邮箱等服务
|
||||
type loginAuth struct {
|
||||
username string
|
||||
password string
|
||||
host string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:", "User Name":
|
||||
return []byte(a.username), nil
|
||||
case "Password:", "password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
// 某些服务器可能发送 base64 编码的提示
|
||||
// 尝试返回用户名或密码
|
||||
prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
|
||||
if strings.Contains(prompt, "user") {
|
||||
return []byte(a.username), nil
|
||||
}
|
||||
return []byte(a.password), nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// plainAuthWithoutCheck 是一个不检查主机名的 PlainAuth 实现
|
||||
// 用于解决某些 SMTP 服务器主机名与配置不匹配的问题
|
||||
type plainAuthWithoutCheck struct {
|
||||
identity string
|
||||
username string
|
||||
password string
|
||||
host string
|
||||
}
|
||||
|
||||
func (a *plainAuthWithoutCheck) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password)
|
||||
return "PLAIN", resp, nil
|
||||
}
|
||||
|
||||
func (a *plainAuthWithoutCheck) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
return nil, fmt.Errorf("unexpected server challenge")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (e *EmailSender) GetName() string {
|
||||
return "email"
|
||||
}
|
||||
@@ -41,13 +94,25 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
return fmt.Errorf("email sending is not fully configured")
|
||||
}
|
||||
|
||||
// Prepare auth
|
||||
auth := smtp.PlainAuth(
|
||||
"",
|
||||
e.Addition.Username,
|
||||
e.Addition.Password,
|
||||
e.Addition.Host,
|
||||
)
|
||||
// 使用更宽松的认证方式,优先尝试 PLAIN,如果失败则尝试 LOGIN
|
||||
// 这样可以兼容更多的 SMTP 服务器,包括微软邮箱、网易邮箱等
|
||||
var auth smtp.Auth
|
||||
if e.Addition.UseLoginAuth {
|
||||
// 使用 LOGIN 认证(适用于某些旧的或特殊的 SMTP 服务器)
|
||||
auth = &loginAuth{
|
||||
username: e.Addition.Username,
|
||||
password: e.Addition.Password,
|
||||
host: e.Addition.Host,
|
||||
}
|
||||
} else {
|
||||
// 使用不检查主机名的 PLAIN 认证(适用于大多数现代 SMTP 服务器)
|
||||
auth = &plainAuthWithoutCheck{
|
||||
identity: "",
|
||||
username: e.Addition.Username,
|
||||
password: e.Addition.Password,
|
||||
host: e.Addition.Host,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sender address (for MAIL FROM and header)
|
||||
var senderAddr string
|
||||
@@ -103,13 +168,22 @@ func (e *EmailSender) SendTextMessage(message, title string) error {
|
||||
return fmt.Errorf("failed to finalize body encoding: %w", err)
|
||||
}
|
||||
|
||||
contentType := "text/plain; charset=UTF-8"
|
||||
// 检测模板是否包含HTML
|
||||
trimmedMsg := strings.TrimSpace(message)
|
||||
if strings.Contains(strings.ToLower(trimmedMsg), "<html") ||
|
||||
strings.Contains(strings.ToLower(trimmedMsg), "<!doctype") ||
|
||||
(strings.Contains(trimmedMsg, "<div") && strings.Contains(trimmedMsg, "</div>")) {
|
||||
contentType = "text/html; charset=UTF-8"
|
||||
}
|
||||
|
||||
// Compose headers
|
||||
headers := []string{
|
||||
"To: " + strings.Join(rcptHeaderParts, ", "),
|
||||
"From: " + senderHeader,
|
||||
"Subject: " + encodedSubject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Type: " + contentType,
|
||||
"Content-Transfer-Encoding: quoted-printable",
|
||||
"Date: " + time.Now().Format(time.RFC1123Z),
|
||||
fmt.Sprintf("Message-ID: <%d@%s>", time.Now().UnixNano(), e.Addition.Host),
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
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"`
|
||||
UseLoginAuth bool `json:"use_login_auth" default:"false" help:"Use LOGIN authentication method instead of PLAIN. Enable this if you encounter authentication errors with Microsoft (Outlook/Office365), NetEase (163.com), or other email providers"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package factory
|
||||
|
||||
import "github.com/komari-monitor/komari/database/models"
|
||||
|
||||
type IMessageSender interface {
|
||||
GetName() string
|
||||
// 请务必返回 &Configuration{} 的指针
|
||||
@@ -9,6 +11,11 @@ type IMessageSender interface {
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
// IEventMessageSender 是可选接口,如果实现则可以接收结构化的事件消息
|
||||
type IEventMessageSender interface {
|
||||
SendEvent(event models.EventMessage) error
|
||||
}
|
||||
|
||||
type Configuration interface{}
|
||||
|
||||
type MessageSenderConstructor func() IMessageSender
|
||||
@@ -0,0 +1,301 @@
|
||||
package javascript
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// createFetchFunction 创建一个 fetch API 实现
|
||||
func (j *JavaScriptSender) createFetchFunction() func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
panic(j.vm.NewTypeError("fetch requires at least 1 argument"))
|
||||
}
|
||||
|
||||
url := call.Argument(0).String()
|
||||
|
||||
// 解析选项
|
||||
options := map[string]interface{}{
|
||||
"method": "GET",
|
||||
"headers": make(map[string]string),
|
||||
"body": "",
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 1 {
|
||||
optObj := call.Argument(1).ToObject(j.vm)
|
||||
if optObj != nil {
|
||||
if method := optObj.Get("method"); method != nil && !goja.IsUndefined(method) {
|
||||
options["method"] = method.String()
|
||||
}
|
||||
if headers := optObj.Get("headers"); headers != nil && !goja.IsUndefined(headers) {
|
||||
headersObj := headers.ToObject(j.vm)
|
||||
if headersObj != nil {
|
||||
headerMap := make(map[string]string)
|
||||
for _, key := range headersObj.Keys() {
|
||||
headerMap[key] = headersObj.Get(key).String()
|
||||
}
|
||||
options["headers"] = headerMap
|
||||
}
|
||||
}
|
||||
if body := optObj.Get("body"); body != nil && !goja.IsUndefined(body) {
|
||||
options["body"] = body.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 Promise
|
||||
promise, resolve, reject := j.vm.NewPromise()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("fetch panic: %v", r)))
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建 HTTP 请求
|
||||
method := options["method"].(string)
|
||||
var body io.Reader
|
||||
if options["body"].(string) != "" {
|
||||
body = strings.NewReader(options["body"].(string))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("Failed to create request: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
headers := options["headers"].(map[string]string)
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("Fetch failed: %v", err)))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应体
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
reject(j.vm.ToValue(fmt.Sprintf("Failed to read response: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建响应对象
|
||||
responseObj := j.vm.NewObject()
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
responseObj.Set("statusText", resp.Status)
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
|
||||
// 响应头
|
||||
headersObj := j.vm.NewObject()
|
||||
for key, values := range resp.Header {
|
||||
if len(values) > 0 {
|
||||
headersObj.Set(key, values[0])
|
||||
}
|
||||
}
|
||||
responseObj.Set("headers", headersObj)
|
||||
|
||||
// text() 方法
|
||||
responseObj.Set("text", func(goja.FunctionCall) goja.Value {
|
||||
textPromise, textResolve, _ := j.vm.NewPromise()
|
||||
textResolve(j.vm.ToValue(string(bodyBytes)))
|
||||
return j.vm.ToValue(textPromise)
|
||||
})
|
||||
|
||||
// json() 方法
|
||||
responseObj.Set("json", func(goja.FunctionCall) goja.Value {
|
||||
jsonPromise, jsonResolve, jsonReject := j.vm.NewPromise()
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
jsonReject(j.vm.ToValue(fmt.Sprintf("Failed to parse JSON: %v", err)))
|
||||
} else {
|
||||
jsonResolve(j.vm.ToValue(result))
|
||||
}
|
||||
return j.vm.ToValue(jsonPromise)
|
||||
})
|
||||
|
||||
resolve(responseObj)
|
||||
}()
|
||||
|
||||
return j.vm.ToValue(promise)
|
||||
}
|
||||
}
|
||||
|
||||
// createXHRConstructor 创建一个 XMLHttpRequest 构造函数
|
||||
func (j *JavaScriptSender) createXHRConstructor() func(goja.ConstructorCall) *goja.Object {
|
||||
return func(call goja.ConstructorCall) *goja.Object {
|
||||
xhr := call.This
|
||||
|
||||
// 内部状态
|
||||
var method, url string
|
||||
var headers = make(map[string]string)
|
||||
var requestBody string
|
||||
var async = true
|
||||
|
||||
// readyState
|
||||
xhr.Set("readyState", 0)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", "")
|
||||
xhr.Set("responseText", "")
|
||||
xhr.Set("response", "")
|
||||
|
||||
// 事件处理器
|
||||
xhr.Set("onreadystatechange", goja.Null())
|
||||
xhr.Set("onload", goja.Null())
|
||||
xhr.Set("onerror", goja.Null())
|
||||
|
||||
// open 方法
|
||||
xhr.Set("open", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
panic(j.vm.NewTypeError("open requires at least 2 arguments"))
|
||||
}
|
||||
method = call.Argument(0).String()
|
||||
url = call.Argument(1).String()
|
||||
if len(call.Arguments) > 2 {
|
||||
async = call.Argument(2).ToBoolean()
|
||||
}
|
||||
xhr.Set("readyState", 1)
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// setRequestHeader 方法
|
||||
xhr.Set("setRequestHeader", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
panic(j.vm.NewTypeError("setRequestHeader requires 2 arguments"))
|
||||
}
|
||||
key := call.Argument(0).String()
|
||||
value := call.Argument(1).String()
|
||||
headers[key] = value
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// send 方法
|
||||
xhr.Set("send", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Argument(0)) && !goja.IsNull(call.Argument(0)) {
|
||||
requestBody = call.Argument(0).String()
|
||||
}
|
||||
|
||||
sendFunc := func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", fmt.Sprintf("Error: %v", r))
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
}
|
||||
}()
|
||||
|
||||
// 创建请求
|
||||
var body io.Reader
|
||||
if requestBody != "" {
|
||||
body = bytes.NewReader([]byte(requestBody))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", err.Error())
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
xhr.Set("readyState", 2)
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", 0)
|
||||
xhr.Set("statusText", err.Error())
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
xhr.Set("readyState", 3)
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", resp.StatusCode)
|
||||
xhr.Set("statusText", err.Error())
|
||||
j.callHandler(xhr, "onerror")
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
return
|
||||
}
|
||||
|
||||
// 完成
|
||||
xhr.Set("readyState", 4)
|
||||
xhr.Set("status", resp.StatusCode)
|
||||
xhr.Set("statusText", resp.Status)
|
||||
xhr.Set("responseText", string(bodyBytes))
|
||||
xhr.Set("response", string(bodyBytes))
|
||||
j.callHandler(xhr, "onreadystatechange")
|
||||
j.callHandler(xhr, "onload")
|
||||
}
|
||||
|
||||
if async {
|
||||
go sendFunc()
|
||||
} else {
|
||||
sendFunc()
|
||||
}
|
||||
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// getAllResponseHeaders 方法
|
||||
xhr.Set("getAllResponseHeaders", func(call goja.FunctionCall) goja.Value {
|
||||
return j.vm.ToValue("")
|
||||
})
|
||||
|
||||
// getResponseHeader 方法
|
||||
xhr.Set("getResponseHeader", func(call goja.FunctionCall) goja.Value {
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// callHandler 调用事件处理器
|
||||
func (j *JavaScriptSender) callHandler(obj *goja.Object, handlerName string) {
|
||||
handler := obj.Get(handlerName)
|
||||
if handler != nil && !goja.IsUndefined(handler) && !goja.IsNull(handler) {
|
||||
if fn, ok := goja.AssertFunction(handler); ok {
|
||||
fn(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package javascript
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
type JavaScriptSender struct {
|
||||
Addition
|
||||
vm *goja.Runtime
|
||||
noopProgram *goja.Program
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) GetName() string {
|
||||
return "Javascript"
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) GetConfiguration() factory.Configuration {
|
||||
return &j.Addition
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) Init() error {
|
||||
if j.Addition.Script == "" {
|
||||
return errors.New("JavaScript script is empty")
|
||||
}
|
||||
|
||||
// 创建 JavaScript 运行时
|
||||
j.vm = goja.New()
|
||||
|
||||
// 预编译一个 no-op 程序,用于驱动微任务队列
|
||||
prog, errc := goja.Compile("noop.js", "void 0", false)
|
||||
if errc == nil {
|
||||
j.noopProgram = prog
|
||||
}
|
||||
|
||||
// 设置 require 支持
|
||||
new(require.Registry).Enable(j.vm)
|
||||
|
||||
// 注入全局对象和函数
|
||||
j.setupGlobals()
|
||||
|
||||
// 加载用户脚本
|
||||
_, err := j.vm.RunString(j.Addition.Script)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load JavaScript script: %v", err)
|
||||
}
|
||||
|
||||
// 验证 sendMessage 函数是否存在
|
||||
sendMessage := j.vm.Get("sendMessage")
|
||||
if sendMessage == nil || goja.IsUndefined(sendMessage) {
|
||||
return errors.New("sendMessage function not defined in script")
|
||||
}
|
||||
|
||||
// 验证是否可调用
|
||||
if _, ok := goja.AssertFunction(sendMessage); !ok {
|
||||
return errors.New("sendMessage is not a function")
|
||||
}
|
||||
|
||||
// sendEvent 函数是可选的,不强制要求存在
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) Destroy() error {
|
||||
if j.vm != nil {
|
||||
j.vm = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) SendTextMessage(message, title string) error {
|
||||
if j.vm == nil {
|
||||
if err := j.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 sendMessage 函数
|
||||
sendMessageFunc, ok := goja.AssertFunction(j.vm.Get("sendMessage"))
|
||||
if !ok {
|
||||
return errors.New("sendMessage is not a callable function")
|
||||
}
|
||||
|
||||
// 调用 sendMessage 函数
|
||||
resultChan := make(chan error, 1)
|
||||
timeoutChan := time.After(30 * time.Second)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := sendMessageFunc(goja.Undefined(), j.vm.ToValue(message), j.vm.ToValue(title))
|
||||
if err != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理 Promise 返回值
|
||||
if promise, ok := result.Export().(*goja.Promise); ok {
|
||||
// 等待 Promise 完成
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutChan:
|
||||
resultChan <- errors.New("JavaScript execution timeout")
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 运行微任务以处理 Promise 回调
|
||||
j.runMicrotasks()
|
||||
|
||||
state := promise.State()
|
||||
if state == goja.PromiseStateFulfilled {
|
||||
// Promise 成功完成,检查返回值
|
||||
promiseResult := promise.Result()
|
||||
if !promiseResult.ToBoolean() {
|
||||
resultChan <- errors.New("sendMessage returned false")
|
||||
} else {
|
||||
resultChan <- nil
|
||||
}
|
||||
return
|
||||
} else if state == goja.PromiseStateRejected {
|
||||
// Promise 被拒绝
|
||||
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
|
||||
return
|
||||
}
|
||||
// state == goja.PromiseStatePending, 继续等待
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 处理布尔或其他返回值
|
||||
if result.ToBoolean() {
|
||||
resultChan <- nil
|
||||
} else {
|
||||
resultChan <- errors.New("sendMessage returned false")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-resultChan:
|
||||
return err
|
||||
case <-timeoutChan:
|
||||
return errors.New("JavaScript execution timeout after 30 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) SendEvent(event models.EventMessage) error {
|
||||
if j.vm == nil {
|
||||
if err := j.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否定义了 sendEvent 函数
|
||||
sendEventValue := j.vm.Get("sendEvent")
|
||||
if sendEventValue == nil || goja.IsUndefined(sendEventValue) {
|
||||
// 如果没有定义 sendEvent,则回退到使用 SendTextMessage
|
||||
return j.fallbackToTextMessage(event)
|
||||
}
|
||||
|
||||
sendEventFunc, ok := goja.AssertFunction(sendEventValue)
|
||||
if !ok {
|
||||
// 如果 sendEvent 不是函数,回退到 SendTextMessage
|
||||
return j.fallbackToTextMessage(event)
|
||||
}
|
||||
|
||||
// 将 EventMessage 转换为 JavaScript 对象
|
||||
eventJSON, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal event: %v", err)
|
||||
}
|
||||
|
||||
var eventMap map[string]interface{}
|
||||
if err := json.Unmarshal(eventJSON, &eventMap); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal event: %v", err)
|
||||
}
|
||||
|
||||
// 调用 sendEvent 函数
|
||||
resultChan := make(chan error, 1)
|
||||
timeoutChan := time.After(30 * time.Second)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := sendEventFunc(goja.Undefined(), j.vm.ToValue(eventMap))
|
||||
if err != nil {
|
||||
resultChan <- fmt.Errorf("JavaScript error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理 Promise 返回值
|
||||
if promise, ok := result.Export().(*goja.Promise); ok {
|
||||
// 等待 Promise 完成
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutChan:
|
||||
resultChan <- errors.New("JavaScript execution timeout")
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 运行微任务以处理 Promise 回调
|
||||
j.runMicrotasks()
|
||||
|
||||
state := promise.State()
|
||||
if state == goja.PromiseStateFulfilled {
|
||||
// Promise 成功完成,检查返回值
|
||||
promiseResult := promise.Result()
|
||||
if !promiseResult.ToBoolean() {
|
||||
resultChan <- errors.New("sendEvent returned false")
|
||||
} else {
|
||||
resultChan <- nil
|
||||
}
|
||||
return
|
||||
} else if state == goja.PromiseStateRejected {
|
||||
// Promise 被拒绝
|
||||
resultChan <- fmt.Errorf("Promise rejected: %v", promise.Result())
|
||||
return
|
||||
}
|
||||
// state == goja.PromiseStatePending, 继续等待
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 处理布尔或其他返回值
|
||||
if result.ToBoolean() {
|
||||
resultChan <- nil
|
||||
} else {
|
||||
resultChan <- errors.New("sendEvent returned false")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-resultChan:
|
||||
return err
|
||||
case <-timeoutChan:
|
||||
return errors.New("JavaScript execution timeout after 30 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackToTextMessage 当没有定义 sendEvent 时,回退到使用文本消息格式
|
||||
func (j *JavaScriptSender) fallbackToTextMessage(event models.EventMessage) error {
|
||||
// 构建简单的文本消息
|
||||
message := fmt.Sprintf("%s%s%s\nEvent: %s\nMessage: %s\nTime: %s",
|
||||
event.Emoji, event.Emoji, event.Emoji,
|
||||
event.Event,
|
||||
event.Message,
|
||||
event.Time.Format(time.RFC3339))
|
||||
|
||||
// 添加客户端信息
|
||||
if len(event.Clients) > 0 {
|
||||
clientNames := make([]string, 0, len(event.Clients))
|
||||
for _, c := range event.Clients {
|
||||
name := c.Name
|
||||
if name == "" {
|
||||
name = c.UUID
|
||||
}
|
||||
clientNames = append(clientNames, name)
|
||||
}
|
||||
message = fmt.Sprintf("%s%s%s\nEvent: %s\nClients: %s\nMessage: %s\nTime: %s",
|
||||
event.Emoji, event.Emoji, event.Emoji,
|
||||
event.Event,
|
||||
clientNames,
|
||||
event.Message,
|
||||
event.Time.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return j.SendTextMessage(message, event.Event)
|
||||
}
|
||||
|
||||
// runMicrotasks 安全地推动 goja 的微任务队列(例如 Promise 回调)
|
||||
func (j *JavaScriptSender) runMicrotasks() {
|
||||
if j.vm == nil {
|
||||
return
|
||||
}
|
||||
if j.noopProgram != nil {
|
||||
_, _ = j.vm.RunProgram(j.noopProgram)
|
||||
return
|
||||
}
|
||||
// 兜底: 直接运行一段 no-op 代码
|
||||
_, _ = j.vm.RunString("void 0")
|
||||
}
|
||||
|
||||
func (j *JavaScriptSender) setupGlobals() {
|
||||
// 注入 console.log
|
||||
console := j.vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
var args []interface{}
|
||||
for _, arg := range call.Arguments {
|
||||
args = append(args, arg.Export())
|
||||
}
|
||||
fmt.Println(args...)
|
||||
return goja.Undefined()
|
||||
})
|
||||
console.Set("error", func(call goja.FunctionCall) goja.Value {
|
||||
fmt.Print("Error: ")
|
||||
for i, arg := range call.Arguments {
|
||||
if i > 0 {
|
||||
fmt.Print(" ")
|
||||
}
|
||||
fmt.Print(arg.Export())
|
||||
}
|
||||
fmt.Println()
|
||||
return goja.Undefined()
|
||||
})
|
||||
j.vm.Set("console", console)
|
||||
|
||||
// 注入 fetch API
|
||||
j.vm.Set("fetch", j.createFetchFunction())
|
||||
|
||||
// 注入 XMLHttpRequest (xhr)
|
||||
j.vm.Set("XMLHttpRequest", j.createXHRConstructor())
|
||||
|
||||
// 注入 setTimeout
|
||||
j.vm.Set("setTimeout", func(call goja.FunctionCall) goja.Value {
|
||||
callback := call.Argument(0)
|
||||
delay := call.Argument(1).ToInteger()
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
if fn, ok := goja.AssertFunction(callback); ok {
|
||||
fn(goja.Undefined())
|
||||
}
|
||||
}()
|
||||
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// 注入 Promise 构造函数
|
||||
j.vm.RunString(`
|
||||
if (typeof Promise === 'undefined') {
|
||||
// Promise polyfill 会由 goja 自动提供
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &JavaScriptSender{}
|
||||
})
|
||||
}
|
||||
|
||||
// 确保实现了 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*JavaScriptSender)(nil)
|
||||
@@ -0,0 +1,5 @@
|
||||
package javascript
|
||||
|
||||
type Addition struct {
|
||||
Script string `json:"script" required:"true" type:"richtext" help:"JavaScript code (ES6 supported) that implements sendMessage(message, title) and optionally sendEvent(event) functions. Both should return a Promise or boolean. Available APIs: fetch(), xhr(), console.log()."`
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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"
|
||||
)
|
||||
@@ -52,21 +52,21 @@ func Initialize() {
|
||||
}
|
||||
})
|
||||
}()
|
||||
cfg, _ := config.Get()
|
||||
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
|
||||
|
||||
if cfg.NotificationMethod == "" || cfg.NotificationMethod == "none" {
|
||||
if NotificationMethod == "" || NotificationMethod == "none" {
|
||||
LoadProvider("empty", "{}")
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试从数据库加载配置
|
||||
senderConfig, err := database.GetMessageSenderConfigByName(cfg.NotificationMethod)
|
||||
senderConfig, err := database.GetMessageSenderConfigByName(NotificationMethod)
|
||||
if err != nil {
|
||||
// 如果没有找到配置,使用empty provider
|
||||
LoadProvider("empty", "{}")
|
||||
return
|
||||
}
|
||||
LoadProvider(cfg.NotificationMethod, senderConfig.Addition)
|
||||
LoadProvider(NotificationMethod, senderConfig.Addition)
|
||||
}
|
||||
|
||||
func SendTextMessage(message string, title string) error {
|
||||
@@ -74,11 +74,11 @@ func SendTextMessage(message string, title string) error {
|
||||
return fmt.Errorf("message sender provider is not initialized")
|
||||
}
|
||||
var err error
|
||||
cfg, err := config.Get()
|
||||
NotificationEnabled, err := config.GetAs[bool](config.NotificationEnabledKey, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.NotificationEnabled {
|
||||
if !NotificationEnabled {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -96,17 +96,34 @@ func SendEvent(event models.EventMessage) error {
|
||||
return fmt.Errorf("message sender provider is not initialized")
|
||||
}
|
||||
var err error
|
||||
cfg, err := config.Get()
|
||||
cfg, err := config.GetMany(map[string]any{
|
||||
config.NotificationEnabledKey: false,
|
||||
config.NotificationTemplateKey: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.NotificationEnabled {
|
||||
if !cfg[config.NotificationEnabledKey].(bool) {
|
||||
return nil
|
||||
}
|
||||
messageTemplate := cfg.NotificationTemplate
|
||||
if messageTemplate == "" {
|
||||
messageTemplate = "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"
|
||||
|
||||
// 检查提供者是否实现了 IEventMessageSender 接口
|
||||
if eventSender, ok := CurrentProvider().(factory.IEventMessageSender); ok {
|
||||
// 如果实现了,直接调用 SendEvent
|
||||
for i := 0; i < 3; i++ {
|
||||
err = eventSender.SendEvent(event)
|
||||
if err == nil || err.Error() == "short response: \x00\x00\x00\x1a\x00\x00\x00" {
|
||||
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果没有实现,使用模板格式化为文本消息
|
||||
messageTemplate := cfg[config.NotificationTemplateKey].(string)
|
||||
|
||||
messageTemplate = parseTemplate(messageTemplate, event)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package serverchan3
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// Addition 为 Server酱³ 推送通道的配置项
|
||||
// 所有字段通过管理页面以 JSON 形式进行设置
|
||||
type Addition struct {
|
||||
// APIURL 为接口完整地址,例如:https://<uid>.push.ft07.com/send/<sendkey>.send
|
||||
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://<uid>.push.ft07.com/send/<sendkey>.send;参考:https://sc3.ft07.com/"`
|
||||
// Tags 为可选标签,使用 | 分割,例如:tag1|tag2|tag3
|
||||
Tags string `json:"tags" help:"可选标签,使用 | 分割,例如 tag1|tag2|tag3"`
|
||||
}
|
||||
|
||||
// 注册 Server酱³ 推送通道到工厂
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &ServerChan3Sender{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package serverchan3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// ServerChan3Sender 为 Server酱³ 推送实现
|
||||
type ServerChan3Sender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
// GetName 返回推送通道名称
|
||||
func (s *ServerChan3Sender) GetName() string {
|
||||
return "Server酱³"
|
||||
}
|
||||
|
||||
// GetConfiguration 返回配置结构体指针
|
||||
func (s *ServerChan3Sender) GetConfiguration() factory.Configuration {
|
||||
return &s.Addition
|
||||
}
|
||||
|
||||
// Init 初始化(当前无需处理)
|
||||
func (s *ServerChan3Sender) Init() error { return nil }
|
||||
|
||||
// Destroy 清理(当前无需处理)
|
||||
func (s *ServerChan3Sender) Destroy() error { return nil }
|
||||
|
||||
// SendTextMessage 发送文本消息(携带标题)。
|
||||
// 简化为固定 JSON POST:内部仅组装 {"title": ..., "desp": ...},不允许用户自定义请求体或内容类型。
|
||||
func (s *ServerChan3Sender) SendTextMessage(message, title string) error {
|
||||
apiURL := strings.TrimSpace(s.Addition.APIURL)
|
||||
if apiURL == "" {
|
||||
return fmt.Errorf("未配置 Server酱³ 接口地址(api_url)")
|
||||
}
|
||||
|
||||
// 简化校验:若标题与正文均为空则拒绝发送
|
||||
finalTitle := strings.TrimSpace(title)
|
||||
finalMessage := strings.TrimSpace(message)
|
||||
if finalTitle == "" && finalMessage == "" {
|
||||
return fmt.Errorf("serverchan3: 标题与正文均为空")
|
||||
}
|
||||
|
||||
// 固定 JSON 载荷,包含 title、desp,并支持可选 tags(通过 | 分割)
|
||||
payload := map[string]string{
|
||||
"title": finalTitle,
|
||||
"desp": finalMessage,
|
||||
}
|
||||
if tagsStr := strings.TrimSpace(s.Addition.Tags); tagsStr != "" {
|
||||
parts := strings.Split(tagsStr, "|")
|
||||
var cleaned []string
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t != "" {
|
||||
cleaned = append(cleaned, t)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
// Server酱³ 校验提示需要字符串,这里按 | 拼接为字符串
|
||||
payload["tags"] = strings.Join(cleaned, "|")
|
||||
}
|
||||
}
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("组装 JSON 失败: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
// 固定设置 Content-Type
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// 读取响应体用于错误信息打印
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
msg := strings.TrimSpace(string(b))
|
||||
if msg == "" {
|
||||
return fmt.Errorf("接口返回非 2xx 状态码: %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 之前的模板、表单解析与手动转义工具不再需要,简化实现
|
||||
|
||||
// 确保实现 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*ServerChan3Sender)(nil)
|
||||
@@ -0,0 +1,25 @@
|
||||
package serverchanturbo
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// Addition 为 Server酱 Turbo 推送通道的配置项
|
||||
// 仅允许配置接口地址及可选的通道/隐藏IP/openid,固定以 JSON 发送
|
||||
type Addition struct {
|
||||
// APIURL 为接口完整地址,例如:https://sctapi.ftqq.com/<sendkey>.send
|
||||
APIURL string `json:"api_url" required:"true" help:"接口完整地址,例如 https://sctapi.ftqq.com/<sendkey>.send;参考:https://sct.ftqq.com/"`
|
||||
// Channel 为本次推送使用的消息通道,最多两个,多个用 | 隔开,例如:9|66
|
||||
Channel string `json:"channel" help:"消息通道,可选,多个用 | 隔开,例如 9|66"`
|
||||
// NoIP 是否隐藏调用 IP,填 1 则隐藏
|
||||
NoIP string `json:"noip" help:"是否隐藏调用IP,填 1 隐藏;为空则不隐藏"`
|
||||
// OpenID 消息抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔
|
||||
OpenID string `json:"openid" help:"抄送 openid,测试号用 , 分隔;企业微信应用用 | 分隔"`
|
||||
}
|
||||
|
||||
// 注册 Server酱 Turbo 推送通道到工厂
|
||||
func init() {
|
||||
factory.RegisterMessageSender(func() factory.IMessageSender {
|
||||
return &ServerChanTurboSender{}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package serverchanturbo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/utils/messageSender/factory"
|
||||
)
|
||||
|
||||
// ServerChanTurboSender 为 Server酱 Turbo 推送实现
|
||||
type ServerChanTurboSender struct {
|
||||
Addition
|
||||
}
|
||||
|
||||
// GetName 返回推送通道名称
|
||||
func (s *ServerChanTurboSender) GetName() string {
|
||||
return "Server酱Turbo"
|
||||
}
|
||||
|
||||
// GetConfiguration 返回配置结构体指针
|
||||
func (s *ServerChanTurboSender) GetConfiguration() factory.Configuration {
|
||||
return &s.Addition
|
||||
}
|
||||
|
||||
// Init 初始化(当前无需处理)
|
||||
func (s *ServerChanTurboSender) Init() error { return nil }
|
||||
|
||||
// Destroy 清理(当前无需处理)
|
||||
func (s *ServerChanTurboSender) Destroy() error { return nil }
|
||||
|
||||
// SendTextMessage 固定以 JSON 发送,仅组装 title 与 desp,支持可选 channel/noip/openid
|
||||
func (s *ServerChanTurboSender) SendTextMessage(message, title string) error {
|
||||
apiURL := strings.TrimSpace(s.Addition.APIURL)
|
||||
if apiURL == "" {
|
||||
return fmt.Errorf("未配置 Server酱 Turbo 接口地址(api_url)")
|
||||
}
|
||||
|
||||
finalTitle := strings.TrimSpace(title)
|
||||
finalMessage := strings.TrimSpace(message)
|
||||
if finalTitle == "" && finalMessage == "" {
|
||||
return fmt.Errorf("serverchanturbo: 标题与正文均为空")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"title": finalTitle,
|
||||
"desp": finalMessage,
|
||||
}
|
||||
if ch := strings.TrimSpace(s.Addition.Channel); ch != "" {
|
||||
payload["channel"] = ch
|
||||
}
|
||||
if noip := strings.TrimSpace(s.Addition.NoIP); noip == "1" {
|
||||
payload["noip"] = "1"
|
||||
}
|
||||
if openid := strings.TrimSpace(s.Addition.OpenID); openid != "" {
|
||||
payload["openid"] = openid
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serverchanturbo: 组装 JSON 失败: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("serverchanturbo: 创建请求失败: %v", err)
|
||||
}
|
||||
// 使用 JSON 传递参数,需要设置 Content-Type
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serverchanturbo: 发送请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
msg := strings.TrimSpace(string(b))
|
||||
if msg == "" {
|
||||
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("serverchanturbo: 接口返回非 2xx 状态码: %d,响应: %s", resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 确保实现 IMessageSender 接口
|
||||
var _ factory.IMessageSender = (*ServerChanTurboSender)(nil)
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
@@ -23,7 +23,10 @@ func CheckExpireScheduledWork() {
|
||||
duration := next.Sub(now)
|
||||
time.Sleep(duration)
|
||||
|
||||
cfg, err := config.Get()
|
||||
cfg, err := config.GetMany(map[string]any{
|
||||
config.ExpireNotificationEnabledKey: false,
|
||||
config.ExpireNotificationLeadDaysKey: 7,
|
||||
})
|
||||
if err != nil {
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
@@ -38,8 +41,8 @@ func CheckExpireScheduledWork() {
|
||||
checkTime := time.Now()
|
||||
|
||||
// 过期提醒检查(仅当启用过期通知时)
|
||||
if cfg.ExpireNotificationEnabled {
|
||||
notificationLeadDays := cfg.ExpireNotificationLeadDays
|
||||
if cfg[config.ExpireNotificationEnabledKey].(bool) {
|
||||
notificationLeadDays := cfg[config.ExpireNotificationLeadDaysKey].(int)
|
||||
|
||||
type clientToExpireInfo struct {
|
||||
Name string
|
||||
|
||||
+28
-5
@@ -140,22 +140,31 @@ func checkMetricThreshold(records []models.Record, task models.LoadNotification)
|
||||
|
||||
// getMetricValue 根据指标名称获取记录中的对应值
|
||||
func getMetricValue(record models.Record, metric string) float32 {
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
switch metric {
|
||||
case "cpu":
|
||||
return record.Cpu
|
||||
case "gpu":
|
||||
return record.Gpu
|
||||
case "net_in", "netin":
|
||||
return bytesPerSecondToMbps(record.NetIn)
|
||||
case "net_out", "netout":
|
||||
return bytesPerSecondToMbps(record.NetOut)
|
||||
case "ram":
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
if record.RamTotal > 0 {
|
||||
return float32(record.Ram) / float32(client.MemTotal) * 100
|
||||
}
|
||||
return 0
|
||||
case "swap":
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
if record.SwapTotal > 0 {
|
||||
return float32(record.Swap) / float32(client.SwapTotal) * 100
|
||||
}
|
||||
@@ -165,6 +174,11 @@ func getMetricValue(record models.Record, metric string) float32 {
|
||||
case "temp":
|
||||
return record.Temp
|
||||
case "disk":
|
||||
client, err := clients.GetClientByUUID(record.Client) // 确保客户端信息已加载
|
||||
if err != nil {
|
||||
log.Printf("Failed to get client info for %s: %v", record.Client, err)
|
||||
return 0
|
||||
}
|
||||
if record.DiskTotal > 0 {
|
||||
return float32(record.Disk) / float32(client.DiskTotal) * 100
|
||||
}
|
||||
@@ -187,6 +201,15 @@ func getMetricValue(record models.Record, metric string) float32 {
|
||||
}
|
||||
}
|
||||
|
||||
func bytesPerSecondToMbps(bytesPerSecond int64) float32 {
|
||||
if bytesPerSecond <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 采用十进制 Mbps:1 Mbps = 1,000,000 bit/s
|
||||
return float32(float64(bytesPerSecond) * 8 / 1_000_000)
|
||||
}
|
||||
|
||||
// sendLoadNotification 发送负载通知
|
||||
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
|
||||
ex_clients := []models.Client{}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"github.com/komari-monitor/komari/database/config"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
|
||||
@@ -32,8 +32,8 @@ var clientStates sync.Map
|
||||
// getNotificationConfig 获取指定客户端的通知配置。
|
||||
// 返回配置对象和一个布尔值,指示全局和该客户端是否启用通知。
|
||||
func getNotificationConfig(clientID string) (*models.OfflineNotification, bool) {
|
||||
conf, err := config.Get()
|
||||
if err != nil || !conf.NotificationEnabled {
|
||||
conf, err := config.GetAs[bool](config.NotificationEnabledKey, false)
|
||||
if err != nil || !conf {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"github.com/komari-monitor/komari/database/clients"
|
||||
"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/ws"
|
||||
@@ -27,17 +27,17 @@ func CheckTraffic() {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Get()
|
||||
cfg, err := config.GetAs[float64](config.TrafficLimitPercentageKey, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.TrafficLimitPercentage <= 0 {
|
||||
if cfg <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 起始阈值:例如 80%,非5的倍数则从上取整到最近的5的倍数,例如 83->85
|
||||
startThreshold := cfg.TrafficLimitPercentage
|
||||
startThreshold := cfg
|
||||
if startThreshold < 0 {
|
||||
startThreshold = 0
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/komari-monitor/komari/config"
|
||||
"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"
|
||||
)
|
||||
@@ -70,15 +70,15 @@ func Initialize() error {
|
||||
}
|
||||
}
|
||||
})
|
||||
cfg, _ := config.Get()
|
||||
if cfg.OAuthProvider == "" || cfg.OAuthProvider == "none" {
|
||||
LoadProvider("empty", "{}")
|
||||
cfg, _ := config.GetAs[string](config.OAuthProviderKey, "github")
|
||||
if cfg == "" || cfg == "none" {
|
||||
LoadProvider("github", "{}")
|
||||
return nil
|
||||
}
|
||||
provider, err := database.GetOidcConfigByName(cfg.OAuthProvider)
|
||||
provider, err := database.GetOidcConfigByName(cfg)
|
||||
if err != nil {
|
||||
// 如果没有找到配置,使用empty provider
|
||||
LoadProvider("empty", "{}")
|
||||
// 如果没有找到配置,使用github provider
|
||||
LoadProvider("github", "{}")
|
||||
return nil
|
||||
}
|
||||
err = LoadProvider(provider.Name, provider.Addition)
|
||||
|
||||
+24
-10
@@ -1,19 +1,33 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"math/rand"
|
||||
"time"
|
||||
"crypto/rand"
|
||||
)
|
||||
|
||||
func GenerateRandomString(length int) string {
|
||||
b := make([]byte, length)
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
_, err := r.Read(b)
|
||||
if err != nil {
|
||||
if length <= 0 {
|
||||
return ""
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b)[:length]
|
||||
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const n = byte(len(charset)) // 62
|
||||
const threshold = byte(256 - (256 % len(charset))) // 248
|
||||
|
||||
out := make([]byte, 0, length)
|
||||
buf := make([]byte, length)
|
||||
for len(out) < length {
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, b := range buf {
|
||||
if b < threshold {
|
||||
out = append(out, charset[int(b%n)])
|
||||
if len(out) == length {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func GeneratePassword() string {
|
||||
@@ -21,5 +35,5 @@ func GeneratePassword() string {
|
||||
}
|
||||
|
||||
func GenerateToken() string {
|
||||
return GenerateRandomString(16)
|
||||
}
|
||||
return GenerateRandomString(22)
|
||||
}
|
||||
Reference in new issue
Block a user