diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 46605fb..692c0ab 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -101,10 +101,10 @@ jobs:
fi
VERSION="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}"
- go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/internal/version.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/internal/version.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
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
- path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
\ No newline at end of file
+ path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml
index 65d9c8a..656e73d 100644
--- a/.github/workflows/development.yml
+++ b/.github/workflows/development.yml
@@ -12,13 +12,13 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
-
+ uses: actions/checkout@v4
+
#- name: Create dummy index.html
# run: |
# mkdir -p public/dist
# echo "
This is komari development server.
" > public/dist/index.html
-
+
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
@@ -40,7 +40,7 @@ jobs:
GOARCH: amd64
run: |
VERSION_SHA="${{ github.sha }}"
- go build -ldflags "-X github.com/komari-monitor/komari/internal/version.CurrentVersion=dev -X github.com/komari-monitor/komari/internal/version.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
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 97641c5..6db8056 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -55,7 +55,7 @@ jobs:
run: |
VERSION="${{ github.ref_name }}"
VERSION_HASH="${{ github.sha }}"
- LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/version.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/internal/version.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
diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml
index 4b99865..fbe846d 100644
--- a/.github/workflows/release-docker.yml
+++ b/.github/workflows/release-docker.yml
@@ -58,7 +58,7 @@ jobs:
run: |
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
- LDFLAGS="-s -w -X github.com/komari-monitor/komari/internal/version.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/internal/version.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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e665eb2..7b8394c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -103,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/internal/version.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/internal/version.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:
diff --git a/cmd/flags/config.go b/cmd/flags/config.go
index 54f21d3..b0b0ee1 100644
--- a/cmd/flags/config.go
+++ b/cmd/flags/config.go
@@ -9,6 +9,6 @@ var (
DatabaseUser string // MySQL/其他数据库用户名
DatabasePass string // MySQL/其他数据库密码
DatabaseName string // MySQL/其他数据库名称
-
- Listen string
+ ConfigFile string // 配置文件路径
+ Listen string
)
diff --git a/cmd/nezha_compat.go b/cmd/nezha_compat.go
index 12c4eb8..6987b3d 100644
--- a/cmd/nezha_compat.go
+++ b/cmd/nezha_compat.go
@@ -13,8 +13,8 @@ import (
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/config"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/geoip"
@@ -370,7 +370,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
if in != nil && in.Ip != nil {
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
updates["ipv4"] = v4
- if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
+ if 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 cfg, err := config.Get(); err == nil && cfg.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
diff --git a/cmd/permitPasswordLogin.go b/cmd/permitPasswordLogin.go
index c4ad7a3..35afb92 100644
--- a/cmd/permitPasswordLogin.go
+++ b/cmd/permitPasswordLogin.go
@@ -3,8 +3,8 @@ package cmd
import (
"os"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/dbcore"
- "github.com/komari-monitor/komari/internal/database/models"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
@@ -16,7 +16,7 @@ var PermitPasswordLoginCmd = &cobra.Command{
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).
+ return tx.Model(&conf.V1Struct{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
})
if err != nil {
diff --git a/cmd/root.go b/cmd/root.go
index 295784c..7b6fdf2 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -21,13 +21,14 @@ 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")
+ 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{
@@ -59,4 +60,5 @@ func init() {
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]")
}
diff --git a/cmd/server.go b/cmd/server.go
index 393b8d6..24e50d2 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -15,10 +15,10 @@ import (
"github.com/gookit/event"
"github.com/komari-monitor/komari/cmd/flags"
api "github.com/komari-monitor/komari/internal/api_v1"
+ "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/auditlog"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
d_notification "github.com/komari-monitor/komari/internal/database/notification"
@@ -30,7 +30,8 @@ import (
"github.com/komari-monitor/komari/internal/messageSender"
"github.com/komari-monitor/komari/internal/notifier"
"github.com/komari-monitor/komari/internal/oauth"
- "github.com/komari-monitor/komari/internal/version"
+ "github.com/komari-monitor/komari/internal/patch"
+ "github.com/komari-monitor/komari/internal/restore"
"github.com/komari-monitor/komari/pkg/cloudflared"
"github.com/komari-monitor/komari/server"
"github.com/spf13/cobra"
@@ -58,13 +59,19 @@ func RunServer() {
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
+ // 进行备份恢复
+ if restore.NeedBackupRestore() {
+ restore.RestoreBackup()
+ }
+ conf.Load()
InitDatabase()
+ patch.ApplyPatch()
- if version.VersionHash != "unknown" {
+ if conf.Version != conf.Version_Development {
gin.SetMode(gin.ReleaseMode)
}
- conf, err := config.Get()
+ config, err := conf.GetWithV1Format()
if err != nil {
log.Fatal(err)
}
@@ -73,21 +80,20 @@ func RunServer() {
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
- event.Trigger(eventType.ServerInitializeStart, event.M{"config": conf, "engine": r})
- defer event.Trigger(eventType.ServerInitializeDone, event.M{"config": conf})
+ event.Trigger(eventType.ServerInitializeStart, event.M{"config": config, "engine": r})
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
go oauth.Initialize()
- server.StartNezhaGRPCServer(conf.NezhaCompatListen)
+ server.StartNezhaGRPCServer(config.NezhaCompatListen)
event.On(eventType.ConfigUpdated, event.ListenerFunc(func(e event.Event) error {
- newConf := e.Get("new").(models.Config)
- oldConf := e.Get("old").(models.Config)
- if newConf.OAuthProvider != oldConf.OAuthProvider {
- oidcProvider, err := database.GetOidcConfigByName(newConf.OAuthProvider)
+ newConf := e.Get("new").(conf.Config)
+ oldConf := e.Get("old").(conf.Config)
+ if newConf.Login.OAuthProvider != oldConf.Login.OAuthProvider {
+ oidcProvider, err := database.GetOidcConfigByName(newConf.Login.OAuthProvider)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
} else {
@@ -98,7 +104,7 @@ func RunServer() {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
- if newConf.NotificationMethod != oldConf.NotificationMethod {
+ if newConf.Notification.NotificationMethod != oldConf.Notification.NotificationMethod {
messageSender.Initialize()
}
return nil
@@ -118,13 +124,14 @@ func RunServer() {
Addr: flags.Listen,
Handler: r,
}
+
+ event.Trigger(eventType.ServerInitializeDone, event.M{"config": config})
+
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)
- }
- }()
+ 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
@@ -168,7 +175,7 @@ func DoScheduledWork() {
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
go notifier.CheckExpireScheduledWork()
for {
select {
diff --git a/internal/api_rpc/JsonRpc.go b/internal/api_rpc/JsonRpc.go
index e9fabad..60f4586 100644
--- a/internal/api_rpc/JsonRpc.go
+++ b/internal/api_rpc/JsonRpc.go
@@ -11,10 +11,9 @@ import (
"sync"
"github.com/gin-gonic/gin"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
"github.com/komari-monitor/komari/internal/database/clients"
- "github.com/komari-monitor/komari/internal/database/config"
- "github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/rpc"
)
@@ -26,7 +25,7 @@ func RegisterRouters(path string, r *gin.Engine) {
// Json Rpc2 over websocket, /api/rpc2
func OnRpcRequest(c *gin.Context) {
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
// GET -> WebSocket
if c.Request.Method == http.MethodGet {
@@ -123,7 +122,7 @@ func OnRpcRequest(c *gin.Context) {
}
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
-func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
+func detectPermissionGroup(c *gin.Context, cfg conf.V1Struct) string {
permissionGroup := "guest"
token := c.Query("Authorization")
if _, err := clients.GetClientUUIDByToken(token); err == nil {
diff --git a/internal/api_rpc/common.go b/internal/api_rpc/common.go
index a2ce010..1f0160b 100644
--- a/internal/api_rpc/common.go
+++ b/internal/api_rpc/common.go
@@ -10,13 +10,12 @@ import (
api "github.com/komari-monitor/komari/internal/api_v1"
"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/clients"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/tasks"
- "github.com/komari-monitor/komari/internal/version"
"github.com/komari-monitor/komari/internal/ws"
"github.com/komari-monitor/komari/pkg/rpc"
@@ -229,7 +228,7 @@ func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcEr
}
meta := rpc.MetaFromContext(ctx)
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
if meta.Permission != "admin" {
// 过滤 Hidden 节点并隐藏敏感字段
filtered := make([]models.Client, 0, len(cinfo))
@@ -455,8 +454,8 @@ func getVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcErro
Version string `json:"version"`
Hash string `json:"hash"`
}{
- Version: version.CurrentVersion,
- Hash: version.VersionHash,
+ Version: conf.Version,
+ Hash: conf.CommitHash,
}, nil
}
diff --git a/internal/api_v1/Common.go b/internal/api_v1/Common.go
index 21b1769..18e7941 100644
--- a/internal/api_v1/Common.go
+++ b/internal/api_v1/Common.go
@@ -9,12 +9,11 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/internal/common"
- "github.com/komari-monitor/komari/internal/version"
"github.com/patrickmn/go-cache"
"strconv"
- "github.com/komari-monitor/komari/internal/database/config"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/pkg/utils"
@@ -141,13 +140,13 @@ func RespondError(c *gin.Context, httpStatus int, message string) {
}
func GetVersion(c *gin.Context) {
RespondSuccess(c, gin.H{
- "version": version.CurrentVersion,
- "hash": version.VersionHash,
+ "version": conf.Version,
+ "hash": conf.CommitHash,
})
}
func isApiKeyValid(apiKey string) bool {
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil {
return false
}
diff --git a/internal/api_v1/PrivateSiteMiddleware.go b/internal/api_v1/PrivateSiteMiddleware.go
index caf0406..dc30f04 100644
--- a/internal/api_v1/PrivateSiteMiddleware.go
+++ b/internal/api_v1/PrivateSiteMiddleware.go
@@ -3,8 +3,8 @@ package api_v1
import (
"net/http"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/accounts"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/gin-gonic/gin"
)
@@ -46,7 +46,7 @@ func PrivateSiteMiddleware() gin.HandlerFunc {
c.Next()
return
}
- conf, err := config.Get()
+ conf, err := conf.GetWithV1Format()
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to get configuration.")
c.Abort()
diff --git a/internal/api_v1/admin/messageSender.go b/internal/api_v1/admin/messageSender.go
index 4315ba2..128b586 100644
--- a/internal/api_v1/admin/messageSender.go
+++ b/internal/api_v1/admin/messageSender.go
@@ -3,8 +3,8 @@ package admin
import (
"github.com/gin-gonic/gin"
api "github.com/komari-monitor/komari/internal/api_v1"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/messageSender"
"github.com/komari-monitor/komari/internal/messageSender/factory"
@@ -50,7 +50,7 @@ func SetMessageSenderProvider(c *gin.Context) {
api.RespondError(c, 500, "Failed to save message sender provider configuration: "+err.Error())
return
}
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
// 正在使用,重载
if cfg.NotificationMethod == senderConfig.Name {
err := messageSender.LoadProvider(senderConfig.Name, senderConfig.Addition)
diff --git a/internal/api_v1/admin/oauth.go b/internal/api_v1/admin/oauth.go
index bd22063..96de2fb 100644
--- a/internal/api_v1/admin/oauth.go
+++ b/internal/api_v1/admin/oauth.go
@@ -3,9 +3,9 @@ package admin
import (
"github.com/gin-gonic/gin"
api "github.com/komari-monitor/komari/internal/api_v1"
+ "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/config"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/oauth"
"github.com/komari-monitor/komari/internal/oauth/factory"
@@ -80,7 +80,7 @@ func SetOidcProvider(c *gin.Context) {
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
return
}
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
// 正在使用,重载
if cfg.OAuthProvider == oidcConfig.Name {
err := oauth.LoadProvider(oidcConfig.Name, oidcConfig.Addition)
diff --git a/internal/api_v1/admin/settings.go b/internal/api_v1/admin/settings.go
index 2316be3..862f56e 100644
--- a/internal/api_v1/admin/settings.go
+++ b/internal/api_v1/admin/settings.go
@@ -4,9 +4,8 @@ import (
"database/sql"
api "github.com/komari-monitor/komari/internal/api_v1"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/auditlog"
- "github.com/komari-monitor/komari/internal/database/config"
- "github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/database/records"
"github.com/komari-monitor/komari/internal/database/tasks"
@@ -15,13 +14,13 @@ import (
// GetSettings 获取自定义配置
func GetSettings(c *gin.Context) {
- cst, err := config.Get()
+ cst, err := conf.GetWithV1Format()
if err != nil {
if err == sql.ErrNoRows {
//override
- cst = models.Config{Sitename: "Komari"}
+ cst = conf.V1Struct{Sitename: "Komari"}
cst.ID = 1
- config.Save(cst)
+ conf.Save(cst)
api.RespondSuccess(c, cst)
return
}
@@ -42,7 +41,7 @@ func EditSettings(c *gin.Context) {
}
cfg["id"] = 1 // Only one record
- if err := config.Update(cfg); err != nil {
+ if err := conf.Update(cfg); err != nil {
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
return
}
diff --git a/internal/api_v1/admin/test/test.go b/internal/api_v1/admin/test/test.go
index 01b5b3a..34be7fd 100644
--- a/internal/api_v1/admin/test/test.go
+++ b/internal/api_v1/admin/test/test.go
@@ -5,7 +5,7 @@ import (
"github.com/gin-gonic/gin"
api "github.com/komari-monitor/komari/internal/api_v1"
- "github.com/komari-monitor/komari/internal/database/config"
+ "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"
@@ -32,7 +32,7 @@ func TestGeoIp(c *gin.Context) {
ip = c.ClientIP()
}
}
- conf, err := config.Get()
+ conf, err := conf.GetWithV1Format()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
diff --git a/internal/api_v1/admin/theme.go b/internal/api_v1/admin/theme.go
index 7d538e1..a03c528 100644
--- a/internal/api_v1/admin/theme.go
+++ b/internal/api_v1/admin/theme.go
@@ -14,7 +14,7 @@ import (
"github.com/gin-gonic/gin"
api "github.com/komari-monitor/komari/internal/api_v1"
- "github.com/komari-monitor/komari/internal/database/config"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
)
@@ -138,7 +138,7 @@ func SetTheme(c *gin.Context) {
"theme": themeName,
}
- if err := config.Update(updateData); err != nil {
+ if err := conf.Update(updateData); err != nil {
api.RespondError(c, http.StatusInternalServerError, "更新主题设置失败: "+err.Error())
return
}
diff --git a/internal/api_v1/client/autoDiscovery.go b/internal/api_v1/client/autoDiscovery.go
index b8fa507..c7ce835 100644
--- a/internal/api_v1/client/autoDiscovery.go
+++ b/internal/api_v1/client/autoDiscovery.go
@@ -3,8 +3,8 @@ package client
import (
"github.com/gin-gonic/gin"
api "github.com/komari-monitor/komari/internal/api_v1"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/clients"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/pkg/utils"
)
@@ -14,7 +14,7 @@ func RegisterClient(c *gin.Context) {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
diff --git a/internal/api_v1/client/uploadBasicInfo.go b/internal/api_v1/client/uploadBasicInfo.go
index 0564ed5..2c21b33 100644
--- a/internal/api_v1/client/uploadBasicInfo.go
+++ b/internal/api_v1/client/uploadBasicInfo.go
@@ -3,8 +3,8 @@ package client
import (
"net"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/clients"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/gin-gonic/gin"
@@ -60,7 +60,7 @@ func UploadBasicInfo(c *gin.Context) {
}
}
- if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
+ if cfg, err := conf.GetWithV1Format(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoInfo(ip4)
diff --git a/internal/api_v1/login.go b/internal/api_v1/login.go
index e287e16..294bbc6 100644
--- a/internal/api_v1/login.go
+++ b/internal/api_v1/login.go
@@ -5,9 +5,11 @@ import (
"io"
"net/http"
+ "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/database/config"
+ "github.com/komari-monitor/komari/internal/eventType"
"github.com/gin-gonic/gin"
)
@@ -19,7 +21,7 @@ type LoginRequest struct {
}
func Login(c *gin.Context) {
- conf, _ := config.Get()
+ conf, _ := conf.GetWithV1Format()
if conf.DisablePasswordLogin {
RespondError(c, http.StatusForbidden, "Password login is disabled")
return
@@ -44,6 +46,15 @@ func Login(c *gin.Context) {
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
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
@@ -67,6 +78,15 @@ func Login(c *gin.Context) {
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}})
+ 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")
diff --git a/internal/api_v1/oauth.go b/internal/api_v1/oauth.go
index 026fa2e..27813b8 100644
--- a/internal/api_v1/oauth.go
+++ b/internal/api_v1/oauth.go
@@ -5,16 +5,18 @@ import (
"slices"
"github.com/gin-gonic/gin"
+ "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/database/config"
+ "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) {
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
if !cfg.OAuthEnabled {
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
return
@@ -97,6 +99,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 +117,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")
+
}
diff --git a/internal/api_v1/ws.go b/internal/api_v1/ws.go
index cbe7a3f..412bffa 100644
--- a/internal/api_v1/ws.go
+++ b/internal/api_v1/ws.go
@@ -7,8 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"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/config"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/ws"
@@ -20,7 +20,7 @@ func GetClients(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
if cfg.AllowCors {
diff --git a/internal/conf/config.go b/internal/conf/config.go
new file mode 100644
index 0000000..9fb6547
--- /dev/null
+++ b/internal/conf/config.go
@@ -0,0 +1,251 @@
+package conf
+
+import (
+ "encoding/json"
+ "os"
+
+ "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,
+ },
+ Record: Record{
+ RecordEnabled: true,
+ RecordPreserveTime: 720,
+ PingRecordPreserveTime: 24,
+ },
+ }
+}
+
+func Override(cst Config) error {
+ b, err := json.MarshalIndent(cst, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(flags.ConfigFile, b, 0644); err != nil {
+ return err
+ }
+
+ oldConf := *Conf
+ Conf = &cst
+ event.Trigger(eventType.ConfigUpdated, event.M{
+ "old": oldConf,
+ "new": cst,
+ })
+ return nil
+}
+
+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
+}
+
+func SaveFull(cst Config) error {
+ return Override(cst)
+}
+
+func Load() (*Config, error) {
+ 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
+ }
+ Conf = cst
+ return cst, nil
+}
+
+// GetWithV1Format 以 v1 API 格式获取配置对象,使用 Conf 直接获取对象引用
+func GetWithV1Format() (V1Struct, error) {
+ return Conf.ToV1Format(), nil
+}
+
+func Save(cst V1Struct) error {
+ cfg := cst.ToConfig()
+ return Override(cfg)
+}
+
+func Update(cst map[string]interface{}) error {
+ // Update 的语义等同于 SavePartial,保持对旧数据格式兼容
+ return SavePartial(cst)
+}
+
+// normalizePartialMap 将可能包含旧版扁平字段的输入映射为新版分组结构。
+// 若已是分组结构(包含 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")
+ compact := ensureGroup("compact")
+ nezha := func() map[string]interface{} {
+ v, ok := compact["nezha"]
+ if !ok || v == nil {
+ m := map[string]interface{}{}
+ compact["nezha"] = m
+ return m
+ }
+ if m, ok := v.(map[string]interface{}); ok {
+ return m
+ }
+ m := map[string]interface{}{}
+ compact["nezha"] = m
+ return m
+ }()
+
+ // 扁平 -> 分组字段映射表
+ 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")
+
+ // Compact.Nezha
+ move("nezha_compat_enabled", nezha, "nezha_compat_enabled")
+ move("nezha_compat_listen", nezha, "nezha_compat_listen")
+
+ return out
+}
+
+// deepMerge 以 dst 为基础,将 src 合并覆盖到 dst。仅对 map[string]interface{} 递归。
+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
+}
diff --git a/internal/conf/const.go b/internal/conf/const.go
new file mode 100644
index 0000000..77cd9c8
--- /dev/null
+++ b/internal/conf/const.go
@@ -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"
+)
diff --git a/internal/conf/vars.go b/internal/conf/vars.go
new file mode 100644
index 0000000..5f809a4
--- /dev/null
+++ b/internal/conf/vars.go
@@ -0,0 +1,203 @@
+package conf
+
+import "time"
+
+var (
+ Version = Version_Development
+ CommitHash = "unknown"
+)
+
+var (
+ Conf *Config // 当直接修改时,请手动触发 eventType.ConfigUpdated 事件,或者使用 EditAndTrigger(func() { ... } 包裹
+)
+
+type Config struct {
+ Site Site `json:"site"`
+ Login Login `json:"login"`
+ GeoIp GeoIp `json:"geo_ip"`
+ Notification Notification `json:"notification"`
+ Record Record `json:"record"`
+ Compact Compact `json:"compact"`
+}
+
+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 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天
+}
+
+type Compact struct {
+ Nezha Nezha `json:"nezha"`
+}
+
+type Nezha struct {
+ // Nezha 兼容(Agent gRPC)
+ NezhaCompatEnabled bool `json:"nezha_compat_enabled"`
+ NezhaCompatListen string `json:"nezha_compat_listen"` // 例如 0.0.0.0:5555
+}
+
+// [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
+ // 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 time.Time
+ UpdatedAt time.Time
+}
+
+func (cst *V1Struct) ToConfig() Config {
+ 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,
+ },
+ Compact: Compact{
+ Nezha: Nezha{
+ NezhaCompatEnabled: cst.NezhaCompatEnabled,
+ NezhaCompatListen: cst.NezhaCompatListen,
+ },
+ },
+ }
+}
+
+func (cfg *Config) ToV1Format() V1Struct {
+ return 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,
+ NezhaCompatEnabled: cfg.Compact.Nezha.NezhaCompatEnabled,
+ NezhaCompatListen: cfg.Compact.Nezha.NezhaCompatListen,
+ 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(),
+ }
+}
diff --git a/internal/database/accounts/sessions.go b/internal/database/accounts/sessions.go
index 0c560d5..a7b8ce6 100644
--- a/internal/database/accounts/sessions.go
+++ b/internal/database/accounts/sessions.go
@@ -6,7 +6,7 @@ import (
"net"
"time"
- "github.com/komari-monitor/komari/internal/database/config"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
messageevent "github.com/komari-monitor/komari/internal/database/models/messageEvent"
@@ -39,7 +39,7 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
LoginMethod: login_method,
LatestOnline: models.FromTime(time.Now()),
}
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
if cfg.LoginNotification {
ipAddr := net.ParseIP(ip)
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
diff --git a/internal/database/config/config.go b/internal/database/config/config.go
deleted file mode 100644
index daca77e..0000000
--- a/internal/database/config/config.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package config
-
-import (
- "errors"
- "log"
- "sync"
- "time"
-
- "github.com/gookit/event"
- "github.com/komari-monitor/komari/internal/database/dbcore"
- "github.com/komari-monitor/komari/internal/database/models"
- "github.com/komari-monitor/komari/internal/eventType"
- "gorm.io/gorm"
-)
-
-var mu sync.Mutex
-
-func init() {
- mu = sync.Mutex{}
-}
-
-func Get() (models.Config, error) {
- mu.Lock()
- defer mu.Unlock()
- db := dbcore.GetDBInstance()
- var config models.Config
- if err := db.First(&config).Error; err != nil {
- if err == gorm.ErrRecordNotFound {
- config = models.Config{
- ID: 1,
- Sitename: "Komari",
- Description: "Komari Monitor, a simple server monitoring tool.",
- AllowCors: false,
- OAuthEnabled: false,
- GeoIpEnabled: true,
- GeoIpProvider: "ipinfo",
- NezhaCompatEnabled: false,
- NezhaCompatListen: "",
- NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
- UpdatedAt: models.FromTime(time.Now()),
- CreatedAt: models.FromTime(time.Now()),
- }
- if err := db.Create(&config).Error; err != nil {
- log.Fatal("Failed to create default config:", err)
- }
- return config, nil
- }
- return config, err
- }
- return config, nil
-}
-
-func Save(cst models.Config) error {
- db := dbcore.GetDBInstance()
- oldConfig, _ := Get()
- // Only one records
- cst.ID = 1
- cst.UpdatedAt = models.FromTime(time.Now())
- if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
- Select("*").
- Updates(cst).Error; err != nil {
- return err
- }
- newConfig, _ := Get()
- event.Trigger(eventType.ConfigUpdated, event.M{"old": oldConfig, "new": newConfig})
- return nil
-}
-
-func Update(cst map[string]interface{}) error {
- db := dbcore.GetDBInstance()
- oldConfig, _ := Get()
- // Proceed with update
- cst["id"] = 1
- cst["updated_at"] = time.Now()
- delete(cst, "created_at")
- delete(cst, "CreatedAt")
-
- // 至少有一种登录方式启用
- newDisablePasswordLogin := oldConfig.DisablePasswordLogin
- newOAuthEnabled := oldConfig.OAuthEnabled
- if val, exists := cst["disable_password_login"]; exists {
- newDisablePasswordLogin = val.(bool)
- }
- if val, exists := cst["o_auth_enabled"]; exists {
- newOAuthEnabled = val.(bool)
- }
- if newDisablePasswordLogin && !newOAuthEnabled {
- return errors.New("at least one login method must be enabled (password/oauth)")
- }
- // 没绑定账号也不能禁用
- if newDisablePasswordLogin {
- usr := &models.User{}
- if err := db.Model(&models.User{}).First(usr).Error; err != nil {
- return errors.Join(err, errors.New("failed to retrieve user"))
- }
- if usr.SSOID == "" {
- return errors.New("cannot disable password login when no SSO-bound account exists")
- }
- }
- err := db.Transaction(func(tx *gorm.DB) error {
- if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
- return errors.Join(err, errors.New("failed to update configuration"))
- }
- newConfig := &models.Config{}
- if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
- return errors.Join(err, errors.New("failed to retrieve updated configuration"))
- }
- event.Trigger(eventType.ConfigUpdated, event.M{"old": oldConfig, "new": *newConfig})
- return nil
- })
- if err != nil {
- return err
- }
- return nil
-}
diff --git a/internal/database/dbcore/dbcore.go b/internal/database/dbcore/dbcore.go
index 071df16..fa3fbac 100644
--- a/internal/database/dbcore/dbcore.go
+++ b/internal/database/dbcore/dbcore.go
@@ -1,19 +1,11 @@
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/internal/common"
"github.com/komari-monitor/komari/internal/database/models"
logutil "github.com/komari-monitor/komari/internal/log"
"gorm.io/driver/mysql"
@@ -21,338 +13,6 @@ import (
"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
@@ -362,50 +22,6 @@ 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(),
}
@@ -441,14 +57,13 @@ func GetDBInstance() *gorm.DB {
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
}
- MergeDatabase(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Record{},
&models.GPURecord{},
- &models.Config{},
+ //&conf.V1Struct{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
diff --git a/internal/database/models/config.go b/internal/database/models/config.go
index daa3a90..588fdb8 100644
--- a/internal/database/models/config.go
+++ b/internal/database/models/config.go
@@ -1,42 +1,4 @@
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
}
diff --git a/internal/database/records/records.go b/internal/database/records/records.go
index 825e83e..c01c454 100644
--- a/internal/database/records/records.go
+++ b/internal/database/records/records.go
@@ -8,7 +8,6 @@ import (
"gorm.io/gorm"
- "github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
)
@@ -171,14 +170,6 @@ func CompactRecord() error {
log.Printf("Error migrating GPU records: %v", err)
return err
}
-
- if flags.DatabaseType == "sqlite" {
- if err := db.Exec("VACUUM").Error; err != nil {
- log.Printf("Error vacuuming database: %v", err)
- }
- db.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
- }
- //log.Printf("Record compaction completed")
return nil
}
diff --git a/internal/database/utils.go b/internal/database/utils.go
index 073c2ed..ed1183d 100644
--- a/internal/database/utils.go
+++ b/internal/database/utils.go
@@ -8,13 +8,13 @@ import (
"strings"
"github.com/gin-gonic/gin"
- "github.com/komari-monitor/komari/internal/database/config"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
)
func GetPublicInfo() (any, error) {
- cst, err := config.Get()
+ cst, err := conf.GetWithV1Format()
if err != nil {
return nil, err
}
diff --git a/internal/geoip/geoip.go b/internal/geoip/geoip.go
index 0147c5a..4cd6478 100644
--- a/internal/geoip/geoip.go
+++ b/internal/geoip/geoip.go
@@ -7,7 +7,7 @@ import (
"time"
"unicode"
- "github.com/komari-monitor/komari/internal/database/config"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/patrickmn/go-cache"
)
@@ -52,14 +52,14 @@ func GetRegionUnicodeEmoji(isoCode string) string {
}
func InitGeoIp() {
- conf, err := config.Get()
+ config, err := conf.GetWithV1Format()
if err != nil {
panic("Failed to get configuration for GeoIP: " + err.Error())
}
- if !conf.GeoIpEnabled {
+ if !config.GeoIpEnabled {
return
}
- switch conf.GeoIpProvider {
+ switch config.GeoIpProvider {
case "mmdb":
NewCurrentProvider, err := NewMaxMindGeoIPService()
if err != nil {
diff --git a/internal/internal.go b/internal/internal.go
index fa63743..68d82dc 100644
--- a/internal/internal.go
+++ b/internal/internal.go
@@ -12,10 +12,10 @@ import (
"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/task"
- "github.com/komari-monitor/komari/internal/database/models"
+ "github.com/komari-monitor/komari/internal/conf"
)
-func LoadApiV1Routes(r *gin.Engine, conf models.Config) {
+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")
diff --git a/internal/log/gorm.go b/internal/log/gorm.go
index beac977..72b98c1 100644
--- a/internal/log/gorm.go
+++ b/internal/log/gorm.go
@@ -7,7 +7,6 @@ import (
"log/slog"
"time"
- "github.com/komari-monitor/komari/internal/version"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"gorm.io/gorm/utils"
@@ -25,12 +24,7 @@ func NewGormLogger() *GormLogger {
return &GormLogger{
SlowThreshold: 200 * time.Millisecond,
IgnoreRecordNotFoundError: true,
- LogLevel: func(hash string) gormlogger.LogLevel {
- if hash == "unknown" {
- return gormlogger.Info
- }
- return gormlogger.Silent
- }(version.VersionHash),
+ LogLevel: gormlogger.Warn,
}
}
diff --git a/internal/log/log.go b/internal/log/log.go
index 0abf8d2..af190ab 100644
--- a/internal/log/log.go
+++ b/internal/log/log.go
@@ -152,7 +152,7 @@ func SetupGlobalLogger(level slog.Level) {
stdlog.SetPrefix("")
// 替换标准库 log 的输出为 slog handler
- stdlog.SetOutput(&writerAdapter{handler: handler, level: level})
+ stdlog.SetOutput(&writerAdapter{handler: handler, level: slog.LevelInfo})
}
// writerAdapter 将标准库 log 的输出适配到 slog
diff --git a/internal/messageSender/sender.go b/internal/messageSender/sender.go
index 9b27228..73ae977 100644
--- a/internal/messageSender/sender.go
+++ b/internal/messageSender/sender.go
@@ -8,9 +8,9 @@ import (
"sync"
"time"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
"github.com/komari-monitor/komari/internal/database/auditlog"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/messageSender/factory"
)
@@ -52,7 +52,7 @@ func Initialize() {
}
})
}()
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
if cfg.NotificationMethod == "" || cfg.NotificationMethod == "none" {
LoadProvider("empty", "{}")
@@ -74,7 +74,7 @@ func SendTextMessage(message string, title string) error {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil {
return err
}
@@ -96,7 +96,7 @@ func SendEvent(event models.EventMessage) error {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil {
return err
}
diff --git a/internal/notifier/expire.go b/internal/notifier/expire.go
index c1afd2d..2732dd9 100644
--- a/internal/notifier/expire.go
+++ b/internal/notifier/expire.go
@@ -5,8 +5,8 @@ import (
"math"
"time"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/clients"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/models"
messageevent "github.com/komari-monitor/komari/internal/database/models/messageEvent"
"github.com/komari-monitor/komari/internal/messageSender"
@@ -23,7 +23,7 @@ func CheckExpireScheduledWork() {
duration := next.Sub(now)
time.Sleep(duration)
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil {
time.Sleep(time.Second)
continue
diff --git a/internal/notifier/offline.go b/internal/notifier/offline.go
index 6b9f378..6526d63 100644
--- a/internal/notifier/offline.go
+++ b/internal/notifier/offline.go
@@ -6,8 +6,8 @@ import (
"sync"
"time"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/clients"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
messageevent "github.com/komari-monitor/komari/internal/database/models/messageEvent"
@@ -32,7 +32,7 @@ var clientStates sync.Map
// getNotificationConfig 获取指定客户端的通知配置。
// 返回配置对象和一个布尔值,指示全局和该客户端是否启用通知。
func getNotificationConfig(clientID string) (*models.OfflineNotification, bool) {
- conf, err := config.Get()
+ conf, err := conf.GetWithV1Format()
if err != nil || !conf.NotificationEnabled {
return nil, false
}
diff --git a/internal/notifier/traffic.go b/internal/notifier/traffic.go
index bd5c91a..ffcce86 100644
--- a/internal/notifier/traffic.go
+++ b/internal/notifier/traffic.go
@@ -6,8 +6,8 @@ import (
"strings"
"time"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database/clients"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/messageSender"
"github.com/komari-monitor/komari/internal/ws"
@@ -27,7 +27,7 @@ func CheckTraffic() {
return
}
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil {
return
}
diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go
index aa9bfb2..b51de88 100644
--- a/internal/oauth/oauth.go
+++ b/internal/oauth/oauth.go
@@ -6,8 +6,8 @@ import (
"log"
"sync"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/database"
- "github.com/komari-monitor/komari/internal/database/config"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/oauth/factory"
)
@@ -70,7 +70,7 @@ func Initialize() error {
}
}
})
- cfg, _ := config.Get()
+ cfg, _ := conf.GetWithV1Format()
if cfg.OAuthProvider == "" || cfg.OAuthProvider == "none" {
LoadProvider("empty", "{}")
return nil
diff --git a/internal/patch/patch.go b/internal/patch/patch.go
new file mode 100644
index 0000000..dca66a7
--- /dev/null
+++ b/internal/patch/patch.go
@@ -0,0 +1,38 @@
+package patch
+
+import (
+ "log"
+
+ "github.com/komari-monitor/komari/internal/conf"
+ "github.com/komari-monitor/komari/internal/database/dbcore"
+ "github.com/komari-monitor/komari/internal/database/models"
+)
+
+func ApplyPatch() {
+ db := dbcore.GetDBInstance()
+ // 0.0.5 迁移ClientInfo
+ if db.Migrator().HasTable("client_infos") {
+ v0_0_5(db)
+ }
+ // 0.0.5a 修正cors拼写错误
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "allow_cros") {
+ v0_0_5a(db)
+ }
+ // 0.1.4 重建LoadNotification表
+ if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
+ log.Println("[>0.1.4] Rebuilding LoadNotification table....")
+ db.Migrator().DropTable(&models.LoadNotification{})
+ }
+ // 1.0.2 合并OIDC提供商表
+ if !db.Migrator().HasTable(&models.OidcProvider{}) && db.Migrator().HasTable(&conf.V1Struct{}) {
+ v1_0_2_Oidc(db)
+ }
+ // 1.0.2 迁移消息发送配置到单独的表
+ if !db.Migrator().HasTable(&models.MessageSenderProvider{}) && db.Migrator().HasTable(&conf.V1Struct{}) {
+ v1_0_2_MessageSender(db)
+ }
+ // 1.1.4 迁移配置表
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "id") {
+ v1_1_4(db)
+ }
+}
diff --git a/internal/patch/v0.0.5.go b/internal/patch/v0.0.5.go
new file mode 100644
index 0000000..dcc885a
--- /dev/null
+++ b/internal/patch/v0.0.5.go
@@ -0,0 +1,66 @@
+package patch
+
+import (
+ "log"
+
+ "github.com/komari-monitor/komari/internal/common"
+ "github.com/komari-monitor/komari/internal/conf"
+ "github.com/komari-monitor/komari/internal/database/models"
+ "gorm.io/gorm"
+)
+
+func v0_0_5(db *gorm.DB) {
+ log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
+ 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 v0_0_5a(db *gorm.DB) {
+ log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
+ db.Migrator().RenameColumn(&conf.V1Struct{}, "allow_cros", "allow_cors")
+}
diff --git a/internal/patch/v1.0.2.go b/internal/patch/v1.0.2.go
new file mode 100644
index 0000000..1f4dda1
--- /dev/null
+++ b/internal/patch/v1.0.2.go
@@ -0,0 +1,133 @@
+package patch
+
+import (
+ "encoding/json"
+ "log"
+
+ "github.com/komari-monitor/komari/internal/conf"
+ "github.com/komari-monitor/komari/internal/database/models"
+ "gorm.io/gorm"
+)
+
+func v1_0_2_Oidc(db *gorm.DB) {
+ 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(&conf.V1Struct{})
+ db.Model(&conf.V1Struct{}).Where("id = 1").Update("o_auth_provider", "github")
+}
+
+func v1_0_2_MessageSender(db *gorm.DB) {
+ 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(&conf.V1Struct{}, "telegram_bot_token") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "telegram_bot_token")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "telegram_chat_id") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "telegram_chat_id")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "telegram_endpoint") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "telegram_endpoint")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "email_host") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "email_host")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "email_port") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "email_port")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "email_username") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "email_username")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "email_password") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "email_password")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "email_sender") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "email_sender")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "email_receiver") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "email_receiver")
+ }
+ if db.Migrator().HasColumn(&conf.V1Struct{}, "email_use_ssl") {
+ db.Migrator().DropColumn(&conf.V1Struct{}, "email_use_ssl")
+ }
+}
diff --git a/internal/patch/v1.1.4.go b/internal/patch/v1.1.4.go
new file mode 100644
index 0000000..41b8588
--- /dev/null
+++ b/internal/patch/v1.1.4.go
@@ -0,0 +1,77 @@
+package patch
+
+import (
+ "encoding/json"
+ "log/slog"
+ "time"
+
+ "github.com/komari-monitor/komari/internal/conf"
+ "gorm.io/gorm"
+)
+
+func v1_1_4(db *gorm.DB) {
+ slog.Info("[>1.1.4] Migrating config table to file config...")
+ var old_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 time.Time
+ UpdatedAt time.Time
+ }
+ if err := db.Raw("SELECT * FROM configs LIMIT 1").Scan(&old_config).Error; err != nil {
+ slog.Error("Failed to get config.", slog.Any("error", err))
+ return
+ }
+ // 将旧结构转为 map[string]interface{},键为扁平 json 标签
+ bytes, err := json.Marshal(&old_config)
+ if err != nil {
+ slog.Error("Failed to marshal old config.", slog.Any("error", err))
+ return
+ }
+ var flat map[string]interface{}
+ if err := json.Unmarshal(bytes, &flat); err != nil {
+ slog.Error("Failed to unmarshal to map.", slog.Any("error", err))
+ return
+ }
+
+ // 使用 conf.Update 以 SavePartial 语义合并,并兼容旧键名
+ if err := conf.Update(flat); err != nil {
+ slog.Error("Failed to write new file config.", slog.Any("error", err))
+ return
+ }
+ db.Migrator().DropTable(&conf.V1Struct{})
+ slog.Info("[>1.1.4] Config migration finished.")
+}
diff --git a/internal/restore/restore.go b/internal/restore/restore.go
new file mode 100644
index 0000000..95bcea4
--- /dev/null
+++ b/internal/restore/restore.go
@@ -0,0 +1,207 @@
+package restore
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+var backupZipPath = filepath.Join(".", "data", "backup.zip")
+
+func NeedBackupRestore() bool {
+ if _, statErr := os.Stat(backupZipPath); statErr == nil {
+ return true
+ }
+ return false
+}
+
+func RestoreBackup() {
+ // 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")
+ }
+
+}
+
+// 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
+}
diff --git a/internal/version/ver.go b/internal/version/ver.go
deleted file mode 100644
index c687abd..0000000
--- a/internal/version/ver.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package version
-
-var (
- CurrentVersion = "0.0.1"
- VersionHash = "unknown"
-)
diff --git a/main.go b/main.go
index aba51af..c1c5087 100644
--- a/main.go
+++ b/main.go
@@ -5,18 +5,18 @@ import (
"log/slog"
"github.com/komari-monitor/komari/cmd"
+ "github.com/komari-monitor/komari/internal/conf"
logutil "github.com/komari-monitor/komari/internal/log"
- "github.com/komari-monitor/komari/internal/version"
)
func main() {
- if version.VersionHash == "unknown" {
+ if conf.Version == conf.Version_Development {
logutil.SetupGlobalLogger(slog.LevelDebug)
} else {
logutil.SetupGlobalLogger(slog.LevelInfo)
}
- log.Printf("Komari Monitor %s (hash: %s)", version.CurrentVersion, version.VersionHash)
+ log.Printf("Komari Monitor %s (hash: %s)", conf.Version, conf.CommitHash)
cmd.Execute()
}
diff --git a/public/public.go b/public/public.go
index 449c00f..47219c4 100644
--- a/public/public.go
+++ b/public/public.go
@@ -14,8 +14,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
- "github.com/komari-monitor/komari/internal/database/config"
- "github.com/komari-monitor/komari/internal/database/models"
+ "github.com/komari-monitor/komari/internal/conf"
)
//go:embed dist
@@ -54,12 +53,12 @@ func initIndex() {
}
RawIndexFile = string(index)
}
-func UpdateIndex(cfg models.Config) {
+func UpdateIndex(cfg conf.V1Struct) {
IndexFile = applyCustomizations(RawIndexFile, cfg)
}
// applyCustomizations 应用自定义内容到HTML字符串
-func applyCustomizations(htmlContent string, cfg models.Config) string {
+func applyCustomizations(htmlContent string, cfg conf.V1Struct) string {
var titleReplacement string
if cfg.Sitename == "Komari" {
titleReplacement = "Komari Monitor"
@@ -144,7 +143,7 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
// 获取当前主题配置
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil || cfg.Theme == "default" || cfg.Theme == "" {
// 使用默认主题(embedded文件)
serveFromEmbedded(c, path)
@@ -269,7 +268,7 @@ func serveThemeIndexWithCustomizations(c *gin.Context, indexPath string) {
}
// 获取配置以应用自定义内容
- cfg, err := config.Get()
+ cfg, err := conf.GetWithV1Format()
if err != nil {
// 如果获取配置失败,直接返回原始文件
c.Header("Content-Type", "text/html")
diff --git a/server/grpc.go b/server/grpc.go
index 0564d26..9377807 100644
--- a/server/grpc.go
+++ b/server/grpc.go
@@ -17,8 +17,8 @@ import (
"github.com/gookit/event"
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/config"
"github.com/komari-monitor/komari/internal/database/dbcore"
"github.com/komari-monitor/komari/internal/database/models"
"github.com/komari-monitor/komari/internal/eventType"
@@ -35,11 +35,11 @@ import (
func StartNezhaGRPCServer(listen string) {
event.On(eventType.ConfigUpdated, event.ListenerFunc(func(e event.Event) error {
- New := e.Get("new").(models.Config)
- Old := e.Get("old").(models.Config)
- if New.NezhaCompatEnabled != Old.NezhaCompatEnabled {
- if New.NezhaCompatEnabled {
- if err := StartNezhaCompat(New.NezhaCompatListen); err != nil {
+ New := e.Get("new").(conf.Config)
+ Old := e.Get("old").(conf.Config)
+ if New.Compact.Nezha.NezhaCompatEnabled != Old.Compact.Nezha.NezhaCompatEnabled {
+ if New.Compact.Nezha.NezhaCompatEnabled {
+ if err := StartNezhaCompat(New.Compact.Nezha.NezhaCompatListen); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
@@ -387,7 +387,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
if in != nil && in.Ip != nil {
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
updates["ipv4"] = v4
- if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
+ if 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
@@ -398,7 +398,7 @@ func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
updates["ipv6"] = v6
if iso == "" { // 优先使用 v4 的国家码
- if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
+ if 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
diff --git a/server/routers.go b/server/routers.go
index 091bd55..8928c6b 100644
--- a/server/routers.go
+++ b/server/routers.go
@@ -8,8 +8,7 @@ import (
"github.com/gookit/event"
"github.com/komari-monitor/komari/internal"
"github.com/komari-monitor/komari/internal/api_rpc"
- "github.com/komari-monitor/komari/internal/database/config"
- "github.com/komari-monitor/komari/internal/database/models"
+ "github.com/komari-monitor/komari/internal/conf"
"github.com/komari-monitor/komari/internal/eventType"
"github.com/komari-monitor/komari/internal/geoip"
"github.com/komari-monitor/komari/internal/messageSender"
@@ -21,18 +20,18 @@ var (
)
func Init(r *gin.Engine) {
- conf, _ := config.Get()
- AllowCors = conf.AllowCors
+ config, _ := conf.GetWithV1Format()
+ AllowCors = config.AllowCors
event.On(eventType.ConfigUpdated, event.ListenerFunc(func(e event.Event) error {
- newConf := e.Get("new").(models.Config)
- oldConf := e.Get("old").(models.Config)
- AllowCors = newConf.AllowCors
- public.UpdateIndex(newConf)
- if newConf.GeoIpProvider != oldConf.GeoIpProvider {
+ newConf := e.Get("new").(conf.Config)
+ oldConf := e.Get("old").(conf.Config)
+ AllowCors = newConf.Site.AllowCors
+ public.UpdateIndex(newConf.ToV1Format())
+ if newConf.GeoIp.GeoIpProvider != oldConf.GeoIp.GeoIpProvider {
go geoip.InitGeoIp()
}
- if newConf.NotificationMethod != oldConf.NotificationMethod {
+ if newConf.Notification.NotificationMethod != oldConf.Notification.NotificationMethod {
go messageSender.Initialize()
}
return nil
@@ -58,9 +57,9 @@ func Init(r *gin.Engine) {
r.NoRoute(handlers...)
})
// #region 静态文件服务
- public.UpdateIndex(conf)
+ public.UpdateIndex(config)
- internal.LoadApiV1Routes(r, conf)
+ internal.LoadApiV1Routes(r, config)
api_rpc.RegisterRouters("/api/rpc2", r)
}