Compare commits

..
Author SHA1 Message Date
Akizon 8165440839 Merge pull request #431 from komari-monitor/dev
refactor: 生命周期管理
2026-01-22 03:15:34 +08:00
Akizon77 356f6903a3 refactor: 使用生命周期管理
- Introduced a new `app` package to manage application lifecycle and dependency injection.
- Created `config` and `db` modules to handle configuration loading and database initialization.
- Removed legacy event-based configuration loading in favor of module initialization.
- Implemented hooks for module lifecycle events (Init, Start, Stop).
- Added a scheduler module for periodic tasks and event triggering.
- Updated server module to integrate with the new lifecycle system and handle HTTP server startup/shutdown.
- Ensured backward compatibility with existing configuration and database handling.
2026-01-22 03:01:27 +08:00
Akizon f693eaf764 Merge pull request #410 from RSJWY/dev
支持HarmonyOS系统的MeoW通知应用(统一使用华为Push)
2026-01-15 22:58:04 +08:00
Akizon 35b49222c0 Merge pull request #426 from komari-monitor/dev
wip: 插件支持
2026-01-15 22:56:37 +08:00
Akizon77 8b832e7ce5 chore: 注释掉dev分支的push触发器 2026-01-15 22:49:54 +08:00
Akizon77 fca23546f1 feat: js监听事件/发布事件 2026-01-15 22:45:40 +08:00
Akizon77 e187116d7a feat: JS运行支持async、实现Fetch和XHR 2026-01-15 21:43:08 +08:00
Akizon77 9af4cbb3f9 撤回首次加载引导(之后再做) 2026-01-15 20:19:29 +08:00
RSJWY 2a3e458f63 支持HarmonyOS系统的MeoW通知应用(统一使用华为Push) 2025-12-31 22:37:40 +08:00
Akizon ca350be7a5 Merge pull request #404 from flyflas/add-email-html-support
Add html support for email
2025-12-31 10:22:22 +08:00
xiaobai 0b8f2fc176 add html support for email 2025-12-23 18:57:52 +08:00
Akizon77 c2d8008abc wip: 添加插件管理功能,支持插件激活和清单获取 2025-12-12 00:05:21 +08:00
Akizon77 56b14320e8 refactor: 迁移client 2025-12-11 13:39:53 +08:00
Akizon77 76f6442fbe fix: CORS配置失效 2025-12-11 00:52:15 +08:00
Akizon77 446311f343 refactor: 将时间处理逻辑统一为 UTC 2025-12-10 21:11:53 +08:00
Akizon77 475b2352db feat: 添加监听地址配置,重构数据库迁移逻辑,优化模型字段定义 2025-12-10 20:43:28 +08:00
Akizon77 ebb024a361 refactor: 重构数据库核心模块 2025-12-08 18:22:02 +08:00
Akizon77 6c608b22f6 refactor: 移除过时的数据库配置,整合配置管理 2025-12-08 18:10:48 +08:00
Akizon77 c70dae1396 feat: 添加扩展配置支持,重构 Nezha 兼容字段 2025-12-08 17:21:58 +08:00
Akizon77 b980857d3e refactor: routers.go -> server.go 2025-12-08 15:57:45 +08:00
github-actions[bot] 2b8280cc48 Merge pull request #387 from komari-monitor/dev
feat: mjpeg图片推流、rpc内部调用
2025-12-07 10:27:48 +00:00
Akizon77 510d672f24 fix: 修复配置文件写入逻辑 2025-12-06 17:30:31 +08:00
Akizon77 ea53facb98 fix: 修复配置文件和数据库文件的存在性检查逻辑 2025-12-06 17:24:06 +08:00
Akizon77 a7a6a7b410 fix: v1.1.4配置表迁移功能 2025-12-06 17:20:39 +08:00
Akizon77 bf9ad3357e feat: mjpeg图片推流、rpc内部调用 2025-12-06 17:08:09 +08:00
Akizon77 48c6030393 wip: 添加插件、mjpeg流相关 2025-12-06 14:43:25 +08:00
Akizon 026b12e8ae Merge pull request #378 from komari-monitor/dev
refactor: 事件路由
2025-12-02 02:12:24 +08:00
Akizon77 90aad4b48a refactor: API initialization and routing
- Moved API route registration to dedicated init files for better organization.
- Introduced event listeners for server initialization to dynamically register routes.
- Removed redundant configuration loading in routers.go.
- Added new API routes for various functionalities including client management, admin tasks, and notifications.
- Implemented a standardized response structure for API responses.
- Established WebSocket connections for terminal sessions and improved session management.
- Created a new database initialization for default admin account creation.
- Enhanced gRPC server setup for Nezha compatibility with dynamic configuration updates.
2025-12-02 01:57:08 +08:00
Akizon77 84ec0a9d87 fix: oauth 初始化意外调用未初始化的 dbcore 2025-12-01 23:21:40 +08:00
Akizon77 801d9415ed refactor: 重构服务器启动、路由加载的初始化 2025-12-01 23:13:44 +08:00
Akizon77 f8b4e90d47 refactor: 移除不必要的依赖和初始化逻辑,简化配置更新处理 2025-12-01 21:51:14 +08:00
Akizon77 5b5cf32a28 refactor: 优化配置更新和定时任务管理 2025-12-01 21:20:22 +08:00
github-actions[bot] 0413c9aa80 Merge pull request #370 from komari-monitor/dev
refactor: 引入事件驱动
2025-11-25 07:07:10 +00:00
Akizon77 1672bac3de refactor: 优化权限检测逻辑,简化配置引用 2025-11-25 15:05:49 +08:00
Akizon77 24322b613d refactor: 添加事件触发逻辑以增强客户端和用户操作的跟踪 2025-11-21 15:31:01 +08:00
Akizon77 3729269fa3 refactor: 重写Config配置管理 2025-11-15 17:18:21 +08:00
Akizon77 0c8095ae11 refactor: 添加 RPC 路由注册 2025-11-15 13:22:45 +08:00
Akizon77 535acc99b8 refactor: api_v1/jsonRpc -> api_rpc 2025-11-15 13:14:55 +08:00
Akizon77 01912be70e refactor: 移除配置事件订阅 2025-11-14 23:42:46 +08:00
Akizon77 df62a87591 refactor: 优化 gRPC 服务器启动逻辑 2025-11-14 23:30:48 +08:00
Akizon77 a3faf856e0 refactor: 移动 PingSchedule 相关逻辑 2025-11-14 23:17:40 +08:00
Akizon77 39f849d03e wip: 引入事件驱动 2025-11-14 23:11:46 +08:00
Akizon77 53b3d7dd71 wip: 重构框架 2025-11-14 18:21:23 +08:00
Akizon eab4b513be Merge pull request #361 from komari-monitor/dev
feat: 添加 WAL 检查点优化数据库性能
2025-11-14 16:46:41 +08:00
243 changed files with 8560 additions and 4568 deletions

No files matched your search

+3 -10
View File
@@ -70,15 +70,8 @@ jobs:
npm install
npm run build
cd ..
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
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
@@ -108,7 +101,7 @@ jobs:
fi
VERSION="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}"
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
+8 -15
View File
@@ -1,9 +1,9 @@
name: Development Environment
on:
push:
branches:
- dev
# push:
# branches:
# - dev
workflow_dispatch:
jobs:
@@ -16,8 +16,8 @@ jobs:
#- name: Create dummy index.html
# run: |
# mkdir -p public/defaultTheme/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/defaultTheme/dist/index.html
# mkdir -p public/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
- name: Clone and build frontend
run: |
@@ -26,15 +26,8 @@ jobs:
npm install
npm run build
cd ..
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
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
@@ -47,7 +40,7 @@ jobs:
GOARCH: amd64
run: |
VERSION_SHA="${{ github.sha }}"
go build -ldflags "-X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
go build -ldflags "-X github.com/komari-monitor/komari/internal/conf.Version=dev -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_SHA}" -o komari .
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
+3 -10
View File
@@ -33,15 +33,8 @@ jobs:
npm install
npm run build
cd ..
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
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
@@ -62,7 +55,7 @@ jobs:
run: |
VERSION="${{ github.ref_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
+3 -10
View File
@@ -36,15 +36,8 @@ jobs:
npm install
npm run build
cd ..
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
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
@@ -65,7 +58,7 @@ jobs:
run: |
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
+3 -10
View File
@@ -73,15 +73,8 @@ jobs:
npm install
npm run build
cd ..
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
mkdir -p public/dist
cp -r web/dist/* public/dist/
- name: Set up Go
uses: actions/setup-go@v5
@@ -110,7 +103,7 @@ jobs:
fi
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/internal/conf.Version=${VERSION} -X github.com/komari-monitor/komari/internal/conf.CommitHash=${VERSION_HASH}" -o $BINARY_NAME
- name: Upload binary to release
env:
+3 -3
View File
@@ -75,7 +75,7 @@ sudo ./install-komari.sh
#### Dependencies
- Go 1.18+ and Node.js 20+ (for manual build)
- Go 1.24+ and Node.js 20+ (for manual build)
1. Build the frontend static files:
```bash
@@ -89,7 +89,7 @@ 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/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/public/defaultTheme`.
Copy the static files generated in step 1 to the `/public/dist` folder in the root of the `komari` project.
```bash
go build -o komari
```
@@ -123,7 +123,7 @@ Issues and Pull Requests are welcome!
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供 Tbps 级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
-75
View File
@@ -1,75 +0,0 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/accounts"
"github.com/gin-gonic/gin"
)
var (
publicPaths = []string{
"/ping",
"/api/public",
"/api/login",
"/api/me",
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/recent",
"/api/admin", // 由AdminAuthMiddleware处理
"/api/clients/", // 由TokenAuthMiddleware处理
}
)
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// 如果是公开的路径,直接放行
for _, path := range publicPaths {
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
c.Next()
return
}
}
// 如果不是 /api,直接放行
if len(c.Request.URL.Path) < 4 || c.Request.URL.Path[:4] != "/api" {
c.Next()
return
}
PrivateSite, err := config.GetAs[bool](config.PrivateSiteKey, false)
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !PrivateSite {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
_, err = accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
c.Next()
}
}
-64
View File
@@ -1,64 +0,0 @@
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/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
func GetMessageSenderProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetMessageSenderConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置项模板
providers := factory.GetSenderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No message sender providers found")
return
}
api.RespondSuccess(c, providers)
}
func SetMessageSenderProvider(c *gin.Context) {
var senderConfig models.MessageSenderProvider
if err := c.ShouldBindJSON(&senderConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if senderConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(senderConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
return
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
NotificationMethod, _ := config.GetAs[string](config.NotificationMethodKey, "none")
// 正在使用,重载
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())
return
}
}
api.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
}
-63
View File
@@ -1,63 +0,0 @@
package admin
import (
"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/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/gin-gonic/gin"
)
// GetSettings 获取自定义配置
func GetSettings(c *gin.Context) {
cst, err := config.GetAll()
if err != nil {
api.RespondError(c, 500, "Failed to get settings: "+err.Error())
return
}
api.RespondSuccess(c, cst)
}
// EditSettings 更新自定义配置
func EditSettings(c *gin.Context) {
cfg := make(map[string]interface{})
if err := c.ShouldBindJSON(&cfg); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if err := config.SetMany(cfg); err != nil {
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
message := "update settings: "
for key := range cfg {
message += key + ", "
}
if len(message) > 2 {
message = message[:len(message)-2]
}
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
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()
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
api.RespondSuccess(c, nil)
}
-50
View File
@@ -1,50 +0,0 @@
package test
import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"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"
)
func TestSendMessage(c *gin.Context) {
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func TestGeoIp(c *gin.Context) {
ip := c.Query("ip")
if ip == "" {
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
ip = cfIP
} else {
ip = c.ClientIP()
}
}
cfg, err := config.GetAs[bool](config.GeoIpEnabledKey, false)
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if !cfg {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
}
api.RespondSuccess(c, GeoIpRecord)
}
-18
View File
@@ -1,18 +0,0 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
-40
View File
@@ -1,40 +0,0 @@
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/utils"
)
func RegisterClient(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
AutoDiscoveryKey, err := config.GetAs[string](config.AutoDiscoveryKeyKey, "")
if err != nil {
api.RespondError(c, 500, "Failed to get AutoDiscovery Key: "+err.Error())
return
}
if AutoDiscoveryKey == "" ||
len(AutoDiscoveryKey) < 12 ||
"Bearer "+AutoDiscoveryKey != auth {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
name := c.Query("name")
if name == "" {
name = utils.GenerateRandomString(8)
}
name = "Auto-" + name
uuid, token, err := clients.CreateClientWithName(name)
if err != nil {
api.RespondError(c, 500, "Failed to create client: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
}
-77
View File
@@ -1,77 +0,0 @@
package api
import (
"encoding/json"
"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/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
DisablePasswordLogin, _ := config.GetAs[bool](config.DisablePasswordLoginKey, false)
if DisablePasswordLogin {
RespondError(c, http.StatusForbidden, "Password login is disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
var data LoginRequest
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
if data.Username == "" || data.Password == "" {
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
return
}
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
return
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" { // 开启了2FA
if data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
c.Redirect(302, "/")
}
-15
View File
@@ -1,15 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database"
)
func GetPublicSettings(c *gin.Context) {
p, e := database.GetPublicInfo()
if e != nil {
RespondError(c, 500, e.Error())
return
}
RespondSuccess(c, p)
}
+29 -21
View File
@@ -1,12 +1,14 @@
package cmd
import (
"context"
"os"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/spf13/cobra"
)
@@ -25,25 +27,31 @@ var ChpasswdCmd = &cobra.Command{
cmd.Help()
return
}
if _, err := os.Stat(flags.DatabaseFile); os.IsNotExist(err) {
cmd.Println("Database file does not exist.")
return
}
user := &models.User{}
dbcore.GetDBInstance().Model(&models.User{}).First(user)
cmd.Println("Changing password for user:", user.Username)
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
a := app.New().With(dbcore.NewDBModule())
err := a.RunWith(context.Background(), func(ctx context.Context) error {
if _, err := os.Stat(conf.Conf.Database.DatabaseFile); os.IsNotExist(err) {
cmd.Println("Database file does not exist.")
return nil
}
user := &models.User{}
dbcore.GetDBInstance().Model(&models.User{}).First(user)
cmd.Println("Changing password for user:", user.Username)
if err := accounts.ForceResetPassword(user.Username, NewPassword); err != nil {
cmd.Println("Error:", err)
return nil
}
cmd.Println("Password changed successfully, new password:", NewPassword)
if err := accounts.DeleteAllSessions(); err != nil {
cmd.Println("Unable to force logout of other devices:", err)
return nil
}
cmd.Println("Please restart the server to apply the changes.")
return nil
})
if err != nil {
cmd.Println("Error:", err)
return
}
cmd.Println("Password changed successfully, new password:", NewPassword)
if err := accounts.DeleteAllSessions(); err != nil {
cmd.Println("Unable to force logout of other devices:", err)
return
}
cmd.Println("Please restart the server to apply the changes.")
},
}
+11 -6
View File
@@ -1,10 +1,12 @@
package cmd
import (
"context"
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
@@ -14,10 +16,13 @@ var Disable2FA = &cobra.Command{
Short: "Force disable 2FA",
Long: `Force disable 2FA`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.User{}).Where("two_factor != ?", "").
Update("two_factor", "").Error
a := app.New().With(dbcore.NewDBModule())
err := a.RunWith(context.Background(), func(ctx context.Context) error {
db := dbcore.GetDBInstance()
return db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.User{}).Where("two_factor != ?", "").
Update("two_factor", "").Error
})
})
if err != nil {
cmd.Println("Error:", err)
+2 -10
View File
@@ -1,14 +1,6 @@
package flags
var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite, mysql
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // MySQL/其他数据库主机地址
DatabasePort string // MySQL/其他数据库端口
DatabaseUser string // MySQL/其他数据库用户名
DatabasePass string // MySQL/其他数据库密码
DatabaseName string // MySQL/其他数据库名称
Listen string
ConfigFile string // 配置文件路径
Listen string
)
+12 -12
View File
@@ -11,17 +11,17 @@ import (
"sync"
"time"
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/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
apiClient "github.com/komari-monitor/komari/internal/api_v1/client"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/komari-monitor/komari/internal/notifier"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/compat/nezha/proto"
"github.com/komari-monitor/komari/internal/nezha/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
@@ -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 GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
if cfg, err := conf.GetWithV1Format(); err == nil && cfg.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 GeoIpEnabled, _ := config.GetAs[bool](config.GeoIpEnabledKey, false); GeoIpEnabled {
if cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
if ip := net.ParseIP(v6); ip != nil {
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
iso = gi.ISOCode
+11 -6
View File
@@ -1,10 +1,12 @@
package cmd
import (
"context"
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
@@ -14,10 +16,13 @@ var PermitPasswordLoginCmd = &cobra.Command{
Short: "Force permit password login",
Long: `Force permit password login`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.Config{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
a := app.New().With(dbcore.NewDBModule())
err := a.RunWith(context.Background(), func(ctx context.Context) error {
db := dbcore.GetDBInstance()
return db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&conf.V1Struct{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
})
})
if err != nil {
cmd.Println("Error:", err)
+51 -18
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"log/slog"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/spf13/cobra"
@@ -19,13 +21,7 @@ func GetEnv(key, defaultValue string) string {
// 从环境变量获取默认值
var (
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
configFileEnv = GetEnv("KOMARI_CONFIG_FILE", "./data/komari.json")
)
var RootCmd = &cobra.Command{
@@ -33,26 +29,63 @@ var RootCmd = &cobra.Command{
Short: "Komari is a simple server monitoring tool",
Long: `Komari is a simple server monitoring tool.
Made by Akizon77 with love.`,
Run: func(cmd *cobra.Command, args []string) {
cmd.SetArgs([]string{"server"})
cmd.Execute()
},
}
func Execute() {
detectDeprecatedFlags()
// Default to server when no subcommand is provided.
if len(os.Args) <= 1 {
RootCmd.SetArgs([]string{"server"})
}
// 创建目录
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
slog.Error("Failed to create theme directory", slog.Any("error", err))
}
if err := RootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// detectDeprecatedFlags 检测过时的数据库相关命令行参数
func detectDeprecatedFlags() {
deprecatedFlags := map[string]bool{
"db-host": false,
"database": false,
"db-user": false,
"db-pass": false,
"db-port": false,
"db-name": false,
"db-type": false,
}
// 检查命令行参数
for _, arg := range os.Args[1:] {
for flagName := range deprecatedFlags {
if arg == "--"+flagName || arg == "-"+string(flagName[0]) {
deprecatedFlags[flagName] = true
break
}
}
}
// 如果检测到过时参数,记录警告
hasDeprecated := false
for flagName, found := range deprecatedFlags {
if found {
hasDeprecated = true
slog.Warn("Deprecated command-line flag detected", slog.String("flag", "--"+flagName))
}
}
if hasDeprecated {
slog.Warn("Command-line database configuration flags are deprecated. " +
"Please migrate to komari.json file. ")
}
}
func init() {
// 设置命令行参数,提供环境变量作为默认值
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
RootCmd.PersistentFlags().StringVarP(&flags.ConfigFile, "config", "c", configFileEnv, "Configuration file path [env: KOMARI_CONFIG_FILE]")
RootCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", GetEnv("KOMARI_LISTEN", ":8080"), "Listen address [env: KOMARI_LISTEN]")
}
+11 -413
View File
@@ -2,433 +2,31 @@ package cmd
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/api/admin"
"github.com/komari-monitor/komari/api/admin/clipboard"
log_api "github.com/komari-monitor/komari/api/admin/log"
"github.com/komari-monitor/komari/api/admin/notification"
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/jsonRpc"
public_api "github.com/komari-monitor/komari/api/public"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/api/task"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/internal/server"
"github.com/spf13/cobra"
)
var (
DynamicCorsEnabled bool = false
)
var ServerCmd = &cobra.Command{
Use: "server",
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
RunServer()
a := app.New().
WithShutdownTimeout(5 * time.Second).
With(server.NewHTTPModule()).
With(scheduler.NewModule())
if err := a.RunUntilSignal(context.Background()); err != nil {
cmd.PrintErrln(err)
os.Exit(1)
}
},
}
func init() {
// 从环境变量获取监听地址
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
RootCmd.AddCommand(ServerCmd)
}
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)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.GetManyAs[config.Legacy]()
if err != nil {
log.Fatal(err)
}
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
go oauth.Initialize()
if conf.NezhaCompatEnabled {
go func() {
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
config.Subscribe(func(event config.ConfigEvent) {
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 {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
if 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))
}
} else {
if err := StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
}
}
})
// 初始化 cloudflared
if strings.ToLower(GetEnv("KOMARI_ENABLE_CLOUDFLARED", "false")) == "true" {
err := cloudflared.RunCloudflared() // 阻塞,确保cloudflared跑起来
if err != nil {
log.Fatalf("Failed to run cloudflared: %v", err)
}
}
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
// 动态 CORS 中间件
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
if ok, t := config.IsChangedT[bool](event, config.AllowCorsKey); ok {
DynamicCorsEnabled = t
}
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
}
c.Next()
})
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", api.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
r.GET("/api/oauth_callback", api.OAuthCallback)
r.GET("/api/logout", api.Logout)
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
r.GET("/api/task/ping", task.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
r.GET("/api/mjpeg_live", public_api.MjpegLiveHandler)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
OnShutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
}
func InitDatabase() {
// // 打印数据库类型和连接信息
// if flags.DatabaseType == "mysql" {
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
// } else {
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
// }
var count int64 = 0
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
user, passwd, err := accounts.CreateDefaultAdminAccount()
if err != nil {
panic(err)
}
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
}
}
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
d_notification.ReloadLoadNotificationSchedule()
ticker := time.NewTicker(time.Minute * 30)
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
go notifier.CheckExpireScheduledWork()
for {
cfg, _ := config.GetManyAs[config.Legacy]()
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
go notifier.CheckTraffic()
}
}
}
func OnShutdown() {
auditlog.Log("", "", "server is shutting down", "info")
cloudflared.Kill()
}
func OnFatal(err error) {
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Kill()
}
-470
View File
@@ -1,470 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ConfigItem struct {
Key string `gorm:"primaryKey;column:key;type:text"`
Value string `gorm:"column:value;type:text"` // 存 JSON 字符串
}
func (ConfigItem) TableName() string {
return "configs"
}
var (
db *gorm.DB
SetDb = func(gdb *gorm.DB) {
db = gdb
migrateInPlace()
}
)
func migrateInPlace() {
if db.Migrator().HasTable("configs") && db.Migrator().HasColumn(&Legacy{}, "Sitename") {
slog.Info("[>1.1.4] Moving legacy config data...")
var oldData Legacy
if err := db.Order("id desc").First(&oldData).Error; err != nil {
db.Migrator().DropTable("configs")
} else {
var newRows []ConfigItem
val := reflect.ValueOf(oldData)
typ := reflect.TypeOf(oldData)
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("json")
key := strings.Split(tag, ",")[0]
// 过滤 id 和无用字段
if key == "" || key == "-" || key == "id" {
continue
}
valInterface := val.Field(i).Interface()
jsonBytes, _ := json.Marshal(valInterface)
newRows = append(newRows, ConfigItem{
Key: key,
Value: string(jsonBytes),
})
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Migrator().DropTable("configs"); err != nil {
return err
}
if err := tx.AutoMigrate(&ConfigItem{}); err != nil {
return err
}
if len(newRows) > 0 {
return tx.Create(&newRows).Error
}
return nil
})
if err != nil {
panic("failed " + err.Error())
}
return
}
}
db.AutoMigrate(&ConfigItem{})
}
// Get 获取原始值 (反序列化为 interface{})
func Get(key string, defaul ...any) (any, error) {
var item ConfigItem
err := db.First(&item, "key = ?", key).Error
if err != nil {
if len(defaul) > 0 {
v := defaul[0]
err = Set(key, v)
return v, err
}
return nil, err
}
var val any
if err := json.Unmarshal([]byte(item.Value), &val); err != nil {
return nil, err
}
return val, nil
}
// GetAs 获取并转换为指定类型 (泛型)
func GetAs[T any](key string, defaul ...any) (T, error) {
var t T
var item ConfigItem
err := db.First(&item, "key = ?", key).Error
if err != nil {
if len(defaul) > 0 {
v, ok := defaul[0].(T)
if !ok {
return t, fmt.Errorf("default value type mismatch: expected %T, got %T", t, defaul[0])
}
err = Set(key, v)
return v, err
}
return t, err
}
err = json.Unmarshal([]byte(item.Value), &t)
return t, err
}
// key[defaults]
func GetMany(keys map[string]any) (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
keyList := make([]string, 0, len(keys))
for k := range keys {
keyList = append(keyList, k)
}
if len(keyList) == 0 {
return result, nil
}
if err := db.Where("key IN ?", keyList).Find(&items).Error; err != nil {
return nil, err
}
foundKeys := make(map[string]bool)
for _, item := range items {
var parsed any
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
result[item.Key] = parsed
foundKeys[item.Key] = true
}
}
// Fill in defaults for missing keys
for k, def := range keys {
if _, found := foundKeys[k]; !found {
if def != nil {
result[k] = def
_ = Set(k, def) // Ignore error
}
}
}
return result, nil
}
// GetManyAs 将多个配置项映射到一个结构体中,json tag 作为 Key
func GetManyAs[T any]() (*T, error) {
var t T
val := reflect.ValueOf(&t).Elem()
typ := val.Type()
tagToFieldMap := make(map[string]int) // Key -> FieldIndex
keys := make([]string, 0)
for i := 0; i < val.NumField(); i++ {
field := typ.Field(i)
tag := field.Tag.Get("json")
if tag != "" && tag != "-" {
keys = append(keys, tag)
tagToFieldMap[tag] = i
}
}
if len(keys) == 0 {
return &t, nil
}
var items []ConfigItem
if err := db.Where("key IN ?", keys).Find(&items).Error; err != nil {
return nil, err
}
for _, item := range items {
fieldIndex, ok := tagToFieldMap[item.Key]
if !ok {
continue
}
fieldVal := val.Field(fieldIndex)
if !fieldVal.CanSet() {
continue
}
target := reflect.New(fieldVal.Type()).Interface()
if err := json.Unmarshal([]byte(item.Value), target); err == nil {
fieldVal.Set(reflect.ValueOf(target).Elem())
}
}
return &t, nil
}
func GetAll() (map[string]any, error) {
var items []ConfigItem
result := make(map[string]any)
if err := db.Find(&items).Error; err != nil {
return nil, err
}
for _, item := range items {
var parsed any
if err := json.Unmarshal([]byte(item.Value), &parsed); err == nil {
result[item.Key] = parsed
}
}
return result, nil
}
// Set 设置单个配置
func Set(key string, value any) error {
oldVal := map[string]any{}
{
var oldItem ConfigItem
if err := db.First(&oldItem, "key = ?", key).Error; err == nil {
var parsed any
if err := json.Unmarshal([]byte(oldItem.Value), &parsed); err == nil {
oldVal[key] = parsed
}
}
}
bytes, err := json.Marshal(value)
if err != nil {
return err
}
item := ConfigItem{
Key: key,
Value: string(bytes),
}
err = db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&item).Error
if err != nil {
return err
}
newVal := map[string]any{key: value}
publishEvent(oldVal, newVal)
return nil
}
// SetMany 将结构体保存为多个配置项
func SetManyAs[T any](config T) error {
val := reflect.ValueOf(config)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
typ := val.Type()
var items []ConfigItem
for i := 0; i < val.NumField(); i++ {
fieldType := typ.Field(i)
tag := fieldType.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
fieldValue := val.Field(i).Interface()
bytes, err := json.Marshal(fieldValue)
if err != nil {
return fmt.Errorf("marshal field %s failed: %w", fieldType.Name, err)
}
items = append(items, ConfigItem{
Key: tag,
Value: string(bytes),
})
}
if len(items) == 0 {
return nil
}
keys := make([]string, 0, len(items))
newVal := make(map[string]any, len(items))
for _, it := range items {
keys = append(keys, it.Key)
var parsed any
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
newVal[it.Key] = parsed
}
}
oldVal := map[string]any{}
if len(keys) > 0 {
var oldItems []ConfigItem
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
for _, oi := range oldItems {
var parsed any
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
oldVal[oi.Key] = parsed
}
}
}
}
err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&items).Error
if err != nil {
return err
}
publishEvent(oldVal, newVal)
return nil
}
func SetMany(cst map[string]any) error {
var items []ConfigItem
for k, v := range cst {
bytes, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal key %s failed: %w", k, err)
}
items = append(items, ConfigItem{
Key: k,
Value: string(bytes),
})
}
if len(items) == 0 {
return nil
}
keys := make([]string, 0, len(items))
newVal := make(map[string]any, len(items))
for _, it := range items {
keys = append(keys, it.Key)
var parsed any
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
newVal[it.Key] = parsed
}
}
oldVal := map[string]any{}
if len(keys) > 0 {
var oldItems []ConfigItem
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
for _, oi := range oldItems {
var parsed any
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
oldVal[oi.Key] = parsed
}
}
}
}
err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&items).Error
if err != nil {
return err
}
publishEvent(oldVal, newVal)
return nil
}
type ConfigEvent struct {
Old map[string]any // Old models.Config
New map[string]any // New models.Config
}
func (e ConfigEvent) IsChanged(key string) bool {
oldVal, oldOk := e.Old[key]
newVal, newOk := e.New[key]
if !oldOk && !newOk {
return false
}
if oldOk != newOk {
return true
}
return !reflect.DeepEqual(oldVal, newVal)
}
func IsChangedT[T any](e ConfigEvent, key string) (bool, T) {
changed := e.IsChanged(key)
var zero T
val, ok := e.New[key]
if !ok {
val, ok = e.Old[key]
if !ok {
return changed, zero
}
}
if val == nil {
return changed, zero
}
// Fast path: direct assertion.
if cast, ok := val.(T); ok {
return changed, cast
}
// Try reflection-based conversion (covers numeric conversions, etc.).
targetType := reflect.TypeOf((*T)(nil)).Elem()
v := reflect.ValueOf(val)
if v.IsValid() {
if v.Type().AssignableTo(targetType) {
return changed, v.Interface().(T)
}
if v.Type().ConvertibleTo(targetType) {
converted := v.Convert(targetType)
return changed, converted.Interface().(T)
}
}
// Fallback: JSON roundtrip for map/struct and other loosely typed values.
if b, err := json.Marshal(val); err == nil {
var out T
if err := json.Unmarshal(b, &out); err == nil {
return changed, out
}
}
return changed, zero
}
// ConfigSubscriber handles config events
type ConfigSubscriber func(event ConfigEvent)
var (
subscribersMu sync.RWMutex
subscribers []ConfigSubscriber
)
// Subscribe registers a subscriber for all config events.
func Subscribe(subscriber ConfigSubscriber) {
subscribersMu.Lock()
defer subscribersMu.Unlock()
subscribers = append(subscribers, subscriber)
}
// publishEvent notifies all subscribers of a config change.
func publishEvent(oldVal, newVal map[string]any) {
subscribersMu.RLock()
defer subscribersMu.RUnlock()
for _, sub := range subscribers {
event := ConfigEvent{Old: oldVal, New: newVal}
go sub(event)
}
}
-129
View File
@@ -1,129 +0,0 @@
package config
import "time"
type Legacy struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
UpdatedAt time.Time
}
const (
SitenameKey = "sitename"
DescriptionKey = "description"
AllowCorsKey = "allow_cors"
ThemeKey = "theme"
PrivateSiteKey = "private_site"
ApiKeyKey = "api_key"
AutoDiscoveryKeyKey = "auto_discovery_key"
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
EulaAcceptedKey = "eula_accepted"
GeoIpEnabledKey = "geo_ip_enabled"
GeoIpProviderKey = "geo_ip_provider"
NezhaCompatEnabledKey = "nezha_compat_enabled"
NezhaCompatListenKey = "nezha_compat_listen"
OAuthEnabledKey = "o_auth_enabled"
OAuthProviderKey = "o_auth_provider"
DisablePasswordLoginKey = "disable_password_login"
CustomHeadKey = "custom_head"
CustomBodyKey = "custom_body"
NotificationEnabledKey = "notification_enabled"
NotificationMethodKey = "notification_method"
NotificationTemplateKey = "notification_template"
ExpireNotificationEnabledKey = "expire_notification_enabled"
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
LoginNotificationKey = "login_notification"
TrafficLimitPercentageKey = "traffic_limit_percentage"
RecordEnabledKey = "record_enabled"
RecordPreserveTimeKey = "record_preserve_time"
PingRecordPreserveTimeKey = "ping_record_preserve_time"
UpdatedAtKey = "updated_at"
)
func (Legacy) TableName() string {
return "configs"
}
// Decrepted
/*
func Update(cst map[string]interface{}) error {
oldConfig, _ := GetManyAs[Legacy]()
// Proceed with update
cst["updated_at"] = time.Now().Unix()
delete(cst, "created_at")
delete(cst, "CreatedAt")
// 至少有一种登录方式启用
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
newOAuthEnabled := oldConfig.OAuthEnabled
if val, exists := cst["disable_password_login"]; exists {
newDisablePasswordLogin = val.(bool)
}
if val, exists := cst["o_auth_enabled"]; exists {
newOAuthEnabled = val.(bool)
}
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
// 没绑定账号也不能禁用
if newDisablePasswordLogin {
usr := &models.User{}
if err := Db.Model(&models.User{}).First(usr).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve user"))
}
if usr.SSOID == "" {
return errors.New("cannot disable password login when no SSO-bound account exists")
}
}
err := Db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
}
newConfig := &models.Config{}
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
}
//publishEvent(oldConfig, *newConfig)
return nil
})
if err != nil {
return err
}
return nil
}
*/
-319
View File
@@ -1,319 +0,0 @@
package clients
import (
"math"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fmt"
"github.com/google/uuid"
)
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
func DeleteClientConfig(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
if err != nil {
return err
}
return nil
}
func DeleteClient(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
if err != nil {
return err
}
return nil
}
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
db := dbcore.GetDBInstance()
updates := make(map[string]interface{})
if cbi.Name != "" {
updates["name"] = cbi.Name
}
if cbi.CpuName != "" {
updates["cpu_name"] = cbi.CpuName
}
if cbi.Arch != "" {
updates["arch"] = cbi.Arch
}
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
updates["cpu_cores"] = cbi.CpuCores
}
if cbi.OS != "" {
updates["os"] = cbi.OS
}
if cbi.GpuName != "" {
updates["gpu_name"] = cbi.GpuName
}
if cbi.IPv4 != "" {
updates["ipv4"] = cbi.IPv4
}
if cbi.IPv6 != "" {
updates["ipv6"] = cbi.IPv6
}
if cbi.Region != "" {
updates["region"] = cbi.Region
}
if cbi.Remark != "" {
updates["remark"] = cbi.Remark
}
updates["mem_total"] = cbi.MemTotal
updates["swap_total"] = cbi.SwapTotal
updates["disk_total"] = cbi.DiskTotal
updates["version"] = cbi.Version
updates["updated_at"] = time.Now()
// 转换为更新Client表
client := models.Client{
UUID: cbi.UUID,
}
err := db.Model(&client).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(map[string]interface{}{
"uuid": cbi.UUID,
"name": cbi.Name,
"cpu_name": cbi.CpuName,
"arch": cbi.Arch,
"cpu_cores": cbi.CpuCores,
"os": cbi.OS,
"gpu_name": cbi.GpuName,
"ipv4": cbi.IPv4,
"ipv6": cbi.IPv6,
"region": cbi.Region,
"remark": cbi.Remark,
"mem_total": cbi.MemTotal,
"swap_total": cbi.SwapTotal,
"disk_total": cbi.DiskTotal,
"version": cbi.Version,
"updated_at": time.Now(),
}).Error
if err != nil {
return err
}
return nil
}
func SaveClientInfo(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(update) == 0 {
return fmt.Errorf("no fields to update")
}
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
return nil
}
verify := func(update map[string]interface{}) error {
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
return nil
}
// 更新客户端设置
func UpdateClientConfig(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
err := db.Save(&config).Error
if err != nil {
return err
}
return nil
}
func EditClientName(clientUUID, clientName string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
if err != nil {
return err
}
return nil
}
/*
// UpdateClientByUUID 更新指定 UUID 的客户端配置
func UpdateClientByUUID(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
*/
func EditClientToken(clientUUID, token string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
if err != nil {
return err
}
return nil
}
// CreateClient 创建新客户端
func CreateClient() (clientUUID, token string, err error) {
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
return clientUUID, token, nil
}
func CreateClientWithName(name string) (clientUUID, token string, err error) {
if name == "" {
return CreateClient()
}
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
return clientUUID, token, nil
}
/*
// GetAllClients 获取所有客户端配置
func getAllClients() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
*/
func GetClientByUUID(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return models.Client{}, err
}
return client, nil
}
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
}
return models.Client{}, err
}
return client, nil
}
func GetClientTokenByUUID(uuid string) (token string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return "", err
}
return client.Token, nil
}
func GetAllClientBasicInfo() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
func SaveClient(updates map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := updates["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(updates) == 0 {
return fmt.Errorf("no fields to update")
}
updates["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
if err != nil {
return err
}
return nil
}
-496
View File
@@ -1,496 +0,0 @@
package dbcore
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"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/sqlite"
"gorm.io/gorm"
)
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
// 规范化排除路径为绝对路径
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
out, err := os.Create(dstZip)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
absSrc, _ := filepath.Abs(srcDir)
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 排除 backup.zip 本身
if _, ok := normExclude[path]; ok {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// 计算 zip 内相对路径
rel, err := filepath.Rel(absSrc, path)
if err != nil {
return err
}
// 根目录跳过
if rel == "." {
return nil
}
// 替换为正斜杠
zipName := filepath.ToSlash(rel)
if info.IsDir() {
_, err := zw.Create(zipName + "/")
return err
}
// 普通文件
fh, err := os.Open(path)
if err != nil {
return err
}
w, err := zw.Create(zipName)
if err != nil {
fh.Close()
return err
}
if _, err := io.Copy(w, fh); err != nil {
fh.Close()
return err
}
fh.Close()
return nil
})
if walkErr != nil {
return walkErr
}
return zw.Close()
}
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
entries, err := os.ReadDir(absDir)
if err != nil {
return err
}
for _, e := range entries {
full := filepath.Join(absDir, e.Name())
if _, ok := normExclude[full]; ok {
continue
}
if err := os.RemoveAll(full); err != nil {
return err
}
}
return nil
}
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
func unzipToDir(zipPath, dstDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
if err := os.MkdirAll(dstDir, 0755); err != nil {
return err
}
absDst, _ := filepath.Abs(dstDir)
for _, f := range zr.File {
// 构造目标路径并做路径遍历保护
cleanName := filepath.Clean(f.Name)
targetPath := filepath.Join(absDst, cleanName)
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
return fmt.Errorf("illegal file path in zip: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(targetPath)
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
rc.Close()
return err
}
out.Close()
rc.Close()
}
return nil
}
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
func mergeClientInfo(db *gorm.DB) {
var clientInfos []common.ClientInfo
if err := db.Find(&clientInfos).Error; err != nil {
log.Printf("Failed to read ClientInfo table: %v", err)
return
}
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
}
// 更新Client记录
client.Name = info.Name
client.CpuName = info.CpuName
client.Virtualization = info.Virtualization
client.Arch = info.Arch
client.CpuCores = info.CpuCores
client.OS = info.OS
client.GpuName = info.GpuName
client.IPv4 = info.IPv4
client.IPv6 = info.IPv6
client.Region = info.Region
client.Remark = info.Remark
client.PublicRemark = info.PublicRemark
client.MemTotal = info.MemTotal
client.SwapTotal = info.SwapTotal
client.DiskTotal = info.DiskTotal
client.Version = info.Version
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = models.FromTime(info.ExpiredAt)
// Save updated Client record
if err := db.Save(&client).Error; err != nil {
log.Printf("Failed to update Client record: %v", err)
continue
}
}
// Backup and rename old table after migration
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
log.Printf("Failed to backup ClientInfo table: %v", err)
return
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
}
func MergeDatabase(db *gorm.DB) {
if db.Migrator().HasTable("client_infos") {
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
mergeClientInfo(db)
}
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
}
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
db.Migrator().DropTable(&models.LoadNotification{})
}
if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Merge OidcProvider table....")
var config struct {
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for OIDC provider migration:", err)
}
db.AutoMigrate(&models.OidcProvider{})
j, err := json.Marshal(&map[string]string{
"client_id": config.OAuthClientID,
"client_secret": config.OAuthClientSecret,
})
if err != nil {
log.Println("Failed to marshal OIDC provider config:", err)
return
}
db.Save(&models.OidcProvider{
Name: "github",
Addition: string(j),
})
db.AutoMigrate(&models.Config{})
db.Model(&models.Config{}).Where("id = 1").Update("o_auth_provider", "github")
}
if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&models.Config{}) {
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var config struct {
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255)"`
EmailHost string `json:"email_host" gorm:"type:varchar(255)"`
EmailPort int `json:"email_port" gorm:"type:int"`
EmailUsername string `json:"email_username" gorm:"type:varchar(255)"`
EmailPassword string `json:"email_password" gorm:"type:varchar(255)"`
EmailSender string `json:"email_sender" gorm:"type:varchar(255)"`
EmailReceiver string `json:"email_receiver" gorm:"type:varchar(255)"`
EmailUseSSL bool `json:"email_use_ssl" gorm:"type:boolean"`
NotificationMethod string `json:"notification_method" gorm:"type:varchar(50)"`
}
if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&config).Error; err != nil {
log.Println("Failed to get config for MessageSender migration:", err)
}
db.AutoMigrate(&models.MessageSenderProvider{})
// 迁移Telegram配置
if config.NotificationMethod == "telegram" && config.TelegramBotToken != "" {
telegramConfig := map[string]interface{}{
"bot_token": config.TelegramBotToken,
"chat_id": config.TelegramChatID,
"endpoint": config.TelegramEndpoint,
}
if telegramConfig["endpoint"] == "" {
telegramConfig["endpoint"] = "https://api.telegram.org/bot"
}
telegramConfigJSON, err := json.Marshal(telegramConfig)
if err != nil {
log.Println("Failed to marshal Telegram config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "telegram",
Addition: string(telegramConfigJSON),
})
}
}
// 迁移Email配置
if config.NotificationMethod == "email" && config.EmailHost != "" {
emailConfig := map[string]interface{}{
"host": config.EmailHost,
"port": config.EmailPort,
"username": config.EmailUsername,
"password": config.EmailPassword,
"sender": config.EmailSender,
"receiver": config.EmailReceiver,
"use_ssl": config.EmailUseSSL,
}
emailConfigJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Println("Failed to marshal Email config:", err)
} else {
db.Save(&models.MessageSenderProvider{
Name: "email",
Addition: string(emailConfigJSON),
})
}
}
// 删除旧的配置字段
if db.Migrator().HasColumn(&models.Config{}, "telegram_bot_token") {
db.Migrator().DropColumn(&models.Config{}, "telegram_bot_token")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_chat_id") {
db.Migrator().DropColumn(&models.Config{}, "telegram_chat_id")
}
if db.Migrator().HasColumn(&models.Config{}, "telegram_endpoint") {
db.Migrator().DropColumn(&models.Config{}, "telegram_endpoint")
}
if db.Migrator().HasColumn(&models.Config{}, "email_host") {
db.Migrator().DropColumn(&models.Config{}, "email_host")
}
if db.Migrator().HasColumn(&models.Config{}, "email_port") {
db.Migrator().DropColumn(&models.Config{}, "email_port")
}
if db.Migrator().HasColumn(&models.Config{}, "email_username") {
db.Migrator().DropColumn(&models.Config{}, "email_username")
}
if db.Migrator().HasColumn(&models.Config{}, "email_password") {
db.Migrator().DropColumn(&models.Config{}, "email_password")
}
if db.Migrator().HasColumn(&models.Config{}, "email_sender") {
db.Migrator().DropColumn(&models.Config{}, "email_sender")
}
if db.Migrator().HasColumn(&models.Config{}, "email_receiver") {
db.Migrator().DropColumn(&models.Config{}, "email_receiver")
}
if db.Migrator().HasColumn(&models.Config{}, "email_use_ssl") {
db.Migrator().DropColumn(&models.Config{}, "email_use_ssl")
}
}
}
var (
instance *gorm.DB
once sync.Once
)
func GetDBInstance() *gorm.DB {
once.Do(func() {
var err error
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
func() {
backupZipPath := filepath.Join(".", "data", "backup.zip")
if _, statErr := os.Stat(backupZipPath); statErr == nil {
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
if err := os.MkdirAll("./backup", 0755); err != nil {
log.Printf("[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[restore] failed to zip current data: %v", zipErr)
} else {
log.Printf("[restore] current data zipped to %s", bakPath)
}
}
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
} else {
log.Printf("[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
} else {
log.Printf("[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
log.Printf("[restore] komari-backup-markup removed")
}
}
}()
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
}
// 根据数据库类型选择不同的连接方式
switch flags.DatabaseType {
case "sqlite", "":
// SQLite 连接
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
if err != nil {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
instance.Exec("PRAGMA wal = ON;")
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
}
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)
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
}
config.SetDb(instance)
MergeDatabase(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Record{},
&models.GPURecord{},
// &models.Config{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
}
err = instance.Table("records_long_term").AutoMigrate(
&models.Record{},
)
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.Table("gpu_records_long_term").AutoMigrate(
&models.GPURecord{},
)
if err != nil {
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
if err != nil {
log.Printf("Failed to create Session table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
)
if err != nil {
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
}
})
return instance
}
-121
View File
@@ -1,121 +0,0 @@
package models
import (
"database/sql/driver"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
)
// LocalTime is a custom time type for GORM to handle time.Time correctly.
// It ensures that all times are stored and retrieved based on the application's configured timezone.
type LocalTime time.Time
// Value implements the driver.Valuer interface.
// It converts UTCTime to a TEXT format that the database understands.
// The time is formatted as a string in the application's local timezone, without timezone information.
func (t LocalTime) Value() (driver.Value, error) {
if time.Time(t).IsZero() {
return nil, nil
}
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan implements the sql.Scanner interface.
// It scans a value from the database into a UTCTime object.
func (t *LocalTime) Scan(v interface{}) error {
if v == nil {
*t = LocalTime(time.Time{})
return nil
}
loc := GetAppLocation()
switch val := v.(type) {
case time.Time:
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
// the date and clock values in the application's actual timezone.
year, month, day := val.Date()
hour, min, sec := val.Clock()
nanosec := val.Nanosecond()
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
return nil
case []byte:
return t.parseTime(string(val), loc)
case string:
return t.parseTime(val, loc)
default:
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
}
}
// parseTime handles parsing a string into UTCTime.
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
*t = LocalTime(time.Time{})
return nil
}
layouts := []string{
time.RFC3339Nano, time.RFC3339,
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
}
for _, layout := range layouts {
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
*t = LocalTime(parsedTime)
return nil
}
}
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
}
// MarshalJSON implements the json.Marshaler interface.
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
func (t LocalTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
return []byte("null"), nil
}
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime converts UTCTime to Go's native time.Time type.
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
// FromTime converts Go's native time.Time type to UTCTime.
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
// Now returns the current time in the application's configured location.
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
var (
appLocation *time.Location
locationOnce sync.Once
)
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
func GetAppLocation() *time.Location {
locationOnce.Do(func() {
tz := os.Getenv("TZ")
if tz == "" {
tz = "UTC"
}
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
appLocation = time.UTC
} else {
appLocation = loc
}
time.Local = appLocation
log.Printf("Application timezone is set to '%s'.", appLocation.String())
})
return appLocation
}
-42
View File
@@ -1,42 +0,0 @@
package models
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt LocalTime
UpdatedAt LocalTime
}
+15 -15
View File
@@ -4,15 +4,15 @@
![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)
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
Komari は、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Web インターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
[ドキュメント](https://komari-document.pages.dev/) | [Telegram グループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
- **Web インターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
## クイックスタート
@@ -24,7 +24,7 @@ Komariは、サーバーのパフォーマンスを監視するためのシン
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
systemd を使用するディストリビューション(Ubuntu、Debian など)に適しています。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
@@ -32,13 +32,13 @@ chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker展開
### 2. Docker 展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
```
2. Dockerコンテナを実行します:
2. Docker コンテナを実行します:
```bash
docker run -d \
-p 25774:25774 \
@@ -57,8 +57,8 @@ sudo ./install-komari.sh
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
1. Komari の[GitHub リリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komari を実行します:
```bash
./komari server -l 0.0.0.0:25774
```
@@ -72,7 +72,7 @@ sudo ./install-komari.sh
#### 依存関係
- Go 1.18+ および Node.js 20+(手動ビルド用)
- Go 1.24+ および Node.js 20+(手動ビルド用)
1. フロントエンドの静的ファイルをビルドします:
```bash
@@ -86,7 +86,7 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/public/defaultTheme` にコピーします。
ステップ 1 で生成された静的ファイルを `komari` プロジェクトのルートにある `/public/dist` フォルダにコピーします。
```bash
go build -o komari
```
@@ -98,17 +98,17 @@ sudo ./install-komari.sh
## フロントエンド開発ガイド
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[Komari テーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
[Crowdin で Komari を翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## クライアントエージェント開発ガイド
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
[Komari エージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
## 貢献
IssueやPull Requestを歓迎します!
Issue や Pull Request を歓迎します!
## 謝辞
@@ -130,6 +130,6 @@ PR を送ってくれた方、テーマを作成してくれた全ての開発
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
## Star 履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+2 -2
View File
@@ -72,7 +72,7 @@ sudo ./install-komari.sh
#### 依賴
- Go 1.18+ 和 Node.js 20+(手工建置)
- Go 1.24+ 和 Node.js 20+(手工建置)
1. 建置前端靜態檔案:
```bash
@@ -86,7 +86,7 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/public/defaultTheme`。
將步驟 1 中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/public/dist` 資料夾。
```bash
go build -o komari
```
+2 -2
View File
@@ -72,7 +72,7 @@ sudo ./install-komari.sh
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
- Go 1.24+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
@@ -86,7 +86,7 @@ sudo ./install-komari.sh
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/public/defaultTheme`。
将步骤 1 中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
go build -o komari
```
+4 -3
View File
@@ -10,19 +10,19 @@ require (
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gookit/event v1.2.0
github.com/gorilla/websocket v1.5.3
github.com/oschwald/maxminddb-golang v1.13.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/image v0.35.0
golang.org/x/image v0.33.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
@@ -51,6 +51,7 @@ require (
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/gookit/goutil v0.7.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -71,7 +72,7 @@ require (
golang.org/x/arch v0.15.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+10 -4
View File
@@ -61,6 +61,10 @@ github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQu
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/event v1.2.0 h1:aa8ouNVlo4E/NRhHVXNU/JbWvlr91Gjh423WtCDYQ4Q=
github.com/gookit/event v1.2.0/go.mod h1:gGYybJL0HEEo/+UmBN+MgLqUBIxcCGOP8FrLPk+J8w4=
github.com/gookit/goutil v0.7.1 h1:AaFJPN9mrdeYBv8HOybri26EHGCC34WJVT7jUStGJsI=
github.com/gookit/goutil v0.7.1/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -144,8 +148,8 @@ golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@@ -153,8 +157,10 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
+29
View File
@@ -0,0 +1,29 @@
package internal
import (
// Import all internal packages to ensure their init() functions are executed
_ "github.com/komari-monitor/komari/internal/api_rpc"
_ "github.com/komari-monitor/komari/internal/api_v1"
_ "github.com/komari-monitor/komari/internal/client"
_ "github.com/komari-monitor/komari/internal/cloudflared"
_ "github.com/komari-monitor/komari/internal/common"
_ "github.com/komari-monitor/komari/internal/conf"
_ "github.com/komari-monitor/komari/internal/database"
_ "github.com/komari-monitor/komari/internal/eventType"
_ "github.com/komari-monitor/komari/internal/geoip"
_ "github.com/komari-monitor/komari/internal/jsruntime"
_ "github.com/komari-monitor/komari/internal/log"
_ "github.com/komari-monitor/komari/internal/messageSender"
_ "github.com/komari-monitor/komari/internal/mjpeg"
_ "github.com/komari-monitor/komari/internal/nezha"
_ "github.com/komari-monitor/komari/internal/notifier"
_ "github.com/komari-monitor/komari/internal/oauth"
_ "github.com/komari-monitor/komari/internal/patch"
_ "github.com/komari-monitor/komari/internal/pingSchedule"
_ "github.com/komari-monitor/komari/internal/plugin"
_ "github.com/komari-monitor/komari/internal/renewal"
_ "github.com/komari-monitor/komari/internal/restore"
_ "github.com/komari-monitor/komari/internal/ws"
)
func All() {}
@@ -1,4 +1,4 @@
package jsonRpc
package api_rpc
import (
"context"
@@ -11,64 +11,25 @@ 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/utils/rpc"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/rpc"
)
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
// group: 调用者的权限分组 (guest/client/admin)
// method: RPC 方法名 (如 "common:getNodes")
// params: 方法参数
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
// 检查私有站点
PrivateSite, _ := config.GetAs[bool](config.PrivateSiteKey)
if PrivateSite && group == "guest" {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
// 解析方法命名空间
fc := strings.Split(method, ":")
namespace := "common"
if len(fc) >= 2 {
namespace = fc[0]
}
// 权限检查
allowed := false
switch namespace {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if group == "client" || group == "admin" {
allowed = true
}
case "admin":
if group == "admin" {
allowed = true
}
}
if !allowed {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
}
// 构建 context meta
meta := &rpc.ContextMeta{Permission: group}
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
func RegisterRouters(path string, r *gin.Engine) {
r.GET(path, OnRpcRequest)
r.POST(path, OnRpcRequest)
}
// Json Rpc2 over websocket, /api/rpc2
func OnRpcRequest(c *gin.Context) {
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 AllowCors {
if conf.Conf.Site.AllowCors {
return true
}
return ws.CheckOrigin(r)
@@ -133,8 +94,8 @@ func OnRpcRequest(c *gin.Context) {
switch fc[0] {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
case GroupClient:
if permissionGroup == GroupClient || permissionGroup == "admin" {
allowed = true
}
case "admin":
@@ -142,11 +103,11 @@ func OnRpcRequest(c *gin.Context) {
allowed = true
}
default:
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
responses = append(responses, rpc.ErrorResponse(rreq.ID, rpc.Unauthenticated, "Unauthorized", nil))
continue
}
if !allowed {
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
responses = append(responses, rpc.ErrorResponse(rreq.ID, rpc.PermissionDenied, "Permission denied", nil))
continue
}
responses = append(responses, rpc.CallWithContext(rpc.NewContextWithMeta(context.TODO(), meta), rreq.ID, rreq.Method, rreq.Params))
@@ -162,9 +123,9 @@ func OnRpcRequest(c *gin.Context) {
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
func detectPermissionGroup(c *gin.Context) string {
permissionGroup := "guest"
token := c.Query("Authorization")
if _, err := clients.GetClientUUIDByToken(token); err == nil {
permissionGroup = "client"
token := c.Query("token")
if _, err := client.GetClientUUIDByToken(token); err == nil {
permissionGroup = GroupClient
}
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if _, err := accounts.GetUserBySession(session_token); err == nil {
@@ -172,11 +133,7 @@ func detectPermissionGroup(c *gin.Context) string {
}
}
apiKey := c.GetHeader("Authorization")
if len(apiKey) <= len("Bearer ") {
return permissionGroup
}
cfg, _ := config.GetAs[string](config.ApiKeyKey, "")
if len(cfg) > 8 && apiKey == "Bearer "+cfg {
if apiKey == "Bearer "+conf.Conf.Login.ApiKey {
permissionGroup = "admin"
}
return permissionGroup
@@ -185,8 +142,8 @@ func detectPermissionGroup(c *gin.Context) string {
// buildContextMeta 从 gin.Context 构建 *rpc.ContextMeta
func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
meta := &rpc.ContextMeta{Permission: permissionGroup}
// 提取客户端 token (query Authorization / Header Authorization Bearer token)
token := c.Query("Authorization")
// 提取客户端 token (query token / Header Authorization Bearer token)
token := c.Query("token")
if token == "" {
// 兼容 header Bearer <token>
hAuth := c.GetHeader("Authorization")
@@ -195,7 +152,7 @@ func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
}
}
if token != "" {
if uuid, err := clients.GetClientUUIDByToken(token); err == nil {
if uuid, err := client.GetClientUUIDByToken(token); err == nil {
meta.ClientToken = token
meta.ClientUUID = uuid
}
@@ -218,27 +175,69 @@ func dispatchByPermissionWithMeta(conn *ws.SafeConn, permissionGroup string, met
if len(fc) == 1 {
fc[0] = "common"
}
ctx := rpc.NewContextWithMeta(context.TODO(), meta)
ctx := rpc.NewContextWithMeta(context.Background(), meta)
switch fc[0] {
case "guest", "", "rpc", "common":
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
case GroupClient:
if permissionGroup == GroupClient || permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.PermissionDenied, "Permission Denied", nil))
}
case "admin":
if permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.PermissionDenied, "Permission Denied", nil))
}
default:
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
conn.WriteJSON(rpc.ErrorResponse(req.ID, rpc.Unauthenticated, "Unauthenticated", nil))
}
}
// OnInternalRequest 内部调用 RPC 方法,支持权限控制
// group: 调用者的权限分组 (guest/client/admin)
// method: RPC 方法名 (如 "common:getNodes")
// params: 方法参数
func OnInternalRequest(ctx context.Context, group string, method string, params interface{}) *rpc.JsonRpcResponse {
// 检查私有站点
if conf.Conf.Site.PrivateSite && group == "guest" {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Private site enabled, please login first", nil)
}
// 解析方法命名空间
fc := strings.Split(method, ":")
namespace := "common"
if len(fc) >= 2 {
namespace = fc[0]
}
// 权限检查
allowed := false
switch namespace {
case "guest", "", "rpc", "common":
allowed = true
case GroupClient:
if group == GroupClient || group == "admin" {
allowed = true
}
case "admin":
if group == "admin" {
allowed = true
}
}
if !allowed {
return rpc.ErrorResponse(nil, rpc.PermissionDenied, "Permission denied", nil)
}
// 构建 context meta
meta := &rpc.ContextMeta{Permission: group}
return rpc.CallWithContext(rpc.NewContextWithMeta(ctx, meta), nil, method, params)
}
// registry holds method handlers keyed by "namespace:MethodName"
var (
registryMu sync.RWMutex
+31
View File
@@ -0,0 +1,31 @@
package api_rpc
import (
"context"
"github.com/komari-monitor/komari/pkg/rpc"
)
const GroupClient = "client"
func init() {
RegisterWithGroupAndMeta("register", GroupClient, registerClient, &rpc.MethodMeta{
Name: "RegisterClient",
Description: "Register a new client with the server (Auto discovery).",
Params: []rpc.ParamMeta{
{
Name: "name",
Type: "string",
Description: "The name of the client to register.",
},
},
})
}
func registerClient(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
Name string `json:"name"`
}
req.BindParams(&params)
return nil, rpc.MakeError(rpc.InternalError, "Not impl yet", nil)
}
@@ -1,4 +1,4 @@
package jsonRpc
package api_rpc
import (
"context"
@@ -8,17 +8,16 @@ import (
"strings"
"time"
"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/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/api_v1/vars"
"github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/rpc"
cache "github.com/patrickmn/go-cache"
)
@@ -223,13 +222,13 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
UUID string `json:"uuid"`
}
req.BindParams(&params)
cinfo, err := clients.GetAllClientBasicInfo()
cinfo, err := client.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", cinfo)
}
meta := rpc.MetaFromContext(ctx)
SendIpAddrToGuest, _ := config.GetAs[bool](config.SendIpAddrToGuestKey)
cfg, _ := conf.GetWithV1Format()
if meta.Permission != "admin" {
// 过滤 Hidden 节点并隐藏敏感字段
filtered := make([]models.Client, 0, len(cinfo))
@@ -237,7 +236,7 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
if node.Hidden { // 非 admin 不显示隐藏节点
continue
}
if SendIpAddrToGuest {
if cfg.SendIpAddrToGuest {
if node.IPv4 != "" {
node.IPv4 = strings.Split(node.IPv4, ".")[0] + ".*.*.*"
}
@@ -297,7 +296,7 @@ func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *r
// Hidden 过滤
if meta.Permission != "admin" {
cinfo, err := clients.GetAllClientBasicInfo()
cinfo, err := client.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
@@ -440,7 +439,7 @@ func getMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError)
resp.SSOType = "client"
resp.Username = "client"
resp.UUID = meta.ClientToken
client, err := clients.GetClientUUIDByToken(meta.ClientToken)
client, err := client.GetClientUUIDByToken(meta.ClientToken)
if err != nil {
resp.UUID = client
}
@@ -455,8 +454,8 @@ func getVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcErro
Version string `json:"version"`
Hash string `json:"hash"`
}{
Version: utils.CurrentVersion,
Hash: utils.VersionHash,
Version: conf.Version,
Hash: conf.CommitHash,
}, nil
}
@@ -490,7 +489,7 @@ func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rp
}
}
raw, _ := api.Records.Get(params.UUID)
raw, _ := vars.Records.Get(params.UUID)
reports, _ := raw.([]common.Report)
// 扁平化为 { count, records: [] }
@@ -1,4 +1,4 @@
package jsonRpc
package api_rpc
import (
"context"
@@ -7,12 +7,12 @@ import (
"sort"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
recordsdb "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/models"
recordsdb "github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/pkg/rpc"
)
func init() {
@@ -73,7 +73,7 @@ func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpc
isAdmin := meta.Permission == "admin"
hidden := map[string]bool{}
if !isAdmin {
cinfo, err := clients.GetAllClientBasicInfo()
cinfo, err := client.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
+15
View File
@@ -0,0 +1,15 @@
package api_rpc
import (
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/eventType"
)
func init() {
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
r := e.Get("engine").(*gin.Engine)
RegisterRouters("/api/rpc2", r)
return nil
}))
}
@@ -1,15 +1,21 @@
package api
package api_v1
import (
"net/http"
"strings"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/gin-gonic/gin"
)
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api/admin") {
c.Next()
return
}
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
@@ -20,7 +26,7 @@ func AdminAuthMiddleware() gin.HandlerFunc {
// session-based authentication
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
resp.RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
@@ -28,7 +34,7 @@ func AdminAuthMiddleware() gin.HandlerFunc {
// Komari is a single user system
uuid, err := accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
resp.RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
+12 -63
View File
@@ -1,46 +1,28 @@
package api
package api_v1
import (
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/internal/api_v1/vars"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/patrickmn/go-cache"
"strconv"
"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"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/pkg/utils"
)
var (
Records = cache.New(1*time.Minute, 1*time.Minute)
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
RequesterIp string
}
var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute).Unix()
var records []models.Record
var gpuRecords []models.GPURecord
// 遍历所有客户端记录
for uuid, x := range Records.Items() {
for uuid, x := range vars.Records.Items() {
if uuid == "" {
continue
}
@@ -60,7 +42,7 @@ func SaveClientReportToDB() error {
}
// 更新缓存
Records.Set(uuid, filtered, cache.DefaultExpiration)
vars.Records.Set(uuid, filtered, cache.DefaultExpiration)
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
@@ -113,46 +95,13 @@ func SaveClientReportToDB() error {
return nil
}
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Respond sends a standardized JSON response.
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
}
// RespondSuccess sends a success response with data.
func RespondSuccess(c *gin.Context, data interface{}) {
Respond(c, http.StatusOK, "success", "", data)
}
// RespondSuccessMessage sends a success response with message and data.
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
Respond(c, http.StatusOK, "success", message, data)
}
// RespondError sends an error response with message.
func RespondError(c *gin.Context, httpStatus int, message string) {
Respond(c, httpStatus, "error", message, nil)
}
func GetVersion(c *gin.Context) {
RespondSuccess(c, gin.H{
"version": utils.CurrentVersion,
"hash": utils.VersionHash,
})
}
func isApiKeyValid(apiKey string) bool {
apiKeyConfig, err := config.GetAs[string](config.ApiKeyKey, "")
cfg, err := conf.GetWithV1Format()
if err != nil {
return false
}
if apiKeyConfig == "" || len(apiKeyConfig) < 12 {
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
return false
}
return apiKey == "Bearer "+apiKeyConfig
return apiKey == "Bearer "+cfg.ApiKey
}
+105
View File
@@ -0,0 +1,105 @@
package api_v1
import (
"net/http"
"strings"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/gin-gonic/gin"
)
var (
requireAuthPaths = []string{
// v1 API at 2025-12-06
// "/api/rpc2",
// "/ping",
// "/api/login",
// "/api/me",
"/api/clients",
"/api/nodes",
// "/api/public",
// "/api/oauth",
// "/api/oauth_callback",
// "/api/logout",
// "/api/version",
"/api/recent/",
"/api/records/",
"/api/task/",
// "/api/clients/register",
// "/api/clients/report",
// "/api/clients/uploadBasicInfo",
// "/api/clients/terminal",
// "/api/clients/task/result",
// "/api/admin/download/backup",
}
)
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
path := c.Request.URL.Path
// 如果不是 /api 开头的路径,直接放行
if !strings.HasPrefix(path, "/api") {
c.Next()
return
}
// /api/clients/* 由 TokenAuthMiddleware 处理
// /api/rpc2 由 api_rpc 处理
// /api/admin/* 由 AdminAuthMiddleware 处理
if strings.HasPrefix(path, "/api/clients/") ||
strings.HasPrefix(path, "/api/admin") ||
strings.HasPrefix(path, "/api/rpc2") {
c.Next()
return
}
require := false
for _, p := range requireAuthPaths {
if strings.HasPrefix(path, p) {
require = true
break
}
}
if !require {
c.Next()
return
}
conf, err := conf.GetWithV1Format()
if err != nil {
resp.RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
return
}
// 验证私有, 如果不是私有站点,直接放行
if !conf.PrivateSite {
c.Next()
return
}
// 如果是私有站点,检查是否有 session
session, err := c.Cookie("session_token")
if err != nil {
resp.RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
_, err = accounts.GetSession(session)
if err != nil {
resp.RespondError(c, http.StatusUnauthorized, "Private site is enabled, please login first.")
c.Abort()
return
}
c.Next()
}
}
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"bytes"
@@ -7,7 +7,7 @@ import (
"io"
"net/http"
"github.com/komari-monitor/komari/database/clients"
clients "github.com/komari-monitor/komari/internal/client"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
@@ -4,15 +4,15 @@ import (
"image/png"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/pquerna/otp/totp"
)
func Generate2FA(c *gin.Context) {
secret, img, err := accounts.Generate2Fa()
if err != nil {
api.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
resp.RespondError(c, 500, "Failed to generate 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", secret, 1800, "/", "", false, true)
@@ -26,29 +26,29 @@ func Enable2FA(c *gin.Context) {
secret, _ := c.Cookie("2fa_secret")
code := c.Query("code")
if secret == "" || uuid == nil || code == "" {
api.RespondError(c, 400, "2FA secret or code not provided")
resp.RespondError(c, 400, "2FA secret or code not provided")
return
}
if !totp.Validate(code, secret) {
api.RespondError(c, 400, "Invalid 2FA code")
resp.RespondError(c, 400, "Invalid 2FA code")
return
}
err := accounts.Enable2Fa(uuid.(string), secret)
if err != nil {
api.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
resp.RespondError(c, 500, "Failed to enable 2FA: "+err.Error())
return
}
c.SetCookie("2fa_secret", "", -1, "/", "", false, true)
api.RespondSuccess(c, "2FA enabled successfully")
resp.RespondSuccess(c, "2FA enabled successfully")
}
func Disable2FA(c *gin.Context) {
uuid, _ := c.Get("uuid")
err := accounts.Disable2Fa(uuid.(string))
if err != nil {
api.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
resp.RespondError(c, 500, "Failed to disable 2FA: "+err.Error())
return
}
api.RespondSuccess(c, "")
resp.RespondSuccess(c, "")
}
@@ -4,10 +4,12 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/ws"
"github.com/gookit/event"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/ws"
)
func AddClient(c *gin.Context) {
@@ -30,6 +32,10 @@ func AddClient(c *gin.Context) {
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
event.Trigger(eventType.ClientCreated, event.M{
"client": uuid,
"token": token,
})
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
}
@@ -45,13 +51,17 @@ func EditClient(c *gin.Context) {
return
}
req["uuid"] = uuid
err := clients.SaveClient(req)
err := clients.PartialUpdate(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
event.Trigger(eventType.ClientUpdated, event.M{
"client": uuid,
"data": req,
})
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
@@ -70,6 +80,9 @@ func RemoveClient(c *gin.Context) {
c.JSON(200, gin.H{"status": "success"})
ws.DeleteConnectedClients(uuid)
ws.DeleteLatestReport(uuid)
event.Trigger(eventType.ClientDeleted, event.M{
"client": uuid,
})
}
func ClearRecord(c *gin.Context) {
@@ -2,27 +2,27 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
func OrderWeight(c *gin.Context) {
var req = make(map[string]int)
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
db := dbcore.GetDBInstance()
for uuid, weight := range req {
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
if err != nil {
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
resp.RespondError(c, 500, "Failed to update client weight: "+err.Error())
return
}
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "order clients", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -5,10 +5,10 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
clipboardDB "github.com/komari-monitor/komari/internal/database/clipboard"
"github.com/komari-monitor/komari/internal/database/models"
)
// GetClipboard retrieves a clipboard entry by ID
@@ -16,41 +16,41 @@ func GetClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
cb, err := clipboardDB.GetClipboardByID(id)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
return
}
api.RespondSuccess(c, cb)
resp.RespondSuccess(c, cb)
}
// ListClipboard lists all clipboard entries
func ListClipboard(c *gin.Context) {
list, err := clipboardDB.ListClipboard()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
return
}
api.RespondSuccess(c, list)
resp.RespondSuccess(c, list)
}
// CreateClipboard creates a new clipboard entry
func CreateClipboard(c *gin.Context) {
var req models.Clipboard
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.CreateClipboard(&req); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
api.RespondSuccess(c, req)
resp.RespondSuccess(c, req)
}
// UpdateClipboard updates an existing clipboard entry
@@ -58,21 +58,21 @@ func UpdateClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
var fields map[string]interface{}
if err := c.ShouldBindJSON(&fields); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
// DeleteClipboard deletes a clipboard entry
@@ -80,16 +80,16 @@ func DeleteClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
resp.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
if err := clipboardDB.DeleteClipboard(id); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
// BatchDeleteClipboard deletes multiple clipboard entries
@@ -98,18 +98,18 @@ func BatchDeleteClipboard(c *gin.Context) {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
api.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
resp.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
return
}
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -11,9 +11,9 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/dbcore"
)
// copyFile 复制单个文件到目标路径(会确保父目录存在)
@@ -108,35 +108,35 @@ func DownloadBackup(c *gin.Context) {
// 1) 创建临时目录
tempDir, err := os.MkdirTemp("", "komari-backup-*")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
return
}
defer os.RemoveAll(tempDir)
// 2) 复制 ./data 下除 .db/.db-wal/.db-shm 外的所有文件到临时目录
if err := copyDataToTempExcludingDB(tempDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
return
}
// 3) 处理数据库备份 -> 临时目录/komari.db
destDB := filepath.Join(tempDir, "komari.db")
dbFilePath := flags.DatabaseFile
dbFilePath := conf.Conf.Database.DatabaseFile
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
if conf.Conf.Database.DatabaseType == "sqlite" || conf.Conf.Database.DatabaseType == "" {
if err := backupSQLiteTo(destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
return
}
} else if dbFilePath != "" {
// 非 sqlite 的情况:若配置了文件路径且存在,则直接复制(按用户需求仍然将名称固定为 komari.db)
if _, err := os.Stat(dbFilePath); err == nil {
if err := copyFile(dbFilePath, destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
} else if !os.IsNotExist(err) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
}
@@ -188,7 +188,7 @@ func DownloadBackup(c *gin.Context) {
return err
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
return
}
@@ -200,11 +200,11 @@ func DownloadBackup(c *gin.Context) {
Modified: time.Now(),
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
if _, err = markupWriter.Write([]byte(markupContent)); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
}
}
@@ -6,12 +6,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/utils"
)
// 接受数据类型:
@@ -25,7 +25,7 @@ func Exec(c *gin.Context) {
var onlineClients []string
var offlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
// for uuid := range ws.GetConnectedClients() {
@@ -33,7 +33,7 @@ func Exec(c *gin.Context) {
// onlineClients = append(onlineClients, uuid)
// }
// // else {
// // api.RespondError(c, 400, "Client not connected: "+uuid)
// // resp.RespondError(c, 400, "Client not connected: "+uuid)
// // return
// // }
// }
@@ -45,12 +45,12 @@ func Exec(c *gin.Context) {
}
}
if len(onlineClients) == 0 {
api.RespondError(c, 400, "No clients connected")
resp.RespondError(c, 400, "No clients connected")
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
resp.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
for _, uuid := range onlineClients {
@@ -67,17 +67,17 @@ func Exec(c *gin.Context) {
client := ws.GetConnectedClients()[uuid]
if client != nil {
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
api.RespondError(c, 400, "Client connection is broke: "+uuid)
resp.RespondError(c, 400, "Client connection is broke: "+uuid)
return
}
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
resp.RespondError(c, 400, "Client connection is null: "+uuid)
return
}
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
resp.RespondSuccess(c, gin.H{
"task_id": taskId,
"clients": onlineClients,
})
@@ -4,9 +4,9 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
func GetLogs(c *gin.Context) {
@@ -22,12 +22,12 @@ func GetLogs(c *gin.Context) {
// If conversion fails, return an error
limitInt, err := strconv.Atoi(limit)
if err != nil || limitInt <= 0 {
api.RespondError(c, 400, "Invalid limit: "+limit)
resp.RespondError(c, 400, "Invalid limit: "+limit)
return
}
pageInt, err := strconv.Atoi(page)
if err != nil || pageInt <= 0 {
api.RespondError(c, 400, "Invalid page: "+page)
resp.RespondError(c, 400, "Invalid page: "+page)
return
}
db := dbcore.GetDBInstance()
@@ -37,13 +37,13 @@ func GetLogs(c *gin.Context) {
var total int64
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
resp.RespondError(c, 500, "Failed to count logs: "+err.Error())
return
}
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
resp.RespondSuccess(c, gin.H{"logs": logs, "total": total})
}
+63
View File
@@ -0,0 +1,63 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/messageSender"
"github.com/komari-monitor/komari/internal/messageSender/factory"
)
func GetMessageSenderProvider(c *gin.Context) {
provider := c.Query("provider")
if provider != "" {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetMessageSenderConfigByName(provider)
if err != nil {
resp.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
resp.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置项模板
providers := factory.GetSenderConfigs()
if len(providers) == 0 {
resp.RespondError(c, 404, "No message sender providers found")
return
}
resp.RespondSuccess(c, providers)
}
func SetMessageSenderProvider(c *gin.Context) {
var senderConfig models.MessageSenderProvider
if err := c.ShouldBindJSON(&senderConfig); err != nil {
resp.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if senderConfig.Name == "" {
resp.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(senderConfig.Name)
if !exists {
resp.RespondError(c, 404, "Provider not found: "+senderConfig.Name)
return
}
if err := database.SaveMessageSenderConfig(&senderConfig); err != nil {
resp.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
cfg, _ := conf.GetWithV1Format()
// 正在使用,重载
if cfg.NotificationMethod == senderConfig.Name {
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
if err != nil {
resp.RespondError(c, 500, "Failed to load message sender provider: "+err.Error())
return
}
}
resp.RespondSuccess(c, gin.H{"message": "Message sender provider set successfully"})
}
@@ -4,9 +4,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/notification"
)
// POST body: clients []string, name string, metric string, threshold float32, ratio float32, interval int
@@ -21,23 +21,23 @@ func AddLoadNotification(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if req.Interval > 4*60 || req.Interval <= 0 {
api.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
resp.RespondError(c, http.StatusBadRequest, "Interval must be between 1 and 240 minutes")
return
}
if req.Ratio <= 0 || req.Ratio > 1 {
api.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
resp.RespondError(c, http.StatusBadRequest, "Ratio must be between 0 and 1")
return
}
if taskID, err := notification.AddLoadNotification(req.Clients, req.Name, req.Metric, req.Threshold, req.Ratio, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
resp.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
@@ -47,14 +47,14 @@ func DeleteLoadNotification(c *gin.Context) {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := notification.DeleteLoadNotification(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
@@ -65,26 +65,26 @@ func EditLoadNotification(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
resp.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := notification.EditLoadNotification(req.Notifications); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, notification := range req.Notifications {
// notification.DeleteLoadNotification([]uint{notification.Id})
// }
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
func GetAllLoadNotifications(c *gin.Context) {
notifications, err := notification.GetAllLoadNotifications()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, notifications)
resp.RespondSuccess(c, notifications)
}
@@ -2,9 +2,9 @@ package notification
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
resp "github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"gorm.io/gorm/clause"
)
@@ -12,7 +12,7 @@ import (
func EnableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
resp.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
@@ -30,17 +30,17 @@ func EnableOfflineNotification(c *gin.Context) {
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
// POST body : []uuid
func DisableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
resp.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
@@ -58,29 +58,29 @@ func DisableOfflineNotification(c *gin.Context) {
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func EditOfflineNotification(c *gin.Context) {
var notifications []models.OfflineNotification
if err := c.ShouldBindJSON(&notifications); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
resp.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if len(notifications) == 0 {
api.RespondError(c, 400, "At least one notification is required")
resp.RespondError(c, 400, "At least one notification is required")
return
}
for _, noti := range notifications {
if noti.Client == "" {
api.RespondError(c, 400, "Client UUID cannot be empty")
resp.RespondError(c, 400, "Client UUID cannot be empty")
return
}
if noti.GracePeriod <= 0 {
api.RespondError(c, 400, "GracePeriod must be a positive integer")
resp.RespondError(c, 400, "GracePeriod must be a positive integer")
return
}
}
@@ -92,18 +92,18 @@ func EditOfflineNotification(c *gin.Context) {
Select("*").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func ListOfflineNotifications(c *gin.Context) {
var notifications []models.OfflineNotification
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(&notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
resp.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, notifications)
resp.RespondSuccess(c, notifications)
}
@@ -2,20 +2,20 @@ 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/models"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/oauth"
"github.com/komari-monitor/komari/internal/oauth/factory"
)
func BindingExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
resp.RespondError(c, 500, "No user found: "+err.Error())
return
}
@@ -26,17 +26,17 @@ func UnbindExternalAccount(c *gin.Context) {
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil {
api.RespondError(c, 500, "No user found: "+err.Error())
resp.RespondError(c, 500, "No user found: "+err.Error())
return
}
err = accounts.UnbindExternalAccount(user.UUID)
if err != nil {
api.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
resp.RespondError(c, 500, "Failed to unbind external account: "+err.Error())
return
}
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func GetOidcProvider(c *gin.Context) {
@@ -45,49 +45,49 @@ func GetOidcProvider(c *gin.Context) {
// 如果指定了provider,返回单个提供者的配置
config, err := database.GetOidcConfigByName(provider)
if err != nil {
api.RespondError(c, 404, "Provider not found: "+err.Error())
resp.RespondError(c, 404, "Provider not found: "+err.Error())
return
}
api.RespondSuccess(c, config)
resp.RespondSuccess(c, config)
return
}
// 否则返回所有提供者的配置
providers := factory.GetProviderConfigs()
if len(providers) == 0 {
api.RespondError(c, 404, "No OIDC providers found")
resp.RespondError(c, 404, "No OIDC providers found")
return
}
api.RespondSuccess(c, providers)
resp.RespondSuccess(c, providers)
}
func SetOidcProvider(c *gin.Context) {
var oidcConfig models.OidcProvider
if err := c.ShouldBindJSON(&oidcConfig); err != nil {
api.RespondError(c, 400, "Invalid configuration: "+err.Error())
resp.RespondError(c, 400, "Invalid configuration: "+err.Error())
return
}
if oidcConfig.Name == "" {
api.RespondError(c, 400, "Provider name is required")
resp.RespondError(c, 400, "Provider name is required")
return
}
_, exists := factory.GetConstructor(oidcConfig.Name)
if !exists {
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
resp.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
return
}
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
resp.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
return
}
OAuthProvider, _ := config.GetAs[string](config.OAuthProviderKey, "github")
cfg, _ := conf.GetWithV1Format()
// 正在使用,重载
if OAuthProvider == oidcConfig.Name {
if cfg.OAuthProvider == oidcConfig.Name {
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
if err != nil {
api.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
resp.RespondError(c, 500, "Failed to load OIDC provider: "+err.Error())
return
}
}
api.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
resp.RespondSuccess(c, gin.H{"message": "OIDC provider set successfully"})
}
@@ -4,9 +4,9 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
)
// POST body: clients []string, target, task_type string, interval int
@@ -20,14 +20,14 @@ func AddPingTask(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
resp.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
@@ -37,14 +37,14 @@ func DeletePingTask(c *gin.Context) {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := tasks.DeletePingTask(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
@@ -55,26 +55,26 @@ func EditPingTask(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
resp.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := tasks.EditPingTask(req.Tasks); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, task := range req.Tasks {
// tasks.DeletePingRecords([]uint{task.Id})
// }
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
}
func GetAllPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, tasks)
resp.RespondSuccess(c, tasks)
}
@@ -1,9 +1,9 @@
package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/gin-gonic/gin"
)
@@ -12,7 +12,7 @@ func GetSessions(c *gin.Context) {
ss, err := accounts.GetAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
return
}
current, _ := c.Cookie("session_token")
@@ -24,27 +24,27 @@ func DeleteSession(c *gin.Context) {
Session string `json:"session" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid request: "+err.Error())
resp.RespondError(c, 400, "Invalid request: "+err.Error())
return
}
err := accounts.DeleteSession(req.Session)
if err != nil {
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
resp.RespondError(c, 500, "Failed to delete session: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "delete session", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func DeleteAllSession(c *gin.Context) {
err := accounts.DeleteAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
resp.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
+80
View File
@@ -0,0 +1,80 @@
package admin
import (
"database/sql"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/gin-gonic/gin"
)
// GetSettings 获取自定义配置
func GetSettings(c *gin.Context) {
cst, err := conf.GetWithV1Format()
if err != nil {
if err == sql.ErrNoRows {
//override
cst = conf.V1Struct{Sitename: "Komari"}
cst.ID = 1
conf.Save(cst)
resp.RespondSuccess(c, cst)
return
}
c.JSON(500, gin.H{
"status": "error",
"message": "Internal Server Error: " + err.Error(),
})
}
resp.RespondSuccess(c, cst)
}
// EditSettings 更新自定义配置
func EditSettings(c *gin.Context) {
cfg := make(map[string]interface{})
if err := c.ShouldBindJSON(&cfg); err != nil {
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
cfg["id"] = 1 // Only one record
if err := conf.Update(cfg); err != nil {
resp.RespondError(c, 500, "Failed to update settings: "+err.Error())
return
}
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 {
message = message[:len(message)-2]
}
auditlog.Log(c.ClientIP(), uuid.(string), message, "info")
resp.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()
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "clear all records", "info")
resp.RespondSuccess(c, nil)
}
@@ -2,21 +2,21 @@ package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/tasks"
)
func GetTasks(c *gin.Context) {
dbTasks, err := tasks.GetAllTasks()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
var responseTasks []gin.H
for _, t := range dbTasks {
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
@@ -38,27 +38,27 @@ func GetTasks(c *gin.Context) {
"results": filteredResults,
})
}
api.RespondSuccess(c, responseTasks)
resp.RespondSuccess(c, responseTasks)
}
func GetTaskById(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
resp.RespondError(c, 400, "Task ID is required")
return
}
task, err := tasks.GetTaskByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
return
}
if task == nil {
api.RespondError(c, 404, "Task not found")
resp.RespondError(c, 404, "Task not found")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
@@ -71,7 +71,7 @@ func GetTaskById(c *gin.Context) {
"created_at": r.CreatedAt,
})
}
api.RespondSuccess(c, gin.H{
resp.RespondSuccess(c, gin.H{
"task_id": task.TaskId,
"clients": task.Clients,
"command": task.Command,
@@ -82,73 +82,73 @@ func GetTaskById(c *gin.Context) {
func GetTasksByClientId(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
resp.RespondError(c, 400, "Client ID is required")
return
}
tasks, err := tasks.GetTasksByClientId(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(tasks) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
resp.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, tasks)
resp.RespondSuccess(c, tasks)
}
func GetSpecificTaskResult(c *gin.Context) {
taskId := c.Param("task_id")
clientId := c.Param("uuid")
if taskId == "" || clientId == "" {
api.RespondError(c, 400, "Task ID and Client ID are required")
resp.RespondError(c, 400, "Task ID and Client ID are required")
return
}
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
return
}
if result == nil {
api.RespondError(c, 404, "No result found for this task and client")
resp.RespondError(c, 404, "No result found for this task and client")
return
}
api.RespondSuccess(c, result)
resp.RespondSuccess(c, result)
}
// Param: task_id
func GetTaskResultsByTaskId(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
resp.RespondError(c, 400, "Task ID is required")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No results found for this task")
resp.RespondError(c, 404, "No results found for this task")
return
}
api.RespondSuccess(c, results)
resp.RespondSuccess(c, results)
}
func GetAllTaskResultByUUID(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
resp.RespondError(c, 400, "Client ID is required")
return
}
results, err := tasks.GetAllTasksResultByUUID(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
resp.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, results)
resp.RespondSuccess(c, results)
}
+50
View File
@@ -0,0 +1,50 @@
package test
import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/komari-monitor/komari/internal/messageSender"
)
func TestSendMessage(c *gin.Context) {
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
resp.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
resp.RespondSuccess(c, nil)
}
func TestGeoIp(c *gin.Context) {
ip := c.Query("ip")
if ip == "" {
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
ip = cfIP
} else {
ip = c.ClientIP()
}
}
conf, err := conf.GetWithV1Format()
if err != nil {
resp.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if !conf.GeoIpEnabled {
resp.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoInfo(net.ParseIP(ip))
if err != nil {
resp.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
}
resp.RespondSuccess(c, GeoIpRecord)
}
@@ -13,11 +13,10 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"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"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
// UploadTheme 上传主题
@@ -25,32 +24,32 @@ func UploadTheme(c *gin.Context) {
// 读取上传的文件内容
data, err := io.ReadAll(c.Request.Body)
if err != nil || len(data) == 0 {
api.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
resp.RespondError(c, http.StatusBadRequest, "请选择要上传的主题文件")
return
}
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "uploaded_theme.zip")
if err := os.WriteFile(tempFile, data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// 检查文件扩展名(这里假定上传的就是zip)
if !strings.HasSuffix(strings.ToLower(tempFile), ".zip") {
api.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
resp.RespondError(c, http.StatusBadRequest, "只支持ZIP格式的主题文件")
return
}
// 解压ZIP文件并验证
themeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
api.RespondSuccessMessage(c, "主题上传成功", themeInfo)
resp.RespondSuccessMessage(c, "主题上传成功", themeInfo)
}
// ListThemes 列出所有主题
@@ -59,26 +58,17 @@ func ListThemes(c *gin.Context) {
// 确保主题目录存在
if _, err := os.Stat(dataDir); os.IsNotExist(err) {
api.RespondSuccess(c, []models.Theme{})
resp.RespondSuccess(c, []models.Theme{})
return
}
entries, err := os.ReadDir(dataDir)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "读取主题目录失败: "+err.Error())
return
}
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")
@@ -88,7 +78,7 @@ func ListThemes(c *gin.Context) {
}
}
api.RespondSuccess(c, themes)
resp.RespondSuccess(c, themes)
}
// DeleteTheme 删除主题
@@ -98,12 +88,12 @@ func DeleteTheme(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.Short == "default" {
api.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
resp.RespondError(c, http.StatusBadRequest, "默认主题不能删除")
return
}
@@ -111,24 +101,24 @@ func DeleteTheme(c *gin.Context) {
// 检查主题是否存在
if _, err := os.Stat(themeDir); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
resp.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 删除主题目录
if err := os.RemoveAll(themeDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "删除主题失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题删除成功", nil)
resp.RespondSuccessMessage(c, "主题删除成功", nil)
}
// SetTheme 设置主题
func SetTheme(c *gin.Context) {
themeName := c.Query("theme")
if themeName == "" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
resp.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
return
}
@@ -138,17 +128,22 @@ func SetTheme(c *gin.Context) {
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
resp.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
}
if err := config.Set("theme", themeName); err != nil {
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
// 更新配置
updateData := map[string]interface{}{
"theme": themeName,
}
if err := conf.Update(updateData); err != nil {
resp.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
return
}
api.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
resp.RespondSuccessMessage(c, "主题设置成功", gin.H{"theme": themeName})
}
// extractAndValidateTheme 解压并验证主题
@@ -436,7 +431,7 @@ func UpdateTheme(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
@@ -445,14 +440,14 @@ func UpdateTheme(c *gin.Context) {
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
resp.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 加载现有主题配置
themeInfo, err := loadThemeConfig(themeConfigPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
return
}
@@ -499,14 +494,14 @@ func UpdateTheme(c *gin.Context) {
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存下载链接,稍后更新到主题配置中
@@ -520,14 +515,14 @@ func UpdateTheme(c *gin.Context) {
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
@@ -538,7 +533,7 @@ func UpdateTheme(c *gin.Context) {
// 新URL不是GitHub仓库地址,直接尝试下载
themeData, err = downloadThemeFromURL(req.URL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
return
}
// downloadURL = req.URL
@@ -548,7 +543,7 @@ func UpdateTheme(c *gin.Context) {
// 如果没有成功下载主题数据
if themeData == nil || len(themeData) == 0 {
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
resp.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
return
}
@@ -561,7 +556,7 @@ func UpdateTheme(c *gin.Context) {
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
@@ -569,7 +564,7 @@ func UpdateTheme(c *gin.Context) {
// 解压ZIP文件并验证
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
return
}
@@ -581,23 +576,23 @@ func UpdateTheme(c *gin.Context) {
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
// if err != nil {
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
// resp.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
// return
// }
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
// resp.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
// return
// }
// }
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
resp.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
}
func UpdateThemeSettings(c *gin.Context) {
theme := c.Query("theme")
if theme == "" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空")
if theme == "" || theme == "default" {
resp.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
return
}
@@ -605,14 +600,14 @@ func UpdateThemeSettings(c *gin.Context) {
err := c.ShouldBindJSON(&req)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
resp.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
db := dbcore.GetDBInstance()
data, err := json.Marshal(&req)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
return
}
@@ -620,5 +615,5 @@ func UpdateThemeSettings(c *gin.Context) {
db.Where("short = ?", theme).
Assign(models.ThemeConfiguration{Short: theme, Data: string(data)}).
FirstOrCreate(&themeCfg)
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
@@ -7,8 +7,8 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
)
func UploadFavicon(c *gin.Context) {
@@ -16,31 +16,31 @@ func UploadFavicon(c *gin.Context) {
data, err := io.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
resp.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
} else {
api.RespondError(c, http.StatusBadRequest, err.Error())
resp.RespondError(c, http.StatusBadRequest, err.Error())
}
return
}
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
func DeleteFavicon(c *gin.Context) {
if err := os.Remove("./data/favicon.ico"); err != nil {
if os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "Favicon not found")
resp.RespondError(c, http.StatusNotFound, "Favicon not found")
} else {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
resp.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
}
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
resp.RespondSuccess(c, nil)
}
+18
View File
@@ -0,0 +1,18 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/geoip"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateDatabase(); err != nil {
resp.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
resp.RespondSuccess(c, nil)
}
@@ -2,9 +2,9 @@ package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
)
func UpdateUser(c *gin.Context) {
@@ -15,26 +15,26 @@ func UpdateUser(c *gin.Context) {
SsoType *string `json:"sso_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
resp.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if req.Password == nil && req.Name == nil {
api.RespondError(c, 400, "At least one field (username or password) must be provided")
resp.RespondError(c, 400, "At least one field (username or password) must be provided")
return
}
if req.Name != nil && len(*req.Name) < 3 {
api.RespondError(c, 400, "Username must be at least 3 characters long")
resp.RespondError(c, 400, "Username must be at least 3 characters long")
return
}
if req.Password != nil && len(*req.Password) < 6 {
api.RespondError(c, 400, "Password must be at least 6 characters long")
resp.RespondError(c, 400, "Password must be at least 6 characters long")
return
}
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
api.RespondError(c, 500, "Failed to update user: "+err.Error())
resp.RespondError(c, 500, "Failed to update user: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
resp.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
@@ -13,7 +13,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/internal/api_v1/resp"
)
// 只有一个备份恢复操作在进行
@@ -23,7 +23,7 @@ var restoreMutex sync.Mutex
func UploadBackup(c *gin.Context) {
// 尝试获取锁,如果已有恢复操作在进行,则立即返回错误
if !restoreMutex.TryLock() {
api.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
resp.RespondError(c, http.StatusConflict, "Another restore operation is already in progress")
return
}
defer restoreMutex.Unlock()
@@ -31,27 +31,27 @@ func UploadBackup(c *gin.Context) {
// 获取上传的文件
file, header, err := c.Request.FormFile("backup")
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
resp.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting uploaded file: %v", err))
return
}
defer file.Close()
// 检查文件是否为zip格式
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
api.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
resp.RespondError(c, http.StatusBadRequest, "Uploaded file must be a ZIP archive")
return
}
// 确保data目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating data directory: %v", err))
return
}
// 创建临时文件保存上传的zip(先校验,再落地到固定位置)
tempFile, err := os.CreateTemp("", "backup-upload-*.zip")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary file: %v", err))
return
}
tempFilePath := tempFile.Name()
@@ -61,7 +61,7 @@ func UploadBackup(c *gin.Context) {
_, err = io.Copy(tempFile, file)
if err != nil {
tempFile.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving uploaded file: %v", err))
return
}
tempFile.Close() // 关闭文件以便后续操作
@@ -77,11 +77,11 @@ func UploadBackup(c *gin.Context) {
}
zr.Close()
if !hasMarkup {
api.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
resp.RespondError(c, http.StatusBadRequest, "Invalid backup file: missing komari-backup-markup file")
return
}
} else {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening zip file: %v", err))
return
}
@@ -93,18 +93,18 @@ func UploadBackup(c *gin.Context) {
// fallback:拷贝
in, err2 := os.Open(tempFilePath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error preparing backup file: %v", err))
return
}
defer in.Close()
out, err2 := os.Create(finalPath)
if err2 != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating target backup file: %v", err2))
return
}
if _, err2 = io.Copy(out, in); err2 != nil {
out.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
resp.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing target backup file: %v", err2))
return
}
out.Close()
+47
View File
@@ -0,0 +1,47 @@
package client
import (
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/api_v1/resp"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/pkg/utils"
)
func RegisterClient(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
resp.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
cfg, err := conf.GetWithV1Format()
if err != nil {
resp.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if cfg.AutoDiscoveryKey == "" ||
len(cfg.AutoDiscoveryKey) < 12 ||
"Bearer "+cfg.AutoDiscoveryKey != auth {
resp.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
name := c.Query("name")
if name == "" {
name = utils.GenerateRandomString(8)
}
name = "Auto-" + name
uuid, token, err := clients.CreateClientWithName(name)
if err != nil {
resp.RespondError(c, 500, "Failed to create client: "+err.Error())
return
}
event.Trigger(eventType.ClientCreated, event.M{
"client": uuid,
"token": token,
})
resp.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
}
@@ -11,14 +11,16 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/api_v1/vars"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/notifier"
"github.com/komari-monitor/komari/internal/ws"
"github.com/patrickmn/go-cache"
)
@@ -123,13 +125,18 @@ func WebSocketReport(c *gin.Context) {
go oldConn.Close()
}
ws.SetConnectedClients(uuid, conn)
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
//log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
go notifier.OnlineNotification(uuid, conn.ID)
defer func() {
ws.DeleteClientConditionally(uuid, conn)
notifier.OfflineNotification(uuid, conn.ID)
}()
event.Trigger(eventType.ClientWebsocketConnected, event.M{
"client": uuid,
"online": len(ws.GetConnectedClients()),
"connID": conn.ID,
})
// 首先处理第一次ws conn收到的消息
processMessage(conn, message, uuid)
@@ -143,8 +150,18 @@ func WebSocketReport(c *gin.Context) {
}
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
}
event.Trigger(eventType.ClientMessageReceived, event.M{
"client": uuid,
"type": "websocket",
"message": string(message),
})
processMessage(conn, message, uuid)
}
event.Trigger(eventType.ClientWebsocketDisconnected, event.M{
"client": uuid,
"online": len(ws.GetConnectedClients()),
"connID": conn.ID,
})
}
// 将消息处理逻辑提取到一个函数中,方便复用
@@ -200,7 +217,7 @@ func processMessage(conn *ws.SafeConn, message []byte, uuid string) {
}
func SaveClientReport(uuid string, report common.Report) error {
reports, _ := api.Records.Get(uuid)
reports, _ := vars.Records.Get(uuid)
if reports == nil {
reports = []common.Report{}
}
@@ -208,7 +225,7 @@ func SaveClientReport(uuid string, report common.Report) error {
report.CPU.Usage = 0.01
}
reports = append(reports.([]common.Report), report)
api.Records.Set(uuid, reports, cache.DefaultExpiration)
vars.Records.Set(uuid, reports, cache.DefaultExpiration)
return nil
}
@@ -4,9 +4,11 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/gookit/event"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/eventType"
)
func TaskResult(c *gin.Context) {
@@ -33,4 +35,9 @@ func TaskResult(c *gin.Context) {
}
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
event.Trigger(eventType.TaskUpdated, event.M{
"task": req.TaskId,
"client": clientId,
"data": req,
})
}
@@ -5,12 +5,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/internal/api_v1/terminal"
)
func EstablishConnection(c *gin.Context) {
session_id := c.Query("id")
session, exists := api.TerminalSessions[session_id]
session, exists := terminal.TerminalSessions[session_id]
if !exists || session == nil || session.Browser == nil {
c.JSON(404, gin.H{"status": "error", "error": "Session not found"})
return
@@ -27,22 +27,22 @@ func EstablishConnection(c *gin.Context) {
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
api.TerminalSessionsMutex.Lock()
terminal.TerminalSessionsMutex.Lock()
if session.Browser != nil {
session.Browser.Close()
}
delete(api.TerminalSessions, session_id)
api.TerminalSessionsMutex.Unlock()
delete(terminal.TerminalSessions, session_id)
terminal.TerminalSessionsMutex.Unlock()
return
}
session.Agent = conn
conn.SetCloseHandler(func(code int, text string) error {
delete(api.TerminalSessions, session_id)
delete(terminal.TerminalSessions, session_id)
// 通知 Browser 关闭终端连接
if session.Browser != nil {
session.Browser.Close()
}
return nil
})
go api.ForwardTerminal(session_id)
go terminal.ForwardTerminal(session_id)
}
@@ -3,9 +3,11 @@ package client
import (
"net"
"github.com/komari-monitor/komari/config"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/gookit/event"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/gin-gonic/gin"
)
@@ -60,7 +62,7 @@ func UploadBasicInfo(c *gin.Context) {
}
}
if cfg, err := config.GetAs[bool](config.GeoIpEnabledKey); err == nil && cfg {
if cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoInfo(ip4)
@@ -76,10 +78,15 @@ func UploadBasicInfo(c *gin.Context) {
}
}
if err := clients.SaveClientInfo(cbi); err != nil {
if err := clients.PartialUpdate(cbi); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
event.Trigger(eventType.ClientMessageReceived, event.M{
"client": cbi["uuid"],
"type": "basic_info",
"data": cbi,
})
c.JSON(200, gin.H{"status": "success"})
}
+200
View File
@@ -0,0 +1,200 @@
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/api_v1/admin"
"github.com/komari-monitor/komari/internal/api_v1/admin/clipboard"
log_api "github.com/komari-monitor/komari/internal/api_v1/admin/log"
"github.com/komari-monitor/komari/internal/api_v1/admin/notification"
"github.com/komari-monitor/komari/internal/api_v1/admin/test"
"github.com/komari-monitor/komari/internal/api_v1/admin/update"
"github.com/komari-monitor/komari/internal/api_v1/client"
"github.com/komari-monitor/komari/internal/api_v1/record"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/api_v1/task"
"github.com/komari-monitor/komari/internal/api_v1/terminal"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/eventType"
)
func init() {
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
r := e.Get("engine").(*gin.Engine)
config, _ := conf.GetWithV1Format()
LoadApiV1Routes(r, config)
return nil
}), event.Normal+5)
event.On(eventType.SchedulerEveryMinute, event.ListenerFunc(func(e event.Event) error {
SaveClientReportToDB()
return nil
}))
}
func LoadApiV1Routes(r *gin.Engine, conf conf.V1Struct) {
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Use(PrivateSiteMiddleware())
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", Login)
r.GET("/api/me", GetMe)
r.GET("/api/clients", GetClients)
r.GET("/api/nodes", GetNodesInformation)
r.GET("/api/public", GetPublicSettings)
r.GET("/api/oauth", OAuth)
r.GET("/api/oauth_callback", OAuthCallback)
r.GET("/api/logout", Logout)
r.GET("/api/version", resp.GetVersion)
r.GET("/api/recent/:uuid", GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
r.GET("/api/task/ping", task.GetPublicPingTasks)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
r.Use(AdminAuthMiddleware())
adminAuthrized := r.Group("/api/admin")
{
adminAuthrized.GET("/download/backup", admin.DownloadBackup)
adminAuthrized.POST("/upload/backup", admin.UploadBackup)
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
settingsGroup := adminAuthrized.Group("/settings")
{
settingsGroup.GET("/", admin.GetSettings)
settingsGroup.POST("/", admin.EditSettings)
settingsGroup.POST("/oidc", admin.SetOidcProvider)
settingsGroup.GET("/oidc", admin.GetOidcProvider)
settingsGroup.POST("/message-sender", admin.SetMessageSenderProvider)
settingsGroup.GET("/message-sender", admin.GetMessageSenderProvider)
}
// themes
themeGroup := adminAuthrized.Group("/theme")
{
themeGroup.PUT("/upload", admin.UploadTheme)
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", terminal.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
recordGroup.POST("/clear/all", admin.ClearAllRecords)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
loadAlertGroup := notificationGroup.Group("/load")
{
loadAlertGroup.GET("/", notification.GetAllLoadNotifications)
loadAlertGroup.POST("/add", notification.AddLoadNotification)
loadAlertGroup.POST("/delete", notification.DeleteLoadNotification)
loadAlertGroup.POST("/edit", notification.EditLoadNotification)
}
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
}
@@ -1,17 +1,19 @@
package api
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/api_v1/vars"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
resp.RespondError(c, 400, "UUID is required")
return
}
@@ -34,11 +36,11 @@ func GetClientRecentRecords(c *gin.Context) {
}
if hiddenMap[uuid] {
RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
resp.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
records, _ := vars.Records.Get(uuid)
resp.RespondSuccess(c, records)
}
+106
View File
@@ -0,0 +1,106 @@
package api_v1
import (
"encoding/json"
"io"
"net/http"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
conf, _ := conf.GetWithV1Format()
if conf.DisablePasswordLogin {
resp.RespondError(c, http.StatusForbidden, "Password login is disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
var data LoginRequest
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
if data.Username == "" || data.Password == "" {
resp.RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
return
}
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
resp.RespondError(c, http.StatusUnauthorized, "Invalid credentials")
event.Trigger(eventType.LoginFailed, event.M{
"username": data.Username,
"method": "password",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
return
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" { // 开启了2FA
if data.TwoFa == "" {
resp.RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
resp.RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
resp.RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), uuid, "logged in (password)", "login")
resp.RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
event.Trigger(eventType.UserLogin, event.M{
"username": data.Username,
"method": "password",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
auditlog.Log(c.ClientIP(), "", "logged out", "logout")
event.Trigger(eventType.UserLogout, event.M{
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
c.Redirect(302, "/")
}
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"bytes"
@@ -8,7 +8,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/stretchr/testify/assert"
)
+2 -2
View File
@@ -1,7 +1,7 @@
package api
package api_v1
import (
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/gin-gonic/gin"
)
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"encoding/json"
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/stretchr/testify/assert"
)
+6 -5
View File
@@ -1,15 +1,16 @@
package api
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/internal/api_v1/resp"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/accounts"
)
func GetNodesInformation(c *gin.Context) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
resp.RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
isLogin := false
@@ -35,5 +36,5 @@ func GetNodesInformation(c *gin.Context) {
}
clientList = clientList[:j]
RespondSuccess(c, clientList)
resp.RespondSuccess(c, clientList)
}
+47 -8
View File
@@ -1,21 +1,23 @@
package api
package api_v1
import (
"fmt"
"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/utils"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/oauth"
"github.com/komari-monitor/komari/pkg/utils"
)
// /api/oauth
func OAuth(c *gin.Context) {
OAuthEnabled, _ := config.GetAs[bool](config.OAuthEnabledKey, false)
if !OAuthEnabled {
cfg, _ := conf.GetWithV1Format()
if !cfg.OAuthEnabled {
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
return
}
@@ -86,7 +88,12 @@ func OAuthCallback(c *gin.Context) {
return
}
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
event.Trigger(eventType.UserOidcBound, event.M{
"user": user.UUID,
"sso_id": sso_id,
})
c.Redirect(302, "/admin")
return
}
@@ -97,6 +104,17 @@ func OAuthCallback(c *gin.Context) {
"status": "error",
"message": "please log in and bind your external account first.",
})
event.Trigger(eventType.UserLogin, event.M{
"username": user.Username,
"method": "oauth",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
"error": "no linked account",
"sso_id": sso_id,
})
return
}
@@ -104,11 +122,32 @@ func OAuthCallback(c *gin.Context) {
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
event.Trigger(eventType.LoginFailed, event.M{
"username": user.Username,
"method": "oauth",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
"error": err.Error(),
"sso_id": sso_id,
})
return
}
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
auditlog.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
event.Trigger(eventType.UserLogin, event.M{
"username": user.Username,
"method": "oauth",
"ip": c.ClientIP(),
"ua": c.Request.UserAgent(),
"header": c.Request.Header,
"referrer": c.Request.Referer(),
"host": c.Request.Host,
})
c.Redirect(302, "/admin")
}
+16
View File
@@ -0,0 +1,16 @@
package api_v1
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database"
)
func GetPublicSettings(c *gin.Context) {
p, e := database.GetPublicInfo()
if e != nil {
resp.RespondError(c, 500, e.Error())
return
}
resp.RespondSuccess(c, p)
}
@@ -6,12 +6,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
records "github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/database/tasks"
"github.com/komari-monitor/komari/internal/dbcore"
)
func GetRecordsByUUID(c *gin.Context) {
@@ -37,14 +37,14 @@ func GetRecordsByUUID(c *gin.Context) {
}
if hiddenMap[uuid] {
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
resp.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
resp.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
@@ -53,7 +53,7 @@ func GetRecordsByUUID(c *gin.Context) {
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
resp.RespondError(c, 400, "Invalid hours parameter")
return
}
@@ -65,13 +65,13 @@ func GetRecordsByUUID(c *gin.Context) {
}
if !validLoadTypes[loadType] {
api.RespondError(c, 400, "Invalid load_type parameter")
resp.RespondError(c, 400, "Invalid load_type parameter")
return
}
clientRecords, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
resp.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
@@ -125,19 +125,19 @@ func GetRecordsByUUID(c *gin.Context) {
}
}
api.RespondSuccess(c, response)
resp.RespondSuccess(c, response)
}
// filterRecordsByLoadType 根据 load_type 过滤记录,只返回相关字段
func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
filteredRecords := make([]gin.H, 0, len(records))
for _, r := range records {
record := gin.H{
"client": r.Client,
"time": r.Time,
}
switch loadType {
case "cpu":
record["cpu"] = r.Cpu
@@ -177,10 +177,10 @@ func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
record["connections_udp"] = r.ConnectionsUdp
record["connections_tcp"] = r.Connections - r.ConnectionsUdp
}
filteredRecords = append(filteredRecords, record)
}
return filteredRecords
}
@@ -195,7 +195,7 @@ func GetPingRecords(c *gin.Context) {
// 必须提供 uuid 或 task_id 其中至少一个
if uuid == "" && taskIdStr == "" {
api.RespondError(c, 400, "UUID or task_id is required")
resp.RespondError(c, 400, "UUID or task_id is required")
return
}
@@ -242,7 +242,7 @@ func GetPingRecords(c *gin.Context) {
}
if uuid != "" {
if hiddenMap[uuid] {
api.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
resp.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
return
}
}
@@ -267,7 +267,7 @@ func GetPingRecords(c *gin.Context) {
if taskIdStr != "" {
taskId, err = strconv.Atoi(taskIdStr)
if err != nil {
api.RespondError(c, 400, "Invalid task_id parameter")
resp.RespondError(c, 400, "Invalid task_id parameter")
return
}
}
@@ -275,7 +275,7 @@ func GetPingRecords(c *gin.Context) {
// 查询记录,现在支持 uuid + task_id 组合查询
records, err = tasks.GetPingRecords(uuid, taskId, startTime, endTime)
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
resp.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
return
}
@@ -341,7 +341,7 @@ func GetPingRecords(c *gin.Context) {
Max: stats.max,
})
}
// 如果同时指定了 uuid 和 task_id,BasicInfo 应该只有一条记录
// 这种情况下可以在响应中标记查询模式
if uuid != "" && taskId != -1 && len(response.BasicInfo) == 1 {
@@ -357,7 +357,7 @@ func GetPingRecords(c *gin.Context) {
// 获取所有 pingTasks
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
resp.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
@@ -396,7 +396,7 @@ func GetPingRecords(c *gin.Context) {
if uuid != "" && r.Client != uuid {
continue
}
totalCount++
if r.Value < 0 {
lossCount++
@@ -431,17 +431,17 @@ func GetPingRecords(c *gin.Context) {
"avg": avgLatency,
"total": totalCount,
}
// 如果是仅 task_id 查询,添加客户端列表信息
if uuid == "" && taskId != -1 {
taskInfo["clients"] = t.Clients
}
tasksList = append(tasksList, taskInfo)
}
response.Tasks = tasksList
}
response.Count = len(response.Records) // 计算最后结果保持计数一致
api.RespondSuccess(c, response)
resp.RespondSuccess(c, response)
}
+40
View File
@@ -0,0 +1,40 @@
package resp
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/conf"
)
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Respond sends a standardized JSON response.
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
}
// RespondSuccess sends a success response with data.
func RespondSuccess(c *gin.Context, data interface{}) {
Respond(c, http.StatusOK, "success", "", data)
}
// RespondSuccessMessage sends a success response with message and data.
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
Respond(c, http.StatusOK, "success", message, data)
}
// RespondError sends an error response with message.
func RespondError(c *gin.Context, httpStatus int, message string) {
Respond(c, httpStatus, "error", message, nil)
}
func GetVersion(c *gin.Context) {
RespondSuccess(c, gin.H{
"version": conf.Version,
"hash": conf.CommitHash,
})
}
@@ -4,8 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/api_v1/resp"
"github.com/komari-monitor/komari/internal/database/tasks"
)
type PublicPingTask struct {
@@ -19,7 +19,7 @@ type PublicPingTask struct {
func GetPublicPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
resp.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
@@ -34,5 +34,5 @@ func GetPublicPingTasks(c *gin.Context) {
}
}
api.RespondSuccess(c, publicTasks)
}
resp.RespondSuccess(c, publicTasks)
}
@@ -1,18 +1,32 @@
package api
package terminal
import (
"sync"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
"github.com/gin-gonic/gin"
clients "github.com/komari-monitor/komari/internal/client"
"github.com/komari-monitor/komari/internal/database/auditlog"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/utils"
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
RequesterIp string
}
var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func RequestTerminal(c *gin.Context) {
uuid := c.Param("uuid")
user_uuid, _ := c.Get("uuid")
+11
View File
@@ -0,0 +1,11 @@
package vars
import (
"time"
"github.com/patrickmn/go-cache"
)
var (
Records = cache.New(1*time.Minute, 1*time.Minute)
)
+9 -9
View File
@@ -1,4 +1,4 @@
package api
package api_v1
import (
"net/http"
@@ -6,12 +6,12 @@ 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/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/ws"
)
func GetClients(c *gin.Context) {
@@ -20,10 +20,10 @@ func GetClients(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
AllowCors, _ := config.GetAs[bool](config.AllowCorsKey, false)
cfg, _ := conf.GetWithV1Format()
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
if AllowCors {
if cfg.AllowCors {
return true
}
return ws.CheckOrigin(r)
+287
View File
@@ -0,0 +1,287 @@
package app
import (
"context"
"fmt"
"os"
"os/signal"
"sort"
"syscall"
"time"
)
type Hooks struct {
Init any
Start any
Stop any
}
// Module is the single, explicit lifecycle unit.
//
// - Provide registers DI providers (optional; return nil if none).
// - Hooks returns optional hook functions; dependencies are expressed as
// parameters and injected by the container.
//
// Hook function shapes:
//
// func(context.Context, ...deps) error
// func(...deps) error
// func(context.Context, ...deps)
// func(...deps)
type Module interface {
Name() string
Depends() []string
Provide(r Registry) error
Hooks() Hooks
}
type App struct {
container *container
shutdownTimeout time.Duration
started bool
regErr error
modules map[string]Module
moduleList []Module
order []string
startedOrder []string
}
func New() *App {
return &App{container: newContainer("root"), shutdownTimeout: 5 * time.Second, modules: make(map[string]Module)}
}
func (a *App) With(mod Module) *App {
if a.regErr != nil {
return a
}
if a.container == nil {
a.container = newContainer("root")
}
a.regErr = a.register(mod)
if a.regErr == nil {
a.moduleList = append(a.moduleList, mod)
a.ensureDeps(mod, map[string]bool{})
}
return a
}
func (a *App) WithShutdownTimeout(d time.Duration) *App {
if d > 0 {
a.shutdownTimeout = d
}
return a
}
func (a *App) Start(ctx context.Context) error {
if a.regErr != nil {
return a.regErr
}
if a.container == nil {
a.container = newContainer("root")
}
if ctx == nil {
ctx = context.Background()
}
ctx = withContainerInContext(ctx, a.container)
// Register providers before hooks.
for _, mod := range a.moduleList {
if err := mod.Provide(a.container); err != nil {
return fmt.Errorf("provide module %s: %w", mod.Name(), err)
}
}
order, err := a.resolveOrder()
if err != nil {
return err
}
// Init hooks.
for _, name := range order {
h := a.modules[name].Hooks()
if h.Init == nil {
continue
}
if err := a.container.Invoke(ctx, h.Init); err != nil {
return fmt.Errorf("init module %s: %w", name, err)
}
}
// Start hooks.
a.startedOrder = a.startedOrder[:0]
for _, name := range order {
h := a.modules[name].Hooks()
if h.Start == nil {
a.startedOrder = append(a.startedOrder, name)
continue
}
if err := a.container.Invoke(ctx, h.Start); err != nil {
_ = a.stopHooks(ctx)
return fmt.Errorf("start module %s: %w", name, err)
}
a.startedOrder = append(a.startedOrder, name)
}
a.started = true
return nil
}
func (a *App) Stop(ctx context.Context) error {
if !a.started {
return nil
}
stopCtx := ctx
cancel := func() {}
if stopCtx == nil {
stopCtx, cancel = context.WithTimeout(context.Background(), a.shutdownTimeout)
} else {
stopCtx, cancel = context.WithTimeout(stopCtx, a.shutdownTimeout)
}
defer cancel()
if stopCtx == nil {
stopCtx = context.Background()
}
stopCtx = withContainerInContext(stopCtx, a.container)
err := a.stopHooks(stopCtx)
_ = a.container.Shutdown(stopCtx)
a.started = false
return err
}
// RunWith starts the app, executes fn, then stops the app.
func (a *App) RunWith(ctx context.Context, fn func(context.Context) error) error {
if ctx == nil {
ctx = context.Background()
}
ctx = withContainerInContext(ctx, a.container)
if err := a.Start(ctx); err != nil {
_ = a.Stop(context.Background())
return err
}
defer func() { _ = a.Stop(context.Background()) }()
if fn == nil {
return nil
}
return fn(ctx)
}
// RunUntilSignal starts the app and blocks until ctx cancellation or SIGINT/SIGTERM.
func (a *App) RunUntilSignal(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
ctx = withContainerInContext(ctx, a.container)
if err := a.Start(ctx); err != nil {
_ = a.Stop(context.Background())
return err
}
defer func() { _ = a.Stop(context.Background()) }()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case <-ctx.Done():
return ctx.Err()
case <-quit:
return nil
}
}
func (a *App) register(mod Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
name := mod.Name()
if name == "" {
return fmt.Errorf("module name is empty")
}
if _, exists := a.modules[name]; exists {
return fmt.Errorf("duplicate module name: %s", name)
}
a.modules[name] = mod
a.order = nil
return nil
}
func (a *App) has(name string) bool {
_, ok := a.modules[name]
return ok
}
func (a *App) resolveOrder() ([]string, error) {
if a.order != nil {
return a.order, nil
}
inDegree := make(map[string]int, len(a.modules))
deps := make(map[string][]string, len(a.modules))
reverse := make(map[string][]string, len(a.modules))
for name, mod := range a.modules {
inDegree[name] = 0
deps[name] = append([]string(nil), mod.Depends()...)
}
for name := range a.modules {
for _, dep := range deps[name] {
if _, ok := a.modules[dep]; !ok {
return nil, fmt.Errorf("module %s depends on missing module %s", name, dep)
}
inDegree[name]++
reverse[dep] = append(reverse[dep], name)
}
}
q := make([]string, 0, len(a.modules))
for name, d := range inDegree {
if d == 0 {
q = append(q, name)
}
}
sort.Strings(q)
order := make([]string, 0, len(a.modules))
for len(q) > 0 {
n := q[0]
q = q[1:]
order = append(order, n)
for _, child := range reverse[n] {
inDegree[child]--
if inDegree[child] == 0 {
q = append(q, child)
}
}
sort.Strings(q)
}
if len(order) != len(a.modules) {
remaining := make([]string, 0)
for name, d := range inDegree {
if d != 0 {
remaining = append(remaining, name)
}
}
sort.Strings(remaining)
return nil, fmt.Errorf("dependency cycle detected among: %v", remaining)
}
a.order = order
return order, nil
}
func (a *App) stopHooks(ctx context.Context) error {
var firstErr error
for i := len(a.startedOrder) - 1; i >= 0; i-- {
name := a.startedOrder[i]
h := a.modules[name].Hooks()
if h.Stop == nil {
continue
}
if err := a.container.Invoke(ctx, h.Stop); err != nil && firstErr == nil {
firstErr = fmt.Errorf("stop module %s: %w", name, err)
}
}
a.startedOrder = a.startedOrder[:0]
return firstErr
}
+355
View File
@@ -0,0 +1,355 @@
package app
import (
"context"
"fmt"
"reflect"
"sync"
)
type containerCtxKey struct{}
func withContainerInContext(ctx context.Context, c *container) context.Context {
if ctx == nil {
ctx = context.Background()
}
if c == nil {
return ctx
}
return context.WithValue(ctx, containerCtxKey{}, c)
}
func containerFromContext(ctx context.Context) (*container, bool) {
if ctx == nil {
return nil, false
}
c, ok := ctx.Value(containerCtxKey{}).(*container)
return c, ok && c != nil
}
// Resolve is an explicit escape hatch for the rare cases where passing
// dependencies via hook parameters is not ergonomic.
//
// Prefer declaring dependencies on hook function parameters instead.
func Resolve[T any](ctx context.Context) (T, error) {
var zero T
c, ok := containerFromContext(ctx)
if !ok {
return zero, fmt.Errorf("no container in context")
}
t := reflect.TypeOf((*T)(nil)).Elem()
v, err := c.resolve(ctx, key{t: t})
if err != nil {
return zero, err
}
return v.Interface().(T), nil
}
// ResolveNamed resolves a named registration from ctx.
func ResolveNamed[T any](ctx context.Context, name string) (T, error) {
var zero T
c, ok := containerFromContext(ctx)
if !ok {
return zero, fmt.Errorf("no container in context")
}
t := reflect.TypeOf((*T)(nil)).Elem()
v, err := c.resolve(ctx, key{t: t, name: name})
if err != nil {
return zero, err
}
return v.Interface().(T), nil
}
// Registry is the minimal surface a module needs to register providers.
//
// It intentionally does NOT expose resolution, so modules can't turn DI into a
// service locator.
type Registry interface {
Provide(fn any, opts ...ProvideOption) error
}
type key struct {
t reflect.Type
name string
}
// container is a lightweight DI container with optional named registrations and scopes.
//
// It supports:
// - Singleton instances per-scope.
// - Providers declared as functions with injectable parameters.
// - Scopes for plugin hot-plug (child container inherits parent providers/values).
// - Optional shutdown hooks for providers (for scope disposal).
//
// Provider function shapes:
// - func(...) T
// - func(...) (T, error)
// - func(...) (T, func(context.Context) error)
// - func(...) (T, func(context.Context) error, error)
//
// Injectable parameters:
// - context.Context: passed through from Invoke.
// - any provided type (resolved recursively).
//
// Notes:
// - For multiple implementations, use Named registrations.
// - Cycles are detected during resolution.
type container struct {
name string
parent *container
mu sync.Mutex
values map[key]reflect.Value
providers map[key]provider
resolving map[key]bool
closers []func(context.Context) error
closed bool
}
type provider struct {
fn reflect.Value
outKey key
hasErr bool
hasCloser bool
closerIndex int
}
func newContainer(name string) *container {
return &container{
name: name,
values: make(map[key]reflect.Value),
providers: make(map[key]provider),
resolving: make(map[key]bool),
}
}
type ProvideOption func(*provideOptions)
type provideOptions struct {
name string
}
func Named(name string) ProvideOption {
return func(o *provideOptions) { o.name = name }
}
func (c *container) Provide(fn any, opts ...ProvideOption) error {
if fn == nil {
return fmt.Errorf("provider is nil")
}
var o provideOptions
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
v := reflect.ValueOf(fn)
t := v.Type()
if t.Kind() != reflect.Func {
return fmt.Errorf("provider must be a func, got %s", t.Kind())
}
if t.NumOut() < 1 || t.NumOut() > 3 {
return fmt.Errorf("provider must return 1..3 values, got %d", t.NumOut())
}
outT := t.Out(0)
p := provider{fn: v, outKey: key{t: outT, name: o.name}, closerIndex: -1}
switch t.NumOut() {
case 1:
// T
case 2:
// (T, error) or (T, closer)
if isErrorType(t.Out(1)) {
p.hasErr = true
} else if isCloserType(t.Out(1)) {
p.hasCloser = true
p.closerIndex = 1
} else {
return fmt.Errorf("provider second return must be error or func(context.Context) error")
}
case 3:
// (T, closer, error)
if !isCloserType(t.Out(1)) || !isErrorType(t.Out(2)) {
return fmt.Errorf("provider must return (T, func(context.Context) error, error)")
}
p.hasCloser = true
p.closerIndex = 1
p.hasErr = true
}
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return fmt.Errorf("container %s is closed", c.name)
}
if _, exists := c.providers[p.outKey]; exists {
return fmt.Errorf("duplicate provider for %s (name=%q)", outT, o.name)
}
c.providers[p.outKey] = p
return nil
}
func (c *container) Invoke(ctx context.Context, fn any) error {
if fn == nil {
return fmt.Errorf("invoke fn is nil")
}
v := reflect.ValueOf(fn)
if v.Kind() != reflect.Func {
return fmt.Errorf("invoke target must be func")
}
args, err := c.buildArgs(ctx, v.Type())
if err != nil {
return err
}
outs := v.Call(args)
if len(outs) == 0 {
return nil
}
last := outs[len(outs)-1]
if isErrorType(last.Type()) && !last.IsNil() {
return last.Interface().(error)
}
return nil
}
func (c *container) Shutdown(ctx context.Context) error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
closers := append([]func(context.Context) error(nil), c.closers...)
c.closed = true
c.mu.Unlock()
var firstErr error
for i := len(closers) - 1; i >= 0; i-- {
if err := closers[i](ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (c *container) resolve(ctx context.Context, k key) (reflect.Value, error) {
// Fast path: check local cache.
c.mu.Lock()
if v, ok := c.values[k]; ok {
c.mu.Unlock()
return v, nil
}
if c.resolving[k] {
c.mu.Unlock()
return reflect.Value{}, fmt.Errorf("dependency cycle resolving %s (name=%q)", k.t, k.name)
}
p, ok := c.providers[k]
if !ok {
parent := c.parent
c.mu.Unlock()
if parent != nil {
return parent.resolve(ctx, k)
}
return reflect.Value{}, fmt.Errorf("no provider for %s (name=%q)", k.t, k.name)
}
c.resolving[k] = true
c.mu.Unlock()
// Build args and call provider.
args, err := c.buildArgs(ctx, p.fn.Type())
if err != nil {
c.mu.Lock()
delete(c.resolving, k)
c.mu.Unlock()
return reflect.Value{}, err
}
outs := p.fn.Call(args)
var outV reflect.Value
var closerFn reflect.Value
var errV reflect.Value
switch len(outs) {
case 1:
outV = outs[0]
case 2:
outV = outs[0]
if p.hasErr {
errV = outs[1]
} else if p.hasCloser {
closerFn = outs[1]
}
case 3:
outV = outs[0]
closerFn = outs[1]
errV = outs[2]
}
if p.hasErr && !errV.IsNil() {
err := errV.Interface().(error)
c.mu.Lock()
delete(c.resolving, k)
c.mu.Unlock()
return reflect.Value{}, err
}
c.mu.Lock()
delete(c.resolving, k)
// Another goroutine could have resolved it while we were building; keep singleton semantics.
if existing, ok := c.values[k]; ok {
c.mu.Unlock()
return existing, nil
}
c.values[k] = outV
if p.hasCloser {
cf := closerFn
if cf.IsValid() && !cf.IsNil() {
c.closers = append(c.closers, func(ctx context.Context) error {
outs := cf.Call([]reflect.Value{reflect.ValueOf(ctx)})
if len(outs) == 1 && !outs[0].IsNil() {
return outs[0].Interface().(error)
}
return nil
})
}
}
c.mu.Unlock()
return outV, nil
}
func (c *container) buildArgs(ctx context.Context, fnType reflect.Type) ([]reflect.Value, error) {
in := fnType.NumIn()
args := make([]reflect.Value, 0, in)
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
for i := 0; i < in; i++ {
paramT := fnType.In(i)
switch {
case paramT == ctxType:
args = append(args, reflect.ValueOf(ctx))
default:
v, err := c.resolve(ctx, key{t: paramT})
if err != nil {
return nil, err
}
args = append(args, v)
}
}
return args, nil
}
func isErrorType(t reflect.Type) bool {
errT := reflect.TypeOf((*error)(nil)).Elem()
return t == errT
}
func isCloserType(t reflect.Type) bool {
closerT := reflect.TypeOf((func(context.Context) error)(nil))
return t == closerT
}
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"fmt"
"sync"
)
var (
factoryMu sync.RWMutex
factories = make(map[string]func() Module)
)
// RegisterModuleFactory registers a factory used by App to auto-materialize missing dependencies.
// Intended for core modules (config/db) and plugin-provided modules.
func RegisterModuleFactory(name string, factory func() Module) {
if name == "" || factory == nil {
return
}
factoryMu.Lock()
factories[name] = factory
factoryMu.Unlock()
}
func getModuleFactory(name string) (func() Module, bool) {
factoryMu.RLock()
f, ok := factories[name]
factoryMu.RUnlock()
return f, ok
}
func (a *App) ensureDeps(mod Module, visiting map[string]bool) {
if a.regErr != nil {
return
}
if mod == nil {
a.regErr = fmt.Errorf("module is nil")
return
}
name := mod.Name()
if visiting[name] {
a.regErr = fmt.Errorf("dependency cycle while materializing: %s", name)
return
}
visiting[name] = true
defer func() { visiting[name] = false }()
for _, dep := range mod.Depends() {
if dep == "" {
continue
}
if a.has(dep) {
continue
}
f, ok := getModuleFactory(dep)
if !ok {
a.regErr = fmt.Errorf("missing module %q (no factory registered)", dep)
return
}
depMod := f()
if depMod == nil {
a.regErr = fmt.Errorf("module factory for %q returned nil", dep)
return
}
// Register dependency module and its dependencies recursively.
a.regErr = a.register(depMod)
if a.regErr != nil {
return
}
a.moduleList = append(a.moduleList, depMod)
a.ensureDeps(depMod, visiting)
if a.regErr != nil {
return
}
}
}
+178
View File
@@ -0,0 +1,178 @@
package client
import (
"math"
"time"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/pkg/utils"
"fmt"
"github.com/google/uuid"
)
func DeleteClient(clientUuid string) error {
db := dbcore.GetDBInstance()
c := models.Client{}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUuid).First(&c).Error
if err != nil {
return err
}
err = db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
if err != nil {
return err
}
event.Trigger(eventType.ClientDeleted, event.M{
"client": c})
return nil
}
func PartialUpdate(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
if !ok || clientUUID == "" {
return fmt.Errorf("invalid client UUID")
}
// 确保更新的字段不为空
if len(update) == 0 {
return fmt.Errorf("no fields to update")
}
c := models.Client{}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).First(&c).Error
if err != nil {
return err
}
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
return nil
}
verify := func(update map[string]interface{}) error {
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
err = db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
new_c := models.Client{}
err = db.Model(&models.Client{}).Where("uuid = ?", clientUUID).First(&new_c).Error
if err != nil {
return err
}
event.Trigger(eventType.ClientUpdated, event.M{
"old": c,
"new": new_c,
})
return nil
}
// CreateClient 创建新客户端
func CreateClient() (clientUUID, token string, err error) {
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
event.Trigger(eventType.ClientCreated, event.M{
"client": client,
})
return clientUUID, token, nil
}
func CreateClientWithName(name string) (clientUUID, token string, err error) {
if name == "" {
return CreateClient()
}
db := dbcore.GetDBInstance()
token = utils.GenerateToken()
clientUUID = uuid.New().String()
client := models.Client{
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
event.Trigger(eventType.ClientCreated, event.M{
"client": client,
})
return clientUUID, token, nil
}
func GetClientByUUID(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return models.Client{}, err
}
return client, nil
}
func GetClientTokenByUUID(uuid string) (token string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return "", err
}
return client.Token, nil
}
func GetAllClientBasicInfo() (clients []models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
@@ -1,35 +1,14 @@
package clients
package client
import (
"encoding/json"
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
"github.com/komari-monitor/komari/internal/common"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
)
// Report 表示客户端报告数据
// SaveReport 保存报告数据
func SaveReport(uuid string, data map[string]interface{}) (err error) {
report, err := ParseReport(data)
if err != nil {
return err
}
err = SaveClientReport(uuid, report)
if err != nil {
return err
}
return nil
}
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
@@ -40,18 +19,6 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
return client.UUID, nil
}
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
jsonData, err := json.Marshal(data)
if err != nil {
return common.Report{}, err
}
err = json.Unmarshal(jsonData, &report)
if err != nil {
return common.Report{}, err
}
return report, nil
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report common.Report) error {
// 防止输入不合理范围
@@ -125,7 +92,7 @@ func ReportVerify(report common.Report) error {
}
// 拒绝所有负数Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
return fmt.Errorf("process must be non-negative: %d", report.Process)
}
if report.Connections.TCP < 0 {
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
@@ -136,78 +103,78 @@ func ReportVerify(report common.Report) error {
return nil
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report common.Report) (err error) {
db := dbcore.GetDBInstance()
// // SaveClientReport 保存客户端报告到 Record 表
// func SaveClientReport(clientUUID string, report common.Report) (err error) {
// db := dbcore.GetDBInstance()
if err := ReportVerify(report); err != nil {
return fmt.Errorf("failed to save Record: %v", err)
}
// if err := ReportVerify(report); err != nil {
// return fmt.Errorf("failed to save Record: %v", err)
// }
// 保存GPU详细记录到独立表
currentTime := time.Now()
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
for idx, gpu := range report.GPU.DetailedInfo {
gpuRecord := models.GPURecord{
Client: clientUUID,
Time: models.FromTime(currentTime),
DeviceIndex: idx,
DeviceName: gpu.Name,
MemTotal: gpu.MemoryTotal,
MemUsed: gpu.MemoryUsed,
Utilization: float32(gpu.Utilization),
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %v", err)
}
}
}
// // 保存GPU详细记录到独立表
// currentTime := time.Now()
// if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
// for idx, gpu := range report.GPU.DetailedInfo {
// gpuRecord := models.GPURecord{
// Client: clientUUID,
// Time: models.FromTime(currentTime),
// DeviceIndex: idx,
// DeviceName: gpu.Name,
// MemTotal: gpu.MemoryTotal,
// MemUsed: gpu.MemoryUsed,
// Utilization: float32(gpu.Utilization),
// Temperature: gpu.Temperature,
// }
// if err := db.Create(&gpuRecord).Error; err != nil {
// return fmt.Errorf("failed to save GPU record: %v", err)
// }
// }
// }
// 计算平均GPU使用率,用于向后兼容
averageGPUUsage := float32(0)
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
averageGPUUsage = float32(report.GPU.AverageUsage)
}
// // 计算平均GPU使用率,用于向后兼容
// averageGPUUsage := float32(0)
// if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
// averageGPUUsage = float32(report.GPU.AverageUsage)
// }
Record := models.Record{
Client: clientUUID,
Time: models.FromTime(currentTime),
Cpu: float32(report.CPU.Usage),
Gpu: averageGPUUsage, // 使用平均GPU使用率
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
SwapTotal: report.Swap.Total,
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
Disk: report.Disk.Used,
DiskTotal: report.Disk.Total,
NetIn: report.Network.Down,
NetOut: report.Network.Up,
NetTotalUp: report.Network.TotalUp,
NetTotalDown: report.Network.TotalDown,
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
//Uptime: report.Uptime,
}
// Record := models.Record{
// Client: clientUUID,
// Time: models.FromTime(currentTime),
// Cpu: float32(report.CPU.Usage),
// Gpu: averageGPUUsage, // 使用平均GPU使用率
// Ram: report.Ram.Used,
// RamTotal: report.Ram.Total,
// Swap: report.Swap.Used,
// SwapTotal: report.Swap.Total,
// Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
// Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
// Disk: report.Disk.Used,
// DiskTotal: report.Disk.Total,
// NetIn: report.Network.Down,
// NetOut: report.Network.Up,
// NetTotalUp: report.Network.TotalUp,
// NetTotalDown: report.Network.TotalDown,
// Process: report.Process,
// Connections: report.Connections.TCP,
// ConnectionsUdp: report.Connections.UDP,
// //Uptime: report.Uptime,
// }
// 使用事务确保 Record 和 ClientsInfo 一致性
err = db.Transaction(func(tx *gorm.DB) error {
// 保存 Record
if err := tx.Create(&Record).Error; err != nil {
return fmt.Errorf("failed to save Record: %v", err)
}
return nil
})
// // 使用事务确保 Record 和 ClientsInfo 一致性
// err = db.Transaction(func(tx *gorm.DB) error {
// // 保存 Record
// if err := tx.Create(&Record).Error; err != nil {
// return fmt.Errorf("failed to save Record: %v", err)
// }
// return nil
// })
if err != nil {
return err
}
// if err != nil {
// return err
// }
return nil
}
// return nil
// }
/*
// getString 从 map 中获取字符串
File renamed without changes.
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/internal/cloudflared"
)
func TestRunCloudflared(t *testing.T) {
+28
View File
@@ -0,0 +1,28 @@
package cloudflared
import (
"fmt"
"os"
"strings"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/eventType"
)
func init() {
event.On(eventType.ServerInitializeStart, event.ListenerFunc(func(e event.Event) error {
if strings.ToLower(strings.ToLower(os.Getenv("KOMARI_ENABLE_CLOUDFLARED"))) == "true" {
err := RunCloudflared()
if err != nil {
// Error in ServerInitializeStart will cause the process to exit
return fmt.Errorf("failed to run cloudflared: %v", err)
}
}
return nil
}))
event.On(eventType.ProcessExit, event.ListenerFunc(func(e event.Event) error {
Kill()
return nil
}))
}
@@ -86,18 +86,18 @@ type CPUReport struct {
// GPUDetailReport 详细GPU信息报告
type GPUDetailReport struct {
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
}
// GPUDeviceInfo 单个GPU设备信息
type GPUDeviceInfo struct {
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// 保持向后兼容的旧GPUReport结构
+280
View File
@@ -0,0 +1,280 @@
package conf
import (
"encoding/json"
"fmt"
"os"
"log/slog"
"github.com/gookit/event"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/internal/eventType"
)
// 返回默认配置对象
func Default() Config {
return Config{
Site: Site{
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
Theme: "default",
},
GeoIp: GeoIp{
GeoIpEnabled: true,
GeoIpProvider: GeoIp_IPInfo,
},
Notification: Notification{
NotificationEnabled: true,
TrafficLimitPercentage: 80.00,
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\n{{event}}\n{{client}}\n{{message}}\n{{time}}",
NotificationMethod: "empty",
ExpireNotificationEnabled: true,
ExpireNotificationLeadDays: 7,
LoginNotification: true,
},
Login: Login{
OAuthEnabled: false,
OAuthProvider: "github",
},
Record: Record{
RecordEnabled: true,
RecordPreserveTime: 720,
PingRecordPreserveTime: 24,
},
Database: Database{
DatabaseType: "sqlite",
DatabaseFile: "./data/komari.db",
},
Listen: "0.0.0.0:25774",
Extensions: map[string]interface{}{},
}
}
// 强制覆盖当前内存中的配置并写入配置文件
func Override(cst Config) error {
ensureExtensionsDefaults(&cst)
b, err := json.MarshalIndent(cst, "", " ")
if err != nil {
return err
}
oldConf := *Conf
Conf = &cst
err, _ = event.Trigger(eventType.ConfigUpdated, event.M{
"old": oldConf,
"new": cst,
})
if err != nil {
// 撤回配置修改
Conf = &oldConf
b, _ = json.MarshalIndent(oldConf, "", " ")
//TODO: 循环引用,解耦dbcore与其他数据库模块
//auditlog.EventLog("error", fmt.Sprintf("Configuration update reverted due to error in ConfigUpdated event: %v", err))
slog.Error("Configuration update reverted due to error in ConfigUpdated event.", slog.Any("error", err))
}
if err := os.WriteFile(flags.ConfigFile, b, 0644); err != nil {
return err
}
return err
}
// 保存部分配置更改到文件,会对V1的旧字段进行转换
func SavePartial(cst map[string]interface{}) error {
// 将当前内存中的配置转换为通用 map,便于合并
baseBytes, err := json.Marshal(Conf)
if err != nil {
return err
}
var base map[string]interface{}
if err := json.Unmarshal(baseBytes, &base); err != nil {
return err
}
// 兼容旧版扁平字段:把扁平键映射到新版分组结构
normalized := normalizePartialMap(cst)
// 深度合并(normalized 覆盖 base)
merged := deepMerge(base, normalized)
// 回写到强类型 Config
mergedBytes, err := json.Marshal(merged)
if err != nil {
return err
}
var newConf Config
if err := json.Unmarshal(mergedBytes, &newConf); err != nil {
return err
}
// 更新内存并落盘
return Override(newConf)
}
func EditAndTrigger(fn func()) error {
oldConf := *Conf
fn()
event.Trigger(eventType.ConfigUpdated, event.M{
"old": oldConf,
"new": *Conf,
})
return nil
}
// 保存完整配置到文件 同Override
func SaveFull(cst Config) error {
return Override(cst)
}
// 以 v1 API 格式获取配置对象,使用 Conf 直接获取对象引用
func GetWithV1Format() (V1Struct, error) {
return Conf.ToV1Format(), nil
}
// 以 v1 API 格式保存配置对象
func Save(cst V1Struct) error {
cfg := cst.ToConfig()
return Override(cfg)
}
// 语义等同于 SavePartial,保持对旧数据格式兼容
func Update(cst map[string]interface{}) error {
// Update 的语义等同于 SavePartial,保持对旧数据格式兼容
return SavePartial(cst)
}
// 将V1字段的输入映射为Config结构。
// 包含 site/login/... 等嵌套结构,则原样保留并与映射结果合并。
func normalizePartialMap(in map[string]interface{}) map[string]interface{} {
if in == nil {
return map[string]interface{}{}
}
// 先复制一份,避免修改入参
out := make(map[string]interface{})
for k, v := range in {
out[k] = v
}
// 准备确保分组 map 存在
ensureGroup := func(name string) map[string]interface{} {
v, ok := out[name]
if !ok || v == nil {
m := map[string]interface{}{}
out[name] = m
return m
}
if m, ok := v.(map[string]interface{}); ok {
return m
}
// 若类型非 map,则覆盖为 map
m := map[string]interface{}{}
out[name] = m
return m
}
site := ensureGroup("site")
login := ensureGroup("login")
geo := ensureGroup("geo_ip")
notif := ensureGroup("notification")
record := ensureGroup("record")
extensions := ensureGroup("extensions")
// 扁平 -> 分组字段映射表
move := func(flatKey string, group map[string]interface{}, groupKey string) {
if v, ok := out[flatKey]; ok {
group[groupKey] = v
delete(out, flatKey)
}
}
// Site
move("sitename", site, "sitename")
move("description", site, "description")
move("allow_cors", site, "allow_cors")
move("theme", site, "theme")
move("private_site", site, "private_site")
move("script_domain", site, "script_domain")
move("send_ip_addr_to_guest", site, "send_ip_addr_to_guest")
move("eula_accepted", site, "eula_accepted")
move("custom_head", site, "custom_head")
move("custom_body", site, "custom_body")
// Login
move("api_key", login, "api_key")
move("auto_discovery_key", login, "auto_discovery_key")
move("o_auth_enabled", login, "o_auth_enabled")
move("o_auth_provider", login, "o_auth_provider")
move("disable_password_login", login, "disable_password_login")
// GeoIP
move("geo_ip_enabled", geo, "geo_ip_enabled")
move("geo_ip_provider", geo, "geo_ip_provider")
// Notification
move("notification_enabled", notif, "notification_enabled")
move("notification_method", notif, "notification_method")
move("notification_template", notif, "notification_template")
move("expire_notification_enabled", notif, "expire_notification_enabled")
move("expire_notification_lead_days", notif, "expire_notification_lead_days")
move("login_notification", notif, "login_notification")
move("traffic_limit_percentage", notif, "traffic_limit_percentage")
// Record
move("record_enabled", record, "record_enabled")
move("record_preserve_time", record, "record_preserve_time")
move("ping_record_preserve_time", record, "ping_record_preserve_time")
// Nezha 兼容字段
nezha := map[string]interface{}{}
move("nezha_compat_enabled", nezha, "nezha_compat_enabled")
move("nezha_compat_listen", nezha, "nezha_compat_listen")
extensions["nezha"] = nezha
return out
}
// = 以src更新dst,返回合并结果
func deepMerge(dst, src map[string]interface{}) map[string]interface{} {
if dst == nil {
dst = map[string]interface{}{}
}
for k, v := range src {
if v == nil {
// 忽略空覆盖,避免误删值
continue
}
if dv, ok := dst[k]; ok {
dm, dIsMap := dv.(map[string]interface{})
sm, sIsMap := v.(map[string]interface{})
if dIsMap && sIsMap {
dst[k] = deepMerge(dm, sm)
continue
}
}
dst[k] = v
}
return dst
}
// FromEvent 从事件对象中提取旧配置和新配置 returns (old,new,error)。
func FromEvent(e event.Event) (Config, Config, error) {
oldVal := e.Get("old")
newVal := e.Get("new")
oldConf, ok := oldVal.(Config)
if !ok {
return Config{}, Config{}, fmt.Errorf("FromEvent: 'old' key value is not of type Config. Got: %T", oldVal)
}
newConf, ok := newVal.(Config)
if !ok {
return Config{}, Config{}, fmt.Errorf("FromEvent: 'new' key value is not of type Config. Got: %T", newVal)
}
return oldConf, newConf, nil
}
+10
View File
@@ -0,0 +1,10 @@
package conf
const (
GeoIp_MMDB = "mmdb"
GeoIp_IPAPI = "ip-api"
GeoIp_GeoJS = "geojs"
GeoIp_IPInfo = "ipinfo"
GeoIp_Empty = "empty"
Version_Development = "development"
)
+146
View File
@@ -0,0 +1,146 @@
package conf
import (
"io/fs"
"log/slog"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/app"
)
func init() {
app.RegisterModuleFactory(NewConfigModule().Name(), NewConfigModule)
}
// InstallGuideFS 用于存储安装引导页面的静态文件系统,由外部包注入以避免循环引用
var InstallGuideFS fs.FS
func installGuide() {
// 未来支持
/*
listen := flags.Listen
if envListen := os.Getenv("LISTEN"); envListen != "" {
listen = envListen
}
if listen == "" {
listen = "0.0.0.0:25774"
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
if InstallGuideFS == nil {
panic("InstallGuideFS is not set, please inject it before starting the server")
}
// 注册 API handlers
api := r.Group("/api/init")
api.POST("/set_https", handleSetHTTPS)
// 处理所有请求
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
cleanPath := strings.TrimPrefix(path, "/")
file, err := InstallGuideFS.Open(cleanPath)
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
c.Data(http.StatusOK, getContentType(path), data)
return
}
}
// 文件不存在,如果是 GET 请求返回 index.html(SPA 路由)
if c.Request.Method == "GET" && strings.HasPrefix(path, "/init") {
indexFile, err := InstallGuideFS.Open("index.html")
if err == nil {
defer indexFile.Close()
data, err := io.ReadAll(indexFile)
if err == nil {
c.Header("Content-Type", "text/html")
c.Data(http.StatusOK, "text/html", data)
return
}
}
}
// 其他情况返回 404
c.Redirect(302, "/init")
})
// 启动服务器(阻塞)
slog.Info("Starting install guide server...", slog.String("listen", listen))
if err := r.Run(listen); err != nil {
panic("failed to start install guide server: " + err.Error())
}
*/
}
// getContentType 根据路径获取Content-Type
func getContentType(path string) string {
if strings.HasSuffix(path, ".html") {
return "text/html"
}
if strings.HasSuffix(path, ".css") {
return "text/css"
}
if strings.HasSuffix(path, ".js") {
return "application/javascript"
}
if strings.HasSuffix(path, ".json") {
return "application/json"
}
if strings.HasSuffix(path, ".png") {
return "image/png"
}
if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
return "image/jpeg"
}
if strings.HasSuffix(path, ".gif") {
return "image/gif"
}
if strings.HasSuffix(path, ".svg") {
return "image/svg+xml"
}
if strings.HasSuffix(path, ".ico") {
return "image/x-icon"
}
if strings.HasSuffix(path, ".woff") {
return "font/woff"
}
if strings.HasSuffix(path, ".woff2") {
return "font/woff2"
}
if strings.HasSuffix(path, ".ttf") {
return "font/ttf"
}
return "application/octet-stream"
}
// handleSetHTTPS 处理 HTTPS 配置
func handleSetHTTPS(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
CertURL string `json:"cert_url,omitempty"`
KeyURL string `json:"key_url,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: 实现 HTTPS 配置逻辑
slog.Info("Setting HTTPS configuration", slog.Bool("enabled", req.Enabled))
c.JSON(http.StatusOK, gin.H{
"message": "HTTPS configuration updated",
"enabled": req.Enabled,
})
}
+84
View File
@@ -0,0 +1,84 @@
package conf
import (
"encoding/json"
"os"
"github.com/gookit/event"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/internal/app"
"github.com/komari-monitor/komari/internal/eventType"
)
type configModule struct{}
var _ app.Module = (*configModule)(nil)
// NewConfigModule provides the "config" module for the lifecycle app.
//
// It loads configuration and also keeps the legacy global variable Conf updated
// to minimize downstream changes.
func NewConfigModule() app.Module { return &configModule{} }
func (m *configModule) Name() string { return "config" }
func (m *configModule) Depends() []string { return nil }
func (m *configModule) Provide(r app.Registry) error {
return r.Provide(func() (*Config, error) {
var cst *Config
if _, err := os.Stat(flags.ConfigFile); os.IsNotExist(err) {
// --- 初始化流程 ---
installGuide()
t := Default()
cst = &t
// 序列化默认配置
b, err := json.MarshalIndent(cst, "", " ")
if err != nil {
return nil, err
}
// 写入文件
if err := os.WriteFile(flags.ConfigFile, b, 0644); err != nil {
return nil, err
}
} else {
// --- 读取流程 ---
b, err := os.ReadFile(flags.ConfigFile)
if err != nil {
return nil, err
}
cst = &Config{}
if err := json.Unmarshal(b, cst); err != nil {
return nil, err
}
}
ensureExtensionsDefaults(cst)
Conf = cst
return Conf, nil
})
}
func (m *configModule) Hooks() app.Hooks {
return app.Hooks{
Init: func(_ *Config) error { return nil },
Start: func(cfg *Config) {
// Keep legacy behavior: once the HTTP stack is initialized,
// broadcast a ConfigUpdated to populate consumers (e.g. CORS, public index).
event.On(eventType.ServerInitializeDone, event.ListenerFunc(func(e event.Event) error {
event.Trigger(eventType.ConfigUpdated, event.M{
"old": Config{},
"new": *cfg,
})
return nil
}), event.Low)
},
}
}
func init() {
app.RegisterModuleFactory("config", NewConfigModule)
}
+341
View File
@@ -0,0 +1,341 @@
package conf
import (
"encoding/json"
"fmt"
"sync"
)
// CustomFieldProvider 是自定义字段提供者需要实现的接口
type CustomFieldProvider interface {
// Key 返回配置中的唯一键名(JSON 字段名)
Key() string
// Default 返回默认值
Default() interface{}
// Validate 验证配置值是否有效(可选,返回 nil 表示验证通过)
Validate(value interface{}) error
}
// BaseFieldProvider 提供一个基础实现,简化自定义字段的创建
type BaseFieldProvider struct {
key string
defaultValue interface{}
validator func(interface{}) error
}
// NewFieldProvider 创建一个基础字段提供者
func NewFieldProvider(key string, defaultValue interface{}, validator func(interface{}) error) *BaseFieldProvider {
return &BaseFieldProvider{
key: key,
defaultValue: defaultValue,
validator: validator,
}
}
func (p *BaseFieldProvider) Key() string {
return p.key
}
func (p *BaseFieldProvider) Default() interface{} {
return p.defaultValue
}
func (p *BaseFieldProvider) Validate(value interface{}) error {
if p.validator != nil {
return p.validator(value)
}
return nil
}
// registry 存储所有注册的自定义字段提供者(只存储 provider,值存在 Conf.Extensions 中)
var (
registryMu sync.RWMutex
customProviders = make(map[string]CustomFieldProvider)
)
// RegisterField 注册一个自定义字段提供者
// 应在 init() 函数中调用,以确保在配置加载前完成注册
func RegisterField(provider CustomFieldProvider) error {
registryMu.Lock()
defer registryMu.Unlock()
key := provider.Key()
if key == "" {
return fmt.Errorf("custom field key cannot be empty")
}
if _, exists := customProviders[key]; exists {
return fmt.Errorf("custom field '%s' is already registered", key)
}
customProviders[key] = provider
return nil
}
// MustRegisterField 注册一个自定义字段提供者,失败时 panic
func MustRegisterField(provider CustomFieldProvider) {
if err := RegisterField(provider); err != nil {
panic(err)
}
}
// RegisterSimpleField 简化的字段注册,无需实现接口
func RegisterSimpleField(key string, defaultValue interface{}) error {
return RegisterField(NewFieldProvider(key, defaultValue, nil))
}
// MustRegisterSimpleField 简化的字段注册,失败时 panic
func MustRegisterSimpleField(key string, defaultValue interface{}) {
MustRegisterField(NewFieldProvider(key, defaultValue, nil))
}
// GetExtension 获取扩展字段的值
func GetExtension(key string) (interface{}, bool) {
if Conf == nil || Conf.Extensions == nil {
return nil, false
}
val, ok := Conf.Extensions[key]
return val, ok
}
// GetExtensionAs 获取扩展字段并转换为指定类型
// 使用示例: val, ok := GetExtensionAs[MyConfig]("my_module")
func GetExtensionAs[T any](key string) (T, bool) {
val, ok := GetExtension(key)
if !ok {
var zero T
return zero, false
}
// 尝试直接类型断言
if typed, ok := val.(T); ok {
return typed, true
}
// 尝试通过 JSON 反序列化转换
var result T
bytes, err := json.Marshal(val)
if err != nil {
var zero T
return zero, false
}
if err := json.Unmarshal(bytes, &result); err != nil {
var zero T
return zero, false
}
return result, true
}
// SetExtension 设置扩展字段的值(不自动保存)
func SetExtension(key string, value interface{}) error {
registryMu.RLock()
provider, exists := customProviders[key]
registryMu.RUnlock()
if !exists {
return fmt.Errorf("extension field '%s' is not registered", key)
}
if err := provider.Validate(value); err != nil {
return fmt.Errorf("validation failed for field '%s': %w", key, err)
}
if Conf.Extensions == nil {
Conf.Extensions = make(map[string]interface{})
}
Conf.Extensions[key] = value
return nil
}
// SetExtensionAndSave 设置扩展字段的值并保存到文件
func SetExtensionAndSave(key string, value interface{}) error {
if err := SetExtension(key, value); err != nil {
return err
}
return Override(*Conf)
}
// GetAllExtensions 获取所有扩展字段
func GetAllExtensions() map[string]interface{} {
if Conf == nil || Conf.Extensions == nil {
return make(map[string]interface{})
}
result := make(map[string]interface{}, len(Conf.Extensions))
for k, v := range Conf.Extensions {
result[k] = v
}
return result
}
// GetRegisteredKeys 获取所有已注册的字段键名
func GetRegisteredKeys() []string {
registryMu.RLock()
defer registryMu.RUnlock()
keys := make([]string, 0, len(customProviders))
for k := range customProviders {
keys = append(keys, k)
}
return keys
}
// IsFieldRegistered 检查字段是否已注册
func IsFieldRegistered(key string) bool {
registryMu.RLock()
defer registryMu.RUnlock()
_, exists := customProviders[key]
return exists
}
// ResetExtension 重置扩展字段为默认值
func ResetExtension(key string) error {
registryMu.RLock()
provider, exists := customProviders[key]
registryMu.RUnlock()
if !exists {
return fmt.Errorf("extension field '%s' is not registered", key)
}
if Conf.Extensions == nil {
Conf.Extensions = make(map[string]interface{})
}
Conf.Extensions[key] = provider.Default()
return nil
}
// ResetAllExtensions 重置所有扩展字段为默认值
func ResetAllExtensions() {
registryMu.RLock()
defer registryMu.RUnlock()
if Conf.Extensions == nil {
Conf.Extensions = make(map[string]interface{})
}
for key, provider := range customProviders {
Conf.Extensions[key] = provider.Default()
}
}
// ensureExtensionsDefaults 确保所有已注册的扩展字段都有值
func ensureExtensionsDefaults(cfg *Config) {
registryMu.RLock()
defer registryMu.RUnlock()
if cfg.Extensions == nil {
cfg.Extensions = make(map[string]interface{})
}
for key, provider := range customProviders {
if _, exists := cfg.Extensions[key]; !exists {
cfg.Extensions[key] = provider.Default()
}
}
}
// GetExtensionDefault 获取扩展字段的默认值
func GetExtensionDefault(key string) (interface{}, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
provider, exists := customProviders[key]
if !exists {
return nil, false
}
return provider.Default(), true
}
// FromEventExtension 从 ConfigUpdated 事件中获取指定扩展字段的旧值和新值
// 返回 (oldValue, newValue, exists)
func FromEventExtension(e interface{ Get(string) interface{} }, key string) (interface{}, interface{}, bool) {
oldConf, oldOk := e.Get("old").(Config)
newConf, newOk := e.Get("new").(Config)
if !oldOk || !newOk {
return nil, nil, false
}
var oldVal, newVal interface{}
var oldExists, newExists bool
if oldConf.Extensions != nil {
oldVal, oldExists = oldConf.Extensions[key]
}
if newConf.Extensions != nil {
newVal, newExists = newConf.Extensions[key]
}
if !oldExists && !newExists {
return nil, nil, false
}
return oldVal, newVal, true
}
// FromEventExtensionAs 从 ConfigUpdated 事件中获取指定扩展字段的旧值和新值(泛型版本)
// 使用示例: old, new, ok := FromEventExtensionAs[MyConfig](e, "my_module")
func FromEventExtensionAs[T any](e interface{ Get(string) interface{} }, key string) (T, T, bool) {
oldVal, newVal, exists := FromEventExtension(e, key)
if !exists {
var zero T
return zero, zero, false
}
convertTo := func(val interface{}) (T, bool) {
if val == nil {
var zero T
return zero, true
}
if typed, ok := val.(T); ok {
return typed, true
}
// JSON 转换
var result T
bytes, err := json.Marshal(val)
if err != nil {
var zero T
return zero, false
}
if err := json.Unmarshal(bytes, &result); err != nil {
var zero T
return zero, false
}
return result, true
}
oldTyped, oldOk := convertTo(oldVal)
newTyped, newOk := convertTo(newVal)
if !oldOk || !newOk {
var zero T
return zero, zero, false
}
return oldTyped, newTyped, true
}
// ---- 兼容旧 API(已废弃,建议使用新的 Extension 系列函数)----
// GetCustomField 已废弃,请使用 GetExtension
func GetCustomField(key string) (interface{}, bool) {
return GetExtension(key)
}
// GetCustomFieldAs 已废弃,请使用 GetExtensionAs
func GetCustomFieldAs[T any](key string) (T, bool) {
return GetExtensionAs[T](key)
}
// SetCustomField 已废弃,请使用 SetExtension
func SetCustomField(key string, value interface{}) error {
return SetExtension(key, value)
}
// SetCustomFieldAndSave 已废弃,请使用 SetExtensionAndSave
func SetCustomFieldAndSave(key string, value interface{}) error {
return SetExtensionAndSave(key, value)
}
+227
View File
@@ -0,0 +1,227 @@
package conf
import "time"
var (
Version = Version_Development
CommitHash = "unknown"
)
var (
// Conf is the global config for legacy code paths.
// Lifecycle-managed startup overwrites it by calling Boot().
Conf = func() *Config {
c := Default()
return &c
}()
)
type Config struct {
Site Site `json:"site"`
Login Login `json:"login"`
GeoIp GeoIp `json:"geo_ip"`
Notification Notification `json:"notification"`
Record Record `json:"record"`
Listen string `json:"listen"`
Database Database `json:"database"`
Extensions map[string]interface{} `json:"extensions,omitempty"` // 扩展配置,由各模块注册
}
type Site struct {
Sitename string `json:"sitename"`
Description string `json:"description"`
AllowCors bool `json:"allow_cors"`
PrivateSite bool `json:"private_site"` // 是否为私有站点,默认 false
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest"` // 是否向访客页面发送 IP 地址,默认 false
ScriptDomain string `json:"script_domain"` // 自定义脚本域名
EulaAccepted bool `json:"eula_accepted"`
// 自定义美化
CustomHead string `json:"custom_head"`
CustomBody string `json:"custom_body"`
Theme string `json:"theme"` // 主题名称,默认 'default'
}
type Login struct {
ApiKey string `json:"api_key"`
AutoDiscoveryKey string `json:"auto_discovery_key"` // 自动发现密钥
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled"`
OAuthProvider string `json:"o_auth_provider"`
DisablePasswordLogin bool `json:"disable_password_login"`
}
type Database struct {
DatabaseType string `json:"database_type"` // 数据库类型:sqlite, mysql
DatabaseFile string `json:"database_file"` // SQLite数据库文件路径
DatabaseHost string `json:"database_host"` // MySQL/其他数据库主机地址
DatabasePort string `json:"database_port"` // MySQL/其他数据库端口
DatabaseUser string `json:"database_user"` // MySQL/其他数据库用户名
DatabasePass string `json:"database_pass"` // MySQL/其他数据库密码
DatabaseName string `json:"database_name"` // MySQL/其他数据库名称
}
type GeoIp struct {
GeoIpEnabled bool `json:"geo_ip_enabled"`
GeoIpProvider string `json:"geo_ip_provider"` // empty, mmdb, ip-api, geojs
}
type Notification struct {
NotificationEnabled bool `json:"notification_enabled"` // 通知总开关
NotificationMethod string `json:"notification_method"`
NotificationTemplate string `json:"notification_template"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage"` // 流量限制百分比,默认80.00%
}
type Record struct {
RecordEnabled bool `json:"record_enabled"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time"` // Ping 记录保留时间,单位小时,默认1天
}
// [DEPRECATED] 旧的数据结构,将不再维护,请考虑使用 conf.Config 结构体
type V1Struct struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
ScriptDomain string `json:"script_domain" gorm:"type:varchar(255);default:''"` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" gorm:"default:false"` // 是否向访客页面发送 IP 地址,默认 false
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// [DEPRECATED] Nezha 兼容字段已迁移到 extensions,保留用于数据库迁移
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"` // Ping 记录保留时间,单位小时,默认1天
CreatedAt time.Time
UpdatedAt time.Time
}
func (cst *V1Struct) ToConfig() Config {
// 同步 V1 中的 Nezha 字段到 extensions
if IsFieldRegistered("nezha") {
nezhaCfg := map[string]interface{}{
"nezha_compat_enabled": cst.NezhaCompatEnabled,
"nezha_compat_listen": cst.NezhaCompatListen,
}
SetCustomField("nezha", nezhaCfg)
}
return Config{
Site: Site{
Sitename: cst.Sitename,
Description: cst.Description,
AllowCors: cst.AllowCors,
PrivateSite: cst.PrivateSite,
SendIpAddrToGuest: cst.SendIpAddrToGuest,
ScriptDomain: cst.ScriptDomain,
EulaAccepted: cst.EulaAccepted,
CustomHead: cst.CustomHead,
CustomBody: cst.CustomBody,
Theme: cst.Theme,
},
Login: Login{
ApiKey: cst.ApiKey,
AutoDiscoveryKey: cst.AutoDiscoveryKey,
OAuthEnabled: cst.OAuthEnabled,
OAuthProvider: cst.OAuthProvider,
DisablePasswordLogin: cst.DisablePasswordLogin,
},
GeoIp: GeoIp{
GeoIpEnabled: cst.GeoIpEnabled,
GeoIpProvider: cst.GeoIpProvider,
},
Notification: Notification{
NotificationEnabled: cst.NotificationEnabled,
NotificationMethod: cst.NotificationMethod,
NotificationTemplate: cst.NotificationTemplate,
ExpireNotificationEnabled: cst.ExpireNotificationEnabled,
ExpireNotificationLeadDays: cst.ExpireNotificationLeadDays,
LoginNotification: cst.LoginNotification,
TrafficLimitPercentage: cst.TrafficLimitPercentage,
},
Record: Record{
RecordEnabled: cst.RecordEnabled,
RecordPreserveTime: cst.RecordPreserveTime,
PingRecordPreserveTime: cst.PingRecordPreserveTime,
},
}
}
func (cfg *Config) ToV1Format() V1Struct {
v1 := V1Struct{
ID: 1,
Sitename: cfg.Site.Sitename,
Description: cfg.Site.Description,
AllowCors: cfg.Site.AllowCors,
Theme: cfg.Site.Theme,
PrivateSite: cfg.Site.PrivateSite,
ApiKey: cfg.Login.ApiKey,
AutoDiscoveryKey: cfg.Login.AutoDiscoveryKey,
ScriptDomain: cfg.Site.ScriptDomain,
SendIpAddrToGuest: cfg.Site.SendIpAddrToGuest,
EulaAccepted: cfg.Site.EulaAccepted,
GeoIpEnabled: cfg.GeoIp.GeoIpEnabled,
GeoIpProvider: cfg.GeoIp.GeoIpProvider,
OAuthEnabled: cfg.Login.OAuthEnabled,
OAuthProvider: cfg.Login.OAuthProvider,
DisablePasswordLogin: cfg.Login.DisablePasswordLogin,
CustomHead: cfg.Site.CustomHead,
CustomBody: cfg.Site.CustomBody,
NotificationEnabled: cfg.Notification.NotificationEnabled,
NotificationMethod: cfg.Notification.NotificationMethod,
NotificationTemplate: cfg.Notification.NotificationTemplate,
ExpireNotificationEnabled: cfg.Notification.ExpireNotificationEnabled,
ExpireNotificationLeadDays: cfg.Notification.ExpireNotificationLeadDays,
LoginNotification: cfg.Notification.LoginNotification,
TrafficLimitPercentage: cfg.Notification.TrafficLimitPercentage,
RecordEnabled: cfg.Record.RecordEnabled,
RecordPreserveTime: cfg.Record.RecordPreserveTime,
PingRecordPreserveTime: cfg.Record.PingRecordPreserveTime,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 从 extensions 中填充 Nezha 兼容字段
if cfg.Extensions != nil {
if nezha, ok := cfg.Extensions["nezha"]; ok {
if nezhaMap, ok := nezha.(map[string]interface{}); ok {
if enabled, ok := nezhaMap["nezha_compat_enabled"].(bool); ok {
v1.NezhaCompatEnabled = enabled
}
if listen, ok := nezhaMap["nezha_compat_listen"].(string); ok {
v1.NezhaCompatListen = listen
}
}
}
}
return v1
}
@@ -3,8 +3,10 @@ package accounts
import (
"image"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/pquerna/otp/totp"
)
@@ -29,7 +31,14 @@ func Generate2Fa() (string, image.Image, error) {
func Enable2Fa(uuid, secret string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", secret).Error
if err != nil {
event.Trigger(eventType.UserTwoFaAdded, event.M{
"user": uuid,
})
return err
}
return nil
}
func Verify2Fa(uuid, code string) (bool, error) {
@@ -54,5 +63,12 @@ func Verify2Fa(uuid, code string) (bool, error) {
func Disable2Fa(uuid string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("two_factor", "").Error
if err != nil {
return err
}
event.Trigger(eventType.UserTwoFaRemoved, event.M{
"user": uuid,
})
return nil
}
@@ -7,9 +7,11 @@ import (
"os"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/dbcore"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/pkg/utils"
"github.com/google/uuid"
)
@@ -179,5 +181,18 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
if password != nil {
DeleteAllSessions()
}
if name != nil {
event.Trigger(eventType.UserUpdateUsername, event.M{
"user": uuid,
})
} else if password != nil {
event.Trigger(eventType.UserUpdatePassword, event.M{
"user": uuid,
})
} else if sso_type != nil && *sso_type == "" {
event.Trigger(eventType.UserOidcUnbound, event.M{
"user": uuid,
})
}
return nil
}
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more