Compare commits

...
20 Commits
Author SHA1 Message Date
Akizon77 92ab67d5f3 feat: 添加临时访问许可功能,允许使用临时密钥访问私有站点 2026-02-16 00:34:43 +08:00
Akizon77 77f5bef1a5 fix: 配置默认值 2026-02-15 14:17:03 +08:00
Akizon77 58e43a0862 fix: 增强 WebSocket 升级错误消息,包含详细错误信息 2026-02-12 13:41:01 +08:00
Akizon 80c1f2747a Merge pull request #455 from komari-monitor/fix/expire-notification-panic
fix: 修复过期通知的通知提前天数类型转换错误
2026-02-12 13:13:12 +08:00
Akizon77 b7b2d8b050 fix: 修复过期通知的通知提前天数类型转换错误 2026-02-12 13:12:16 +08:00
Akizon77 b9eabd7cdc docs: 添加 1Panel 应用商店的安装说明 2026-02-10 22:54:48 +08:00
Akizon77 0a19465140 docs: 添加破碎工坊云的介绍和链接 2026-02-09 23:08:57 +08:00
Akizon e874b5db18 Merge pull request #451 from komari-monitor/fix/config-panic
Fix/config panic
2026-02-08 15:49:00 +08:00
Akizon77 23e7d97e3c fix: 流量提醒 2026-02-08 15:48:02 +08:00
Akizon77 a222de2c55 fix: 修复 GetAs 函数中的默认值类型不匹配错误 2026-02-08 13:26:43 +08:00
Akizon bb1eed1ccb Merge pull request #448 from komari-monitor/fix-path-traversal-vulnerability
fix: 修复路径穿透安全漏洞
2026-02-07 23:39:57 +08:00
Akizon77 c34bc8da69 fix: 修复路径穿透安全漏洞
- 添加 isSafePath 函数验证路径是否在允许的目录内
- 在 getFileContent 中验证主题 ID 和文件路径
- 清理所有用户输入的路径,防止 ../ 等路径穿透攻击
- 确保嵌入式文件系统路径也经过验证

Security: 防止攻击者通过构造特殊路径访问系统任意文件
2026-02-07 23:39:08 +08:00
Akizon77 249c4376f1 fix: 异步发送登录信息 2026-02-07 23:02:45 +08:00
Akizon77 d36bde7d94 fix: 默认主题不显示 2026-02-07 22:59:50 +08:00
Akizon 493d2a2707 Merge pull request #447 from komari-monitor/feat/default-theme
feat: default theme
2026-02-07 20:39:53 +08:00
Akizon77 93a9fbe5cc feat: default theme 2026-02-04 22:20:45 +08:00
Akizon b070f33bf9 Merge pull request #445 from komari-monitor/refactor/config-structure
refactor: 调整配置结构
2026-02-04 19:46:01 +08:00
Akizon ee5480dba7 Merge pull request #442 from flyflas/add-email-html-support
feat:add_email_html_support
2026-02-04 19:43:09 +08:00
Akizon77 2e9ef3d5ba refactor: 调整配置结构 2026-02-04 19:39:48 +08:00
xiaobai 1061b84087 feat:add_email_html_support 2026-01-29 14:44:58 +08:00
45 changed files with 1478 additions and 690 deletions

No files matched your search

+10 -3
View File
@@ -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 }}*
+14 -7
View File
@@ -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
+9 -2
View File
@@ -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
+9 -2
View File
@@ -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
+9 -2
View File
@@ -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
+23 -10
View File
@@ -1,5 +1,4 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
@@ -13,6 +12,7 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
[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.
@@ -20,13 +20,19 @@ 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://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Rainyun - CNY 4.5/month
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel App Store
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
### 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
@@ -34,6 +40,7 @@ sudo ./install-komari.sh
```
### 2. Docker Deployment
1. Create a data directory:
```bash
mkdir -p ./data
@@ -56,6 +63,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
@@ -67,7 +75,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
@@ -86,34 +93,41 @@ 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)
## 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
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 Sponsored by SharonNetworks
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
@@ -126,4 +140,3 @@ All the developers who submitted PRs and created themes
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+5 -4
View File
@@ -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
}
+31 -3
View File
@@ -2,9 +2,10 @@ package api
import (
"net/http"
"time"
"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,17 +46,44 @@ 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
}
// 临时访问许可
if func() bool {
tempKey, err := c.Cookie("temp_key")
if err != nil {
return false
}
allowTempKey, err := config.GetAs[string]("tempory_share_token", "")
if err != nil {
return false
}
if tempKey != allowTempKey || allowTempKey == "" {
return false
}
tempKeyExipreTime, err := config.GetAs[int64]("tempory_share_token_expire_at", 0)
if err != nil {
return false
}
if tempKeyExipreTime < time.Now().Unix() {
return false
}
c.SetCookie("temp_key", tempKey, int(tempKeyExipreTime-time.Now().Unix()), "/", "", false, false)
return true
}() {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
+4 -3
View File
@@ -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
View File
@@ -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 -32
View File
@@ -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 {
@@ -63,15 +45,6 @@ func EditSettings(c *gin.Context) {
api.RespondSuccess(c, nil)
}
func contains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
func ClearAllRecords(c *gin.Context) {
records.DeleteAll()
tasks.DeleteAllPingRecords()
+3 -3
View File
@@ -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
View File
@@ -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
}
+6 -7
View File
@@ -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
+1 -1
View File
@@ -77,7 +77,7 @@ func WebSocketReport(c *gin.Context) {
// Upgrade the HTTP connection to a WebSocket connection
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
conn := ws.NewSafeConn(unsafeConn)
+2 -2
View File
@@ -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)
+14 -11
View File
@@ -11,10 +11,9 @@ 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"
)
@@ -25,9 +24,9 @@ import (
// params: 方法参数
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
// 检查私有站点
conf, _ := config.Get()
PrivateSite, _ := config.GetAs[bool](config.PrivateSiteKey)
if conf.PrivateSite && group == "guest" {
if PrivateSite && group == "guest" {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
@@ -64,22 +63,22 @@ func OnInternalRequest(ctx context.Context, group string, method string, 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)
})
conn := ws.NewSafeConn(_conn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
permissionGroup := detectPermissionGroup(c, cfg)
permissionGroup := detectPermissionGroup(c)
meta := buildContextMeta(c, permissionGroup)
defer conn.Close()
for {
@@ -120,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))
@@ -161,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 {
@@ -173,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
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+3 -11
View File
@@ -22,7 +22,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api/jsonRpc"
conf "github.com/komari-monitor/komari/database/config"
conf "github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/models"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
@@ -665,11 +665,7 @@ func renderStatusTable(ctx context.Context, lang string, tzOffset *int, fontErr
y := padding
// 标题
c, _ := conf.Get()
siteName := c.Sitename
if siteName == "" {
siteName = "Komari Monitor"
}
siteName, _ := conf.GetAs[string](conf.SitenameKey, "Komari Monitor")
fontMutex.RLock()
boldFace := fontFaceBold
normalFace := fontFace
@@ -837,11 +833,7 @@ func renderStatusTableWithBasicFont(ctx context.Context, lang string, tzOffset *
y := padding
// 标题
c, _ := conf.Get()
siteName := c.Sitename
if siteName == "" {
siteName = "Komari Monitor"
}
siteName, _ := conf.GetAs[string](conf.SitenameKey, "Komari Monitor")
drawStringBasic(img, siteName, padding+5, y+titleFontSize, color.Black)
y += headerHeight
+4 -4
View File
@@ -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)
@@ -32,7 +32,7 @@ func GetClients(c *gin.Context) {
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket." + err.Error()})
return
}
defer conn.Close()
+3 -3
View File
@@ -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
+21 -23
View File
@@ -25,10 +25,11 @@ import (
"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"
@@ -67,10 +68,6 @@ func init() {
func RunServer() {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
// 创建主题目录
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
@@ -78,7 +75,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)
}
@@ -98,8 +95,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 {
@@ -110,12 +110,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))
}
@@ -144,13 +143,17 @@ func RunServer() {
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 {
@@ -338,11 +341,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,
@@ -400,7 +398,7 @@ func DoScheduledWork() {
records.CompactRecord()
go notifier.CheckExpireScheduledWork()
for {
cfg, _ := config.Get()
cfg, _ := config.GetManyAs[config.Legacy]()
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
+687
View File
@@ -0,0 +1,687 @@
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 {
// 尝试直接类型断言
if v, ok := defaul[0].(T); ok {
err = Set(key, v)
return v, err
}
// 尝试类型转换
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(defaul[0], val); err != nil {
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
}
err = Set(key, t)
return t, err
}
return t, err
}
// 先尝试直接反序列化
if err = json.Unmarshal([]byte(item.Value), &t); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(item.Value), &generic); err != nil {
return t, err
}
val := reflect.ValueOf(&t).Elem()
if err := convertAndSet(generic, val); err != nil {
return t, err
}
}
return t, nil
}
// GetMany 获取多个配置项,keys 为 map[key]defaultValue
// 如果 defaultValue 为 nil,则数据库不存在时不写入
// 如果 defaultValue 不为 nil,则数据库不存在时写入默认值
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
}
}
// 收集需要写入数据库的默认值
var toInsert []ConfigItem
for k, def := range keys {
if _, found := foundKeys[k]; !found {
if def != nil {
result[k] = def
// 序列化后加入待写入列表
jsonBytes, err := json.Marshal(def)
if err != nil {
slog.Warn("marshal default value failed", "key", k, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: k,
Value: string(jsonBytes),
})
}
}
}
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return result, nil
}
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
// 支持 default tag 作为默认值,如果数据库中不存在且有 default tag 则写入数据库
// 没有 default tag 的字段使用零值,不写入数据库
func GetManyAs[T any]() (*T, error) {
var t T
val := reflect.ValueOf(&t).Elem()
typ := val.Type()
type fieldInfo struct {
index int
key string
hasDefault bool
defaultVal string
}
fields := make([]fieldInfo, 0)
keys := make([]string, 0)
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// 解析 json tag,处理 "key,omitempty" 格式
key := strings.Split(jsonTag, ",")[0]
if key == "" || key == "-" {
continue
}
defaultTag := field.Tag.Get("default")
hasDefault := defaultTag != "" || field.Tag.Get("default") == ""
// 检查是否显式定义了 default tag (即使值为空)
_, hasDefault = field.Tag.Lookup("default")
fields = append(fields, fieldInfo{
index: i,
key: key,
hasDefault: hasDefault,
defaultVal: defaultTag,
})
keys = append(keys, key)
}
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
}
// 建立数据库中存在的 key 映射
foundItems := make(map[string]string) // key -> value
for _, item := range items {
foundItems[item.Key] = item.Value
}
// 需要写入数据库的新配置项
var toInsert []ConfigItem
for _, fi := range fields {
fieldVal := val.Field(fi.index)
if !fieldVal.CanSet() {
continue
}
if dbValue, found := foundItems[fi.key]; found {
// 数据库中存在,使用数据库值
if err := unmarshalToField(dbValue, fieldVal); err != nil {
slog.Warn("unmarshal config failed", "key", fi.key, "error", err)
}
} else if fi.hasDefault {
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
slog.Warn("parse default value failed", "key", fi.key, "error", err)
continue
}
// 序列化后写入数据库
jsonBytes, err := json.Marshal(fieldVal.Interface())
if err != nil {
slog.Warn("marshal default value failed", "key", fi.key, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
Key: fi.key,
Value: string(jsonBytes),
})
}
// 没有 default tag 且数据库中不存在,保持零值,不写入数据库
}
// 批量写入默认值到数据库
if len(toInsert) > 0 {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
slog.Warn("batch insert default config failed", "error", err)
}
}
return &t, nil
}
// unmarshalToField 将 JSON 字符串反序列化到字段,支持数值类型转换
func unmarshalToField(jsonStr string, fieldVal reflect.Value) error {
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(jsonStr), target); err != nil {
// 尝试通用解析后转换
var generic any
if err := json.Unmarshal([]byte(jsonStr), &generic); err != nil {
return err
}
return convertAndSet(generic, fieldVal)
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return nil
}
// parseDefaultToField 解析 default tag 值到字段
func parseDefaultToField(defaultVal string, fieldVal reflect.Value) error {
kind := fieldVal.Kind()
switch kind {
case reflect.String:
fieldVal.SetString(defaultVal)
case reflect.Bool:
fieldVal.SetBool(defaultVal == "true" || defaultVal == "1")
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
var v int64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
// 尝试解析浮点数后转换
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = int64(f)
}
}
fieldVal.SetInt(v)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
var v uint64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%d", &v); err != nil {
var f float64
if _, err := fmt.Sscanf(defaultVal, "%f", &f); err != nil {
return err
}
v = uint64(f)
}
}
fieldVal.SetUint(v)
case reflect.Float32, reflect.Float64:
var v float64
if defaultVal != "" {
if _, err := fmt.Sscanf(defaultVal, "%f", &v); err != nil {
return err
}
}
fieldVal.SetFloat(v)
default:
// 对于复杂类型,尝试 JSON 解析
if defaultVal == "" {
return nil // 保持零值
}
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(defaultVal), target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
}
return nil
}
// convertAndSet 通用类型转换并设置字段值
func convertAndSet(val any, fieldVal reflect.Value) error {
if val == nil {
return nil
}
targetType := fieldVal.Type()
v := reflect.ValueOf(val)
// 直接类型匹配
if v.Type().AssignableTo(targetType) {
fieldVal.Set(v)
return nil
}
// 类型可转换
if v.Type().ConvertibleTo(targetType) {
fieldVal.Set(v.Convert(targetType))
return nil
}
// 数值类型特殊处理 (JSON 数字默认解析为 float64)
if f, ok := val.(float64); ok {
switch fieldVal.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fieldVal.SetInt(int64(f))
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fieldVal.SetUint(uint64(f))
return nil
case reflect.Float32, reflect.Float64:
fieldVal.SetFloat(f)
return nil
}
}
// JSON 回环转换
b, err := json.Marshal(val)
if err != nil {
return err
}
target := reflect.New(targetType).Interface()
if err := json.Unmarshal(b, target); err != nil {
return err
}
fieldVal.Set(reflect.ValueOf(target).Elem())
return 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)
}
}
+129
View File
@@ -0,0 +1,129 @@
package config
import "time"
type Legacy struct {
ID uint `json:"id,omitempty"` // 1
Sitename string `json:"sitename" default:"Komari"` // 站点名称,默认 "Komari"
Description string `json:"description" default:"A simple server monitor tool."` // 站点描述
AllowCors bool `json:"allow_cors" default:"false"` // 是否允许跨域,默认 false
Theme string `json:"theme" default:"default"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" default:"false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" default:""` // API 密钥,默认空字符串
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" default:"false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" default:"false"`
NezhaCompatListen string `json:"nezha_compat_listen" default:""` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
OAuthProvider string `json:"o_auth_provider" default:"github"`
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
// 自定义美化
CustomHead string `json:"custom_head" default:""`
CustomBody string `json:"custom_body" default:""`
// 通知
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
NotificationMethod string `json:"notification_method" default:"none"`
NotificationTemplate string `json:"notification_template" default:"{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" default:"true"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" default:"80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" default:"true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" default:"720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" 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
}
*/
+17 -15
View File
@@ -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 {
-113
View File
@@ -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
}
-38
View File
@@ -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)
}
}
+18 -15
View File
@@ -14,9 +14,9 @@ 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"
logutil "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -360,6 +360,7 @@ var (
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
@@ -425,22 +426,23 @@ func GetDBInstance() *gorm.DB {
}
instance.Exec("VACUUM;")
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)
// 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(
@@ -448,7 +450,7 @@ func GetDBInstance() *gorm.DB {
&models.Client{},
&models.Record{},
&models.GPURecord{},
&models.Config{},
// &models.Config{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
@@ -489,5 +491,6 @@ func GetDBInstance() *gorm.DB {
}
})
return instance
}
+3 -3
View File
@@ -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 {
+67 -2
View File
@@ -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
+24 -8
View File
@@ -1,5 +1,5 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -9,6 +9,7 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
@@ -16,12 +17,19 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
## クイックスタート
### 0. クラウドホスティングによるワンクリック展開
- Rainyun - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- Rainyun - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel アプリストア
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
### 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
@@ -29,6 +37,7 @@ sudo ./install-komari.sh
```
### 2. Docker展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
@@ -51,6 +60,7 @@ sudo ./install-komari.sh
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
```bash
@@ -62,7 +72,6 @@ sudo ./install-komari.sh
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
@@ -81,36 +90,43 @@ 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を歓迎します!
## 謝辞
### 破碎工坊云 (Crash Work)
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
+23 -9
View File
@@ -1,5 +1,5 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -9,6 +9,7 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
@@ -17,12 +18,18 @@ Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
- 雨雲雲應用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 應用商店
已上架 1Panel 應用商店,應用商店-實用工具-Komari 即可安裝
### 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
@@ -30,6 +37,7 @@ sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
@@ -52,6 +60,7 @@ sudo ./install-komari.sh
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
@@ -63,7 +72,6 @@ sudo ./install-komari.sh
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
@@ -82,36 +90,43 @@ 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!
## 鳴謝
### 破碎工坊雲
[破碎工坊雲 - 專業雲計算服務平台,提供高效、穩定、安全的高防伺服器與CDN解決方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
@@ -126,4 +141,3 @@ SharonNetworks 為您的業務起飛保駕護航!
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+23 -8
View File
@@ -1,5 +1,5 @@
# Komari
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
@@ -9,6 +9,7 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
@@ -17,12 +18,18 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 应用商店
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
### 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
@@ -30,6 +37,7 @@ sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
@@ -52,6 +60,7 @@ sudo ./install-komari.sh
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
@@ -63,7 +72,6 @@ sudo ./install-komari.sh
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
@@ -82,36 +90,43 @@ 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!
## 鸣谢
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
+2 -1
View File
@@ -1 +1,2 @@
dist/*
dist/*
defaultTheme/*
+201 -267
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+10 -1
View File
@@ -168,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),
+14 -13
View File
@@ -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,11 +96,14 @@ 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
}
@@ -119,10 +122,8 @@ func SendEvent(event models.EventMessage) error {
}
// 如果没有实现,使用模板格式化为文本消息
messageTemplate := cfg.NotificationTemplate
if messageTemplate == "" {
messageTemplate = "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"
}
messageTemplate := cfg[config.NotificationTemplateKey].(string)
messageTemplate = parseTemplate(messageTemplate, event)
for i := 0; i < 3; i++ {
+7 -4
View File
@@ -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 := int(cfg[config.ExpireNotificationLeadDaysKey].(float64)) // Json unmarshal 会将数字解析为 float64
type clientToExpireInfo struct {
Name string
+3 -3
View File
@@ -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 -6
View File
@@ -2,12 +2,13 @@ package notifier
import (
"fmt"
"log/slog"
"math"
"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"
@@ -26,18 +27,17 @@ func CheckTraffic() {
if len(reports) == 0 {
return
}
cfg, err := config.Get()
cfg, err := config.GetAs[float64](config.TrafficLimitPercentageKey, 80.0)
if err != nil {
return
slog.Error("failed to get traffic limit percentage", "error", err)
}
if cfg.TrafficLimitPercentage <= 0 {
if cfg <= 0 {
return
}
// 起始阈值:例如 80%,非5的倍数则从上取整到最近的5的倍数,例如 83->85
startThreshold := cfg.TrafficLimitPercentage
startThreshold := cfg
if startThreshold < 0 {
startThreshold = 0
}
+7 -7
View File
@@ -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)