From 3729269fa3d1c3e8184492df2ee9f0c0cfcef782 Mon Sep 17 00:00:00 2001 From: Akizon77 Date: Sat, 15 Nov 2025 17:18:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E5=86=99Config=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 4 +- .github/workflows/development.yml | 8 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/release-docker.yml | 2 +- .github/workflows/release.yml | 2 +- cmd/flags/config.go | 4 +- cmd/nezha_compat.go | 6 +- cmd/permitPasswordLogin.go | 4 +- cmd/root.go | 16 +- cmd/server.go | 45 +-- internal/api_rpc/JsonRpc.go | 7 +- internal/api_rpc/common.go | 9 +- internal/api_v1/Common.go | 9 +- internal/api_v1/PrivateSiteMiddleware.go | 4 +- internal/api_v1/admin/messageSender.go | 4 +- internal/api_v1/admin/oauth.go | 4 +- internal/api_v1/admin/settings.go | 11 +- internal/api_v1/admin/test/test.go | 4 +- internal/api_v1/admin/theme.go | 4 +- internal/api_v1/client/autoDiscovery.go | 4 +- internal/api_v1/client/uploadBasicInfo.go | 4 +- internal/api_v1/login.go | 24 +- internal/api_v1/oauth.go | 38 ++- internal/api_v1/ws.go | 4 +- internal/conf/config.go | 251 ++++++++++++++ internal/conf/const.go | 10 + internal/conf/vars.go | 203 ++++++++++++ internal/database/accounts/sessions.go | 4 +- internal/database/config/config.go | 115 ------- internal/database/dbcore/dbcore.go | 387 +--------------------- internal/database/models/config.go | 38 --- internal/database/records/records.go | 9 - internal/database/utils.go | 4 +- internal/geoip/geoip.go | 8 +- internal/internal.go | 4 +- internal/log/gorm.go | 8 +- internal/log/log.go | 2 +- internal/messageSender/sender.go | 8 +- internal/notifier/expire.go | 4 +- internal/notifier/offline.go | 4 +- internal/notifier/traffic.go | 4 +- internal/oauth/oauth.go | 4 +- internal/patch/patch.go | 38 +++ internal/patch/v0.0.5.go | 66 ++++ internal/patch/v1.0.2.go | 133 ++++++++ internal/patch/v1.1.4.go | 77 +++++ internal/restore/restore.go | 207 ++++++++++++ internal/version/ver.go | 6 - main.go | 6 +- public/public.go | 11 +- server/grpc.go | 16 +- server/routers.go | 23 +- 52 files changed, 1178 insertions(+), 695 deletions(-) create mode 100644 internal/conf/config.go create mode 100644 internal/conf/const.go create mode 100644 internal/conf/vars.go delete mode 100644 internal/database/config/config.go create mode 100644 internal/patch/patch.go create mode 100644 internal/patch/v0.0.5.go create mode 100644 internal/patch/v1.0.2.go create mode 100644 internal/patch/v1.1.4.go create mode 100644 internal/restore/restore.go delete mode 100644 internal/version/ver.go 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) }