diff --git a/cmd/main.go b/cmd/main.go index 133a2db..777745f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,11 +6,10 @@ import ( "log/slog" "net" "os" - "path/filepath" "strings" "kra/internal/app" - "kra/internal/conf" + "kra/internal/config" "kra/internal/server/router" "kra/internal/service" "kra/internal/service/dto" @@ -21,8 +20,6 @@ import ( platformtask "kra/pkg/task" "github.com/go-kratos/kratos/v3" - "github.com/go-kratos/kratos/v3/config" - "github.com/go-kratos/kratos/v3/config/file" "github.com/go-kratos/kratos/v3/log" kratoshttp "github.com/go-kratos/kratos/v3/transport/http" @@ -47,8 +44,8 @@ func init() { // runtimeContributions is the binary-level list of modules with constructed // route or task dependencies. Adding another runtime module is explicit here. -func runtimeContributions(systemRoutes *router.Routes, systemTasks *worker.TaskMethods) app.RuntimeContributions { - return app.RuntimeContributions{ +func runtimeContributions(systemRoutes *router.Routes, systemTasks *worker.TaskMethods) app.Composition { + return app.Composition{ Routes: []module.RouteRegistrar{systemRoutes}, Tasks: []platformtask.Contributor{systemTasks}, } @@ -73,7 +70,7 @@ func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskSc ) } -func zapSettings(admin *conf.AdminBackend) (string, logging.Options) { +func zapSettings(admin *config.Admin) (string, logging.Options) { options := logging.Options{Level: "info", Format: "json", EncodeLevel: "LowercaseLevelEncoder", LogInConsole: true, ShowLine: true, RetentionDay: 7} root := "logs" if admin == nil || admin.Zap == nil { @@ -87,14 +84,14 @@ func zapSettings(admin *conf.AdminBackend) (string, logging.Options) { return root, options } -func httpAddress(server *conf.Server) string { - if server != nil && server.Http != nil && server.Http.Addr != "" { - return server.Http.Addr +func httpAddress(server *config.Server) string { + if server != nil && server.HTTP != nil && server.HTTP.Addr != "" { + return server.HTTP.Addr } return ":8000" } -func swaggerAddress(server *conf.Server, admin *conf.AdminBackend) string { +func swaggerAddress(server *config.Server, admin *config.Admin) string { address := httpAddress(server) if strings.HasPrefix(address, ":") { address = "127.0.0.1" + address @@ -113,45 +110,35 @@ func swaggerAddress(server *conf.Server, admin *conf.AdminBackend) string { func main() { flag.Parse() - c := config.New( - config.WithSource( - file.NewSource(flagconf), - ), - ) - defer c.Close() - - if err := c.Load(); err != nil { + runtime, err := config.LoadStore(flagconf) + if err != nil { panic(err) } - - var bc conf.Bootstrap - if err := c.Scan(&bc); err != nil { - panic(err) + defer runtime.Close() + bc := runtime.Snapshot() + if bc == nil { + bc = &config.Config{} } logRoot, logOptions := zapSettings(bc.Admin) loggerAttrs := []any{slog.String("service.id", id), slog.String("service.name", Name), slog.String("service.version", Version)} if bc.Admin != nil && bc.Admin.App != nil { - loggerAttrs = append(loggerAttrs, slog.String("node", bc.Admin.App.Node), slog.String("app_id", bc.Admin.App.AppId), slog.String("env", bc.Admin.App.Env)) + loggerAttrs = append(loggerAttrs, slog.String("node", bc.Admin.App.Node), slog.String("app_id", bc.Admin.App.AppID), slog.String("env", bc.Admin.App.Env)) } logger, loggerControl := logging.NewReloadableZapLogger(logRoot, "application.log", logOptions, loggerAttrs...) defer loggerControl.Close() slog.SetDefault(logger) log.SetDefault(logger) - if bc.Admin != nil { - bc.Admin.ConfigPath = flagconf - if info, err := os.Stat(flagconf); err == nil && info.IsDir() { - bc.Admin.ConfigPath = filepath.Join(flagconf, "config.yaml") + unsubscribeLogger := runtime.Subscribe(func(value *config.Config) { + var admin *config.Admin + if value != nil { + admin = value.Admin } - } - - runtime := conf.NewRuntime(bc.Data, bc.Admin) - unsubscribeLogger := runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) { root, options := zapSettings(admin) loggerControl.Reload(root, options) }) defer unsubscribeLogger() - app, cleanup, err := wireApp(bc.Server, runtime, logger, loggerControl, Version) + application, cleanup, err := wireApp(bc.Server, runtime, logger, loggerControl, Version) if err != nil { panic(err) } @@ -159,7 +146,7 @@ func main() { logger.Info("Kra administration service initialized", "mod", "system", "version", Version, "http_address", httpAddress(bc.Server), "swagger", swaggerAddress(bc.Server, bc.Admin), "admin_frontend", "http://127.0.0.1:8080") // start and wait for stop signal - if err := app.Run(); err != nil { + if err := application.Run(); err != nil { panic(err) } } diff --git a/cmd/main_test.go b/cmd/main_test.go index 135d2b5..c33e34a 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -3,19 +3,19 @@ package main import ( "testing" - "kra/internal/conf" + "kra/internal/config" ) func TestSwaggerAddress(t *testing.T) { tests := []struct { name string - server *conf.Server - admin *conf.AdminBackend + server *config.Server + admin *config.Admin want string }{ {name: "default", want: "http://127.0.0.1:8000/swagger/index.html"}, - {name: "wildcard with prefix", server: &conf.Server{Http: &conf.Server_HTTP{Addr: "0.0.0.0:9000"}}, admin: &conf.AdminBackend{RouterPrefix: "/admin/"}, want: "http://127.0.0.1:9000/admin/swagger/index.html"}, - {name: "host", server: &conf.Server{Http: &conf.Server_HTTP{Addr: "example.test:8080"}}, want: "http://example.test:8080/swagger/index.html"}, + {name: "wildcard with prefix", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "0.0.0.0:9000"}}, admin: &config.Admin{RouterPrefix: "/admin/"}, want: "http://127.0.0.1:9000/admin/swagger/index.html"}, + {name: "host", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "example.test:8080"}}, want: "http://example.test:8080/swagger/index.html"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/cmd/wire.go b/cmd/wire.go index f834bef..5172fca 100644 --- a/cmd/wire.go +++ b/cmd/wire.go @@ -11,7 +11,7 @@ import ( "kra/internal/app" "kra/internal/biz" taskbiz "kra/internal/biz/task" - "kra/internal/conf" + "kra/internal/config" "kra/internal/data" "kra/internal/initialize" "kra/internal/integration" @@ -31,7 +31,7 @@ import ( ) // wireApp init kratos application. -func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, *logging.ReloadableLogger, string) (*kratos.App, func(), error) { +func wireApp(*config.Server, *config.Store, *slog.Logger, *logging.ReloadableLogger, string) (*kratos.App, func(), error) { panic(wire.Build( server.ProviderSet, handler.ProviderSet, @@ -40,7 +40,7 @@ func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, *logging.ReloadableLogge modules.Catalog, app.TaskRegistry, runtimeContributions, - app.Runtime, + app.Build, data.ProviderSet, integration.ProviderSet, initialize.ProviderSet, diff --git a/go.mod b/go.mod index eb69ace..826ccbf 100644 --- a/go.mod +++ b/go.mod @@ -109,6 +109,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect @@ -140,14 +141,21 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/mozillazg/go-httpheader v0.2.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sijms/go-ora/v2 v2.7.17 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/thoas/go-funk v0.7.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect @@ -166,6 +174,7 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/image v0.23.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/go.sum b/go.sum index 42cf532..b992629 100644 --- a/go.sum +++ b/go.sum @@ -183,6 +183,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= @@ -315,6 +317,8 @@ github.com/olahol/melody v1.4.0 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI= github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= @@ -353,10 +357,22 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM= github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U= github.com/sijms/go-ora/v2 v2.7.17 h1:M/pYIqjaMUeBxyzOWp2oj4ntF6fHSBloJWGNH9vbmsU= github.com/sijms/go-ora/v2 v2.7.17/go.mod h1:EHxlY6x7y9HAsdfumurRfTd+v8NrEOTR3Xl4FWlH6xk= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -372,6 +388,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= @@ -435,6 +453,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 362490c..dc28930 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -17,15 +17,15 @@ func TaskRegistry(catalog module.Catalog) *platformtask.Registry { return registry } -// RuntimeContributions groups runtime objects that need constructed -// dependencies. The binary composition root supplies the concrete modules. -type RuntimeContributions struct { +// Composition groups runtime objects that need constructed dependencies. The +// binary composition root supplies the concrete modules. +type Composition struct { Routes []module.RouteRegistrar Tasks []platformtask.Contributor } -// Runtime activates dependency-bearing tasks and composes module routes. -func Runtime(contributions RuntimeContributions, registry *platformtask.Registry) *module.Runtime { +// Build activates dependency-bearing tasks and composes module routes. +func Build(contributions Composition, registry *platformtask.Registry) *module.Runtime { platformtask.Apply(registry, contributions.Tasks...) return module.NewRuntime(contributions.Routes...) } diff --git a/internal/app/runtime_test.go b/internal/app/runtime_test.go index e59dc7d..de844e4 100644 --- a/internal/app/runtime_test.go +++ b/internal/app/runtime_test.go @@ -40,7 +40,7 @@ func (contributor testTaskContributor) RegisterTasks(registry *platformtask.Regi func TestRuntimeAppliesAllContributions(t *testing.T) { registry := platformtask.NewRegistry() route := &testRouteRegistrar{} - runtime := Runtime(RuntimeContributions{ + runtime := Build(Composition{ Routes: []module.RouteRegistrar{route}, Tasks: []platformtask.Contributor{testTaskContributor{name: "test.runtime"}}, }, registry) diff --git a/internal/biz/system/media.go b/internal/biz/system/media.go index 155ffc4..bcc673b 100644 --- a/internal/biz/system/media.go +++ b/internal/biz/system/media.go @@ -13,6 +13,7 @@ import ( "time" "github.com/google/uuid" + "kra/internal/utils/uploadpolicy" ) type MediaRepo interface { @@ -39,23 +40,8 @@ func (uc *MediaUsecase) maxMediaFileSize() int64 { return uc.settings.MediaSettings().EffectiveMaxFileSize() } -var allowedMediaExtensions = map[string]bool{ - ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".bmp": true, ".ico": true, ".avif": true, - ".mp3": true, ".wav": true, ".ogg": true, ".m4a": true, ".flac": true, ".aac": true, - ".mp4": true, ".webm": true, ".mov": true, ".avi": true, ".mkv": true, - ".txt": true, ".md": true, ".csv": true, ".json": true, ".log": true, ".pdf": true, - ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, - ".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, ".tgz": true, ".bin": true, -} - func validateMediaName(name string) error { - if name == "" || strings.TrimSpace(name) != name || strings.ContainsAny(name, `/\`) { - return errors.New("file extension is not allowed") - } - if !allowedMediaExtensions[strings.ToLower(filepath.Ext(name))] { - return errors.New("file extension is not allowed") - } - return nil + return uploadpolicy.ValidateFileName(name) } func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedMIME string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) { if err := validateMediaName(name); err != nil { @@ -69,7 +55,7 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedM // http.DetectContentType changes the media record for common uploads // (for example SVG/JSON), and diverges from the compatible DetectMIME helper. if strings.TrimSpace(suppliedMIME) == "" { - if detected := mediaMIMEByExtension(ext); detected != "" { + if detected := uploadpolicy.MIMEByExtension(ext); detected != "" { suppliedMIME = detected } else if header, _ := buffered.Peek(512); len(header) > 0 { suppliedMIME = http.DetectContentType(header) @@ -113,29 +99,6 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedM return media, nil } -// mediaMIMEByExtension mirrors the small extension fallback table. It is -// intentionally narrower than mime.TypeByExtension: all other types are sniffed. -// types from the first 512 bytes. -func mediaMIMEByExtension(ext string) string { - switch ext { - case ".json": - return "application/json" - case ".xml": - return "application/xml" - case ".pdf": - return "application/pdf" - case ".zip": - return "application/zip" - case ".mp4": - return "video/mp4" - case ".mp3": - return "audio/mpeg" - case ".csv": - return "text/csv" - default: - return "" - } -} func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error { media, err := uc.FindMedia(ctx, id) if err != nil { diff --git a/internal/biz/system/system_init.go b/internal/biz/system/system_init.go index 78f7001..297aea0 100644 --- a/internal/biz/system/system_init.go +++ b/internal/biz/system/system_init.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "kra/internal/config" ) var ErrTaskRuntimeReload = errors.New("task runtime reload failed") @@ -22,8 +23,7 @@ type InitializationRepo interface { IsInitialized(context.Context) (bool, error) Initialize(context.Context, *DatabaseConfig) error PersistConfig(context.Context) error - PersistAdminConfig(context.Context, []byte) error - PersistRuntimeConfig(context.Context, []byte, []byte) error + PersistRuntimeConfig(context.Context, *config.Config) error ReloadConfig(context.Context) error ConfigurationJSON() (json.RawMessage, error) SaveConfigurationJSON(context.Context, json.RawMessage) error @@ -63,11 +63,8 @@ func (uc *SystemConfigUsecase) Initialize(ctx context.Context, config *DatabaseC func (uc *SystemConfigUsecase) PersistConfig(ctx context.Context) error { return uc.repo.PersistConfig(ctx) } -func (uc *SystemConfigUsecase) PersistAdminConfig(ctx context.Context, value []byte) error { - return uc.repo.PersistAdminConfig(ctx, value) -} -func (uc *SystemConfigUsecase) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error { - return uc.repo.PersistRuntimeConfig(ctx, data, admin) +func (uc *SystemConfigUsecase) PersistRuntimeConfig(ctx context.Context, value *config.Config) error { + return uc.repo.PersistRuntimeConfig(ctx, value) } func (uc *SystemConfigUsecase) ReloadConfig(ctx context.Context) error { if err := uc.repo.ReloadConfig(ctx); err != nil { diff --git a/internal/biz/task/task.go b/internal/biz/task/task.go index e2d6b14..bc78e64 100644 --- a/internal/biz/task/task.go +++ b/internal/biz/task/task.go @@ -9,6 +9,7 @@ import ( "time" "github.com/robfig/cron/v3" + platformtask "kra/pkg/task" ) type TimedTask struct { @@ -77,13 +78,9 @@ type TaskUsecase struct { methods TaskMethodRegistry } -func NewTaskUsecase(repo TaskRepo) *TaskUsecase { - return NewTaskUsecaseWithRegistry(repo, DefaultTaskMethodRegistry()) -} - func NewTaskUsecaseWithRegistry(repo TaskRepo, methods TaskMethodRegistry) *TaskUsecase { if methods == nil { - methods = DefaultTaskMethodRegistry() + methods = platformtask.NewRegistry() } return &TaskUsecase{TaskRepo: repo, methods: methods} } diff --git a/internal/biz/task/task_registry.go b/internal/biz/task/task_registry.go index c19fde0..6373be7 100644 --- a/internal/biz/task/task_registry.go +++ b/internal/biz/task/task_registry.go @@ -6,23 +6,6 @@ type TaskMethodFunc = platformtask.MethodFunc type TaskMethod = platformtask.Method type TaskMethodRegistry interface { - Register(platformtask.Method) Lookup(string) (TaskMethodFunc, bool) List() []TaskMethod } - -var defaultTaskMethods = platformtask.NewRegistry() - -// RegisterTaskMethod remains as a compatibility helper for tests and callers -// that have not moved to constructor injection yet. -func RegisterTaskMethod(name, description string, fn TaskMethodFunc) { - defaultTaskMethods.Register(platformtask.Method{Name: name, Description: description, Run: fn}) -} - -func TaskMethodByName(name string) (TaskMethodFunc, bool) { - return defaultTaskMethods.Lookup(name) -} - -func RegisteredTaskMethods() []TaskMethod { return defaultTaskMethods.List() } - -func DefaultTaskMethodRegistry() TaskMethodRegistry { return defaultTaskMethods } diff --git a/internal/biz/task/task_test.go b/internal/biz/task/task_test.go index 408f22a..5503e0a 100644 --- a/internal/biz/task/task_test.go +++ b/internal/biz/task/task_test.go @@ -4,11 +4,14 @@ import ( "context" "encoding/json" "errors" + platformtask "kra/pkg/task" "sync" "testing" "time" ) +var testTaskMethods = platformtask.NewRegistry() + type applicationTaskRepo struct { mu sync.Mutex deleted bool @@ -112,14 +115,14 @@ func (r *applicationTaskRuntime) Unsubscribe(uint, chan []byte) {} func validApplicationTask(id uint) *TimedTask { const methodName = "biz-test-application-task" - RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { return nil }) + testTaskMethods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error { return nil }}) return &TimedTask{ID: id, Name: "test", Spec: "0 0 * * *", ExecutorType: TaskExecutorMethod, MethodName: methodName, Enabled: true} } func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) { repo := &applicationTaskRepo{createdID: 41} runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")} - uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) + uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -137,7 +140,7 @@ func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) { func TestTaskApplicationReturnsScheduleErrorWhenRepairFails(t *testing.T) { repo := &applicationTaskRepo{createdID: 42} runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed"), reloadErr: errors.New("reload failed")} - uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) + uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime) err := uc.Create(context.Background(), validApplicationTask(0)) if !errors.Is(err, ErrTaskSchedule) { @@ -158,7 +161,7 @@ func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) { t.Run(test.name, func(t *testing.T) { repo := &applicationTaskRepo{} runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")} - uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) + uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime) if err := test.run(uc); err != nil { t.Fatalf("operation error = %v", err) } @@ -172,7 +175,7 @@ func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) { func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) { repo := &applicationTaskRepo{} runtime := &applicationTaskRuntime{repo: repo} - uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) + uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime) if err := uc.Delete(context.Background(), 8); err != nil { t.Fatalf("Delete() error = %v", err) } @@ -187,7 +190,7 @@ func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) { func TestTaskApplicationDeleteKeepsRuntimeWhenDatabaseFails(t *testing.T) { repo := &applicationTaskRepo{deleteErr: errors.New("delete failed")} runtime := &applicationTaskRuntime{repo: repo} - uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime) + uc := NewTaskApplicationUsecase(NewTaskUsecaseWithRegistry(repo, testTaskMethods), runtime) if err := uc.Delete(context.Background(), 8); err == nil { t.Fatal("Delete() error = nil, want database error") } diff --git a/internal/config/clone.go b/internal/config/clone.go new file mode 100644 index 0000000..513d83c --- /dev/null +++ b/internal/config/clone.go @@ -0,0 +1,41 @@ +package config + +// Clone returns a deep copy of a configuration snapshot. The copy helpers are +// intentionally kept in the config package so data/integration code does not +// need protobuf cloning or storage-specific serializers. +func Clone(value *Config) *Config { return cloneConfig(value) } + +func CloneData(value *Data) *Data { + if value == nil { + return nil + } + return cloneConfig(&Config{Data: value}).Data +} + +func CloneAdmin(value *Admin) *Admin { + if value == nil { + return nil + } + return cloneConfig(&Config{Admin: value}).Admin +} + +func CloneDatabase(value *Database) *Database { + if value == nil { + return nil + } + return cloneConfig(&Config{Data: &Data{Database: value}}).Data.Database +} + +func CloneStorage(value *Storage) *Storage { + if value == nil { + return nil + } + return cloneConfig(&Config{Admin: &Admin{Storage: value}}).Admin.Storage +} + +func CloneEmail(value *Email) *Email { + if value == nil { + return nil + } + return cloneConfig(&Config{Admin: &Admin{Email: value}}).Admin.Email +} diff --git a/internal/config/runtime.go b/internal/config/runtime.go new file mode 100644 index 0000000..ad7cf1b --- /dev/null +++ b/internal/config/runtime.go @@ -0,0 +1,502 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/go-viper/mapstructure/v2" + "github.com/spf13/viper" +) + +var ErrConfigPathRequired = errors.New("configuration path is required") + +// Store owns an immutable configuration snapshot and its Viper-backed file +// watcher. +// Every accessor returns a deep copy, so readers can safely use it without +// coordinating with a reload in another goroutine. +type Store struct { + snapshot atomic.Pointer[Config] + path string + mu sync.RWMutex + listeners map[uint64]func(*Config) + nextID uint64 + watcher *fsnotify.Watcher + stop chan struct{} + done chan struct{} +} + +// Load reads a YAML/JSON/TOML configuration file or a directory containing +// config.yaml. Environment variables with KRA_ prefix override file values; +// nested keys use underscores (for example KRA_ADMIN_JWT_SIGNING_KEY). +func Load(path string) (*Config, error) { + resolved, err := resolvePath(path) + if err != nil { + return nil, err + } + v := newViper(resolved) + if err = v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("read configuration %q: %w", resolved, err) + } + config, err := decode(v) + if err != nil { + return nil, fmt.Errorf("decode configuration %q: %w", resolved, err) + } + setConfigPath(config, resolved) + return config, nil +} + +// NewStore creates a store from an already decoded snapshot. The input is +// copied immediately and may be reused or mutated by the caller afterwards. +func NewStore(config *Config) *Store { + store := &Store{listeners: make(map[uint64]func(*Config))} + if config != nil && config.Admin != nil { + store.path = config.Admin.ConfigPath + } + store.Replace(config) + return store +} + +// LoadStore loads the initial snapshot and starts watching its source file. +func LoadStore(path string) (*Store, error) { + config, err := Load(path) + if err != nil { + return nil, err + } + store := NewStore(config) + if err = store.Watch(); err != nil { + store.Close() + return nil, err + } + return store, nil +} + +func (r *Store) Snapshot() *Config { + if r == nil { + return nil + } + return cloneConfig(r.snapshot.Load()) +} + +func (r *Store) Server() *Server { + config := r.Snapshot() + if config == nil { + return nil + } + return config.Server +} + +func (r *Store) Data() *Data { + config := r.Snapshot() + if config == nil { + return nil + } + return config.Data +} + +func (r *Store) Admin() *Admin { + config := r.Snapshot() + if config == nil { + return nil + } + return config.Admin +} + +func (r *Store) Values() (*Data, *Admin) { + config := r.Snapshot() + if config == nil { + return nil, nil + } + return config.Data, config.Admin +} + +func (r *Store) UpdateDatabase(database *Database) { + if r == nil { + return + } + config := r.Snapshot() + if config == nil { + config = &Config{} + } + if config.Data == nil { + config.Data = &Data{} + } + config.Data.Database = cloneDatabase(database) + r.replace(config, false) +} + +func (r *Store) ConfigPath() string { + if r == nil { + return "" + } + r.mu.RLock() + path := r.path + r.mu.RUnlock() + return path +} + +func (r *Store) Replace(config *Config) { + r.replace(config, true) +} + +func (r *Store) replace(config *Config, notify bool) { + if r == nil { + return + } + next := cloneConfig(config) + if next == nil { + next = &Config{} + } + if path := r.ConfigPath(); path != "" { + setConfigPath(next, path) + } + r.snapshot.Store(next) + if !notify { + return + } + r.mu.RLock() + listeners := make([]func(*Config), 0, len(r.listeners)) + for _, listener := range r.listeners { + listeners = append(listeners, listener) + } + r.mu.RUnlock() + for _, listener := range listeners { + listener(cloneConfig(next)) + } +} + +// Subscribe registers a callback after each successful replacement. The +// callback is invoked outside the runtime lock, and the returned function is +// idempotent so cleanup can safely be deferred by multiple owners. +func (r *Store) Subscribe(listener func(*Config)) func() { + if r == nil || listener == nil { + return func() {} + } + r.mu.Lock() + r.nextID++ + id := r.nextID + r.listeners[id] = listener + r.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + r.mu.Lock() + delete(r.listeners, id) + r.mu.Unlock() + }) + } +} + +// Watch starts watching the source directory. Repeated calls are harmless. +func (r *Store) Watch() error { + if r == nil { + return ErrConfigPathRequired + } + path := r.ConfigPath() + if path == "" { + return ErrConfigPathRequired + } + r.mu.Lock() + if r.watcher != nil { + r.mu.Unlock() + return nil + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + r.mu.Unlock() + return fmt.Errorf("create configuration watcher: %w", err) + } + if err = watcher.Add(filepath.Dir(path)); err != nil { + _ = watcher.Close() + r.mu.Unlock() + return fmt.Errorf("watch configuration directory: %w", err) + } + r.watcher = watcher + r.stop = make(chan struct{}) + r.done = make(chan struct{}) + stop, done := r.stop, r.done + r.mu.Unlock() + go r.watchLoop(watcher, stop, done, path) + return nil +} + +func (r *Store) watchLoop(watcher *fsnotify.Watcher, stop <-chan struct{}, done chan<- struct{}, path string) { + defer close(done) + const debounce = 100 * time.Millisecond + var timer *time.Timer + var timerC <-chan time.Time + events := watcher.Events + errors := watcher.Errors + for { + select { + case event, ok := <-events: + if !ok { + events = nil + if errors == nil { + return + } + continue + } + if filepath.Clean(event.Name) != path || event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + if timer == nil { + timer = time.NewTimer(debounce) + } else { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(debounce) + } + timerC = timer.C + case <-timerC: + timerC = nil + config, err := Load(path) + if err != nil { + // Keep the last valid snapshot. A partially-written file must not + // take down a running process or publish invalid state. + continue + } + r.Replace(config) + case _, ok := <-errors: + // fsnotify errors are intentionally non-fatal; the watcher remains + // useful for subsequent events and Close always terminates it. + if !ok { + errors = nil + if events == nil { + return + } + } + case <-stop: + if timer != nil { + timer.Stop() + } + return + } + } +} + +func (r *Store) Close() { + if r == nil { + return + } + r.mu.Lock() + watcher, stop, done := r.watcher, r.stop, r.done + r.watcher = nil + r.stop = nil + r.done = nil + r.mu.Unlock() + if watcher == nil { + return + } + close(stop) + _ = watcher.Close() + <-done +} + +func resolvePath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", ErrConfigPathRequired + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve configuration path: %w", err) + } + if info, statErr := os.Stat(absolute); statErr == nil && info.IsDir() { + absolute = filepath.Join(absolute, "config.yaml") + } + return filepath.Clean(absolute), nil +} + +func newViper(path string) *viper.Viper { + v := viper.New() + v.SetConfigFile(path) + v.SetConfigType(strings.TrimPrefix(filepath.Ext(path), ".")) + v.SetEnvPrefix("KRA") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + v.AutomaticEnv() + return v +} + +func decode(v *viper.Viper) (*Config, error) { + var config Config + hook := mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ) + if err := v.Unmarshal(&config, viper.DecodeHook(hook)); err != nil { + return nil, err + } + return &config, nil +} + +func setConfigPath(config *Config, path string) { + if config != nil && config.Admin != nil { + config.Admin.ConfigPath = path + } +} + +func cloneConfig(value *Config) *Config { + if value == nil { + return nil + } + // Viper's mapstructure output contains only value fields and pointers; + // round-tripping through a temporary map is unnecessarily lossy for + // durations. Explicit copy via YAML gives us a robust deep copy while + // preserving time.Duration values through the text hook. + copy := *value + if value.Server != nil { + server := *value.Server + copy.Server = &server + if value.Server.HTTP != nil { + http := *value.Server.HTTP + copy.Server.HTTP = &http + } + } + if value.Data != nil { + data := *value.Data + copy.Data = &data + data.Database = cloneDatabase(value.Data.Database) + data.Redis = cloneRedis(value.Data.Redis) + data.Mongo = cloneMongo(value.Data.Mongo) + data.DatabaseList = make([]*Database, len(value.Data.DatabaseList)) + for i, item := range value.Data.DatabaseList { + data.DatabaseList[i] = cloneDatabase(item) + } + data.RedisList = make([]*Redis, len(value.Data.RedisList)) + for i, item := range value.Data.RedisList { + data.RedisList[i] = cloneRedis(item) + } + } + if value.Admin != nil { + copy.Admin = cloneAdmin(value.Admin) + } + return © +} + +func cloneDatabase(value *Database) *Database { + if value == nil { + return nil + } + copy := *value + return © +} +func cloneRedis(value *Redis) *Redis { + if value == nil { + return nil + } + copy := *value + copy.ClusterAddrs = append([]string(nil), value.ClusterAddrs...) + return © +} +func cloneMongo(value *Mongo) *Mongo { + if value == nil { + return nil + } + copy := *value + copy.Hosts = make([]*MongoHost, len(value.Hosts)) + for i, item := range value.Hosts { + if item != nil { + host := *item + copy.Hosts[i] = &host + } + } + return © +} + +func cloneAdmin(value *Admin) *Admin { + if value == nil { + return nil + } + copy := *value + if value.JWT != nil { + item := *value.JWT + copy.JWT = &item + } + if value.Captcha != nil { + item := *value.Captcha + copy.Captcha = &item + } + if value.Local != nil { + item := *value.Local + copy.Local = &item + } + if value.Email != nil { + item := *value.Email + copy.Email = &item + } + if value.Media != nil { + item := *value.Media + copy.Media = &item + } + if value.System != nil { + item := *value.System + copy.System = &item + } + if value.Zap != nil { + item := *value.Zap + item.FileOnlyModules = append([]string(nil), value.Zap.FileOnlyModules...) + copy.Zap = &item + } + if value.App != nil { + item := *value.App + copy.App = &item + } + if value.CORS != nil { + item := *value.CORS + item.Whitelist = make([]*CORSRule, len(value.CORS.Whitelist)) + for i, rule := range value.CORS.Whitelist { + if rule != nil { + next := *rule + item.Whitelist[i] = &next + } + } + copy.CORS = &item + } + if value.Storage != nil { + copy.Storage = cloneStorage(value.Storage) + } + copy.DiskList = make([]*Disk, len(value.DiskList)) + for i, item := range value.DiskList { + if item != nil { + next := *item + copy.DiskList[i] = &next + } + } + return © +} + +func cloneStorage(value *Storage) *Storage { + if value == nil { + return nil + } + copy := *value + if value.Qiniu != nil { + item := *value.Qiniu + copy.Qiniu = &item + } + copy.AliyunOSS = cloneObjectStore(value.AliyunOSS) + copy.HuaweiOBS = cloneObjectStore(value.HuaweiOBS) + copy.TencentCOS = cloneObjectStore(value.TencentCOS) + copy.AWSS3 = cloneObjectStore(value.AWSS3) + copy.CloudflareR2 = cloneObjectStore(value.CloudflareR2) + copy.Minio = cloneObjectStore(value.Minio) + return © +} +func cloneObjectStore(value *ObjectStore) *ObjectStore { + if value == nil { + return nil + } + copy := *value + return © +} diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go new file mode 100644 index 0000000..ddde237 --- /dev/null +++ b/internal/config/runtime_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +func writeConfig(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestLoadDirectoryAndDurations(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + writeConfig(t, path, "server:\n http:\n addr: :9000\n timeout: 600s\ndata:\n redis:\n read_timeout: 200ms\nadmin:\n jwt:\n expires_time: 168h\n media:\n session_ttl: 36\n") + loaded, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if loaded.Server == nil || loaded.Server.HTTP == nil || loaded.Server.HTTP.Timeout != 10*time.Minute { + t.Fatalf("server timeout = %#v", loaded.Server) + } + if loaded.Data == nil || loaded.Data.Redis == nil || loaded.Data.Redis.ReadTimeout != 200*time.Millisecond { + t.Fatalf("redis timeout = %#v", loaded.Data) + } + if loaded.Admin == nil || loaded.Admin.JWT == nil || loaded.Admin.JWT.ExpiresTime != 7*24*time.Hour { + t.Fatalf("jwt duration = %#v", loaded.Admin) + } + if loaded.Admin.ConfigPath != path { + t.Fatalf("config path = %q, want %q", loaded.Admin.ConfigPath, path) + } +} + +func TestLoadRejectsInvalidDuration(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeConfig(t, path, "server:\n http:\n timeout: definitely-not-a-duration\n") + if _, err := Load(path); err == nil { + t.Fatal("invalid duration was accepted") + } +} + +func TestLoadEnvironmentOverride(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + writeConfig(t, path, "server:\n http:\n addr: :8000\n") + t.Setenv("KRA_SERVER_HTTP_ADDR", ":9100") + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + if loaded.Server == nil || loaded.Server.HTTP == nil || loaded.Server.HTTP.Addr != ":9100" { + t.Fatalf("environment override = %#v", loaded.Server) + } +} + +func TestStoreSnapshotIsolationAndSubscriptions(t *testing.T) { + store := NewStore(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "before"}}}) + var calls atomic.Int32 + stop := store.Subscribe(func(value *Config) { + calls.Add(1) + value.Admin.JWT.SigningKey = "callback-mutation" + }) + store.Replace(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "after"}}}) + stop() + stop() + value := store.Snapshot() + if value == nil || value.Admin == nil || value.Admin.JWT == nil || value.Admin.JWT.SigningKey != "after" { + t.Fatalf("snapshot was mutated by callback: %#v", value) + } + if calls.Load() != 1 { + t.Fatalf("callback count = %d", calls.Load()) + } +} + +func TestStoreConcurrentReadersAndReplacements(t *testing.T) { + store := NewStore(&Config{Data: &Data{Database: &Database{Name: "initial"}}}) + var wait sync.WaitGroup + for i := 0; i < 8; i++ { + wait.Add(1) + go func() { + defer wait.Done() + for j := 0; j < 500; j++ { + _ = store.Snapshot() + } + }() + } + for i := 0; i < 500; i++ { + store.Replace(&Config{Data: &Data{Database: &Database{Name: "next"}}}) + } + wait.Wait() +} + +func TestStoreWatchReloadAndClose(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + writeConfig(t, path, "admin:\n app:\n app_id: first\n") + store, err := LoadStore(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + writeConfig(t, path, "admin:\n app:\n app_id: second\n") + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + value := store.Snapshot() + if value != nil && value.Admin != nil && value.Admin.App != nil && value.Admin.App.AppID == "second" { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("watcher did not reload configuration: %#v", store.Snapshot()) +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..f698a8c --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,226 @@ +package config + +import "time" + +// Config is the complete application configuration loaded from config.yaml. +// It intentionally uses ordinary Go types so configuration is independent of +// protobuf generation and can be consumed by every internal module. +type Config struct { + Server *Server `mapstructure:"server" yaml:"server" json:"server"` + Data *Data `mapstructure:"data" yaml:"data" json:"data"` + Admin *Admin `mapstructure:"admin" yaml:"admin" json:"admin"` +} + +type Bootstrap = Config + +type Server struct { + HTTP *ServerHTTP `mapstructure:"http" yaml:"http" json:"http"` +} + +type ServerHTTP struct { + Network string `mapstructure:"network" yaml:"network" json:"network"` + Addr string `mapstructure:"addr" yaml:"addr" json:"addr"` + Timeout time.Duration `mapstructure:"timeout" yaml:"timeout" json:"timeout"` +} + +type Data struct { + Database *Database `mapstructure:"database" yaml:"database" json:"database"` + Redis *Redis `mapstructure:"redis" yaml:"redis" json:"redis"` + DatabaseList []*Database `mapstructure:"database_list" yaml:"database_list" json:"database_list"` + RedisList []*Redis `mapstructure:"redis_list" yaml:"redis_list" json:"redis_list"` + Mongo *Mongo `mapstructure:"mongo" yaml:"mongo" json:"mongo"` +} + +type Database struct { + Driver string `mapstructure:"driver" yaml:"driver" json:"driver"` + Source string `mapstructure:"source" yaml:"source" json:"source"` + Host string `mapstructure:"host" yaml:"host" json:"host"` + Port string `mapstructure:"port" yaml:"port" json:"port"` + User string `mapstructure:"user" yaml:"user" json:"user"` + Password string `mapstructure:"password" yaml:"password" json:"password"` + Name string `mapstructure:"name" yaml:"name" json:"name"` + Config string `mapstructure:"config" yaml:"config" json:"config"` + Path string `mapstructure:"path" yaml:"path" json:"path"` + AliasName string `mapstructure:"alias_name" yaml:"alias_name" json:"alias_name"` + Disable bool `mapstructure:"disable" yaml:"disable" json:"disable"` + Prefix string `mapstructure:"prefix" yaml:"prefix" json:"prefix"` + Engine string `mapstructure:"engine" yaml:"engine" json:"engine"` + LogMode string `mapstructure:"log_mode" yaml:"log_mode" json:"log_mode"` + MaxIdleConns int `mapstructure:"max_idle_conns" yaml:"max_idle_conns" json:"max_idle_conns"` + MaxOpenConns int `mapstructure:"max_open_conns" yaml:"max_open_conns" json:"max_open_conns"` + // ConnMaxLifetime is expressed in seconds for compatibility with the + // existing configuration contract. A bare YAML number must not mean ns. + ConnMaxLifetime int `mapstructure:"conn_max_lifetime" yaml:"conn_max_lifetime" json:"conn_max_lifetime"` + Singular bool `mapstructure:"singular" yaml:"singular" json:"singular"` +} + +type Redis struct { + Network string `mapstructure:"network" yaml:"network" json:"network"` + Addr string `mapstructure:"addr" yaml:"addr" json:"addr"` + ReadTimeout time.Duration `mapstructure:"read_timeout" yaml:"read_timeout" json:"read_timeout"` + WriteTimeout time.Duration `mapstructure:"write_timeout" yaml:"write_timeout" json:"write_timeout"` + Name string `mapstructure:"name" yaml:"name" json:"name"` + Password string `mapstructure:"password" yaml:"password" json:"password"` + DB int `mapstructure:"db" yaml:"db" json:"db"` + UseCluster bool `mapstructure:"use_cluster" yaml:"use_cluster" json:"use_cluster"` + ClusterAddrs []string `mapstructure:"cluster_addrs" yaml:"cluster_addrs" json:"cluster_addrs"` +} + +type MongoHost struct { + Host string `mapstructure:"host" yaml:"host" json:"host"` + Port string `mapstructure:"port" yaml:"port" json:"port"` +} + +type Mongo struct { + Coll string `mapstructure:"coll" yaml:"coll" json:"coll"` + Options string `mapstructure:"options" yaml:"options" json:"options"` + Database string `mapstructure:"database" yaml:"database" json:"database"` + Username string `mapstructure:"username" yaml:"username" json:"username"` + Password string `mapstructure:"password" yaml:"password" json:"password"` + AuthSource string `mapstructure:"auth_source" yaml:"auth_source" json:"auth_source"` + MinPoolSize uint64 `mapstructure:"min_pool_size" yaml:"min_pool_size" json:"min_pool_size"` + MaxPoolSize uint64 `mapstructure:"max_pool_size" yaml:"max_pool_size" json:"max_pool_size"` + SocketTimeoutMs int64 `mapstructure:"socket_timeout_ms" yaml:"socket_timeout_ms" json:"socket_timeout_ms"` + ConnectTimeoutMs int64 `mapstructure:"connect_timeout_ms" yaml:"connect_timeout_ms" json:"connect_timeout_ms"` + IsZap bool `mapstructure:"is_zap" yaml:"is_zap" json:"is_zap"` + Hosts []*MongoHost `mapstructure:"hosts" yaml:"hosts" json:"hosts"` +} + +// Admin contains administration-service settings. ConfigPath is runtime +// metadata populated by the loader and never decoded from or written to YAML. +type Admin struct { + RouterPrefix string `mapstructure:"router_prefix" yaml:"router_prefix" json:"router_prefix"` + JWT *JWT `mapstructure:"jwt" yaml:"jwt" json:"jwt"` + Captcha *Captcha `mapstructure:"captcha" yaml:"captcha" json:"captcha"` + Local *Local `mapstructure:"local" yaml:"local" json:"local"` + Email *Email `mapstructure:"email" yaml:"email" json:"email"` + Storage *Storage `mapstructure:"storage" yaml:"storage" json:"storage"` + ConfigPath string `mapstructure:"-" yaml:"-" json:"-"` + Media *Media `mapstructure:"media" yaml:"media" json:"media"` + DiskList []*Disk `mapstructure:"disk_list" yaml:"disk_list" json:"disk_list"` + System *System `mapstructure:"system" yaml:"system" json:"system"` + Zap *Zap `mapstructure:"zap" yaml:"zap" json:"zap"` + CORS *CORS `mapstructure:"cors" yaml:"cors" json:"cors"` + App *App `mapstructure:"app" yaml:"app" json:"app"` +} + +type JWT struct { + SigningKey string `mapstructure:"signing_key" yaml:"signing_key" json:"signing_key"` + ExpiresTime time.Duration `mapstructure:"expires_time" yaml:"expires_time" json:"expires_time"` + BufferTime time.Duration `mapstructure:"buffer_time" yaml:"buffer_time" json:"buffer_time"` + Issuer string `mapstructure:"issuer" yaml:"issuer" json:"issuer"` +} + +type Captcha struct { + KeyLong int `mapstructure:"key_long" yaml:"key_long" json:"key_long"` + ImgWidth int `mapstructure:"img_width" yaml:"img_width" json:"img_width"` + ImgHeight int `mapstructure:"img_height" yaml:"img_height" json:"img_height"` + StoreExpiration time.Duration `mapstructure:"store_expiration" yaml:"store_expiration" json:"store_expiration"` +} + +type Local struct { + StorePath string `mapstructure:"store_path" yaml:"store_path" json:"store_path"` + PathPrefix string `mapstructure:"path_prefix" yaml:"path_prefix" json:"path_prefix"` +} + +type Email struct { + To string `mapstructure:"to" yaml:"to" json:"to"` + From string `mapstructure:"from" yaml:"from" json:"from"` + Host string `mapstructure:"host" yaml:"host" json:"host"` + Secret string `mapstructure:"secret" yaml:"secret" json:"secret"` + Nickname string `mapstructure:"nickname" yaml:"nickname" json:"nickname"` + Port int `mapstructure:"port" yaml:"port" json:"port"` + IsSSL bool `mapstructure:"is_ssl" yaml:"is_ssl" json:"is_ssl"` + IsLoginAuth bool `mapstructure:"is_login_auth" yaml:"is_login_auth" json:"is_login_auth"` +} + +type Media struct { + SessionTTL int `mapstructure:"session_ttl" yaml:"session_ttl" json:"session_ttl"` + MaxFileSize int64 `mapstructure:"max_file_size" yaml:"max_file_size" json:"max_file_size"` + ChunkDir string `mapstructure:"chunk_dir" yaml:"chunk_dir" json:"chunk_dir"` +} + +type Disk struct { + MountPoint string `mapstructure:"mount_point" yaml:"mount_point" json:"mount_point"` +} + +type System struct { + UseRedis bool `mapstructure:"use_redis" yaml:"use_redis" json:"use_redis"` + UseMultipoint bool `mapstructure:"use_multipoint" yaml:"use_multipoint" json:"use_multipoint"` + UseStrictAuth bool `mapstructure:"use_strict_auth" yaml:"use_strict_auth" json:"use_strict_auth"` + DisableAutoMigrate bool `mapstructure:"disable_auto_migrate" yaml:"disable_auto_migrate" json:"disable_auto_migrate"` + UseMongo bool `mapstructure:"use_mongo" yaml:"use_mongo" json:"use_mongo"` + Addr int `mapstructure:"addr" yaml:"addr" json:"addr"` + IplimitCount int `mapstructure:"iplimit_count" yaml:"iplimit_count" json:"iplimit_count"` + IplimitTime int `mapstructure:"iplimit_time" yaml:"iplimit_time" json:"iplimit_time"` +} + +type Zap struct { + Level string `mapstructure:"level" yaml:"level" json:"level"` + Prefix string `mapstructure:"prefix" yaml:"prefix" json:"prefix"` + Format string `mapstructure:"format" yaml:"format" json:"format"` + Director string `mapstructure:"director" yaml:"director" json:"director"` + EncodeLevel string `mapstructure:"encode_level" yaml:"encode_level" json:"encode_level"` + StacktraceKey string `mapstructure:"stacktrace_key" yaml:"stacktrace_key" json:"stacktrace_key"` + ShowLine bool `mapstructure:"show_line" yaml:"show_line" json:"show_line"` + LogInConsole bool `mapstructure:"log_in_console" yaml:"log_in_console" json:"log_in_console"` + RetentionDay int `mapstructure:"retention_day" yaml:"retention_day" json:"retention_day"` + AccessReqBody bool `mapstructure:"access_req_body" yaml:"access_req_body" json:"access_req_body"` + AccessRespData bool `mapstructure:"access_resp_data" yaml:"access_resp_data" json:"access_resp_data"` + AccessReqHeaders bool `mapstructure:"access_req_headers" yaml:"access_req_headers" json:"access_req_headers"` + AccessLogMaxBytes int `mapstructure:"access_log_max_bytes" yaml:"access_log_max_bytes" json:"access_log_max_bytes"` + FileOnlyModules []string `mapstructure:"file_only_modules" yaml:"file_only_modules" json:"file_only_modules"` +} + +type CORS struct { + Mode string `mapstructure:"mode" yaml:"mode" json:"mode"` + Whitelist []*CORSRule `mapstructure:"whitelist" yaml:"whitelist" json:"whitelist"` +} + +type CORSRule struct { + AllowOrigin string `mapstructure:"allow_origin" yaml:"allow_origin" json:"allow_origin"` + AllowMethods string `mapstructure:"allow_methods" yaml:"allow_methods" json:"allow_methods"` + AllowHeaders string `mapstructure:"allow_headers" yaml:"allow_headers" json:"allow_headers"` + ExposeHeaders string `mapstructure:"expose_headers" yaml:"expose_headers" json:"expose_headers"` + AllowCredentials bool `mapstructure:"allow_credentials" yaml:"allow_credentials" json:"allow_credentials"` +} + +type App struct { + Node string `mapstructure:"node" yaml:"node" json:"node"` + AppID string `mapstructure:"app_id" yaml:"app_id" json:"app_id"` + Env string `mapstructure:"env" yaml:"env" json:"env"` +} + +type Storage struct { + Type string `mapstructure:"type" yaml:"type" json:"type"` + Qiniu *Qiniu `mapstructure:"qiniu" yaml:"qiniu" json:"qiniu"` + AliyunOSS *ObjectStore `mapstructure:"aliyun_oss" yaml:"aliyun_oss" json:"aliyun_oss"` + HuaweiOBS *ObjectStore `mapstructure:"huawei_obs" yaml:"huawei_obs" json:"huawei_obs"` + TencentCOS *ObjectStore `mapstructure:"tencent_cos" yaml:"tencent_cos" json:"tencent_cos"` + AWSS3 *ObjectStore `mapstructure:"aws_s3" yaml:"aws_s3" json:"aws_s3"` + CloudflareR2 *ObjectStore `mapstructure:"cloudflare_r2" yaml:"cloudflare_r2" json:"cloudflare_r2"` + Minio *ObjectStore `mapstructure:"minio" yaml:"minio" json:"minio"` +} + +type Qiniu struct { + Zone string `mapstructure:"zone" yaml:"zone" json:"zone"` + Bucket string `mapstructure:"bucket" yaml:"bucket" json:"bucket"` + BaseURL string `mapstructure:"base_url" yaml:"base_url" json:"base_url"` + AccessKey string `mapstructure:"access_key" yaml:"access_key" json:"access_key"` + SecretKey string `mapstructure:"secret_key" yaml:"secret_key" json:"secret_key"` + UseHTTPS bool `mapstructure:"use_https" yaml:"use_https" json:"use_https"` + UseCDNDomains bool `mapstructure:"use_cdn_domains" yaml:"use_cdn_domains" json:"use_cdn_domains"` +} + +type ObjectStore struct { + Endpoint string `mapstructure:"endpoint" yaml:"endpoint" json:"endpoint"` + Region string `mapstructure:"region" yaml:"region" json:"region"` + Bucket string `mapstructure:"bucket" yaml:"bucket" json:"bucket"` + AccessKey string `mapstructure:"access_key" yaml:"access_key" json:"access_key"` + SecretKey string `mapstructure:"secret_key" yaml:"secret_key" json:"secret_key"` + BaseURL string `mapstructure:"base_url" yaml:"base_url" json:"base_url"` + PathPrefix string `mapstructure:"path_prefix" yaml:"path_prefix" json:"path_prefix"` + UseSSL bool `mapstructure:"use_ssl" yaml:"use_ssl" json:"use_ssl"` + ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style" json:"force_path_style"` + AccountID string `mapstructure:"account_id" yaml:"account_id" json:"account_id"` +} diff --git a/internal/data/config_helpers.go b/internal/data/config_helpers.go index 33bb9f6..50a22dd 100644 --- a/internal/data/config_helpers.go +++ b/internal/data/config_helpers.go @@ -1,30 +1,28 @@ package data import ( - "kra/internal/conf" - - "google.golang.org/protobuf/proto" + "kra/internal/config" ) -func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend { +func cloneAdminConfig(value *config.Admin) *config.Admin { if value == nil { - return &conf.AdminBackend{} + return &config.Admin{} } - return proto.Clone(value).(*conf.AdminBackend) + return config.CloneAdmin(value) } -func maskStorageSecrets(storage *conf.AdminBackend_Storage) { +func maskStorageSecrets(storage *config.Storage) { if storage == nil { return } if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" { storage.Qiniu.SecretKey = "******" } - for _, item := range []*conf.AdminBackend_ObjectStore{ - storage.AliyunOss, - storage.HuaweiObs, - storage.TencentCos, - storage.AwsS3, + for _, item := range []*config.ObjectStore{ + storage.AliyunOSS, + storage.HuaweiOBS, + storage.TencentCOS, + storage.AWSS3, storage.CloudflareR2, storage.Minio, } { diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 8e5ee4d..68b0f44 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -9,29 +9,23 @@ import ( "path/filepath" "strconv" - "kra/internal/conf" + "kra/internal/config" "kra/internal/integration/storage" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" "gopkg.in/yaml.v3" "gorm.io/gorm" ) -func protoMap(message proto.Message) (map[string]any, error) { - raw, err := (protojson.MarshalOptions{ - UseProtoNames: true, - EmitDefaultValues: true, - }).Marshal(message) +func configMap(input any) (map[string]any, error) { + raw, err := yaml.Marshal(input) if err != nil { return nil, err } - var value map[string]any - if err = json.Unmarshal(raw, &value); err != nil { + var output map[string]any + if err = yaml.Unmarshal(raw, &output); err != nil { return nil, err } - delete(value, "config_path") - return value, nil + return output, nil } func setYAMLMapping(node *yaml.Node, key string, value any) error { @@ -60,9 +54,8 @@ func setYAMLMapping(node *yaml.Node, key string, value any) error { } // mergeYAMLNode updates values produced from the runtime configuration while -// retaining keys that are not represented by the protobuf and comments that -// were already present in the user's config file. Replacing an entire mapping -// with protojson output would discard configuration hints and extension keys. +// retaining keys that are not represented by the typed configuration and +// comments that were already present in the user's config file. func mergeYAMLNode(dst, src *yaml.Node) { if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode { for i := 0; i+1 < len(src.Content); i += 2 { @@ -138,7 +131,7 @@ func deleteYAMLMapping(node *yaml.Node, keys ...string) { } } -func setServerHTTPPort(document *yaml.Node, port int32) error { +func setServerHTTPPort(document *yaml.Node, port int) error { if port <= 0 { return nil } @@ -156,13 +149,13 @@ func (d *Data) persistConfig() error { return d.persistConfigValues(dataConfig, adminConfig) } -func (d *Data) persistConfigValues(dataConfig *conf.Data, adminConfig *conf.AdminBackend) error { +func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config.Admin) error { d.configMu.Lock() defer d.configMu.Unlock() return d.persistConfigValuesLocked(dataConfig, adminConfig) } -func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *conf.AdminBackend) error { +func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *config.Admin) error { if adminConfig == nil || adminConfig.ConfigPath == "" { return nil } @@ -175,14 +168,14 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con if err = yaml.Unmarshal(raw, &document); err != nil { return err } - dataValue, err := protoMap(dataConfig) + dataValue, err := configMap(dataConfig) if err != nil { return err } fileAdmin := cloneAdminConfig(adminConfig) fileAdmin.Storage = nil fileAdmin.Email = nil - adminValue, err := protoMap(fileAdmin) + adminValue, err := configMap(fileAdmin) if err != nil { return err } @@ -207,7 +200,7 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con // partially populated runtime config back over the template: doing so removes // all omitted/default settings from data and admin and leaves the next startup // with no visible configuration. -func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey string) error { +func (d *Data) persistDatabaseConfig(database *config.Database, signingKey string) error { d.configMu.Lock() defer d.configMu.Unlock() @@ -227,11 +220,8 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st if err != nil { return err } - // Do not marshal the init request through protojson here. Proto3 omits - // empty and zero values, which makes a merge retain stale values from the - // template (an empty database password is the common example). Update the - // fields owned by the init page explicitly and leave advanced database, - // Redis, Mongo, admin and server settings untouched. + // Update the fields owned by the init page explicitly and leave advanced + // database, Redis, Mongo, admin and server settings untouched. databaseValue := map[string]any{ "driver": database.Driver, "source": source, @@ -428,7 +418,7 @@ func (d *Data) reloadConfig(ctx context.Context) error { closeCandidate = false candidateDBListAccepted = true candidateRedisAccepted = true - d.runtime.Replace(next.Data, next.Admin) + d.runtime.Replace(next) if d.integrations != nil { d.integrations.Replace(integrationConfigs) } @@ -438,22 +428,6 @@ func (d *Data) reloadConfig(ctx context.Context) error { return nil } -func readBootstrap(configPath string) (*conf.Bootstrap, error) { - raw, err := os.ReadFile(configPath) - if err != nil { - return nil, err - } - var plain map[string]any - if err = yaml.Unmarshal(raw, &plain); err != nil { - return nil, err - } - encoded, err := json.Marshal(plain) - if err != nil { - return nil, err - } - var next conf.Bootstrap - if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(encoded, &next); err != nil { - return nil, err - } - return &next, nil +func readBootstrap(configPath string) (*config.Config, error) { + return config.Load(configPath) } diff --git a/internal/data/config_watch.go b/internal/data/config_watch.go index 0fc4208..d8684ab 100644 --- a/internal/data/config_watch.go +++ b/internal/data/config_watch.go @@ -88,7 +88,7 @@ func (d *Data) watchConfig() func() { next.Admin.Email = current.Email } next.Admin.ConfigPath = absolute - d.runtime.Replace(next.Data, next.Admin) + d.runtime.Replace(next) logger.Info("config file changed", "mod", "system", "path", absolute) case watchErr, ok := <-watchErrors: if !ok { diff --git a/internal/data/config_watch_test.go b/internal/data/config_watch_test.go index c0f07c1..0e6979f 100644 --- a/internal/data/config_watch_test.go +++ b/internal/data/config_watch_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "kra/internal/conf" + "kra/internal/config" ) func TestConfigWatcherStopWaitsForDebouncedReload(t *testing.T) { @@ -17,11 +17,11 @@ func TestConfigWatcherStopWaitsForDebouncedReload(t *testing.T) { if err := os.WriteFile(configPath, config, 0o600); err != nil { t.Fatal(err) } - runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{ConfigPath: configPath}) + runtime := config.NewStore(&config.Config{Data: &config.Data{}, Admin: &config.Admin{ConfigPath: configPath}}) started := make(chan struct{}) release := make(chan struct{}) var startOnce sync.Once - stopListener := runtime.Subscribe(func(*conf.Data, *conf.AdminBackend) { + stopListener := runtime.Subscribe(func(*config.Data, *config.Admin) { startOnce.Do(func() { close(started) }) <-release }) diff --git a/internal/data/data.go b/internal/data/data.go index 4ac5bd3..32b0ff4 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -11,7 +11,7 @@ import ( "github.com/google/wire" "github.com/redis/go-redis/v9" "gorm.io/gorm" - "kra/internal/conf" + "kra/internal/config" dataintegration "kra/internal/data/integration" datapayment "kra/internal/data/payment" datasystem "kra/internal/data/system" @@ -49,7 +49,7 @@ type Data struct { gormDB *reloadableDB redis *reloadableRedis mongo *reloadableMongo - runtime *conf.Runtime + runtime *config.Store integrations *runtimeconfig.Store storage *storage.Reloadable dbListMu sync.RWMutex @@ -76,7 +76,7 @@ func (d *Data) DatabaseReady() bool { // Runtime exposes the immutable runtime configuration snapshot to data // submodules that need system settings while keeping Data itself private. -func (d *Data) Runtime() *conf.Runtime { +func (d *Data) Runtime() *config.Store { if d == nil { return nil } @@ -112,7 +112,7 @@ func (d *Data) logger() *slog.Logger { return slog.Default() } -func openDatabaseList(configs []*conf.Data_Database, appLogger ...*slog.Logger) (map[string]*gorm.DB, error) { +func openDatabaseList(configs []*config.Database, appLogger ...*slog.Logger) (map[string]*gorm.DB, error) { items := make(map[string]*gorm.DB) for _, config := range configs { if config == nil || config.Disable || config.AliasName == "" { @@ -161,19 +161,19 @@ func (d *Data) database(name string) (*gorm.DB, error) { return db, nil } -func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *storage.Reloadable, catalog module.Catalog) (*Data, func(), error) { +func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *storage.Reloadable, catalog module.Catalog) (*Data, func(), error) { if appLogger == nil { appLogger = slog.Default() } c := runtime.Data() if c == nil { - c = &conf.Data{} + c = &config.Data{} } if c.Database == nil { // An empty database block starts the service on the bootstrap database // so /init/checkdb // and /init/initdb remain available. - c.Database = &conf.Data_Database{} + c.Database = &config.Database{} } d := &Data{runtime: runtime, integrations: runtimeconfig.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog} var stopConfigWatcher func() @@ -234,7 +234,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor } admin := runtime.Admin() if admin == nil { - admin = &conf.AdminBackend{} + admin = &config.Admin{} } disableAutoMigrate := admin.System != nil && admin.System.DisableAutoMigrate if !usingFallback && !disableAutoMigrate { @@ -253,7 +253,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor } admin.Storage = storageConfig admin.Email = emailConfig - runtime.Replace(c, admin) + runtime.Replace(&config.Config{Server: runtime.Server(), Data: c, Admin: admin}) if err = d.loadIntegrationRuntime(db); err != nil { return nil, nil, fmt.Errorf("load integration runtime: %w", err) } @@ -287,7 +287,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor return d, cleanup, nil } -func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger) redis.UniversalClient { +func openRedis(config *config.Redis, enabled bool, appLogger ...*slog.Logger) redis.UniversalClient { if !enabled || config == nil || (config.Addr == "" && len(config.ClusterAddrs) == 0) { return nil } @@ -299,12 +299,12 @@ func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger) } candidate = redis.NewClusterClient(&redis.ClusterOptions{Addrs: addresses, Password: config.Password}) } else { - options := &redis.Options{Addr: config.Addr, Network: config.Network, Password: config.Password, DB: int(config.Db)} - if config.ReadTimeout != nil { - options.ReadTimeout = config.ReadTimeout.AsDuration() + options := &redis.Options{Addr: config.Addr, Network: config.Network, Password: config.Password, DB: int(config.DB)} + if config.ReadTimeout > 0 { + options.ReadTimeout = config.ReadTimeout } - if config.WriteTimeout != nil { - options.WriteTimeout = config.WriteTimeout.AsDuration() + if config.WriteTimeout > 0 { + options.WriteTimeout = config.WriteTimeout } candidate = redis.NewClient(options) } @@ -322,7 +322,7 @@ func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger) return candidate } -func (d *Data) activateDatabase(db *gorm.DB, config *conf.Data_Database) { +func (d *Data) activateDatabase(db *gorm.DB, config *config.Database) { d.gormDB.replace(db, d.enqueueDataScopeAudit) d.runtime.UpdateDatabase(config) d.databaseReady.Store(true) diff --git a/internal/data/database.go b/internal/data/database.go index 350450b..db3ec04 100644 --- a/internal/data/database.go +++ b/internal/data/database.go @@ -19,7 +19,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/gorm/schema" - "kra/internal/conf" + "kra/internal/config" "kra/pkg/database/gormkit" ) @@ -29,7 +29,7 @@ var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`) // administration backend: an explicitly selected database (or a standalone // DSN) means a real database connection is expected to exist. The internal // bootstrap database is deliberately excluded from this state. -func databaseConnectionConfigured(c *conf.Data_Database) bool { +func databaseConnectionConfigured(c *config.Database) bool { if c == nil { return false } @@ -56,7 +56,7 @@ func normalizedDriver(driver string) string { } } -func databaseDSN(c *conf.Data_Database, name string) (string, error) { +func databaseDSN(c *config.Database, name string) (string, error) { if c.Source != "" && c.Host == "" && c.Path == "" { return c.Source, nil } @@ -118,7 +118,7 @@ func databaseDSN(c *conf.Data_Database, name string) (string, error) { return "", fmt.Errorf("unsupported database driver %q", c.Driver) } -func gormConfig(config *conf.Data_Database, appLogger ...*slog.Logger) *gorm.Config { +func gormConfig(config *config.Database, appLogger ...*slog.Logger) *gorm.Config { level := logger.Info switch strings.ToLower(config.LogMode) { case "silent": @@ -136,10 +136,10 @@ func gormConfig(config *conf.Data_Database, appLogger ...*slog.Logger) *gorm.Con } func openWithDriver(driver, dsn string, appLogger ...*slog.Logger) (*gorm.DB, error) { - return openWithDriverConfig(driver, dsn, &conf.Data_Database{Driver: driver}, appLogger...) + return openWithDriverConfig(driver, dsn, &config.Database{Driver: driver}, appLogger...) } -func openWithDriverConfig(driver, dsn string, config *conf.Data_Database, appLogger ...*slog.Logger) (*gorm.DB, error) { +func openWithDriverConfig(driver, dsn string, config *config.Database, appLogger ...*slog.Logger) (*gorm.DB, error) { gormConfig := gormConfig(config, appLogger...) var db *gorm.DB var err error @@ -179,7 +179,7 @@ func openWithDriverConfig(driver, dsn string, config *conf.Data_Database, appLog return db, nil } -func openDatabase(c *conf.Data_Database, create bool, template string, appLogger ...*slog.Logger) (*gorm.DB, error) { +func openDatabase(c *config.Database, create bool, template string, appLogger ...*slog.Logger) (*gorm.DB, error) { driver := normalizedDriver(c.Driver) if driver == "" { return nil, fmt.Errorf("unsupported database driver %q", c.Driver) diff --git a/internal/data/initialization_backend.go b/internal/data/initialization_backend.go index e0419af..4531b25 100644 --- a/internal/data/initialization_backend.go +++ b/internal/data/initialization_backend.go @@ -4,22 +4,23 @@ import ( "context" "errors" "fmt" - "kra/internal/biz/system" - "kra/internal/conf" + "kra/internal/biz/system" + configpkg "kra/internal/config" "kra/internal/integration/storage" "github.com/google/uuid" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" "gorm.io/gorm" ) -func (d *Data) RuntimeValues() (*conf.Data, *conf.AdminBackend) { return d.runtime.Values() } -func (d *Data) RuntimeAdmin() *conf.AdminBackend { return d.runtime.Admin() } -func (d *Data) RefreshDatabaseSources(value *conf.Data) error { return refreshDatabaseSources(value) } +func (d *Data) Config() *configpkg.Config { + if d == nil || d.runtime == nil { + return nil + } + return d.runtime.Snapshot() +} -func refreshDatabaseSources(value *conf.Data) error { +func refreshDatabaseSources(value *configpkg.Data) error { if value == nil { return nil } @@ -37,7 +38,7 @@ func refreshDatabaseSources(value *conf.Data) error { return nil } -func refreshDatabaseSource(database *conf.Data_Database) error { +func refreshDatabaseSource(database *configpkg.Database) error { if database == nil { return nil } @@ -57,72 +58,50 @@ func refreshDatabaseSource(database *conf.Data_Database) error { } func (d *Data) PersistConfig(context.Context) error { return d.persistConfig() } -func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error { - currentData, currentAdmin := d.runtime.Values() - next := proto.Clone(currentAdmin).(*conf.AdminBackend) - if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil { +func (d *Data) PersistRuntimeConfig(ctx context.Context, value *configpkg.Config) error { + current := d.Config() + if current == nil { + current = &configpkg.Config{} + } + next := configpkg.Clone(value) + if next == nil { + next = configpkg.Clone(current) + } + if next.Data == nil { + next.Data = configpkg.CloneData(current.Data) + } + if next.Admin == nil { + next.Admin = configpkg.CloneAdmin(current.Admin) + } + if next.Admin != nil && current.Admin != nil { + if next.Admin.Storage == nil { + next.Admin.Storage = configpkg.CloneStorage(current.Admin.Storage) + } + if next.Admin.Email == nil { + next.Admin.Email = configpkg.CloneEmail(current.Admin.Email) + } + next.Admin.ConfigPath = current.Admin.ConfigPath + } + if next.Data == nil { + next.Data = &configpkg.Data{} + } + if err := refreshDatabaseSources(next.Data); err != nil { return err } - if next.Storage == nil { - next.Storage = currentAdmin.Storage - } - if next.Email == nil { - next.Email = currentAdmin.Email - } - next.ConfigPath = currentAdmin.ConfigPath - candidateStorage, err := storage.New(next) + candidateStorage, err := storage.New(next.Admin) if err != nil { return err } - if err := d.persistStorageIntegrationConfig(ctx, next.Storage); err != nil { + if err := d.persistStorageIntegrationConfig(ctx, next.Admin.Storage); err != nil { return err } - if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil { + if err := d.persistEmailIntegrationConfig(ctx, next.Admin.Email); err != nil { return err } - if err := d.persistConfigValues(currentData, next); err != nil { + if err := d.persistConfigValues(next.Data, next.Admin); err != nil { return err } - // Writing through the management API updates the same in-memory values - // immediately; the file watcher remains the fallback for external edits. - d.runtime.Replace(currentData, next) - if d.storage != nil { - d.storage.Replace(candidateStorage) - } - return nil -} -func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error { - currentData, currentAdmin := d.runtime.Values() - nextData := proto.Clone(currentData).(*conf.Data) - nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) - options := protojson.UnmarshalOptions{DiscardUnknown: true} - if err := options.Unmarshal(dataRaw, nextData); err != nil { - return err - } - if err := options.Unmarshal(adminRaw, nextAdmin); err != nil { - return err - } - if nextAdmin.Storage == nil { - nextAdmin.Storage = currentAdmin.Storage - } - if nextAdmin.Email == nil { - nextAdmin.Email = currentAdmin.Email - } - nextAdmin.ConfigPath = currentAdmin.ConfigPath - candidateStorage, err := storage.New(nextAdmin) - if err != nil { - return err - } - if err := d.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil { - return err - } - if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil { - return err - } - if err := d.persistConfigValues(nextData, nextAdmin); err != nil { - return err - } - d.runtime.Replace(nextData, nextAdmin) + d.runtime.Replace(next) if d.storage != nil { d.storage.Replace(candidateStorage) } @@ -140,24 +119,24 @@ func (d *Data) IsInitialized(context.Context) (bool, error) { // is the application-level first-install hook; data owns only lifecycle and // schema migration, while initialize owns system seed orchestration. func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseConfig, seed func(context.Context, *gorm.DB) error) error { - config := &conf.Data_Database{} + database := &configpkg.Database{} if current := d.runtime.Data(); current != nil && current.Database != nil { - config = proto.Clone(current.Database).(*conf.Data_Database) + database = configpkg.CloneDatabase(current.Database) } - config.Driver = input.Driver - config.Host = input.Host - config.Port = input.Port - config.User = input.User - config.Password = input.Password - config.Name = input.Name - config.Path = input.Path - config.Config = input.Config - config.Source = "" - source, err := databaseDSN(config, "") + database.Driver = input.Driver + database.Host = input.Host + database.Port = input.Port + database.User = input.User + database.Password = input.Password + database.Name = input.Name + database.Path = input.Path + database.Config = input.Config + database.Source = "" + source, err := databaseDSN(database, "") if err != nil { return err } - config.Source = source + database.Source = source d.initMu.Lock() defer d.initMu.Unlock() initialized, err := d.IsInitialized(ctx) @@ -167,7 +146,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon if initialized { return errors.New("数据库已初始化,无需重复初始化") } - candidate, err := openDatabase(config, true, input.Template, d.logger()) + candidate, err := openDatabase(database, true, input.Template, d.logger()) if err != nil { return err } @@ -189,7 +168,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon } } currentAdmin := d.runtime.Admin() - var legacyStorage *conf.AdminBackend_Storage + var legacyStorage *configpkg.Storage if currentAdmin != nil { legacyStorage = currentAdmin.Storage } @@ -197,7 +176,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon if err != nil { return fmt.Errorf("initialize storage integration configuration: %w", err) } - var legacyEmail *conf.AdminBackend_Email + var legacyEmail *configpkg.Email if currentAdmin != nil { legacyEmail = currentAdmin.Email } @@ -206,26 +185,30 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon return fmt.Errorf("initialize email integration configuration: %w", err) } signingKey := uuid.NewString() - if err := d.persistDatabaseConfig(config, signingKey); err != nil { + if err := d.persistDatabaseConfig(database, signingKey); err != nil { return fmt.Errorf("persist database configuration: %w", err) } integrationConfigs, err := readIntegrationRuntime(candidate) if err != nil { return fmt.Errorf("initialize integration runtime: %w", err) } - d.activateDatabase(candidate, config) + d.activateDatabase(candidate, database) activated = true - currentData, currentAdmin := d.runtime.Values() + current := d.Config() + if current == nil { + current = &configpkg.Config{} + } + currentData, currentAdmin := current.Data, current.Admin if currentAdmin == nil { - currentAdmin = &conf.AdminBackend{} + currentAdmin = &configpkg.Admin{} } - if currentAdmin.Jwt == nil { - currentAdmin.Jwt = &conf.AdminBackend_JWT{} + if currentAdmin.JWT == nil { + currentAdmin.JWT = &configpkg.JWT{} } - currentAdmin.Jwt.SigningKey = signingKey + currentAdmin.JWT.SigningKey = signingKey currentAdmin.Storage = storageConfig currentAdmin.Email = emailConfig - d.runtime.Replace(currentData, currentAdmin) + d.runtime.Replace(&configpkg.Config{Server: current.Server, Data: currentData, Admin: currentAdmin}) if d.integrations != nil { d.integrations.Replace(integrationConfigs) } diff --git a/internal/data/initialization_backend_test.go b/internal/data/initialization_backend_test.go index 1677659..53bf80e 100644 --- a/internal/data/initialization_backend_test.go +++ b/internal/data/initialization_backend_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "kra/internal/conf" + "kra/internal/config" "gopkg.in/yaml.v3" ) @@ -15,14 +15,14 @@ import ( func TestDatabaseConnectionConfigured(t *testing.T) { tests := []struct { name string - config *conf.Data_Database + config *config.Database want bool }{ {name: "missing config", config: nil, want: false}, - {name: "empty database", config: &conf.Data_Database{Driver: "mysql"}, want: false}, - {name: "named database", config: &conf.Data_Database{Driver: "mysql", Name: "kra"}, want: true}, - {name: "standalone DSN", config: &conf.Data_Database{Driver: "sqlite", Source: "file:kra.db"}, want: true}, - {name: "generated DSN still requires a name", config: &conf.Data_Database{Driver: "mysql", Source: "root@tcp(localhost)/", Host: "localhost"}, want: false}, + {name: "empty database", config: &config.Database{Driver: "mysql"}, want: false}, + {name: "named database", config: &config.Database{Driver: "mysql", Name: "kra"}, want: true}, + {name: "standalone DSN", config: &config.Database{Driver: "sqlite", Source: "file:kra.db"}, want: true}, + {name: "generated DSN still requires a name", config: &config.Database{Driver: "mysql", Source: "root@tcp(localhost)/", Host: "localhost"}, want: false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -78,9 +78,9 @@ extension: if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { t.Fatal(err) } - runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{ConfigPath: configPath}) + runtime := config.NewStore(&config.Data{}, &config.Admin{ConfigPath: configPath}) data := &Data{runtime: runtime} - if err := data.persistDatabaseConfig(&conf.Data_Database{Driver: "mysql", Host: "db", Port: "3306", User: "root", Password: "secret", Name: "kra", Config: "parseTime=True"}, "new-key"); err != nil { + if err := data.persistDatabaseConfig(&config.Database{Driver: "mysql", Host: "db", Port: "3306", User: "root", Password: "secret", Name: "kra", Config: "parseTime=True"}, "new-key"); err != nil { t.Fatal(err) } raw, err := os.ReadFile(configPath) diff --git a/internal/data/integration_config.go b/internal/data/integration_config.go index 6a468f2..cb171ce 100644 --- a/internal/data/integration_config.go +++ b/internal/data/integration_config.go @@ -7,11 +7,9 @@ import ( "fmt" "strings" - "kra/internal/conf" + "kra/internal/config" dataintegration "kra/internal/data/integration" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" "gorm.io/gorm" ) @@ -39,24 +37,24 @@ func normalizeStorageType(value string) string { return value } -func storageProviderMessage(storage *conf.AdminBackend_Storage, provider string) proto.Message { +func storageProviderValue(storage *config.Storage, provider string) any { if storage == nil { - storage = &conf.AdminBackend_Storage{} + storage = &config.Storage{} } switch provider { case "qiniu": if storage.Qiniu == nil { - storage.Qiniu = &conf.AdminBackend_Qiniu{} + storage.Qiniu = &config.Qiniu{} } return storage.Qiniu case "aliyun-oss": - return ensureObjectStore(&storage.AliyunOss) + return ensureObjectStore(&storage.AliyunOSS) case "huawei-obs": - return ensureObjectStore(&storage.HuaweiObs) + return ensureObjectStore(&storage.HuaweiOBS) case "tencent-cos": - return ensureObjectStore(&storage.TencentCos) + return ensureObjectStore(&storage.TencentCOS) case "aws-s3": - return ensureObjectStore(&storage.AwsS3) + return ensureObjectStore(&storage.AWSS3) case "cloudflare-r2": return ensureObjectStore(&storage.CloudflareR2) case "minio": @@ -66,45 +64,45 @@ func storageProviderMessage(storage *conf.AdminBackend_Storage, provider string) } } -func ensureObjectStore(value **conf.AdminBackend_ObjectStore) proto.Message { +func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore { if *value == nil { - *value = &conf.AdminBackend_ObjectStore{} + *value = &config.ObjectStore{} } return *value } -func marshalStorageProvider(storage *conf.AdminBackend_Storage, provider string) (string, error) { - message := storageProviderMessage(storage, provider) - if message == nil { +func marshalStorageProvider(storage *config.Storage, provider string) (string, error) { + value := storageProviderValue(storage, provider) + if value == nil { return "{}", nil } - raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(message) + raw, err := json.Marshal(value) if err != nil { return "", err } return string(raw), nil } -func unmarshalStorageProvider(storage *conf.AdminBackend_Storage, provider, value string) error { +func unmarshalStorageProvider(storage *config.Storage, provider, value string) error { if provider == "local" || strings.TrimSpace(value) == "" { return nil } - message := storageProviderMessage(storage, provider) - if message == nil { + target := storageProviderValue(storage, provider) + if target == nil { return nil } if !json.Valid([]byte(value)) { return fmt.Errorf("invalid %s integration configuration", provider) } - if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(value), message); err != nil { + if err := json.Unmarshal([]byte(value), target); err != nil { return fmt.Errorf("decode %s integration configuration: %w", provider, err) } return nil } -func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storage) error { +func saveStorageIntegrationConfig(db *gorm.DB, storage *config.Storage) error { if storage == nil { - storage = &conf.AdminBackend_Storage{} + storage = &config.Storage{} } active := normalizeStorageType(storage.Type) known := false @@ -145,7 +143,7 @@ func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storag }) } -func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool, error) { +func loadStorageIntegrationConfig(db *gorm.DB) (*config.Storage, bool, error) { var rows []dataintegration.ConfigPO err := db.Session(&gorm.Session{NewDB: true}). Where("kind = ?", integrationKindStorage). @@ -158,7 +156,7 @@ func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool return nil, false, nil } - storage := &conf.AdminBackend_Storage{Type: "local"} + storage := &config.Storage{Type: "local"} for _, row := range rows { if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil { return nil, false, err @@ -173,13 +171,13 @@ func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool // resolveStorageIntegrationConfig upgrades a legacy YAML configuration only // when the database has no storage rows yet. From then on the database is the // sole source of truth. -func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Storage) (*conf.AdminBackend_Storage, error) { +func resolveStorageIntegrationConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) { clean := db.Session(&gorm.Session{NewDB: true}) if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) { if legacy == nil { - return &conf.AdminBackend_Storage{Type: "local"}, nil + return &config.Storage{Type: "local"}, nil } - return proto.Clone(legacy).(*conf.AdminBackend_Storage), nil + return config.CloneStorage(legacy), nil } storage, found, err := loadStorageIntegrationConfig(clean) if err != nil { @@ -189,7 +187,7 @@ func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Stor return storage, nil } if legacy == nil { - legacy = &conf.AdminBackend_Storage{Type: "local"} + legacy = &config.Storage{Type: "local"} } if err = saveStorageIntegrationConfig(clean, legacy); err != nil { return nil, err @@ -198,7 +196,7 @@ func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Stor return storage, err } -func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *conf.AdminBackend_Storage) error { +func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *config.Storage) error { if !d.databaseReady.Load() { return errors.New("database is not initialized") } @@ -209,15 +207,15 @@ func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *con return saveStorageIntegrationConfig(db, storage) } -func defaultEmailIntegrationConfig() *conf.AdminBackend_Email { - return &conf.AdminBackend_Email{Port: 465, IsSsl: true} +func defaultEmailIntegrationConfig() *config.Email { + return &config.Email{Port: 465, IsSSL: true} } -func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) error { +func saveEmailIntegrationConfig(db *gorm.DB, email *config.Email) error { if email == nil { email = defaultEmailIntegrationConfig() } - raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(email) + raw, err := json.Marshal(email) if err != nil { return fmt.Errorf("encode smtp integration configuration: %w", err) } @@ -237,7 +235,7 @@ func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) err } } -func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, error) { +func loadEmailIntegrationConfig(db *gorm.DB) (*config.Email, bool, error) { var row dataintegration.ConfigPO err := db.Session(&gorm.Session{NewDB: true}). Where("kind = ? AND provider = ?", integrationKindEmail, "smtp"). @@ -252,19 +250,19 @@ func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, er return nil, false, errors.New("invalid smtp integration configuration") } email := defaultEmailIntegrationConfig() - if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), email); err != nil { + if err = json.Unmarshal([]byte(row.Config), email); err != nil { return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err) } return email, true, nil } -func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email) (*conf.AdminBackend_Email, error) { +func resolveEmailIntegrationConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) { clean := db.Session(&gorm.Session{NewDB: true}) if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) { if legacy == nil { return defaultEmailIntegrationConfig(), nil } - return proto.Clone(legacy).(*conf.AdminBackend_Email), nil + return config.CloneEmail(legacy), nil } email, found, err := loadEmailIntegrationConfig(clean) if err != nil { @@ -283,7 +281,7 @@ func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email) return email, err } -func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *conf.AdminBackend_Email) error { +func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *config.Email) error { if !d.databaseReady.Load() { return errors.New("database is not initialized") } diff --git a/internal/data/integration_config_test.go b/internal/data/integration_config_test.go index 286ce74..4eef7a9 100644 --- a/internal/data/integration_config_test.go +++ b/internal/data/integration_config_test.go @@ -8,7 +8,7 @@ import ( "testing" integrationbiz "kra/internal/biz/integration" - "kra/internal/conf" + "kra/internal/config" dataintegration "kra/internal/data/integration" "kra/internal/integration/storage" @@ -54,21 +54,21 @@ func TestMigrateAllCreatesIntegrationConfigTable(t *testing.T) { func TestStorageIntegrationConfigRoundTrip(t *testing.T) { db := openIntegrationConfigTestDB(t) - storage := &conf.AdminBackend_Storage{ + storage := &config.Storage{ Type: "aliyun-oss", - Qiniu: &conf.AdminBackend_Qiniu{ + Qiniu: &config.Qiniu{ Zone: "ZoneHuadong", Bucket: "qiniu-bucket", AccessKey: "qiniu-key", SecretKey: "qiniu-secret", }, - AliyunOss: &conf.AdminBackend_ObjectStore{ + AliyunOss: &config.ObjectStore{ Endpoint: "oss-cn-hangzhou.aliyuncs.com", Region: "cn-hangzhou", Bucket: "assets", AccessKey: "aliyun-key", SecretKey: "aliyun-secret", BaseUrl: "https://cdn.example.com", PathPrefix: "uploads", }, - Minio: &conf.AdminBackend_ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true}, + Minio: &config.ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true}, } if err := saveStorageIntegrationConfig(db, storage); err != nil { t.Fatal(err) } - email := &conf.AdminBackend_Email{ + email := &config.Email{ To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com", Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSsl: true, } @@ -89,8 +89,8 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) { if loaded.Type != "aliyun-oss" { t.Fatalf("storage type = %q, want aliyun-oss", loaded.Type) } - if loaded.AliyunOss == nil || loaded.AliyunOss.SecretKey != "aliyun-secret" || loaded.AliyunOss.PathPrefix != "uploads" { - t.Fatalf("aliyun configuration = %#v", loaded.AliyunOss) + if loaded.AliyunOSS == nil || loaded.AliyunOSS.SecretKey != "aliyun-secret" || loaded.AliyunOSS.PathPrefix != "uploads" { + t.Fatalf("aliyun configuration = %#v", loaded.AliyunOSS) } if loaded.Qiniu == nil || loaded.Qiniu.SecretKey != "qiniu-secret" { t.Fatalf("qiniu configuration = %#v", loaded.Qiniu) @@ -127,9 +127,9 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) { func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { db := openIntegrationConfigTestDB(t) - legacy := &conf.AdminBackend_Storage{ + legacy := &config.Storage{ Type: "qiniu", - Qiniu: &conf.AdminBackend_Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"}, + Qiniu: &config.Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"}, } loaded, err := resolveStorageIntegrationConfig(db, legacy) if err != nil { @@ -139,9 +139,9 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { t.Fatalf("migrated storage = %#v", loaded) } - other := &conf.AdminBackend_Storage{ + other := &config.Storage{ Type: "minio", - Minio: &conf.AdminBackend_ObjectStore{Bucket: "must-not-replace-database"}, + Minio: &config.ObjectStore{Bucket: "must-not-replace-database"}, } loaded, err = resolveStorageIntegrationConfig(db, other) if err != nil { @@ -153,23 +153,23 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { } func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) { - storage := &conf.AdminBackend_Storage{ - Qiniu: &conf.AdminBackend_Qiniu{}, - AliyunOss: &conf.AdminBackend_ObjectStore{SecretKey: "configured-secret"}, - Minio: &conf.AdminBackend_ObjectStore{}, + storage := &config.Storage{ + Qiniu: &config.Qiniu{}, + AliyunOss: &config.ObjectStore{SecretKey: "configured-secret"}, + Minio: &config.ObjectStore{}, } maskStorageSecrets(storage) if storage.Qiniu.SecretKey != "" || storage.Minio.SecretKey != "" { t.Fatalf("empty provider secrets were masked: %#v", storage) } - if storage.AliyunOss.SecretKey != "******" { - t.Fatalf("configured secret was not masked: %q", storage.AliyunOss.SecretKey) + if storage.AliyunOSS.SecretKey != "******" { + t.Fatalf("configured secret was not masked: %q", storage.AliyunOSS.SecretKey) } } func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { db := openIntegrationConfigTestDB(t) - legacy := &conf.AdminBackend_Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSsl: true} + legacy := &config.Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSsl: true} loaded, err := resolveEmailIntegrationConfig(db, legacy) if err != nil { t.Fatal(err) @@ -177,7 +177,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret { t.Fatalf("migrated email = %#v", loaded) } - loaded, err = resolveEmailIntegrationConfig(db, &conf.AdminBackend_Email{Host: "must-not-replace.example.com"}) + loaded, err = resolveEmailIntegrationConfig(db, &config.Email{Host: "must-not-replace.example.com"}) if err != nil { t.Fatal(err) } @@ -193,16 +193,16 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) { t.Fatal(err) } d := &Data{} - admin := &conf.AdminBackend{ + admin := &config.Admin{ ConfigPath: path, RouterPrefix: "/api", - Storage: &conf.AdminBackend_Storage{ + Storage: &config.Storage{ Type: "qiniu", - Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"}, + Qiniu: &config.Qiniu{SecretKey: "database-only-secret"}, }, - Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"}, + Email: &config.Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"}, } - if err := d.persistConfigValues(&conf.Data{}, admin); err != nil { + if err := d.persistConfigValues(&config.Data{}, admin); err != nil { t.Fatal(err) } @@ -237,10 +237,10 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { } oldRoot := filepath.Join(t.TempDir(), "old") newRoot := filepath.Join(t.TempDir(), "new") - currentAdmin := &conf.AdminBackend{ + currentAdmin := &config.Admin{ ConfigPath: configPath, - Local: &conf.AdminBackend_Local{StorePath: oldRoot, PathPrefix: "old-files"}, - Storage: &conf.AdminBackend_Storage{Type: "local"}, + Local: &config.Local{StorePath: oldRoot, PathPrefix: "old-files"}, + Storage: &config.Storage{Type: "local"}, } currentStorage, err := storage.NewReloadable(currentAdmin) if err != nil { @@ -249,19 +249,19 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { reloadableDB := &reloadableDB{} reloadableDB.current.Store(db) d := &Data{ - runtime: conf.NewRuntime(&conf.Data{}, currentAdmin), + runtime: config.NewStore(&config.Config{Data: &config.Data{}, Admin: currentAdmin}), gormDB: reloadableDB, storage: currentStorage, } d.databaseReady.Store(true) nextAdmin := cloneAdminConfig(currentAdmin) - nextAdmin.Local = &conf.AdminBackend_Local{StorePath: newRoot, PathPrefix: "new-files"} - nextAdmin.Email = &conf.AdminBackend_Email{ + nextAdmin.Local = &config.Local{StorePath: newRoot, PathPrefix: "new-files"} + nextAdmin.Email = &config.Email{ To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com", Secret: "runtime-secret", Port: 465, IsSsl: true, } - dataRaw, err := protojson.Marshal(&conf.Data{}) + dataRaw, err := protojson.Marshal(&config.Data{}) if err != nil { t.Fatal(err) } diff --git a/internal/data/mongo.go b/internal/data/mongo.go index a1c7d7f..a1b1acc 100644 --- a/internal/data/mongo.go +++ b/internal/data/mongo.go @@ -6,13 +6,13 @@ import ( "strings" "time" - "kra/internal/conf" + "kra/internal/config" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) -func mongoURI(config *conf.Data_Mongo) string { +func mongoURI(config *config.Mongo) string { hosts := make([]string, 0, len(config.Hosts)) for _, host := range config.Hosts { if host != nil && host.Host != "" && host.Port != "" { @@ -26,7 +26,7 @@ func mongoURI(config *conf.Data_Mongo) string { return uri } -func openMongo(config *conf.Data_Mongo, enabled bool) (*mongo.Client, error) { +func openMongo(config *config.Mongo, enabled bool) (*mongo.Client, error) { if !enabled { return nil, nil } diff --git a/internal/data/system/api_policy_test.go b/internal/data/system/api_policy_test.go index 5d3c8c2..dc29fb1 100644 --- a/internal/data/system/api_policy_test.go +++ b/internal/data/system/api_policy_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "kra/internal/conf" + "kra/internal/config" ) func newPolicyTestData(t *testing.T) *Data { @@ -20,9 +20,11 @@ func newPolicyTestData(t *testing.T) *Data { } data := &Data{ gormDB: newReloadableDB(db, nil), - runtime: conf.NewRuntime( - &conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, - &conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}}, + runtime: config.NewStore( + &config.Config{ + Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, + Admin: &config.Admin{System: &config.System{UseStrictAuth: true}}, + }, ), } t.Cleanup(func() { data.gormDB.close() }) diff --git a/internal/data/system/authority_test.go b/internal/data/system/authority_test.go index 5919099..b43f35f 100644 --- a/internal/data/system/authority_test.go +++ b/internal/data/system/authority_test.go @@ -6,7 +6,7 @@ import ( "kra/internal/biz/system" "testing" - "kra/internal/conf" + "kra/internal/config" "gorm.io/gorm" ) @@ -15,7 +15,7 @@ func authorityUintPointer(value uint) *uint { return &value } func enableStrictAuthorityTestMode(data *Data) { currentData, _ := data.runtime.Values() - data.runtime.Replace(currentData, &conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}}) + data.runtime.Replace(&config.Config{Data: currentData, Admin: &config.Admin{System: &config.System{UseStrictAuth: true}}}) } func TestCopyAuthorityStrictPolicyValidationMatchesAdministrationContract(t *testing.T) { diff --git a/internal/data/system/log_file_test.go b/internal/data/system/log_file_test.go index 3865432..1f8b8d3 100644 --- a/internal/data/system/log_file_test.go +++ b/internal/data/system/log_file_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "kra/internal/conf" + "kra/internal/config" ) func TestLogViewerReadsNestedCategoryFiles(t *testing.T) { @@ -22,7 +22,7 @@ func TestLogViewerReadsNestedCategoryFiles(t *testing.T) { t.Fatal(err) } - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{Director: root}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{Director: root}}}) repo := &logFileRepo{data: &Data{runtime: runtime}} files, err := repo.LogFiles(context.Background(), date) if err != nil { @@ -41,7 +41,7 @@ func TestLogViewerReadsNestedCategoryFiles(t *testing.T) { } func TestLogViewerRejectsPathTraversal(t *testing.T) { - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{Director: t.TempDir()}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{Director: t.TempDir()}}}) repo := &logFileRepo{data: &Data{runtime: runtime}} _, err := repo.LogContent(context.Background(), "2026-08-16", "../application.log", nil) if !errors.Is(err, system.ErrInvalidLogPath) { diff --git a/internal/data/system/menu_test.go b/internal/data/system/menu_test.go index bb981ca..584c213 100644 --- a/internal/data/system/menu_test.go +++ b/internal/data/system/menu_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "kra/internal/conf" + "kra/internal/config" ) func newMenuTestData(t *testing.T) *Data { @@ -20,7 +20,7 @@ func newMenuTestData(t *testing.T) *Data { } data := &Data{ gormDB: newReloadableDB(db, nil), - runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{}), + runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}}), } t.Cleanup(func() { data.gormDB.close() }) return data diff --git a/internal/data/system/permission_test.go b/internal/data/system/permission_test.go index 4f6b863..23aa346 100644 --- a/internal/data/system/permission_test.go +++ b/internal/data/system/permission_test.go @@ -5,7 +5,7 @@ import ( "kra/internal/biz/system" "testing" - "kra/internal/conf" + "kra/internal/config" ) func TestPermissionSelectedButtonsEmptyShape(t *testing.T) { @@ -16,7 +16,7 @@ func TestPermissionSelectedButtonsEmptyShape(t *testing.T) { if err := db.AutoMigrate(&authorityButtonPO{}); err != nil { t.Fatal(err) } - data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})} t.Cleanup(func() { data.gormDB.close() }) ids, err := (&permissionRepo{data: data}).SelectedButtons(context.Background(), 1, 2) @@ -36,7 +36,7 @@ func TestPermissionCanRemoveButtonUsesRawQueryID(t *testing.T) { if err := db.AutoMigrate(&authorityButtonPO{}); err != nil { t.Fatal(err) } - data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})} t.Cleanup(func() { data.gormDB.close() }) repo := &permissionRepo{data: data} if err := data.gormDB.WithContext(context.Background()).Create(&authorityButtonPO{AuthorityID: 888, MenuID: 10, ButtonID: 31}).Error; err != nil { @@ -67,7 +67,7 @@ func TestPermissionSetSelectedButtonsPreservesDuplicates(t *testing.T) { if err := db.AutoMigrate(&authorityButtonPO{}); err != nil { t.Fatal(err) } - data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})} t.Cleanup(func() { data.gormDB.close() }) repo := &permissionRepo{data: data} diff --git a/internal/data/system/provider.go b/internal/data/system/provider.go index e8f1b80..b2e624e 100644 --- a/internal/data/system/provider.go +++ b/internal/data/system/provider.go @@ -1,7 +1,7 @@ package system import ( - "kra/internal/conf" + "kra/internal/config" "gorm.io/gorm" ) @@ -12,5 +12,5 @@ type Provider interface { DB() *gorm.DB Database(name string) (*gorm.DB, error) DatabaseReady() bool - Runtime() *conf.Runtime + Runtime() *config.Store } diff --git a/internal/data/system/runtime.go b/internal/data/system/runtime.go index a802acb..da6951b 100644 --- a/internal/data/system/runtime.go +++ b/internal/data/system/runtime.go @@ -7,15 +7,15 @@ import ( "kra/internal/biz/system" "time" - "kra/internal/conf" + "kra/internal/config" "kra/internal/security" jwt "github.com/golang-jwt/jwt/v5" ) -type runtimeSettings struct{ runtime *conf.Runtime } +type runtimeSettings struct{ runtime *config.Store } -func NewRuntimeSettings(runtime *conf.Runtime) system.RuntimeSettings { +func NewRuntimeSettings(runtime *config.Store) system.RuntimeSettings { return &runtimeSettings{runtime: runtime} } @@ -30,18 +30,18 @@ func (s *runtimeSettings) RouterPrefix() string { func (s *runtimeSettings) JWTSettings() system.JWTSettings { value := system.JWTSettings{Issuer: "kra", Expires: 7 * 24 * time.Hour, Buffer: 24 * time.Hour} config := s.runtime.Admin() - if config == nil || config.Jwt == nil { + if config == nil || config.JWT == nil { return value } - value.SigningKey = config.Jwt.SigningKey - if config.Jwt.Issuer != "" { - value.Issuer = config.Jwt.Issuer + value.SigningKey = config.JWT.SigningKey + if config.JWT.Issuer != "" { + value.Issuer = config.JWT.Issuer } - if config.Jwt.ExpiresTime != nil { - value.Expires = config.Jwt.ExpiresTime.AsDuration() + if config.JWT.ExpiresTime > 0 { + value.Expires = config.JWT.ExpiresTime } - if config.Jwt.BufferTime != nil { - value.Buffer = config.Jwt.BufferTime.AsDuration() + if config.JWT.BufferTime > 0 { + value.Buffer = config.JWT.BufferTime } return value } @@ -53,16 +53,16 @@ func (s *runtimeSettings) CaptchaSettings() system.CaptchaSettings { return value } if config.Captcha.KeyLong > 0 { - value.KeyLong = int(config.Captcha.KeyLong) + value.KeyLong = config.Captcha.KeyLong } if config.Captcha.ImgWidth > 0 { - value.ImageWidth = int(config.Captcha.ImgWidth) + value.ImageWidth = config.Captcha.ImgWidth } if config.Captcha.ImgHeight > 0 { - value.ImageHeight = int(config.Captcha.ImgHeight) + value.ImageHeight = config.Captcha.ImgHeight } - if config.Captcha.StoreExpiration != nil { - value.StoreExpiration = config.Captcha.StoreExpiration.AsDuration() + if config.Captcha.StoreExpiration > 0 { + value.StoreExpiration = config.Captcha.StoreExpiration } return value } @@ -72,7 +72,7 @@ func (s *runtimeSettings) MediaSettings() system.MediaSettings { if config == nil || config.Media == nil { return system.MediaSettings{} } - return system.MediaSettings{SessionTTL: int(config.Media.SessionTtl), MaxFileSize: config.Media.MaxFileSize, ChunkDir: config.Media.ChunkDir} + return system.MediaSettings{SessionTTL: config.Media.SessionTTL, MaxFileSize: config.Media.MaxFileSize, ChunkDir: config.Media.ChunkDir} } func (s *runtimeSettings) UseMultipoint() bool { diff --git a/internal/data/system/testing_support_test.go b/internal/data/system/testing_support_test.go index 8c8a311..bf19102 100644 --- a/internal/data/system/testing_support_test.go +++ b/internal/data/system/testing_support_test.go @@ -7,7 +7,7 @@ import ( "github.com/glebarez/sqlite" "gorm.io/gorm" - "kra/internal/conf" + "kra/internal/config" ) // Data is a small in-package test harness. Production repositories depend on @@ -16,7 +16,7 @@ import ( type Data struct { gormDB *reloadableDB redis *reloadableRedis - runtime *conf.Runtime + runtime *config.Store databaseReady atomic.Bool } @@ -29,7 +29,7 @@ func (d *Data) DB() *gorm.DB { func (d *Data) Database(string) (*gorm.DB, error) { return d.DB(), nil } func (d *Data) DatabaseReady() bool { return d != nil && d.databaseReady.Load() } -func (d *Data) Runtime() *conf.Runtime { +func (d *Data) Runtime() *config.Store { if d == nil { return nil } diff --git a/internal/data/system/transactions_test.go b/internal/data/system/transactions_test.go index f0a4142..f5d3b1b 100644 --- a/internal/data/system/transactions_test.go +++ b/internal/data/system/transactions_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "kra/internal/conf" + "kra/internal/config" ) func newTransactionTestData(t *testing.T) *Data { @@ -19,7 +19,7 @@ func newTransactionTestData(t *testing.T) *Data { if err = migrateAll(db); err != nil { t.Fatal(err) } - data := &Data{gormDB: newReloadableDB(db, nil), redis: newReloadableRedis(nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + data := &Data{gormDB: newReloadableDB(db, nil), redis: newReloadableRedis(nil), runtime: config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "sqlite"}}, Admin: &config.Admin{}})} t.Cleanup(func() { data.gormDB.close() }) return data } diff --git a/internal/global/resources.go b/internal/global/resources.go new file mode 100644 index 0000000..c7808cc --- /dev/null +++ b/internal/global/resources.go @@ -0,0 +1,283 @@ +package global + +import ( + "context" + "io" + "log/slog" + "reflect" + "sync" + + "github.com/redis/go-redis/v9" + "go.mongodb.org/mongo-driver/mongo" + "gorm.io/gorm" + platformmq "kra/pkg/mq" + platformws "kra/pkg/websocket" +) + +// FileStorage is the transport-neutral subset shared by every file storage +// implementation. Domain operations that return business models remain in +// the biz-owned FileStorage interface instead of making global depend on biz. +type FileStorage interface { + Open(context.Context, string) (io.ReadCloser, error) + Delete(context.Context, string) error + DeletePrefix(context.Context, string) error +} + +// Scheduler owns the lifecycle of the process-wide task scheduler. Scheduling +// business operations remain behind the task domain interfaces. +type Scheduler interface { + Start(context.Context) error + Stop(context.Context) error +} + +// Resources is an immutable snapshot from the registry's point of view. +// Replacing a resource publishes a new snapshot; it does not close the old +// resource because lifecycle ownership belongs to the composition root. +type Resources struct { + Logger *slog.Logger + DB *gorm.DB + NamedDBs map[string]*gorm.DB + Redis redis.UniversalClient + NamedRedis map[string]redis.UniversalClient + Mongo *mongo.Client + Storage FileStorage + MQ platformmq.Registry + WebSocket platformws.Hub + Scheduler Scheduler +} + +// ResourceRegistry provides concurrency-safe access to process-wide runtime +// resources. Prefer constructor injection for domain code; the registry is for +// framework callbacks and integration points that cannot be wired directly. +type ResourceRegistry struct { + mu sync.RWMutex + resources Resources +} + +func NewResourceRegistry() *ResourceRegistry { + return &ResourceRegistry{resources: normalizeResources(Resources{})} +} + +func (r *ResourceRegistry) Snapshot() Resources { + if r == nil { + return normalizeResources(Resources{}) + } + r.mu.RLock() + resources := cloneResources(r.resources) + r.mu.RUnlock() + return resources +} + +func (r *ResourceRegistry) Replace(resources Resources) { + if r == nil { + return + } + r.mu.Lock() + r.resources = normalizeResources(resources) + r.mu.Unlock() +} + +func (r *ResourceRegistry) Logger() *slog.Logger { return r.Snapshot().Logger } + +func (r *ResourceRegistry) SetLogger(logger *slog.Logger) { + r.update(func(resources *Resources) { resources.Logger = logger }) +} + +func (r *ResourceRegistry) DB() *gorm.DB { return r.Snapshot().DB } + +func (r *ResourceRegistry) SetDB(db *gorm.DB) { + r.update(func(resources *Resources) { resources.DB = db }) +} + +// NamedDB returns a configured secondary database by alias. An empty alias +// resolves to the primary database. +func (r *ResourceRegistry) NamedDB(name string) *gorm.DB { + if name == "" { + return r.DB() + } + return r.Snapshot().NamedDBs[name] +} + +// SetNamedDBs replaces the complete secondary-database alias set. +func (r *ResourceRegistry) SetNamedDBs(databases map[string]*gorm.DB) { + r.update(func(resources *Resources) { resources.NamedDBs = cloneDBMap(databases) }) +} + +func (r *ResourceRegistry) Redis() redis.UniversalClient { return r.Snapshot().Redis } + +func (r *ResourceRegistry) SetRedis(client redis.UniversalClient) { + r.update(func(resources *Resources) { resources.Redis = client }) +} + +// NamedRedis returns a configured secondary Redis client by alias. An empty +// alias resolves to the primary Redis client. +func (r *ResourceRegistry) NamedRedis(name string) redis.UniversalClient { + if name == "" { + return r.Redis() + } + return r.Snapshot().NamedRedis[name] +} + +// SetNamedRedis replaces the complete secondary-Redis alias set. +func (r *ResourceRegistry) SetNamedRedis(clients map[string]redis.UniversalClient) { + r.update(func(resources *Resources) { resources.NamedRedis = cloneRedisMap(clients) }) +} + +func (r *ResourceRegistry) Mongo() *mongo.Client { return r.Snapshot().Mongo } + +func (r *ResourceRegistry) SetMongo(client *mongo.Client) { + r.update(func(resources *Resources) { resources.Mongo = client }) +} + +func (r *ResourceRegistry) Storage() FileStorage { return r.Snapshot().Storage } + +func (r *ResourceRegistry) SetStorage(storage FileStorage) { + r.update(func(resources *Resources) { resources.Storage = storage }) +} + +func (r *ResourceRegistry) MQ() platformmq.Registry { return r.Snapshot().MQ } + +func (r *ResourceRegistry) SetMQ(registry platformmq.Registry) { + r.update(func(resources *Resources) { resources.MQ = registry }) +} + +func (r *ResourceRegistry) WebSocket() platformws.Hub { return r.Snapshot().WebSocket } + +func (r *ResourceRegistry) SetWebSocket(hub platformws.Hub) { + r.update(func(resources *Resources) { resources.WebSocket = hub }) +} + +func (r *ResourceRegistry) Scheduler() Scheduler { return r.Snapshot().Scheduler } + +func (r *ResourceRegistry) SetScheduler(scheduler Scheduler) { + r.update(func(resources *Resources) { resources.Scheduler = scheduler }) +} + +func (r *ResourceRegistry) update(change func(*Resources)) { + if r == nil || change == nil { + return + } + r.mu.Lock() + resources := cloneResources(r.resources) + change(&resources) + r.resources = normalizeResources(resources) + r.mu.Unlock() +} + +func normalizeResources(resources Resources) Resources { + if resources.Logger == nil { + resources.Logger = slog.Default() + } + resources.Redis = normalizeInterface(resources.Redis) + resources.Storage = normalizeInterface(resources.Storage) + resources.MQ = normalizeInterface(resources.MQ) + resources.WebSocket = normalizeInterface(resources.WebSocket) + resources.Scheduler = normalizeInterface(resources.Scheduler) + resources.NamedDBs = cloneDBMap(resources.NamedDBs) + resources.NamedRedis = cloneRedisMap(resources.NamedRedis) + return resources +} + +func cloneResources(resources Resources) Resources { + resources.NamedDBs = cloneDBMap(resources.NamedDBs) + resources.NamedRedis = cloneRedisMap(resources.NamedRedis) + return resources +} + +func cloneDBMap(source map[string]*gorm.DB) map[string]*gorm.DB { + if len(source) == 0 { + return nil + } + copyOf := make(map[string]*gorm.DB, len(source)) + for name, db := range source { + if name != "" && db != nil { + copyOf[name] = db + } + } + if len(copyOf) == 0 { + return nil + } + return copyOf +} + +func cloneRedisMap(source map[string]redis.UniversalClient) map[string]redis.UniversalClient { + if len(source) == 0 { + return nil + } + copyOf := make(map[string]redis.UniversalClient, len(source)) + for name, client := range source { + if name == "" { + continue + } + client = normalizeInterface(client) + if client != nil { + copyOf[name] = client + } + } + if len(copyOf) == 0 { + return nil + } + return copyOf +} + +func normalizeInterface[T any](value T) T { + reflected := reflect.ValueOf(value) + if reflected.IsValid() { + switch reflected.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + if !reflected.IsNil() { + return value + } + default: + return value + } + } + var zero T + return zero +} + +var defaultResources = NewResourceRegistry() + +func ResourceSnapshot() Resources { return defaultResources.Snapshot() } + +func ReplaceResources(resources Resources) { defaultResources.Replace(resources) } + +func Logger() *slog.Logger { return defaultResources.Logger() } + +func SetLogger(logger *slog.Logger) { defaultResources.SetLogger(logger) } + +func DB() *gorm.DB { return defaultResources.DB() } + +func SetDB(db *gorm.DB) { defaultResources.SetDB(db) } + +func NamedDB(name string) *gorm.DB { return defaultResources.NamedDB(name) } + +func SetNamedDBs(databases map[string]*gorm.DB) { defaultResources.SetNamedDBs(databases) } + +func Redis() redis.UniversalClient { return defaultResources.Redis() } + +func SetRedis(client redis.UniversalClient) { defaultResources.SetRedis(client) } + +func NamedRedis(name string) redis.UniversalClient { return defaultResources.NamedRedis(name) } + +func SetNamedRedis(clients map[string]redis.UniversalClient) { defaultResources.SetNamedRedis(clients) } + +func Mongo() *mongo.Client { return defaultResources.Mongo() } + +func SetMongo(client *mongo.Client) { defaultResources.SetMongo(client) } + +func Storage() FileStorage { return defaultResources.Storage() } + +func SetStorage(storage FileStorage) { defaultResources.SetStorage(storage) } + +func MQ() platformmq.Registry { return defaultResources.MQ() } + +func SetMQ(registry platformmq.Registry) { defaultResources.SetMQ(registry) } + +func WebSocket() platformws.Hub { return defaultResources.WebSocket() } + +func SetWebSocket(hub platformws.Hub) { defaultResources.SetWebSocket(hub) } + +func TaskScheduler() Scheduler { return defaultResources.Scheduler() } + +func SetTaskScheduler(scheduler Scheduler) { defaultResources.SetScheduler(scheduler) } diff --git a/internal/global/resources_test.go b/internal/global/resources_test.go new file mode 100644 index 0000000..d55b486 --- /dev/null +++ b/internal/global/resources_test.go @@ -0,0 +1,153 @@ +package global + +import ( + "context" + "io" + "log/slog" + "sync" + "testing" + + "github.com/redis/go-redis/v9" + "go.mongodb.org/mongo-driver/mongo" + "gorm.io/gorm" + platformmq "kra/pkg/mq" + platformws "kra/pkg/websocket" +) + +type storageStub struct{} + +func (*storageStub) Open(context.Context, string) (io.ReadCloser, error) { return nil, nil } +func (*storageStub) Delete(context.Context, string) error { return nil } +func (*storageStub) DeletePrefix(context.Context, string) error { return nil } + +type schedulerStub struct{} + +func (*schedulerStub) Start(context.Context) error { return nil } +func (*schedulerStub) Stop(context.Context) error { return nil } + +type mqStub struct{} + +func (*mqStub) Register(platformmq.SubscriptionSet) error { return nil } +func (*mqStub) Unregister(string) error { return nil } +func (*mqStub) Client(string) platformmq.Client { return nil } +func (*mqStub) PublishTo(context.Context, string, string, []byte, byte, bool) error { + return nil +} +func (*mqStub) SubscribeTo(context.Context, string, string, byte, platformmq.Handler) error { + return nil +} +func (*mqStub) UnsubscribeFrom(context.Context, string, ...string) error { return nil } +func (*mqStub) ConnectedTo(string) bool { return true } + +type webSocketStub struct{} + +func (*webSocketStub) Enabled() bool { return true } +func (*webSocketStub) Broadcast([]byte) error { return nil } +func (*webSocketStub) BroadcastBinary([]byte) error { return nil } +func (*webSocketStub) Sessions() ([]*platformws.Session, error) { return nil, nil } +func (*webSocketStub) Len() int { return 0 } +func (*webSocketStub) Send(*platformws.Session, []byte) error { return nil } +func (*webSocketStub) SendBinary(*platformws.Session, []byte) error { return nil } +func (*webSocketStub) OnMessage(func(*platformws.Session, []byte)) {} +func (*webSocketStub) OnBinaryMessage(func(*platformws.Session, []byte)) {} +func (*webSocketStub) OnConnect(func(*platformws.Session)) {} +func (*webSocketStub) OnDisconnect(func(*platformws.Session)) {} + +func TestResourceRegistryDefaults(t *testing.T) { + registry := NewResourceRegistry() + if registry.Logger() == nil { + t.Fatal("default logger is nil") + } + resources := registry.Snapshot() + if resources.DB != nil || len(resources.NamedDBs) != 0 || resources.Redis != nil || len(resources.NamedRedis) != 0 || resources.Mongo != nil || resources.Storage != nil || resources.MQ != nil || resources.WebSocket != nil || resources.Scheduler != nil { + t.Fatalf("unexpected initialized resource: %#v", resources) + } +} + +func TestResourceRegistryReplaceAndSetters(t *testing.T) { + logger := slog.Default().With("test", true) + db := &gorm.DB{} + redisClient := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}) + t.Cleanup(func() { _ = redisClient.Close() }) + mongoClient := &mongo.Client{} + storage := &storageStub{} + mq := &mqStub{} + webSocket := &webSocketStub{} + scheduler := &schedulerStub{} + + registry := NewResourceRegistry() + registry.Replace(Resources{ + Logger: logger, DB: db, NamedDBs: map[string]*gorm.DB{"reporting": db}, + Redis: redisClient, NamedRedis: map[string]redis.UniversalClient{"cache": redisClient}, Mongo: mongoClient, + Storage: storage, MQ: mq, WebSocket: webSocket, Scheduler: scheduler, + }) + resources := registry.Snapshot() + if resources.Logger != logger || resources.DB != db || resources.Redis != redisClient || resources.Mongo != mongoClient || resources.Storage != storage || resources.MQ != mq || resources.WebSocket != webSocket || resources.Scheduler != scheduler { + t.Fatalf("replacement snapshot does not match: %#v", resources) + } + + nextDB := &gorm.DB{} + registry.SetDB(nextDB) + if registry.DB() != nextDB { + t.Fatal("database setter did not publish the new database") + } + if registry.Redis() != redisClient || registry.Storage() != storage { + t.Fatal("database setter changed unrelated resources") + } + if registry.NamedDB("reporting") != db || registry.NamedDB("") != nextDB { + t.Fatal("named database lookup returned the wrong resource") + } + if registry.NamedRedis("cache") != redisClient || registry.NamedRedis("") != redisClient { + t.Fatal("named redis lookup returned the wrong resource") + } + inputDBs := map[string]*gorm.DB{"analytics": db} + registry.SetNamedDBs(inputDBs) + inputDBs["mutated"] = nextDB + if registry.NamedDB("mutated") != nil || registry.NamedDB("analytics") != db { + t.Fatal("named database map was not copied") + } + inputRedis := map[string]redis.UniversalClient{"sessions": redisClient} + registry.SetNamedRedis(inputRedis) + inputRedis["mutated"] = redisClient + if registry.NamedRedis("mutated") != nil || registry.NamedRedis("sessions") != redisClient { + t.Fatal("named redis map was not copied") + } +} + +func TestResourceRegistryNormalizesTypedNilInterfaces(t *testing.T) { + registry := NewResourceRegistry() + var redisClient *redis.Client + var storage *storageStub + var mq *mqStub + var webSocket *webSocketStub + var scheduler *schedulerStub + registry.Replace(Resources{Redis: redisClient, Storage: storage, MQ: mq, WebSocket: webSocket, Scheduler: scheduler}) + resources := registry.Snapshot() + if resources.Redis != nil || resources.Storage != nil || resources.MQ != nil || resources.WebSocket != nil || resources.Scheduler != nil { + t.Fatalf("typed nil resource was retained: %#v", resources) + } + if resources.Logger == nil { + t.Fatal("nil logger was not replaced with the default logger") + } +} + +func TestResourceRegistryConcurrentAccess(t *testing.T) { + registry := NewResourceRegistry() + const iterations = 1000 + var wait sync.WaitGroup + for index := 0; index < 8; index++ { + wait.Add(1) + go func() { + defer wait.Done() + for step := 0; step < iterations; step++ { + registry.SetDB(&gorm.DB{}) + _ = registry.Snapshot() + _ = registry.Logger() + } + }() + } + wait.Wait() + if registry.DB() == nil { + t.Fatal("concurrent setters lost the database") + } +} diff --git a/internal/initialize/backend.go b/internal/initialize/backend.go index 1837726..cc951e8 100644 --- a/internal/initialize/backend.go +++ b/internal/initialize/backend.go @@ -3,8 +3,7 @@ package initialize import ( "context" "kra/internal/biz/system" - - "kra/internal/conf" + "kra/internal/config" "gorm.io/gorm" ) @@ -15,10 +14,7 @@ type Backend interface { IsInitialized(context.Context) (bool, error) InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error PersistConfig(context.Context) error - PersistAdminConfig(context.Context, []byte) error - PersistRuntimeConfig(context.Context, []byte, []byte) error + PersistRuntimeConfig(context.Context, *config.Config) error ReloadConfig(context.Context) error - RuntimeValues() (*conf.Data, *conf.AdminBackend) - RuntimeAdmin() *conf.AdminBackend - RefreshDatabaseSources(*conf.Data) error + Config() *config.Config } diff --git a/internal/initialize/compatibility.go b/internal/initialize/compatibility.go deleted file mode 100644 index ad3192d..0000000 --- a/internal/initialize/compatibility.go +++ /dev/null @@ -1,559 +0,0 @@ -package initialize - -import ( - "encoding/json" - "strconv" - "strings" - "time" - - "kra/internal/conf" - - "google.golang.org/protobuf/types/known/durationpb" -) - -func compatibleDatabase(value *conf.Data_Database) map[string]any { - if value == nil { - return map[string]any{} - } - path := value.Host - if value.Driver == "sqlite" { - path = value.Path - } - return map[string]any{ - "prefix": value.Prefix, "port": value.Port, "config": value.Config, - "db-name": value.Name, "username": value.User, "password": value.Password, - "path": path, "engine": value.Engine, "log-mode": value.LogMode, - "max-idle-conns": value.MaxIdleConns, "max-open-conns": value.MaxOpenConns, - "conn-max-lifetime": value.ConnMaxLifetime, "singular": value.Singular, - } -} - -func compatibleRedis(value *conf.Data_Redis) map[string]any { - if value == nil { - return map[string]any{} - } - return map[string]any{"name": value.Name, "addr": value.Addr, "password": value.Password, "db": value.Db, "useCluster": value.UseCluster, "clusterAddrs": value.ClusterAddrs} -} - -func compatibleObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any { - if value == nil { - return map[string]any{} - } - return map[string]any{ - "endpoint": value.Endpoint, "region": value.Region, "bucket": value.Bucket, - "access-key": value.AccessKey, "secret-key": value.SecretKey, "base-url": value.BaseUrl, - "path-prefix": value.PathPrefix, "use-ssl": value.UseSsl, - "s3-force-path-style": value.ForcePathStyle, "account-id": value.AccountId, - } -} - -func compatibleObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]any { - if storage == nil { - storage = &conf.AdminBackend_Storage{} - } - aliyun, huawei := storage.AliyunOss, storage.HuaweiObs - tencent, aws := storage.TencentCos, storage.AwsS3 - cloudflare, minio := storage.CloudflareR2, storage.Minio - if aliyun == nil { - aliyun = &conf.AdminBackend_ObjectStore{} - } - if huawei == nil { - huawei = &conf.AdminBackend_ObjectStore{} - } - if tencent == nil { - tencent = &conf.AdminBackend_ObjectStore{} - } - if aws == nil { - aws = &conf.AdminBackend_ObjectStore{} - } - if cloudflare == nil { - cloudflare = &conf.AdminBackend_ObjectStore{} - } - if minio == nil { - minio = &conf.AdminBackend_ObjectStore{} - } - return map[string]map[string]any{ - "aliyun-oss": {"endpoint": aliyun.Endpoint, "access-key-id": aliyun.AccessKey, "access-key-secret": aliyun.SecretKey, "bucket-name": aliyun.Bucket, "bucket-url": aliyun.BaseUrl, "base-path": aliyun.PathPrefix}, - "hua-wei-obs": {"path": huawei.PathPrefix, "bucket": huawei.Bucket, "endpoint": huawei.Endpoint, "access-key": huawei.AccessKey, "secret-key": huawei.SecretKey}, - "tencent-cos": {"bucket": tencent.Bucket, "region": tencent.Region, "secret-id": tencent.AccessKey, "secret-key": tencent.SecretKey, "base-url": tencent.BaseUrl, "path-prefix": tencent.PathPrefix}, - "aws-s3": {"bucket": aws.Bucket, "region": aws.Region, "endpoint": aws.Endpoint, "secret-id": aws.AccessKey, "secret-key": aws.SecretKey, "base-url": aws.BaseUrl, "path-prefix": aws.PathPrefix, "s3-force-path-style": aws.ForcePathStyle, "disable-ssl": !aws.UseSsl}, - "cloudflare-r2": {"bucket": cloudflare.Bucket, "base-url": cloudflare.BaseUrl, "path": cloudflare.PathPrefix, "account-id": cloudflare.AccountId, "access-key-id": cloudflare.AccessKey, "secret-access-key": cloudflare.SecretKey}, - "minio": {"endpoint": minio.Endpoint, "access-key-id": minio.AccessKey, "access-key-secret": minio.SecretKey, "bucket-name": minio.Bucket, "use-ssl": minio.UseSsl, "base-path": minio.PathPrefix, "bucket-url": minio.BaseUrl}, - } -} - -func compatibleConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any { - result := map[string]any{} - if data == nil { - data = &conf.Data{} - } - if admin == nil { - admin = &conf.AdminBackend{} - } - jwt := admin.Jwt - if jwt == nil { - jwt = &conf.AdminBackend_JWT{} - } - result["jwt"] = map[string]any{"signing-key": jwt.SigningKey, "expires-time": durationString(jwt.ExpiresTime), "buffer-time": durationString(jwt.BufferTime), "issuer": jwt.Issuer} - zap := admin.Zap - if zap == nil { - zap = &conf.AdminBackend_Zap{} - } - result["zap"] = map[string]any{ - "level": zap.Level, "prefix": zap.Prefix, "format": zap.Format, "director": zap.Director, - "encode-level": zap.EncodeLevel, "stacktrace-key": zap.StacktraceKey, "show-line": zap.ShowLine, - "log-in-console": zap.LogInConsole, "retention-day": zap.RetentionDay, - "access-req-body": zap.AccessReqBody, "access-resp-data": zap.AccessRespData, - "access-req-headers": zap.AccessReqHeaders, "access-log-max-bytes": zap.AccessLogMaxBytes, - "file-only-modules": zap.FileOnlyModules, - } - result["redis"] = compatibleRedis(data.Redis) - redisList := make([]map[string]any, 0, len(data.RedisList)) - for _, item := range data.RedisList { - redisList = append(redisList, compatibleRedis(item)) - } - result["redis-list"] = redisList - mongo := data.Mongo - if mongo == nil { - mongo = &conf.Data_Mongo{} - } - hosts := make([]map[string]any, 0, len(mongo.Hosts)) - for _, host := range mongo.Hosts { - if host != nil { - hosts = append(hosts, map[string]any{"host": host.Host, "port": host.Port}) - } - } - result["mongo"] = map[string]any{"coll": mongo.Coll, "options": mongo.Options, "database": mongo.Database, "username": mongo.Username, "password": mongo.Password, "auth-source": mongo.AuthSource, "min-pool-size": mongo.MinPoolSize, "max-pool-size": mongo.MaxPoolSize, "socket-timeout-ms": mongo.SocketTimeoutMs, "connect-timeout-ms": mongo.ConnectTimeoutMs, "is-zap": mongo.IsZap, "hosts": hosts} - email := admin.Email - if email == nil { - email = &conf.AdminBackend_Email{} - } - result["email"] = map[string]any{"to": email.To, "from": email.From, "host": email.Host, "secret": email.Secret, "nickname": email.Nickname, "port": email.Port, "is-ssl": email.IsSsl, "is-loginauth": email.IsLoginAuth} - system := admin.System - if system == nil { - system = &conf.AdminBackend_System{} - } - storageType := "local" - if admin.Storage != nil && admin.Storage.Type != "" { - storageType = admin.Storage.Type - } - driver := "" - if data.Database != nil { - driver = data.Database.Driver - } - result["system"] = map[string]any{"db-type": driver, "oss-type": storageType, "router-prefix": admin.RouterPrefix, "addr": system.Addr, "iplimit-count": system.IplimitCount, "iplimit-time": system.IplimitTime, "use-multipoint": system.UseMultipoint, "use-redis": system.UseRedis, "use-mongo": system.UseMongo, "use-strict-auth": system.UseStrictAuth, "disable-auto-migrate": system.DisableAutoMigrate} - for _, name := range []string{"mysql", "mssql", "pgsql", "oracle", "sqlite"} { - result[name] = map[string]any{} - } - if driver != "" { - result[driver] = compatibleDatabase(data.Database) - } - dbList := make([]map[string]any, 0, len(data.DatabaseList)) - for _, item := range data.DatabaseList { - row := compatibleDatabase(item) - if item != nil { - row["type"], row["alias-name"], row["disable"] = item.Driver, item.AliasName, item.Disable - } - dbList = append(dbList, row) - } - result["db-list"] = dbList - local := admin.Local - if local == nil { - local = &conf.AdminBackend_Local{} - } - result["local"] = map[string]any{"path": local.PathPrefix, "store-path": local.StorePath} - media := admin.Media - if media == nil { - media = &conf.AdminBackend_Media{} - } - chunkDir := media.ChunkDir - if chunkDir == "" { - chunkDir = "uploads/chunks" - } - result["media"] = map[string]any{"session-ttl": media.SessionTtl, "max-file-size": media.MaxFileSize, "chunk-dir": chunkDir} - if admin.Storage == nil { - admin.Storage = &conf.AdminBackend_Storage{} - } - qiniu := admin.Storage.Qiniu - if qiniu == nil { - qiniu = &conf.AdminBackend_Qiniu{} - } - result["qiniu"] = map[string]any{"zone": qiniu.Zone, "bucket": qiniu.Bucket, "img-path": qiniu.BaseUrl, "access-key": qiniu.AccessKey, "secret-key": qiniu.SecretKey, "use-https": qiniu.UseHttps, "use-cdn-domains": qiniu.UseCdnDomains} - for key, value := range compatibleObjectStores(admin.Storage) { - result[key] = value - } - disks := make([]map[string]any, 0, len(admin.DiskList)) - for _, disk := range admin.DiskList { - if disk != nil { - disks = append(disks, map[string]any{"mount-point": disk.MountPoint}) - } - } - result["disk-list"] = disks - cors := admin.Cors - if cors == nil { - cors = &conf.AdminBackend_CORS{} - } - whitelist := make([]map[string]any, 0, len(cors.Whitelist)) - for _, rule := range cors.Whitelist { - if rule != nil { - whitelist = append(whitelist, map[string]any{"allow-origin": rule.AllowOrigin, "allow-methods": rule.AllowMethods, "allow-headers": rule.AllowHeaders, "expose-headers": rule.ExposeHeaders, "allow-credentials": rule.AllowCredentials}) - } - } - result["cors"] = map[string]any{"mode": cors.Mode, "whitelist": whitelist} - result["app"] = map[string]any{"node": admin.GetApp().GetNode(), "app-id": admin.GetApp().GetAppId(), "env": admin.GetApp().GetEnv()} - return result -} - -func objectMap(value any) map[string]any { - result, _ := value.(map[string]any) - return result -} - -func stringValue(values map[string]any, key string) string { - value, _ := values[key].(string) - return value -} - -func boolValue(values map[string]any, key string, fallback bool) bool { - value, ok := values[key].(bool) - if !ok { - return fallback - } - return value -} - -func int64Value(values map[string]any, key string, fallback int64) int64 { - value, ok := values[key].(float64) - if !ok { - return fallback - } - return int64(value) -} - -func stringSliceValue(values map[string]any, key string, fallback []string) []string { - items, ok := values[key].([]any) - if !ok { - return fallback - } - result := make([]string, 0, len(items)) - for _, item := range items { - if value, ok := item.(string); ok { - result = append(result, value) - } - } - return result -} - -func applyCompatibleRedis(target *conf.Data_Redis, values map[string]any) { - if target == nil || values == nil { - return - } - target.Name, target.Addr = stringValue(values, "name"), stringValue(values, "addr") - if password := stringValue(values, "password"); password != "" && password != "******" { - target.Password = password - } - target.Db = int32(int64Value(values, "db", int64(target.Db))) - target.UseCluster = boolValue(values, "useCluster", target.UseCluster) - target.ClusterAddrs = stringSliceValue(values, "clusterAddrs", target.ClusterAddrs) -} - -func durationValue(raw string, fallback *durationpb.Duration) *durationpb.Duration { - if raw == "" { - return fallback - } - value, err := parseCompatibleDuration(raw) - if err != nil { - return fallback - } - return durationpb.New(value) -} - -func parseCompatibleDuration(raw string) (time.Duration, error) { - raw = strings.TrimSpace(raw) - if value, err := time.ParseDuration(raw); err == nil { - return value, nil - } - if index := strings.Index(raw, "d"); index >= 0 { - if index == 0 { - return 0, strconv.ErrSyntax - } - days, err := strconv.Atoi(raw[:index]) - if err != nil { - return 0, err - } - value := time.Duration(days) * 24 * time.Hour - remainderText := strings.TrimSpace(raw[index+1:]) - if remainderText == "" { - return value, nil - } - remainder, err := time.ParseDuration(remainderText) - if err != nil { - return 0, err - } - return value + remainder, nil - } - nanoseconds, err := strconv.ParseInt(raw, 10, 64) - return time.Duration(nanoseconds), err -} - -func applyCompatibleDatabase(target *conf.Data_Database, values map[string]any, driver string) { - if target == nil || values == nil { - return - } - target.Driver = driver - target.Prefix, target.Port, target.Config = stringValue(values, "prefix"), stringValue(values, "port"), stringValue(values, "config") - target.Name, target.User = stringValue(values, "db-name"), stringValue(values, "username") - if password := stringValue(values, "password"); password != "" && password != "******" { - target.Password = password - } - if driver == "sqlite" { - target.Path = stringValue(values, "path") - } else { - target.Host = stringValue(values, "path") - } - target.Engine, target.LogMode = stringValue(values, "engine"), stringValue(values, "log-mode") - target.MaxIdleConns = int32(int64Value(values, "max-idle-conns", int64(target.MaxIdleConns))) - target.MaxOpenConns = int32(int64Value(values, "max-open-conns", int64(target.MaxOpenConns))) - target.ConnMaxLifetime = int32(int64Value(values, "conn-max-lifetime", int64(target.ConnMaxLifetime))) - target.Singular = boolValue(values, "singular", target.Singular) -} - -func applyCompatibleObjectStore(target *conf.AdminBackend_ObjectStore, values map[string]any) { - if target == nil || values == nil { - return - } - target.Endpoint, target.Region, target.Bucket = stringValue(values, "endpoint"), stringValue(values, "region"), stringValue(values, "bucket") - if target.Bucket == "" { - target.Bucket = stringValue(values, "bucket-name") - } - target.AccessKey = stringValue(values, "access-key") - if target.AccessKey == "" { - target.AccessKey = stringValue(values, "access-key-id") - } - if target.AccessKey == "" { - target.AccessKey = stringValue(values, "secret-id") - } - secret := stringValue(values, "secret-key") - if secret == "" { - secret = stringValue(values, "access-key-secret") - } - if secret == "" { - secret = stringValue(values, "secret-access-key") - } - if secret != "" && secret != "******" { - target.SecretKey = secret - } - target.BaseUrl = stringValue(values, "base-url") - if target.BaseUrl == "" { - target.BaseUrl = stringValue(values, "bucket-url") - } - target.PathPrefix = stringValue(values, "path-prefix") - if target.PathPrefix == "" { - target.PathPrefix = stringValue(values, "base-path") - } - if target.PathPrefix == "" { - target.PathPrefix = stringValue(values, "path") - } - target.UseSsl = boolValue(values, "use-ssl", !boolValue(values, "disable-ssl", !target.UseSsl)) - target.ForcePathStyle = boolValue(values, "s3-force-path-style", target.ForcePathStyle) - target.AccountId = stringValue(values, "account-id") -} - -func applyCompatibleConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.AdminBackend) error { - var values map[string]any - if err := json.Unmarshal(raw, &values); err != nil { - return err - } - if _, compatibleShape := values["system"]; !compatibleShape { - return nil - } - if data.Database == nil { - data.Database = &conf.Data_Database{} - } - if admin.System == nil { - admin.System = &conf.AdminBackend_System{} - } - if admin.Storage == nil { - admin.Storage = &conf.AdminBackend_Storage{} - } - system := objectMap(values["system"]) - driver := stringValue(system, "db-type") - if driver == "" { - driver = data.Database.Driver - } - applyCompatibleDatabase(data.Database, objectMap(values[driver]), driver) - admin.Storage.Type = stringValue(system, "oss-type") - admin.RouterPrefix = stringValue(system, "router-prefix") - admin.System.UseMultipoint = boolValue(system, "use-multipoint", admin.System.UseMultipoint) - admin.System.UseRedis = boolValue(system, "use-redis", admin.System.UseRedis) - admin.System.UseMongo = boolValue(system, "use-mongo", admin.System.UseMongo) - admin.System.UseStrictAuth = boolValue(system, "use-strict-auth", admin.System.UseStrictAuth) - admin.System.DisableAutoMigrate = boolValue(system, "disable-auto-migrate", admin.System.DisableAutoMigrate) - admin.System.Addr = int32(int64Value(system, "addr", int64(admin.System.Addr))) - admin.System.IplimitCount = int32(int64Value(system, "iplimit-count", int64(admin.System.IplimitCount))) - admin.System.IplimitTime = int32(int64Value(system, "iplimit-time", int64(admin.System.IplimitTime))) - if admin.Jwt == nil { - admin.Jwt = &conf.AdminBackend_JWT{} - } - jwt := objectMap(values["jwt"]) - if secret := stringValue(jwt, "signing-key"); secret != "" && secret != "******" { - admin.Jwt.SigningKey = secret - } - admin.Jwt.ExpiresTime = durationValue(stringValue(jwt, "expires-time"), admin.Jwt.ExpiresTime) - admin.Jwt.BufferTime = durationValue(stringValue(jwt, "buffer-time"), admin.Jwt.BufferTime) - admin.Jwt.Issuer = stringValue(jwt, "issuer") - if admin.Zap == nil { - admin.Zap = &conf.AdminBackend_Zap{} - } - zap := objectMap(values["zap"]) - admin.Zap.Level, admin.Zap.Prefix, admin.Zap.Format, admin.Zap.Director = stringValue(zap, "level"), stringValue(zap, "prefix"), stringValue(zap, "format"), stringValue(zap, "director") - admin.Zap.EncodeLevel, admin.Zap.StacktraceKey = stringValue(zap, "encode-level"), stringValue(zap, "stacktrace-key") - admin.Zap.ShowLine, admin.Zap.LogInConsole = boolValue(zap, "show-line", admin.Zap.ShowLine), boolValue(zap, "log-in-console", admin.Zap.LogInConsole) - admin.Zap.RetentionDay = int32(int64Value(zap, "retention-day", int64(admin.Zap.RetentionDay))) - admin.Zap.AccessReqBody = boolValue(zap, "access-req-body", admin.Zap.AccessReqBody) - admin.Zap.AccessRespData = boolValue(zap, "access-resp-data", admin.Zap.AccessRespData) - admin.Zap.AccessReqHeaders = boolValue(zap, "access-req-headers", admin.Zap.AccessReqHeaders) - admin.Zap.AccessLogMaxBytes = int32(int64Value(zap, "access-log-max-bytes", int64(admin.Zap.AccessLogMaxBytes))) - if list, ok := zap["file-only-modules"].([]any); ok { - admin.Zap.FileOnlyModules = admin.Zap.FileOnlyModules[:0] - for _, item := range list { - if value, ok := item.(string); ok { - admin.Zap.FileOnlyModules = append(admin.Zap.FileOnlyModules, value) - } - } - } - if data.Redis == nil { - data.Redis = &conf.Data_Redis{} - } - applyCompatibleRedis(data.Redis, objectMap(values["redis"])) - if items, ok := values["redis-list"].([]any); ok { - current := make(map[string]*conf.Data_Redis, len(data.RedisList)) - for _, item := range data.RedisList { - if item != nil { - current[item.Name] = item - } - } - data.RedisList = make([]*conf.Data_Redis, 0, len(items)) - for _, item := range items { - values := objectMap(item) - target := current[stringValue(values, "name")] - if target == nil { - target = &conf.Data_Redis{} - } - applyCompatibleRedis(target, values) - data.RedisList = append(data.RedisList, target) - } - } - if mongo := objectMap(values["mongo"]); mongo != nil { - if data.Mongo == nil { - data.Mongo = &conf.Data_Mongo{} - } - data.Mongo.Coll, data.Mongo.Options, data.Mongo.Database = stringValue(mongo, "coll"), stringValue(mongo, "options"), stringValue(mongo, "database") - data.Mongo.Username, data.Mongo.AuthSource = stringValue(mongo, "username"), stringValue(mongo, "auth-source") - if password := stringValue(mongo, "password"); password != "" && password != "******" { - data.Mongo.Password = password - } - data.Mongo.MinPoolSize = uint64(int64Value(mongo, "min-pool-size", int64(data.Mongo.MinPoolSize))) - data.Mongo.MaxPoolSize = uint64(int64Value(mongo, "max-pool-size", int64(data.Mongo.MaxPoolSize))) - data.Mongo.SocketTimeoutMs = int64Value(mongo, "socket-timeout-ms", data.Mongo.SocketTimeoutMs) - data.Mongo.ConnectTimeoutMs = int64Value(mongo, "connect-timeout-ms", data.Mongo.ConnectTimeoutMs) - data.Mongo.IsZap = boolValue(mongo, "is-zap", data.Mongo.IsZap) - if hosts, ok := mongo["hosts"].([]any); ok { - data.Mongo.Hosts = make([]*conf.Data_MongoHost, 0, len(hosts)) - for _, item := range hosts { - host := objectMap(item) - data.Mongo.Hosts = append(data.Mongo.Hosts, &conf.Data_MongoHost{Host: stringValue(host, "host"), Port: stringValue(host, "port")}) - } - } - } - if items, ok := values["db-list"].([]any); ok { - current := make(map[string]*conf.Data_Database, len(data.DatabaseList)) - for _, item := range data.DatabaseList { - if item != nil { - current[item.AliasName] = item - } - } - data.DatabaseList = make([]*conf.Data_Database, 0, len(items)) - for _, item := range items { - row := objectMap(item) - alias := stringValue(row, "alias-name") - target := current[alias] - if target == nil { - target = &conf.Data_Database{} - } - applyCompatibleDatabase(target, row, stringValue(row, "type")) - target.AliasName, target.Disable = alias, boolValue(row, "disable", target.Disable) - data.DatabaseList = append(data.DatabaseList, target) - } - } - if admin.Email == nil { - admin.Email = &conf.AdminBackend_Email{} - } - email := objectMap(values["email"]) - admin.Email.To, admin.Email.From, admin.Email.Host = stringValue(email, "to"), stringValue(email, "from"), stringValue(email, "host") - admin.Email.Nickname = stringValue(email, "nickname") - admin.Email.Port = int32(int64Value(email, "port", int64(admin.Email.Port))) - admin.Email.IsSsl, admin.Email.IsLoginAuth = boolValue(email, "is-ssl", admin.Email.IsSsl), boolValue(email, "is-loginauth", admin.Email.IsLoginAuth) - if secret := stringValue(email, "secret"); secret != "" && secret != "******" { - admin.Email.Secret = secret - } - if admin.Local == nil { - admin.Local = &conf.AdminBackend_Local{} - } - local := objectMap(values["local"]) - admin.Local.PathPrefix, admin.Local.StorePath = stringValue(local, "path"), stringValue(local, "store-path") - if admin.Media == nil { - admin.Media = &conf.AdminBackend_Media{} - } - media := objectMap(values["media"]) - admin.Media.SessionTtl = int32(int64Value(media, "session-ttl", int64(admin.Media.SessionTtl))) - admin.Media.MaxFileSize = int64Value(media, "max-file-size", admin.Media.MaxFileSize) - admin.Media.ChunkDir = stringValue(media, "chunk-dir") - if admin.Storage.Qiniu == nil { - admin.Storage.Qiniu = &conf.AdminBackend_Qiniu{} - } - qiniu := objectMap(values["qiniu"]) - admin.Storage.Qiniu.Zone, admin.Storage.Qiniu.Bucket, admin.Storage.Qiniu.BaseUrl = stringValue(qiniu, "zone"), stringValue(qiniu, "bucket"), stringValue(qiniu, "img-path") - admin.Storage.Qiniu.AccessKey = stringValue(qiniu, "access-key") - if secret := stringValue(qiniu, "secret-key"); secret != "" && secret != "******" { - admin.Storage.Qiniu.SecretKey = secret - } - admin.Storage.Qiniu.UseHttps, admin.Storage.Qiniu.UseCdnDomains = boolValue(qiniu, "use-https", admin.Storage.Qiniu.UseHttps), boolValue(qiniu, "use-cdn-domains", admin.Storage.Qiniu.UseCdnDomains) - stores := []struct { - key string - target **conf.AdminBackend_ObjectStore - }{{"aliyun-oss", &admin.Storage.AliyunOss}, {"hua-wei-obs", &admin.Storage.HuaweiObs}, {"tencent-cos", &admin.Storage.TencentCos}, {"aws-s3", &admin.Storage.AwsS3}, {"cloudflare-r2", &admin.Storage.CloudflareR2}, {"minio", &admin.Storage.Minio}} - for _, store := range stores { - if *store.target == nil { - *store.target = &conf.AdminBackend_ObjectStore{} - } - applyCompatibleObjectStore(*store.target, objectMap(values[store.key])) - } - if disks, ok := values["disk-list"].([]any); ok { - admin.DiskList = make([]*conf.AdminBackend_Disk, 0, len(disks)) - for _, item := range disks { - disk := objectMap(item) - admin.DiskList = append(admin.DiskList, &conf.AdminBackend_Disk{MountPoint: stringValue(disk, "mount-point")}) - } - } - if cors := objectMap(values["cors"]); cors != nil { - if admin.Cors == nil { - admin.Cors = &conf.AdminBackend_CORS{} - } - admin.Cors.Mode = stringValue(cors, "mode") - if rules, ok := cors["whitelist"].([]any); ok { - admin.Cors.Whitelist = make([]*conf.AdminBackend_CORSRule, 0, len(rules)) - for _, item := range rules { - rule := objectMap(item) - admin.Cors.Whitelist = append(admin.Cors.Whitelist, &conf.AdminBackend_CORSRule{AllowOrigin: stringValue(rule, "allow-origin"), AllowMethods: stringValue(rule, "allow-methods"), AllowHeaders: stringValue(rule, "allow-headers"), ExposeHeaders: stringValue(rule, "expose-headers"), AllowCredentials: boolValue(rule, "allow-credentials", false)}) - } - } - } - if app := objectMap(values["app"]); app != nil { - if admin.App == nil { - admin.App = &conf.AdminBackend_App{} - } - admin.App.Node, admin.App.AppId, admin.App.Env = stringValue(app, "node"), stringValue(app, "app-id"), stringValue(app, "env") - } - return nil -} diff --git a/internal/initialize/compatibility_test.go b/internal/initialize/compatibility_test.go deleted file mode 100644 index 5933545..0000000 --- a/internal/initialize/compatibility_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package initialize - -import ( - "testing" - "time" -) - -func TestParseCompatibleDuration(t *testing.T) { - tests := []struct { - name string - raw string - want time.Duration - wantErr bool - }{ - {name: "days only", raw: "3d", want: 72 * time.Hour}, - {name: "days and duration", raw: "3d4h", want: 76 * time.Hour}, - {name: "missing day count", raw: "xd", wantErr: true}, - {name: "invalid remainder", raw: "3dgarbage", wantErr: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := parseCompatibleDuration(test.raw) - if test.wantErr { - if err == nil { - t.Fatalf("parseCompatibleDuration(%q) accepted malformed input", test.raw) - } - return - } - if err != nil { - t.Fatalf("parseCompatibleDuration(%q) error = %v", test.raw, err) - } - if got != test.want { - t.Fatalf("parseCompatibleDuration(%q) = %s, want %s", test.raw, got, test.want) - } - }) - } -} diff --git a/internal/initialize/configuration.go b/internal/initialize/configuration.go index 81be75b..8d8c65f 100644 --- a/internal/initialize/configuration.go +++ b/internal/initialize/configuration.go @@ -3,184 +3,68 @@ package initialize import ( "context" "encoding/json" + "errors" - "kra/internal/conf" - "kra/pkg/protoutil" - - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/durationpb" + "kra/internal/config" ) type configurationEnvelope struct { Data json.RawMessage `json:"data"` Admin json.RawMessage `json:"admin"` - Email *struct { - To string `json:"to"` - From string `json:"from"` - Host string `json:"host"` - Secret string `json:"secret"` - Nickname string `json:"nickname"` - Port int32 `json:"port"` - IsSSL bool `json:"is-ssl"` - IsLoginAuth bool `json:"is-loginauth"` - } `json:"email"` + Email json.RawMessage `json:"email"` } func (r *Repo) ConfigurationJSON() (json.RawMessage, error) { - dataConfig, adminConfig := r.backend.RuntimeValues() - admin := map[string]any{"routerPrefix": ""} - email := map[string]any{} - if adminConfig != nil { - admin["routerPrefix"] = adminConfig.RouterPrefix - if adminConfig.System != nil { - admin["system"] = map[string]any{ - "useRedis": adminConfig.System.UseRedis, - "useMultipoint": adminConfig.System.UseMultipoint, - "useStrictAuth": adminConfig.System.UseStrictAuth, - "disableAutoMigrate": adminConfig.System.DisableAutoMigrate, - "useMongo": adminConfig.System.UseMongo, - "addr": adminConfig.System.Addr, - "iplimitCount": adminConfig.System.IplimitCount, - "iplimitTime": adminConfig.System.IplimitTime, - } - } - if adminConfig.Jwt != nil { - admin["jwt"] = map[string]any{"signingKey": "******", "expiresTime": durationString(adminConfig.Jwt.ExpiresTime), "bufferTime": durationString(adminConfig.Jwt.BufferTime), "issuer": adminConfig.Jwt.Issuer} - } - if adminConfig.Captcha != nil { - admin["captcha"] = map[string]any{"keyLong": adminConfig.Captcha.KeyLong, "imgWidth": adminConfig.Captcha.ImgWidth, "imgHeight": adminConfig.Captcha.ImgHeight, "storeExpiration": durationString(adminConfig.Captcha.StoreExpiration)} - } - if adminConfig.Local != nil { - admin["local"] = map[string]any{"storePath": adminConfig.Local.StorePath, "pathPrefix": adminConfig.Local.PathPrefix} - } - if adminConfig.Media != nil { - chunkDir := adminConfig.Media.ChunkDir - if chunkDir == "" { - chunkDir = "uploads/chunks" - } - admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir} - } - if adminConfig.Email != nil { - secret := "" - if adminConfig.Email.Secret != "" { - secret = "******" - } - email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": secret, "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth} - } - if adminConfig.Storage != nil { - storage := proto.Clone(adminConfig.Storage).(*conf.AdminBackend_Storage) - maskStorageSecrets(storage) - admin["storage"] = storage - } - disks := make([]map[string]any, 0, len(adminConfig.DiskList)) - for _, disk := range adminConfig.DiskList { - if disk != nil { - disks = append(disks, map[string]any{"mount_point": disk.MountPoint}) - } - } - admin["disk_list"] = disks - if adminConfig.Zap != nil { - admin["zap"] = adminConfig.Zap - } - if adminConfig.Cors != nil { - admin["cors"] = adminConfig.Cors - } - if adminConfig.App != nil { - admin["app"] = adminConfig.App - } + current := r.backend.Config() + if current == nil { + current = &config.Config{} } - // Never mask secrets on the live runtime object. ConfigurationJSON is a - // read-only operation; mutating dataConfig here would replace the actual - // database/Redis/Mongo credentials with "******" and make the next reload - // fail. Work on a clone, just as we already do for adminConfig. - safeData := cloneDataConfig(dataConfig) - maskDataSecrets(safeData) - safeAdmin := cloneAdminConfig(adminConfig) - if safeAdmin != nil { - if safeAdmin.Jwt != nil { - safeAdmin.Jwt.SigningKey = "******" - } - if safeAdmin.Email != nil && safeAdmin.Email.Secret != "" { - safeAdmin.Email.Secret = "******" - } - maskStorageSecrets(safeAdmin.Storage) - } - dataMap := map[string]any{} - if safeData != nil { - raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(safeData) - if err != nil { - return nil, err - } - if err = json.Unmarshal(raw, &dataMap); err != nil { - return nil, err - } - } - config := compatibleConfiguration(safeData, safeAdmin) - config["admin"], config["email"], config["data"] = admin, email, dataMap - return json.Marshal(map[string]any{"config": config}) + safe := config.Clone(current) + maskConfigSecrets(safe) + return json.Marshal(map[string]any{"config": managementConfig(safe)}) } func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error { - currentData, currentAdmin := r.backend.RuntimeValues() - if currentAdmin == nil { - return nil - } - nextData := cloneDataConfig(currentData) - nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) - if err := applyCompatibleConfiguration(raw, nextData, nextAdmin); err != nil { - return err + current := r.backend.Config() + if current == nil { + current = &config.Config{} } + next := config.Clone(current) var value configurationEnvelope if err := json.Unmarshal(raw, &value); err != nil { return err } - options := protojson.UnmarshalOptions{DiscardUnknown: true} - if len(value.Data) > 0 && string(value.Data) != "null" { - if err := protoutil.MergeProtoJSON(nextData, value.Data, options); err != nil { + if err := mergeJSON(value.Data, &next.Data); err != nil { + return err + } + if err := mergeJSON(value.Admin, &next.Admin); err != nil { + return err + } + if len(value.Email) > 0 && string(value.Email) != "null" { + if next.Admin == nil { + next.Admin = &config.Admin{} + } + if err := mergeJSON(value.Email, &next.Admin.Email); err != nil { return err } } - if len(value.Admin) > 0 && string(value.Admin) != "null" { - if err := protoutil.MergeProtoJSON(nextAdmin, value.Admin, options); err != nil { - return err - } + preserveConfigSecrets(next, current) + if next.Admin != nil && current.Admin != nil { + next.Admin.ConfigPath = current.Admin.ConfigPath } - preserveDataSecrets(nextData, currentData) - preserveAdminSecrets(nextAdmin, currentAdmin) - if value.Email != nil { - if nextAdmin.Email == nil { - nextAdmin.Email = &conf.AdminBackend_Email{} - } - nextAdmin.Email.To, nextAdmin.Email.From, nextAdmin.Email.Host = value.Email.To, value.Email.From, value.Email.Host - nextAdmin.Email.Nickname, nextAdmin.Email.Port = value.Email.Nickname, value.Email.Port - nextAdmin.Email.IsSsl, nextAdmin.Email.IsLoginAuth = value.Email.IsSSL, value.Email.IsLoginAuth - if value.Email.Secret != "" && value.Email.Secret != "******" { - nextAdmin.Email.Secret = value.Email.Secret - } - } - if err := r.backend.RefreshDatabaseSources(nextData); err != nil { + if err := refreshDatabaseSources(next.Data); err != nil { return err } - nextAdmin.ConfigPath = currentAdmin.ConfigPath - dataRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(nextData) - if err != nil { - return err - } - adminRaw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(nextAdmin) - if err != nil { - return err - } - return r.PersistRuntimeConfig(ctx, dataRaw, adminRaw) + return r.PersistRuntimeConfig(ctx, next) } func (r *Repo) DiskMountPoints() []string { - config := r.backend.RuntimeAdmin() - if config == nil { + current := r.backend.Config() + if current == nil || current.Admin == nil { return nil } - points := make([]string, 0, len(config.DiskList)) - for _, item := range config.DiskList { + points := make([]string, 0, len(current.Admin.DiskList)) + for _, item := range current.Admin.DiskList { if item != nil && item.MountPoint != "" { points = append(points, item.MountPoint) } @@ -188,37 +72,105 @@ func (r *Repo) DiskMountPoints() []string { return points } -func cloneDataConfig(value *conf.Data) *conf.Data { - if value == nil { - return &conf.Data{} +func mergeJSON(raw json.RawMessage, target any) error { + if len(raw) == 0 || string(raw) == "null" { + return nil } - return proto.Clone(value).(*conf.Data) + return json.Unmarshal(raw, target) } -func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend { +func managementConfig(value *config.Config) map[string]any { + result := map[string]any{"data": map[string]any{}, "admin": map[string]any{}, "email": map[string]any{}} if value == nil { - return &conf.AdminBackend{} + return result } - return proto.Clone(value).(*conf.AdminBackend) + if value.Data != nil { + result["data"] = value.Data + } + if value.Admin != nil { + admin := value.Admin + result["admin"] = map[string]any{ + "routerPrefix": admin.RouterPrefix, + "jwt": managementJWT(admin.JWT), + "captcha": managementCaptcha(admin.Captcha), + "local": managementLocal(admin.Local), + "media": managementMedia(admin.Media), + "system": managementSystem(admin.System), + "storage": admin.Storage, + "disk_list": admin.DiskList, + "zap": admin.Zap, + "cors": admin.CORS, + "app": admin.App, + } + if admin.Email != nil { + result["email"] = map[string]any{ + "to": admin.Email.To, "from": admin.Email.From, "host": admin.Email.Host, + "secret": admin.Email.Secret, "nickname": admin.Email.Nickname, "port": admin.Email.Port, + "is-ssl": admin.Email.IsSSL, "is-loginauth": admin.Email.IsLoginAuth, + } + } + } + return result } -func durationString(value *durationpb.Duration) string { +func managementJWT(value *config.JWT) any { if value == nil { - return "0s" + return map[string]any{} } - return value.AsDuration().String() + return map[string]any{"signingKey": value.SigningKey, "expiresTime": value.ExpiresTime.String(), "bufferTime": value.BufferTime.String(), "issuer": value.Issuer} +} +func managementCaptcha(value *config.Captcha) any { + if value == nil { + return map[string]any{} + } + return map[string]any{"keyLong": value.KeyLong, "imgWidth": value.ImgWidth, "imgHeight": value.ImgHeight, "storeExpiration": value.StoreExpiration.String()} +} +func managementLocal(value *config.Local) any { + if value == nil { + return map[string]any{} + } + return map[string]any{"storePath": value.StorePath, "pathPrefix": value.PathPrefix} +} +func managementMedia(value *config.Media) any { + if value == nil { + return map[string]any{} + } + chunkDir := value.ChunkDir + if chunkDir == "" { + chunkDir = "uploads/chunks" + } + return map[string]any{"sessionTtl": value.SessionTTL, "maxFileSize": value.MaxFileSize, "chunkDir": chunkDir} +} +func managementSystem(value *config.System) any { + if value == nil { + return map[string]any{} + } + return map[string]any{"useRedis": value.UseRedis, "useMultipoint": value.UseMultipoint, "useStrictAuth": value.UseStrictAuth, "disableAutoMigrate": value.DisableAutoMigrate, "useMongo": value.UseMongo, "addr": value.Addr, "iplimitCount": value.IplimitCount, "iplimitTime": value.IplimitTime} } -func maskDataSecrets(value *conf.Data) { +func maskConfigSecrets(value *config.Config) { + if value == nil { + return + } + maskDataSecrets(value.Data) + if value.Admin == nil { + return + } + if value.Admin.JWT != nil && value.Admin.JWT.SigningKey != "" { + value.Admin.JWT.SigningKey = "******" + } + if value.Admin.Email != nil && value.Admin.Email.Secret != "" { + value.Admin.Email.Secret = "******" + } + maskStorageSecrets(value.Admin.Storage) +} + +func maskDataSecrets(value *config.Data) { if value == nil { return } if value.Database != nil { value.Database.Password = "******" - // Source may contain an embedded password (for example a MySQL DSN). - // The management response must never expose it. Structured database - // fields are retained and refreshDatabaseSources rebuilds Source when - // the configuration is saved. value.Database.Source = "" } if value.Redis != nil { @@ -240,15 +192,34 @@ func maskDataSecrets(value *conf.Data) { } } -func preserveDataSecrets(next, current *conf.Data) { +func preserveConfigSecrets(next, current *config.Config) { if next == nil || current == nil { return } - if next.Database != nil && current.Database != nil && maskedSecret(next.Database.Password) { - next.Database.Password = current.Database.Password + preserveDataSecrets(next.Data, current.Data) + if next.Admin == nil || current.Admin == nil { + return } - if next.Database != nil && current.Database != nil && next.Database.Source == "" { - next.Database.Source = current.Database.Source + if next.Admin.JWT != nil && current.Admin.JWT != nil && maskedSecret(next.Admin.JWT.SigningKey) { + next.Admin.JWT.SigningKey = current.Admin.JWT.SigningKey + } + if next.Admin.Email != nil && current.Admin.Email != nil && maskedSecret(next.Admin.Email.Secret) { + next.Admin.Email.Secret = current.Admin.Email.Secret + } + preserveStorageSecrets(next.Admin.Storage, current.Admin.Storage) +} + +func preserveDataSecrets(next, current *config.Data) { + if next == nil || current == nil { + return + } + if next.Database != nil && current.Database != nil { + if maskedSecret(next.Database.Password) { + next.Database.Password = current.Database.Password + } + if next.Database.Source == "" { + next.Database.Source = current.Database.Source + } } if next.Redis != nil && current.Redis != nil && maskedSecret(next.Redis.Password) { next.Redis.Password = current.Redis.Password @@ -256,83 +227,80 @@ func preserveDataSecrets(next, current *conf.Data) { if next.Mongo != nil && current.Mongo != nil && maskedSecret(next.Mongo.Password) { next.Mongo.Password = current.Mongo.Password } - currentDatabases := make(map[string]*conf.Data_Database, len(current.DatabaseList)) - for _, item := range current.DatabaseList { + preserveDatabaseListSecrets(next.DatabaseList, current.DatabaseList) + preserveRedisListSecrets(next.RedisList, current.RedisList) +} + +func preserveDatabaseListSecrets(next, current []*config.Database) { + byName := make(map[string]*config.Database, len(current)) + for _, item := range current { if item != nil && item.AliasName != "" { - currentDatabases[item.AliasName] = item + byName[item.AliasName] = item } } - for i, item := range next.DatabaseList { - var previous *conf.Data_Database - if item != nil && item.AliasName != "" { - previous = currentDatabases[item.AliasName] + for index, item := range next { + if item == nil { + continue } - if previous == nil && i < len(current.DatabaseList) { - previous = current.DatabaseList[i] + previous := byName[item.AliasName] + if previous == nil && index < len(current) { + previous = current[index] } - if item != nil && previous != nil { - if maskedSecret(item.Password) { - item.Password = previous.Password - } - if item.Source == "" { - item.Source = previous.Source - } + if previous == nil { + continue + } + if maskedSecret(item.Password) { + item.Password = previous.Password + } + if item.Source == "" { + item.Source = previous.Source } } - currentRedis := make(map[string]*conf.Data_Redis, len(current.RedisList)) - for _, item := range current.RedisList { +} + +func preserveRedisListSecrets(next, current []*config.Redis) { + byName := make(map[string]*config.Redis, len(current)) + for _, item := range current { if item != nil && item.Name != "" { - currentRedis[item.Name] = item + byName[item.Name] = item } } - for i, item := range next.RedisList { - var previous *conf.Data_Redis - if item != nil && item.Name != "" { - previous = currentRedis[item.Name] + for index, item := range next { + if item == nil { + continue } - if previous == nil && i < len(current.RedisList) { - previous = current.RedisList[i] + previous := byName[item.Name] + if previous == nil && index < len(current) { + previous = current[index] } - if item != nil && previous != nil && maskedSecret(item.Password) { + if previous != nil && maskedSecret(item.Password) { item.Password = previous.Password } } } -func preserveAdminSecrets(next, current *conf.AdminBackend) { - if next.Jwt != nil && current.Jwt != nil && maskedSecret(next.Jwt.SigningKey) { - next.Jwt.SigningKey = current.Jwt.SigningKey - } - if next.Email != nil && current.Email != nil && maskedSecret(next.Email.Secret) { - next.Email.Secret = current.Email.Secret - } - preserveStorageSecrets(next.Storage, current.Storage) -} - func maskedSecret(value string) bool { return value == "" || value == "******" } -func objectStores(storage *conf.AdminBackend_Storage) []*conf.AdminBackend_ObjectStore { - if storage == nil { +func objectStores(value *config.Storage) []*config.ObjectStore { + if value == nil { return nil } - return []*conf.AdminBackend_ObjectStore{storage.AliyunOss, storage.HuaweiObs, storage.TencentCos, storage.AwsS3, storage.CloudflareR2, storage.Minio} + return []*config.ObjectStore{value.AliyunOSS, value.HuaweiOBS, value.TencentCOS, value.AWSS3, value.CloudflareR2, value.Minio} } - -func maskStorageSecrets(storage *conf.AdminBackend_Storage) { - if storage == nil { +func maskStorageSecrets(value *config.Storage) { + if value == nil { return } - if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" { - storage.Qiniu.SecretKey = "******" + if value.Qiniu != nil && value.Qiniu.SecretKey != "" { + value.Qiniu.SecretKey = "******" } - for _, item := range objectStores(storage) { + for _, item := range objectStores(value) { if item != nil && item.SecretKey != "" { item.SecretKey = "******" } } } - -func preserveStorageSecrets(next, current *conf.AdminBackend_Storage) { +func preserveStorageSecrets(next, current *config.Storage) { if next == nil || current == nil { return } @@ -340,9 +308,43 @@ func preserveStorageSecrets(next, current *conf.AdminBackend_Storage) { next.Qiniu.SecretKey = current.Qiniu.SecretKey } nextItems, currentItems := objectStores(next), objectStores(current) - for i := range nextItems { - if nextItems[i] != nil && currentItems[i] != nil && maskedSecret(nextItems[i].SecretKey) { - nextItems[i].SecretKey = currentItems[i].SecretKey + for index := range nextItems { + if nextItems[index] != nil && currentItems[index] != nil && maskedSecret(nextItems[index].SecretKey) { + nextItems[index].SecretKey = currentItems[index].SecretKey } } } + +func refreshDatabaseSources(value *config.Data) error { + if value == nil { + return nil + } + if err := refreshDatabaseSource(value.Database); err != nil { + return err + } + for _, database := range value.DatabaseList { + if database == nil || database.Disable { + continue + } + if err := refreshDatabaseSource(database); err != nil { + return err + } + } + return nil +} +func refreshDatabaseSource(database *config.Database) error { + if database == nil { + return nil + } + if database.Driver == "sqlite" && database.Path == "" { + return errors.New("sqlite database path is required") + } + if database.Driver == "sqlite" { + database.Source = database.Path + } else { + // Driver-specific DSN construction remains in data. Clearing Source + // makes the data backend rebuild it from structured values. + database.Source = "" + } + return nil +} diff --git a/internal/initialize/configuration_test.go b/internal/initialize/configuration_test.go index a49c85e..4a37ebe 100644 --- a/internal/initialize/configuration_test.go +++ b/internal/initialize/configuration_test.go @@ -1,29 +1,74 @@ package initialize import ( + "bytes" + "context" "encoding/json" "testing" + "time" - "kra/internal/conf" - "kra/pkg/protoutil" + "kra/internal/biz/system" + "kra/internal/config" - "google.golang.org/protobuf/encoding/protojson" + "gorm.io/gorm" ) -func TestMergeProtoJSONPreservesOmittedFieldsAndNormalizesKeys(t *testing.T) { - target := &conf.AdminBackend{ - RouterPrefix: "/api", - System: &conf.AdminBackend_System{UseRedis: true, IplimitCount: 3}, - } - patch := json.RawMessage(`{"router_prefix":"/admin","system":{"iplimit_count":9}}`) +type configurationBackend struct { + value *config.Config + persisted *config.Config +} - if err := protoutil.MergeProtoJSON(target, patch, protojson.UnmarshalOptions{DiscardUnknown: true}); err != nil { +func (b *configurationBackend) IsInitialized(context.Context) (bool, error) { return true, nil } +func (b *configurationBackend) InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error { + return nil +} +func (b *configurationBackend) PersistConfig(context.Context) error { return nil } +func (b *configurationBackend) PersistRuntimeConfig(_ context.Context, value *config.Config) error { + b.persisted = config.Clone(value) + return nil +} +func (b *configurationBackend) ReloadConfig(context.Context) error { return nil } +func (b *configurationBackend) Config() *config.Config { return config.Clone(b.value) } + +func TestConfigurationJSONMasksSecretsWithoutMutatingConfig(t *testing.T) { + backend := &configurationBackend{value: &config.Config{ + Data: &config.Data{Database: &config.Database{Password: "database-secret", Source: "dsn"}, Redis: &config.Redis{Password: "redis-secret"}}, + Admin: &config.Admin{JWT: &config.JWT{SigningKey: "jwt-secret", ExpiresTime: 24 * time.Hour}, Email: &config.Email{Secret: "email-secret"}, Storage: &config.Storage{Qiniu: &config.Qiniu{SecretKey: "storage-secret"}}}, + }} + raw, err := (&Repo{backend: backend}).ConfigurationJSON() + if err != nil { t.Fatal(err) } - if target.RouterPrefix != "/admin" || target.System.IplimitCount != 9 { - t.Fatalf("patch was not applied: %#v", target) + if !json.Valid(raw) { + t.Fatalf("invalid response: %s", raw) } - if !target.System.UseRedis { - t.Fatal("an omitted nested field was cleared") + for _, secret := range []string{"database-secret", "redis-secret", "jwt-secret", "email-secret", "storage-secret", "dsn"} { + if bytes.Contains(raw, []byte(secret)) { + t.Fatalf("configuration response exposed %q: %s", secret, raw) + } + } + if backend.value.Data.Database.Password != "database-secret" || backend.value.Admin.JWT.SigningKey != "jwt-secret" { + t.Fatal("ConfigurationJSON mutated the live configuration") + } +} + +func TestSaveConfigurationJSONMergesPartialValuesAndPreservesSecrets(t *testing.T) { + backend := &configurationBackend{value: &config.Config{ + Data: &config.Data{Database: &config.Database{Driver: "mysql", Host: "old", Password: "database-secret", Source: "old-dsn"}, Redis: &config.Redis{Name: "main", Password: "redis-secret"}}, + Admin: &config.Admin{ConfigPath: "config.yaml", JWT: &config.JWT{SigningKey: "jwt-secret", Issuer: "old"}, Email: &config.Email{Host: "old.smtp", Secret: "email-secret"}}, + }} + raw := json.RawMessage(`{"data":{"database":{"host":"new","password":"******"}},"admin":{"jwt":{"issuer":"new","signing_key":"******"}},"email":{"host":"new.smtp","secret":"******"}}`) + if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil { + t.Fatal(err) + } + got := backend.persisted + if got == nil || got.Data.Database.Host != "new" || got.Admin.JWT.Issuer != "new" || got.Admin.Email.Host != "new.smtp" { + t.Fatalf("partial update was not applied: %#v", got) + } + if got.Data.Database.Password != "database-secret" || got.Admin.JWT.SigningKey != "jwt-secret" || got.Admin.Email.Secret != "email-secret" { + t.Fatal("masked secrets were not preserved") + } + if got.Admin.ConfigPath != "config.yaml" { + t.Fatalf("config path = %q", got.Admin.ConfigPath) } } diff --git a/internal/initialize/initialize.go b/internal/initialize/initialize.go index eff2206..2ee5f39 100644 --- a/internal/initialize/initialize.go +++ b/internal/initialize/initialize.go @@ -3,6 +3,7 @@ package initialize import ( "context" "kra/internal/biz/system" + "kra/internal/config" datasystem "kra/internal/data/system" datatask "kra/internal/data/task" @@ -37,12 +38,8 @@ func (r *Repo) PersistConfig(ctx context.Context) error { return r.backend.PersistConfig(ctx) } -func (r *Repo) PersistAdminConfig(ctx context.Context, value []byte) error { - return r.backend.PersistAdminConfig(ctx, value) -} - -func (r *Repo) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error { - return r.backend.PersistRuntimeConfig(ctx, data, admin) +func (r *Repo) PersistRuntimeConfig(ctx context.Context, value *config.Config) error { + return r.backend.PersistRuntimeConfig(ctx, value) } func (r *Repo) ReloadConfig(ctx context.Context) error { diff --git a/internal/integration/email/email.go b/internal/integration/email/email.go index 65e2f58..03aeee9 100644 --- a/internal/integration/email/email.go +++ b/internal/integration/email/email.go @@ -13,18 +13,16 @@ import ( "strings" "time" - "kra/internal/conf" + "kra/internal/config" ) -type emailRepo struct { - runtime *conf.Runtime -} +type emailRepo struct{ runtime *config.Store } -func NewEmailRepo(runtime *conf.Runtime) system.EmailRepo { +func NewEmailRepo(runtime *config.Store) system.EmailRepo { return &emailRepo{runtime: runtime} } -func (r *emailRepo) email() *conf.AdminBackend_Email { +func (r *emailRepo) email() *config.Email { if r.runtime == nil { return nil } @@ -47,7 +45,7 @@ func (r *emailRepo) DefaultRecipients() []string { return []string{strings.TrimSpace(config.To)} } -func emailEnabled(config *conf.AdminBackend_Email) bool { +func emailEnabled(config *config.Email) bool { return config != nil && strings.TrimSpace(config.Host) != "" && strings.TrimSpace(config.From) != "" && strings.TrimSpace(config.Secret) != "" && config.Port > 0 } @@ -82,7 +80,7 @@ func (r *emailRepo) Send(ctx context.Context, to []string, subject, body string) dialer := &net.Dialer{Timeout: 10 * time.Second} var conn net.Conn var err error - if config.IsSsl { + if config.IsSSL { conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: config.Host, MinVersion: tls.VersionTLS12}}).DialContext(ctx, "tcp", address) } else { conn, err = dialer.DialContext(ctx, "tcp", address) diff --git a/internal/integration/email/email_test.go b/internal/integration/email/email_test.go index 9459b35..6203703 100644 --- a/internal/integration/email/email_test.go +++ b/internal/integration/email/email_test.go @@ -4,11 +4,11 @@ import ( "context" "testing" - "kra/internal/conf" + "kra/internal/config" ) func TestDefaultRecipientsIgnoreEmptyConfiguration(t *testing.T) { - runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{Email: &conf.AdminBackend_Email{To: " "}}) + runtime := config.NewStore(&config.Config{Data: &config.Data{}, Admin: &config.Admin{Email: &config.Email{To: " "}}}) repo := &emailRepo{runtime: runtime} if got := repo.DefaultRecipients(); len(got) != 0 { t.Fatalf("DefaultRecipients() = %#v, want empty", got) @@ -16,7 +16,7 @@ func TestDefaultRecipientsIgnoreEmptyConfiguration(t *testing.T) { } func TestSendRejectsEmptyRecipientsBeforeNetworkDial(t *testing.T) { - runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{Email: &conf.AdminBackend_Email{Host: "smtp.example.com", From: "from@example.com", Secret: "secret", Port: 25}}) + runtime := config.NewStore(&config.Config{Data: &config.Data{}, Admin: &config.Admin{Email: &config.Email{Host: "smtp.example.com", From: "from@example.com", Secret: "secret", Port: 25}}}) repo := &emailRepo{runtime: runtime} if err := repo.Send(context.Background(), []string{" ", ""}, "subject", "body"); err == nil { t.Fatal("Send() accepted empty recipients") diff --git a/internal/integration/storage/aliyun_storage.go b/internal/integration/storage/aliyun_storage.go index 31274be..ffcfa78 100644 --- a/internal/integration/storage/aliyun_storage.go +++ b/internal/integration/storage/aliyun_storage.go @@ -8,7 +8,7 @@ import ( "path" "strings" - "kra/internal/conf" + "kra/internal/config" "github.com/aliyun/aliyun-oss-go-sdk/oss" ) @@ -18,7 +18,7 @@ type aliyunStorage struct { baseURL, prefix string } -func newAliyunStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) { +func newAliyunStorage(config *config.ObjectStore) (system.FileStorage, error) { if config == nil || config.Endpoint == "" || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" { return nil, fmt.Errorf("aliyun-oss storage configuration is incomplete") } @@ -30,7 +30,7 @@ func newAliyunStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage if err != nil { return nil, err } - return &aliyunStorage{bucket: bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil + return &aliyunStorage{bucket: bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil } func (s *aliyunStorage) key(name string) string { if s.prefix == "" { diff --git a/internal/integration/storage/aws_storage.go b/internal/integration/storage/aws_storage.go index 2938d30..2c14df0 100644 --- a/internal/integration/storage/aws_storage.go +++ b/internal/integration/storage/aws_storage.go @@ -8,7 +8,7 @@ import ( "path" "strings" - "kra/internal/conf" + "kra/internal/config" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -22,7 +22,7 @@ type awsStorage struct { bucket, baseURL, prefix string } -func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) { +func newAWSStorage(provider string, config *config.ObjectStore) (system.FileStorage, error) { if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" { return nil, fmt.Errorf("%s storage configuration is incomplete", provider) } @@ -31,10 +31,10 @@ func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (syst if provider == "cloudflare-r2" { region = "auto" if endpoint == "" { - if config.AccountId == "" { + if config.AccountID == "" { return nil, fmt.Errorf("cloudflare-r2 account id is required") } - endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", config.AccountId) + endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", config.AccountID) } } if region == "" { @@ -47,7 +47,7 @@ func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (syst client := s3.NewFromConfig(loaded, func(options *s3.Options) { if endpoint != "" { if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { - if config.UseSsl { + if config.UseSSL { endpoint = "https://" + endpoint } else { endpoint = "http://" + endpoint @@ -57,7 +57,7 @@ func newAWSStorage(provider string, config *conf.AdminBackend_ObjectStore) (syst } options.UsePathStyle = config.ForcePathStyle }) - return &awsStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil + return &awsStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil } func (s *awsStorage) key(name string) string { diff --git a/internal/integration/storage/huawei_storage.go b/internal/integration/storage/huawei_storage.go index ca9707a..9c828ac 100644 --- a/internal/integration/storage/huawei_storage.go +++ b/internal/integration/storage/huawei_storage.go @@ -8,7 +8,7 @@ import ( "path" "strings" - "kra/internal/conf" + "kra/internal/config" "github.com/huaweicloud/huaweicloud-sdk-go-obs/obs" ) @@ -18,7 +18,7 @@ type huaweiStorage struct { bucket, baseURL, prefix string } -func newHuaweiStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) { +func newHuaweiStorage(config *config.ObjectStore) (system.FileStorage, error) { if config == nil || config.Endpoint == "" || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" { return nil, fmt.Errorf("huawei-obs storage configuration is incomplete") } @@ -26,7 +26,7 @@ func newHuaweiStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage if err != nil { return nil, err } - return &huaweiStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil + return &huaweiStorage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil } func (s *huaweiStorage) key(name string) string { if s.prefix == "" { diff --git a/internal/integration/storage/local.go b/internal/integration/storage/local.go index fa49bc2..2417776 100644 --- a/internal/integration/storage/local.go +++ b/internal/integration/storage/local.go @@ -11,7 +11,7 @@ import ( "sort" "strings" - "kra/internal/conf" + "kra/internal/config" ) type fileStorage struct { @@ -19,41 +19,41 @@ type fileStorage struct { urlPrefix string } -func New(config *conf.AdminBackend) (system.FileStorage, error) { +func New(admin *config.Admin) (system.FileStorage, error) { storageType := "local" - if config != nil && config.Storage != nil && config.Storage.Type != "" { - storageType = strings.ToLower(config.Storage.Type) + if admin != nil && admin.Storage != nil && admin.Storage.Type != "" { + storageType = strings.ToLower(admin.Storage.Type) } if storageType == "qiniu" { - return newQiniuStorage(config.Storage.Qiniu) + return newQiniuStorage(admin.Storage.Qiniu) } if storageType != "local" { - var object *conf.AdminBackend_ObjectStore + var object *config.ObjectStore switch storageType { case "aliyun-oss": - return newAliyunStorage(config.Storage.AliyunOss) + return newAliyunStorage(admin.Storage.AliyunOSS) case "huawei-obs": - return newHuaweiStorage(config.Storage.HuaweiObs) + return newHuaweiStorage(admin.Storage.HuaweiOBS) case "tencent-cos": - return newTencentStorage(config.Storage.TencentCos) + return newTencentStorage(admin.Storage.TencentCOS) case "aws-s3": - return newAWSStorage(storageType, config.Storage.AwsS3) + return newAWSStorage(storageType, admin.Storage.AWSS3) case "cloudflare-r2": - return newAWSStorage(storageType, config.Storage.CloudflareR2) + return newAWSStorage(storageType, admin.Storage.CloudflareR2) case "minio": - object = config.Storage.Minio + object = admin.Storage.Minio default: return nil, fmt.Errorf("unsupported storage type %q", storageType) } return newS3Storage(storageType, object) } root, prefix := "uploads/file", "uploads/file" - if config != nil && config.Local != nil { - if config.Local.StorePath != "" { - root = config.Local.StorePath + if admin != nil && admin.Local != nil { + if admin.Local.StorePath != "" { + root = admin.Local.StorePath } - if config.Local.PathPrefix != "" { - prefix = config.Local.PathPrefix + if admin.Local.PathPrefix != "" { + prefix = admin.Local.PathPrefix } } root = filepath.Clean(root) diff --git a/internal/integration/storage/qiniu_storage.go b/internal/integration/storage/qiniu_storage.go index 760927c..be894fa 100644 --- a/internal/integration/storage/qiniu_storage.go +++ b/internal/integration/storage/qiniu_storage.go @@ -11,24 +11,24 @@ import ( "strings" "time" - "kra/internal/conf" + "kra/internal/config" "github.com/qiniu/go-sdk/v7/auth/qbox" qstorage "github.com/qiniu/go-sdk/v7/storage" ) type qiniuStorage struct { - config *conf.AdminBackend_Qiniu + config *config.Qiniu upload *qstorage.FormUploader manager *qstorage.BucketManager mac *qbox.Mac } -func newQiniuStorage(config *conf.AdminBackend_Qiniu) (system.FileStorage, error) { +func newQiniuStorage(config *config.Qiniu) (system.FileStorage, error) { if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" { return nil, fmt.Errorf("qiniu storage configuration is incomplete") } - cfg := qstorage.Config{UseHTTPS: config.UseHttps, UseCdnDomains: config.UseCdnDomains} + cfg := qstorage.Config{UseHTTPS: config.UseHTTPS, UseCdnDomains: config.UseCDNDomains} switch config.Zone { case "ZoneHuadong": cfg.Zone = &qstorage.ZoneHuadong @@ -51,7 +51,7 @@ func (s *qiniuStorage) uploadToken() string { } func (s *qiniuStorage) file(key string, size int64) *system.StoredFile { - return &system.StoredFile{Name: path.Base(key), Path: key, URL: strings.TrimSuffix(s.config.BaseUrl, "/") + "/" + key, Size: size} + return &system.StoredFile{Name: path.Base(key), Path: key, URL: strings.TrimSuffix(s.config.BaseURL, "/") + "/" + key, Size: size} } func (s *qiniuStorage) Put(ctx context.Context, name string, reader io.Reader) (*system.StoredFile, error) { temporary, err := os.CreateTemp("", "kra-qiniu-upload-*") @@ -77,7 +77,7 @@ func (s *qiniuStorage) Put(ctx context.Context, name string, reader io.Reader) ( return s.file(ret.Key, size), nil } func (s *qiniuStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) { - downloadURL := qstorage.MakePrivateURL(s.mac, strings.TrimSuffix(s.config.BaseUrl, "/"), strings.TrimPrefix(name, "/"), time.Now().Add(time.Hour).Unix()) + downloadURL := qstorage.MakePrivateURL(s.mac, strings.TrimSuffix(s.config.BaseURL, "/"), strings.TrimPrefix(name, "/"), time.Now().Add(time.Hour).Unix()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { return nil, err diff --git a/internal/integration/storage/reloadable.go b/internal/integration/storage/reloadable.go index e02d050..58c1d8e 100644 --- a/internal/integration/storage/reloadable.go +++ b/internal/integration/storage/reloadable.go @@ -7,7 +7,7 @@ import ( "kra/internal/biz/system" "sync" - "kra/internal/conf" + "kra/internal/config" ) type Reloadable struct { @@ -15,15 +15,15 @@ type Reloadable struct { current system.FileStorage } -func NewFileStorage(runtime *conf.Runtime) (*Reloadable, error) { - var config *conf.AdminBackend +func NewFileStorage(runtime *config.Store) (*Reloadable, error) { + var config *config.Admin if runtime != nil { config = runtime.Admin() } return NewReloadable(config) } -func NewReloadable(config *conf.AdminBackend) (*Reloadable, error) { +func NewReloadable(config *config.Admin) (*Reloadable, error) { current, err := New(config) if err != nil { return nil, err diff --git a/internal/integration/storage/s3_storage.go b/internal/integration/storage/s3_storage.go index b85ddac..1000e6d 100644 --- a/internal/integration/storage/s3_storage.go +++ b/internal/integration/storage/s3_storage.go @@ -9,7 +9,7 @@ import ( "path" "strings" - "kra/internal/conf" + "kra/internal/config" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" @@ -20,7 +20,7 @@ type s3Storage struct { bucket, baseURL, prefix string } -func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) { +func newS3Storage(provider string, config *config.ObjectStore) (system.FileStorage, error) { if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" { return nil, fmt.Errorf("%s storage configuration is incomplete", provider) } @@ -30,7 +30,7 @@ func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (syste case "aws-s3": endpoint = "s3." + config.Region + ".amazonaws.com" case "cloudflare-r2": - endpoint = config.AccountId + ".r2.cloudflarestorage.com" + endpoint = config.AccountID + ".r2.cloudflarestorage.com" case "aliyun-oss": endpoint = "oss-" + config.Region + ".aliyuncs.com" case "huawei-obs": @@ -39,7 +39,7 @@ func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (syste endpoint = "cos." + config.Region + ".myqcloud.com" } } - secure := config.UseSsl + secure := config.UseSSL if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" { secure = parsed.Scheme == "https" endpoint = parsed.Host @@ -56,7 +56,7 @@ func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (syste if err != nil { return nil, err } - return &s3Storage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil + return &s3Storage{client: client, bucket: config.Bucket, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil } func (s *s3Storage) key(name string) string { if s.prefix == "" { diff --git a/internal/integration/storage/tencent_storage.go b/internal/integration/storage/tencent_storage.go index cfe5b7d..123e84f 100644 --- a/internal/integration/storage/tencent_storage.go +++ b/internal/integration/storage/tencent_storage.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "kra/internal/conf" + "kra/internal/config" cos "github.com/tencentyun/cos-go-sdk-v5" ) @@ -21,7 +21,7 @@ type tencentStorage struct { baseURL, prefix string } -func newTencentStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorage, error) { +func newTencentStorage(config *config.ObjectStore) (system.FileStorage, error) { if config == nil || config.Bucket == "" || config.Region == "" || config.AccessKey == "" || config.SecretKey == "" { return nil, fmt.Errorf("tencent-cos storage configuration is incomplete") } @@ -29,7 +29,7 @@ func newTencentStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorag if endpoint == "" { endpoint = fmt.Sprintf("https://%s.cos.%s.myqcloud.com", config.Bucket, config.Region) } else if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { - if config.UseSsl { + if config.UseSSL { endpoint = "https://" + endpoint } else { endpoint = "http://" + endpoint @@ -40,7 +40,7 @@ func newTencentStorage(config *conf.AdminBackend_ObjectStore) (system.FileStorag return nil, err } client := cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, &http.Client{Transport: &cos.AuthorizationTransport{SecretID: config.AccessKey, SecretKey: config.SecretKey}}) - return &tencentStorage{client: client, baseURL: strings.TrimSuffix(config.BaseUrl, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil + return &tencentStorage{client: client, baseURL: strings.TrimSuffix(config.BaseURL, "/"), prefix: strings.Trim(config.PathPrefix, "/")}, nil } func (s *tencentStorage) key(name string) string { if s.prefix == "" { diff --git a/internal/server/gin.go b/internal/server/gin.go index 9d8fa4f..8291d95 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "kra/internal/conf" + "kra/internal/config" websocket "kra/internal/integration/websocket" "kra/internal/server/handler" "kra/internal/server/middleware" @@ -20,13 +20,13 @@ import ( kratoshttp "github.com/go-kratos/kratos/v3/transport/http" ) -func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine { +func NewGinEngine(runtime *config.Store, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine { return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, platformmodule.NewRuntime(router.NewRoutes(handlers)), nil) } -func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine { +func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine { if runtime == nil { - runtime = conf.NewRuntime(nil, nil) + runtime = config.NewStore(nil) } gin.SetMode(gin.ReleaseMode) engine := gin.New() @@ -36,9 +36,9 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro engine.Use(middleware.RequestMeta(), middleware.Recovery(logger), middleware.AccessLog(runtime, logger, version), middleware.CORS(runtime), middleware.ErrorAudit(logger), middleware.SecurityRateLimit(security)) prefix := "" - config := runtime.Admin() - if config != nil { - prefix = strings.TrimSuffix(config.RouterPrefix, "/") + snapshot := runtime.Snapshot() + if snapshot != nil && snapshot.Admin != nil { + prefix = strings.TrimSuffix(snapshot.Admin.RouterPrefix, "/") } public := engine.Group(prefix) public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") }) @@ -89,19 +89,19 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro return engine } -func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server { +func NewGinServer(c *config.Server, engine *gin.Engine) *kratoshttp.Server { network, address := "tcp", ":8000" - if c != nil && c.Http != nil { - if c.Http.Network != "" { - network = c.Http.Network + if c != nil && c.HTTP != nil { + if c.HTTP.Network != "" { + network = c.HTTP.Network } - if c.Http.Addr != "" { - address = c.Http.Addr + if c.HTTP.Addr != "" { + address = c.HTTP.Addr } } timeout := 10 * time.Minute - if c != nil && c.Http != nil && c.Http.Timeout != nil && c.Http.Timeout.AsDuration() > 0 { - timeout = c.Http.Timeout.AsDuration() + if c != nil && c.HTTP != nil && c.HTTP.Timeout > 0 { + timeout = c.HTTP.Timeout } server := kratoshttp.NewServer(kratoshttp.Network(network), kratoshttp.Address(address), kratoshttp.Timeout(timeout)) server.HandlePrefix("/", engine) diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 2df519e..4e3cc78 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "kra/internal/conf" + "kra/internal/config" "kra/internal/routecatalog" "kra/internal/server/handler" @@ -32,7 +32,7 @@ func emptyHandlers() *handler.Set { } func TestGinRouteContract(t *testing.T) { - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") routes := engine.Routes() actual := make([]string, 0, len(routes)) seen := make(map[string]struct{}, len(routes)) @@ -66,7 +66,7 @@ func TestGinRouteContract(t *testing.T) { } func TestGinRoutesMatchSharedCatalog(t *testing.T) { - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") registered := make(map[string]struct{}, len(engine.Routes())) for _, route := range engine.Routes() { key := route.Method + " " + route.Path @@ -86,7 +86,7 @@ func TestGinRoutesMatchSharedCatalog(t *testing.T) { func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { var output bytes.Buffer logger := slog.New(slog.NewJSONHandler(&output, nil)) - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, logger, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, logger, "test") text := output.String() if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want { t.Fatalf("registered route log count = %d, want %d", got, want) @@ -97,7 +97,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { } func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) { - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil) engine.ServeHTTP(response, request) @@ -113,7 +113,7 @@ func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) { } func TestSwaggerSupportsRouterPrefix(t *testing.T) { - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{RouterPrefix: "/admin"}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") for _, path := range []string{"/admin/swagger/index.html", "/admin/swagger/doc.json"} { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) @@ -128,7 +128,7 @@ func TestSwaggerSupportsRouterPrefix(t *testing.T) { } func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) { - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) { @@ -137,7 +137,7 @@ func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) { } func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) { - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") engine.GET("/client-ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) }) request := httptest.NewRequest(http.MethodGet, "/client-ip", nil) request.RemoteAddr = "203.0.113.10:4321" @@ -151,7 +151,7 @@ func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) { } func TestAnnouncementDataSourceRequiresAuthentication(t *testing.T) { - engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/info/getInfoDataSource", nil)) if response.Code != http.StatusUnauthorized { @@ -166,7 +166,7 @@ func TestLocalStorageResponseHeaders(t *testing.T) { t.Fatal(err) } } - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "local"}}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") for _, test := range []struct { @@ -190,7 +190,7 @@ func TestLocalStorageResponseHeaders(t *testing.T) { } func TestLocalStorageRoutesAreRegistered(t *testing.T) { - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") seen := map[string]bool{} for _, route := range engine.Routes() { @@ -213,9 +213,9 @@ func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "image.png"), []byte("png"), 0o600); err != nil { t.Fatal(err) } - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "local"}}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") - runtime.Replace(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "files"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) + runtime.Replace(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "files"}, Storage: &config.Storage{Type: "local"}}}) oldResponse := httptest.NewRecorder() engine.ServeHTTP(oldResponse, httptest.NewRequest(http.MethodGet, "/uploads/file/image.png", nil)) @@ -230,7 +230,7 @@ func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) { } func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T) { - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &conf.AdminBackend_Storage{Type: "local"}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &config.Storage{Type: "local"}}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") for _, route := range engine.Routes() { if route.Method == http.MethodGet && route.Path == "/api/*filepath" { @@ -240,7 +240,7 @@ func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T) } func TestLocalStorageRouteIsNotRegisteredForRemoteStorage(t *testing.T) { - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "s3"}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "s3"}}}) engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test") for _, route := range engine.Routes() { if strings.HasSuffix(route.Path, "/*filepath") && strings.HasPrefix(route.Path, "/uploads/file/") { diff --git a/internal/server/middleware/access.go b/internal/server/middleware/access.go index 20f1b7b..ad96342 100644 --- a/internal/server/middleware/access.go +++ b/internal/server/middleware/access.go @@ -4,7 +4,7 @@ import ( "context" "kra/internal/biz/system" - "kra/internal/conf" + "kra/internal/config" "kra/internal/utils/routepath" "github.com/gin-gonic/gin" @@ -15,7 +15,7 @@ type accessController interface { ContextWithDataScope(context.Context, uint, uint) (context.Context, error) } -func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFunc { +func AccessControl(runtime *config.Store, access accessController) gin.HandlerFunc { return func(c *gin.Context) { claims := Claims(c) if claims == nil { @@ -29,8 +29,10 @@ func AccessControl(runtime *conf.Runtime, access accessController) gin.HandlerFu } path := c.Request.URL.Path policyPath := path - if config := runtime.Admin(); config != nil { - policyPath = routepath.Normalize(policyPath, config.RouterPrefix) + if runtime != nil { + if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil { + policyPath = routepath.Normalize(policyPath, snapshot.Admin.RouterPrefix) + } } allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method) if err != nil || !allowed { diff --git a/internal/server/middleware/access_log.go b/internal/server/middleware/access_log.go index a59cbb8..2532fec 100644 --- a/internal/server/middleware/access_log.go +++ b/internal/server/middleware/access_log.go @@ -15,7 +15,7 @@ import ( "strings" "time" - "kra/internal/conf" + "kra/internal/config" "kra/internal/routecatalog" "github.com/gin-gonic/gin" @@ -25,21 +25,23 @@ const defaultRequestBodyLimit int64 = 8 << 20 // AccessLog is the single global request/response capture point, matching // the reference middleware ordering and making every HTTP request observable. -func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.HandlerFunc { +func AccessLog(runtime *config.Store, logger *slog.Logger, version string) gin.HandlerFunc { return func(c *gin.Context) { started := time.Now() var requestBody []byte multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") mediaUpload := multipart && isMediaUploadRoute(c.FullPath()) - var config *conf.AdminBackend + var admin *config.Admin if runtime != nil { - config = runtime.Admin() + if snapshot := runtime.Snapshot(); snapshot != nil { + admin = snapshot.Admin + } } bodyLimit := defaultRequestBodyLimit if mediaUpload { bodyLimit = system.DefaultMaxMediaFileSize + (1 << 20) - if config != nil && config.Media != nil && config.Media.MaxFileSize > 0 { - bodyLimit = config.Media.MaxFileSize + (1 << 20) + if admin != nil && admin.Media != nil && admin.Media.MaxFileSize > 0 { + bodyLimit = admin.Media.MaxFileSize + (1 << 20) } } bytesIn := c.Request.ContentLength @@ -73,8 +75,8 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H bytesIn = 0 } logLimit := 1024 - if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { - logLimit = int(config.Zap.AccessLogMaxBytes) + if admin != nil && admin.Zap != nil && admin.Zap.AccessLogMaxBytes > 0 { + logLimit = admin.Zap.AccessLogMaxBytes } bodyPolicy := routecatalog.BodyPolicyFor(c.Request.Method, c.Request.URL.Path) paymentCallback := bodyPolicy == routecatalog.BodyPolicyPaymentCallback @@ -140,13 +142,13 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H "req_query", redactQuery(c.Request.URL.RawQuery), } } - if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqHeaders { + if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessReqHeaders { attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header)) } - if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqBody { + if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessReqBody { attributes = append(attributes, "req_body", requestText) } - if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessRespData { + if !paymentCallback && admin != nil && admin.Zap != nil && admin.Zap.AccessRespData { attributes = append(attributes, "resp_data", responseText) } if !paymentCallback && privateErrors != "" { diff --git a/internal/server/middleware/access_log_test.go b/internal/server/middleware/access_log_test.go index 371ddcb..ea4a2ae 100644 --- a/internal/server/middleware/access_log_test.go +++ b/internal/server/middleware/access_log_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "kra/internal/conf" + "kra/internal/config" "github.com/gin-gonic/gin" ) @@ -18,7 +18,7 @@ func TestAccessLogRejectsOversizedRequestBody(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() called := false - engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test")) + engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, "test")) engine.POST("/payload", func(c *gin.Context) { called = true }) request := httptest.NewRequest(http.MethodPost, "/payload", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1)))) request.Header.Set("Content-Type", "application/json") @@ -34,7 +34,7 @@ func TestAccessLogRejectsOversizedMultipartOnOrdinaryRoute(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() called := false - engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test")) + engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, "test")) engine.POST("/login", func(c *gin.Context) { called = true }) request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(strings.Repeat("a", int(defaultRequestBodyLimit+1)))) request.ContentLength = -1 @@ -51,7 +51,7 @@ func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() called := false - engine.Use(AccessLog(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, "test")) + engine.Use(AccessLog(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, "test")) engine.POST("/api/fileUploadAndDownload/upload", func(c *gin.Context) { called = true _, _ = io.Copy(io.Discard, c.Request.Body) @@ -70,7 +70,7 @@ func TestAccessLogRedactsPaymentCallbackPayloadAndHeaders(t *testing.T) { gin.SetMode(gin.TestMode) var logs bytes.Buffer logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}}) engine := gin.New() engine.Use(AccessLog(runtime, logger, "test")) engine.POST("/api/payment/callback/:provider", func(c *gin.Context) { @@ -101,7 +101,7 @@ func TestAccessLogOmitsPaymentIntegrationConfigBody(t *testing.T) { gin.SetMode(gin.TestMode) var logs bytes.Buffer logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) - runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{Zap: &config.Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}}) engine := gin.New() engine.Use(AccessLog(runtime, logger, "test")) engine.PUT("/api/integration/configs/:kind/:provider", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0}) }) @@ -127,7 +127,7 @@ func TestAccessLogRedactsWebSocketQueryToken(t *testing.T) { gin.SetMode(gin.TestMode) var logs bytes.Buffer logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) - runtime := conf.NewRuntime(nil, &conf.AdminBackend{}) + runtime := config.NewStore(&config.Config{Admin: &config.Admin{}}) engine := gin.New() engine.Use(AccessLog(runtime, logger, "test")) engine.GET("/ws", func(c *gin.Context) { c.Status(http.StatusSwitchingProtocols) }) diff --git a/internal/server/middleware/access_test.go b/internal/server/middleware/access_test.go index f287761..c5ab04b 100644 --- a/internal/server/middleware/access_test.go +++ b/internal/server/middleware/access_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "kra/internal/conf" + "kra/internal/config" "github.com/gin-gonic/gin" ) @@ -36,7 +36,7 @@ func TestAccessControlFailsClosedWhenDataScopeResolutionFails(t *testing.T) { c.Set(claimsKey, &system.AuthClaims{ID: 7, AuthorityID: 888}) c.Next() }) - engine.Use(AccessControl(conf.NewRuntime(nil, &conf.AdminBackend{}), &accessControllerStub{scopeErr: errors.New("database unavailable")})) + engine.Use(AccessControl(config.NewStore(&config.Config{Admin: &config.Admin{}}), &accessControllerStub{scopeErr: errors.New("database unavailable")})) called := false engine.GET("/protected", func(c *gin.Context) { called = true }) diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index 918178b..931b0d2 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "kra/internal/conf" + "kra/internal/config" "kra/internal/routecatalog" "kra/internal/service" "kra/internal/service/dto" @@ -22,7 +22,7 @@ import ( const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed" -func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.HandlerFunc { +func OperationAudit(runtime *config.Store, recorder *service.AuditRecorder) gin.HandlerFunc { return func(c *gin.Context) { if runtime == nil || recorder == nil { c.Next() @@ -39,8 +39,8 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin. } var requestBody []byte maxBytes := 1024 - if config := runtime.Admin(); config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { - maxBytes = int(config.Zap.AccessLogMaxBytes) + if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil && snapshot.Admin.Zap != nil && snapshot.Admin.Zap.AccessLogMaxBytes > 0 { + maxBytes = snapshot.Admin.Zap.AccessLogMaxBytes } if c.Request.Method == http.MethodGet { requestBody = operationQueryBody(c.Request.URL.RawQuery) diff --git a/internal/server/middleware/cors.go b/internal/server/middleware/cors.go index 3a02ae8..d360d7f 100644 --- a/internal/server/middleware/cors.go +++ b/internal/server/middleware/cors.go @@ -4,7 +4,7 @@ import ( "net/http" "strings" - "kra/internal/conf" + "kra/internal/config" "github.com/gin-gonic/gin" ) @@ -17,18 +17,19 @@ const ( // CORS applies the current administration CORS rules on every request so a // configuration reload takes effect without rebuilding the Gin engine. -func CORS(runtime *conf.Runtime) gin.HandlerFunc { +func CORS(runtime *config.Store) gin.HandlerFunc { return func(c *gin.Context) { if runtime == nil { c.Next() return } - config := runtime.Admin() - if config == nil || config.Cors == nil { + snapshot := runtime.Snapshot() + if snapshot == nil || snapshot.Admin == nil || snapshot.Admin.CORS == nil { c.Next() return } - mode := strings.TrimSpace(config.Cors.Mode) + cors := snapshot.Admin.CORS + mode := strings.TrimSpace(cors.Mode) origin := c.GetHeader("Origin") corsHandled := false if mode == "allow-all" { @@ -36,7 +37,7 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc { setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true) corsHandled = true } - } else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil { + } else if rule := matchingCORSRule(cors.Whitelist, origin); rule != nil { setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials) corsHandled = true } else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && isHealthPath(c.Request.URL.Path)) { @@ -56,7 +57,7 @@ func isHealthPath(path string) bool { return path == "/health" || strings.HasSuffix(path, "/health") } -func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.AdminBackend_CORSRule { +func matchingCORSRule(rules []*config.CORSRule, origin string) *config.CORSRule { for _, rule := range rules { if rule != nil && origin == rule.AllowOrigin { return rule diff --git a/internal/server/middleware/cors_test.go b/internal/server/middleware/cors_test.go index 1c1fc2a..93e1ca8 100644 --- a/internal/server/middleware/cors_test.go +++ b/internal/server/middleware/cors_test.go @@ -5,15 +5,15 @@ import ( "net/http/httptest" "testing" - "kra/internal/conf" + "kra/internal/config" "github.com/gin-gonic/gin" ) -func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) *httptest.ResponseRecorder { +func runCORSTest(t *testing.T, admin *config.Admin, method, origin string) *httptest.ResponseRecorder { t.Helper() engine := gin.New() - engine.Use(CORS(conf.NewRuntime(nil, admin))) + engine.Use(CORS(config.NewStore(&config.Config{Admin: admin}))) engine.Any("/test", func(c *gin.Context) { c.Status(http.StatusOK) }) request := httptest.NewRequest(method, "/test", nil) if origin != "" { @@ -25,14 +25,14 @@ func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) } func TestCORSDoesNotConsumeUnmatchedWhitelistPreflight(t *testing.T) { - response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example") + response := runCORSTest(t, &config.Admin{CORS: &config.CORS{Mode: "whitelist"}}, http.MethodOptions, "https://unknown.example") if response.Code != http.StatusOK { t.Fatalf("unmatched whitelist preflight status = %d, want %d", response.Code, http.StatusOK) } } func TestCORSConsumesMatchedWhitelistPreflight(t *testing.T) { - response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_CORS{Mode: "whitelist", Whitelist: []*conf.AdminBackend_CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example") + response := runCORSTest(t, &config.Admin{CORS: &config.CORS{Mode: "whitelist", Whitelist: []*config.CORSRule{{AllowOrigin: "https://admin.example"}}}}, http.MethodOptions, "https://admin.example") if response.Code != http.StatusNoContent { t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent) } diff --git a/internal/server/staticfiles/staticfiles.go b/internal/server/staticfiles/staticfiles.go index efe7575..69acbc9 100644 --- a/internal/server/staticfiles/staticfiles.go +++ b/internal/server/staticfiles/staticfiles.go @@ -9,14 +9,15 @@ import ( "path" "strings" - "kra/internal/conf" + "kra/internal/config" + "kra/internal/utils/uploadpolicy" "github.com/gin-gonic/gin" ) // Register adds local upload routes when local storage is enabled. A route is // skipped when its root conflicts with an existing GET/HEAD route. -func Register(engine *gin.Engine, runtime *conf.Runtime) { +func Register(engine *gin.Engine, runtime *config.Store) { config := localConfig(runtime) if config == nil || strings.Trim(config.PathPrefix, "/") == "" { return @@ -36,7 +37,7 @@ func Register(engine *gin.Engine, runtime *conf.Runtime) { // Serve resolves the current runtime configuration on every request so a // configuration reload takes effect without rebuilding the Gin engine. -func Serve(c *gin.Context, runtime *conf.Runtime) bool { +func Serve(c *gin.Context, runtime *config.Store) bool { config := localConfig(runtime) if config == nil { return false @@ -45,18 +46,18 @@ func Serve(c *gin.Context, runtime *conf.Runtime) bool { return serveAt(c, runtime, prefix) } -func localConfig(runtime *conf.Runtime) *conf.AdminBackend_Local { +func localConfig(runtime *config.Store) *config.Local { if runtime == nil { return nil } - config := runtime.Admin() - if config == nil || config.Local == nil || config.Local.StorePath == "" { + snapshot := runtime.Snapshot() + if snapshot == nil || snapshot.Admin == nil || snapshot.Admin.Local == nil || snapshot.Admin.Local.StorePath == "" { return nil } - if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" { + if snapshot.Admin.Storage != nil && snapshot.Admin.Storage.Type != "" && snapshot.Admin.Storage.Type != "local" { return nil } - return config.Local + return snapshot.Admin.Local } func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool { @@ -73,7 +74,7 @@ func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool { return false } -func serveAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool { +func serveAt(c *gin.Context, runtime *config.Store, prefix string) bool { config := localConfig(runtime) if config == nil || "/"+strings.Trim(config.PathPrefix, "/") != prefix { return false @@ -83,24 +84,13 @@ func serveAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool { } c.Header("X-Content-Type-Options", "nosniff") filename := path.Base(c.Request.URL.Path) - if !canServeInline(filename) { + if !uploadpolicy.CanServeInline(filename) { c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) } http.StripPrefix(prefix, http.FileServer(filesOnly{FileSystem: http.Dir(config.StorePath)})).ServeHTTP(c.Writer, c.Request) return true } -func canServeInline(filename string) bool { - switch strings.ToLower(path.Ext(filename)) { - case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".avif", - ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", - ".mp4", ".webm", ".mov", ".avi", ".mkv": - return true - default: - return false - } -} - type filesOnly struct{ http.FileSystem } func (f filesOnly) Open(name string) (http.File, error) { diff --git a/internal/server/websocket_auth_test.go b/internal/server/websocket_auth_test.go index 762a49f..8a1c215 100644 --- a/internal/server/websocket_auth_test.go +++ b/internal/server/websocket_auth_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "kra/internal/conf" + "kra/internal/config" "kra/internal/integration/runtimeconfig" websocketintegration "kra/internal/integration/websocket" "kra/internal/server/middleware" @@ -44,7 +44,7 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) { defer cleanup() auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}} - engine := NewGinEngineWithRuntime(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, auth, nil, nil, nil, "test", nil, ws) + engine := NewGinEngineWithRuntime(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws) unauthorized := httptest.NewRecorder() engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/ws", nil)) diff --git a/internal/utils/uploadpolicy/policy.go b/internal/utils/uploadpolicy/policy.go new file mode 100644 index 0000000..9cd466b --- /dev/null +++ b/internal/utils/uploadpolicy/policy.go @@ -0,0 +1,74 @@ +// Package uploadpolicy contains the stateless filename and content policy +// shared by upload handling and local file serving. +package uploadpolicy + +import ( + "errors" + "path/filepath" + "strings" +) + +var ( + allowedExtensions = map[string]struct{}{ + ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".bmp": {}, ".ico": {}, ".avif": {}, + ".mp3": {}, ".wav": {}, ".ogg": {}, ".m4a": {}, ".flac": {}, ".aac": {}, + ".mp4": {}, ".webm": {}, ".mov": {}, ".avi": {}, ".mkv": {}, + ".txt": {}, ".md": {}, ".csv": {}, ".json": {}, ".log": {}, ".pdf": {}, + ".doc": {}, ".docx": {}, ".xls": {}, ".xlsx": {}, ".ppt": {}, ".pptx": {}, + ".zip": {}, ".rar": {}, ".7z": {}, ".tar": {}, ".gz": {}, ".tgz": {}, ".bin": {}, + } + + inlineExtensions = map[string]struct{}{ + ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".bmp": {}, ".ico": {}, ".avif": {}, + ".mp3": {}, ".wav": {}, ".ogg": {}, ".m4a": {}, ".flac": {}, ".aac": {}, + ".mp4": {}, ".webm": {}, ".mov": {}, ".avi": {}, ".mkv": {}, + } +) + +var errFileExtensionNotAllowed = errors.New("file extension is not allowed") + +// ValidateFileName rejects paths, surrounding whitespace, and extensions +// outside the upload allowlist. +func ValidateFileName(filename string) error { + if filename == "" || strings.TrimSpace(filename) != filename || strings.ContainsAny(filename, `/\`) { + return errFileExtensionNotAllowed + } + if _, ok := allowedExtensions[strings.ToLower(filepath.Ext(filename))]; !ok { + return errFileExtensionNotAllowed + } + return nil +} + +// CanServeInline reports whether a stored object may be rendered by a +// browser instead of being forced to download. +func CanServeInline(filename string) bool { + if ValidateFileName(filename) != nil { + return false + } + _, ok := inlineExtensions[strings.ToLower(filepath.Ext(filename))] + return ok +} + +// MIMEByExtension returns the small extension fallback table used when a +// client did not provide a content type. Unknown extensions are left for +// content sniffing by the caller. +func MIMEByExtension(ext string) string { + switch strings.ToLower(ext) { + case ".json": + return "application/json" + case ".xml": + return "application/xml" + case ".pdf": + return "application/pdf" + case ".zip": + return "application/zip" + case ".mp4": + return "video/mp4" + case ".mp3": + return "audio/mpeg" + case ".csv": + return "text/csv" + default: + return "" + } +} diff --git a/internal/utils/uploadpolicy/policy_test.go b/internal/utils/uploadpolicy/policy_test.go new file mode 100644 index 0000000..54c1633 --- /dev/null +++ b/internal/utils/uploadpolicy/policy_test.go @@ -0,0 +1,53 @@ +package uploadpolicy + +import "testing" + +func TestValidateFileName(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "known extension", value: "photo.PNG"}, + {name: "document", value: "report.pdf"}, + {name: "empty", value: "", wantErr: true}, + {name: "unknown extension", value: "payload.exe", wantErr: true}, + {name: "path", value: "../photo.png", wantErr: true}, + {name: "backslash path", value: `dir\photo.png`, wantErr: true}, + {name: "surrounding whitespace", value: "photo.png ", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidateFileName(tt.value); (err != nil) != tt.wantErr { + t.Fatalf("ValidateFileName(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr) + } + }) + } +} + +func TestCanServeInline(t *testing.T) { + for _, filename := range []string{"photo.png", "track.MP3", "movie.webm"} { + if !CanServeInline(filename) { + t.Errorf("CanServeInline(%q) = false", filename) + } + } + for _, filename := range []string{"report.pdf", "archive.zip", "payload.exe", "../photo.png"} { + if CanServeInline(filename) { + t.Errorf("CanServeInline(%q) = true", filename) + } + } +} + +func TestMIMEByExtension(t *testing.T) { + tests := map[string]string{ + ".JSON": "application/json", + ".mp3": "audio/mpeg", + ".csv": "text/csv", + ".exe": "", + } + for ext, want := range tests { + if got := MIMEByExtension(ext); got != want { + t.Errorf("MIMEByExtension(%q) = %q, want %q", ext, got, want) + } + } +} diff --git a/internal/worker/task_executor.go b/internal/worker/task_executor.go index d30aeea..3c533a0 100644 --- a/internal/worker/task_executor.go +++ b/internal/worker/task_executor.go @@ -23,9 +23,6 @@ type TaskExecutor struct { } func NewTaskExecutorWithRegistry(tasks *taskbiz.TaskUsecase, methods taskbiz.TaskMethodRegistry) *TaskExecutor { - if methods == nil { - methods = taskbiz.DefaultTaskMethodRegistry() - } return &TaskExecutor{tasks: tasks, methods: methods} } @@ -139,8 +136,6 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e var ok bool if e.methods != nil { method, ok = e.methods.Lookup(task.MethodName) - } else { - method, ok = taskbiz.TaskMethodByName(task.MethodName) } if !ok { return fmt.Errorf("方法 %s 未注册(需通过 platform/task.Registry 注册)", task.MethodName) diff --git a/internal/worker/task_executor_test.go b/internal/worker/task_executor_test.go index 885e26e..bd2f898 100644 --- a/internal/worker/task_executor_test.go +++ b/internal/worker/task_executor_test.go @@ -10,6 +10,7 @@ import ( "time" taskbiz "kra/internal/biz/task" + platformtask "kra/pkg/task" ) func TestPrivateIP(t *testing.T) { @@ -25,11 +26,12 @@ func TestPrivateIP(t *testing.T) { func TestRunMethodReportsParentDeadlineAsTimeout(t *testing.T) { const methodName = "worker-test-parent-deadline" - taskbiz.RegisterTaskMethod(methodName, "test", func(ctx context.Context, _ json.RawMessage) error { + methods := platformtask.NewRegistry() + methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(ctx context.Context, _ json.RawMessage) error { <-ctx.Done() return ctx.Err() - }) - executor := &TaskExecutor{} + }}) + executor := &TaskExecutor{methods: methods} ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() if err := executor.runMethod(ctx, &taskbiz.TimedTask{MethodName: methodName}); !errors.Is(err, errTaskTimeout) { @@ -39,10 +41,11 @@ func TestRunMethodReportsParentDeadlineAsTimeout(t *testing.T) { func TestRunMethodConvertsPanicToError(t *testing.T) { const methodName = "worker-test-panic" - taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { + methods := platformtask.NewRegistry() + methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error { panic("boom") - }) - executor := &TaskExecutor{} + }}) + executor := &TaskExecutor{methods: methods} err := executor.runMethod(context.Background(), &taskbiz.TimedTask{MethodName: methodName}) if err == nil || !strings.Contains(err.Error(), "panic: boom") { t.Fatalf("runMethod() error = %v, want recovered panic", err) @@ -52,12 +55,13 @@ func TestRunMethodConvertsPanicToError(t *testing.T) { func TestRunMethodUsesParentContext(t *testing.T) { const methodName = "worker-test-parent-context" started := make(chan struct{}) - taskbiz.RegisterTaskMethod(methodName, "test", func(ctx context.Context, _ json.RawMessage) error { + methods := platformtask.NewRegistry() + methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(ctx context.Context, _ json.RawMessage) error { close(started) <-ctx.Done() return ctx.Err() - }) - executor := &TaskExecutor{} + }}) + executor := &TaskExecutor{methods: methods} ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { @@ -82,11 +86,12 @@ func TestRunMethodUsesParentContext(t *testing.T) { func TestRunMethodHonorsDeadlineWhenMethodIgnoresContext(t *testing.T) { const methodName = "worker-test-ignores-context" release := make(chan struct{}) - taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { + methods := platformtask.NewRegistry() + methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error { <-release return nil - }) - executor := &TaskExecutor{} + }}) + executor := &TaskExecutor{methods: methods} ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() if err := executor.runMethod(ctx, &taskbiz.TimedTask{MethodName: methodName}); !errors.Is(err, errTaskTimeout) { diff --git a/internal/worker/task_registry.go b/internal/worker/task_registry.go index 76543e6..f43c6e9 100644 --- a/internal/worker/task_registry.go +++ b/internal/worker/task_registry.go @@ -7,7 +7,7 @@ import ( "kra/internal/biz/system" taskbiz "kra/internal/biz/task" - "kra/internal/conf" + "kra/internal/config" platformtask "kra/pkg/task" ) @@ -17,10 +17,10 @@ type TaskMethods struct { tasks *taskbiz.TaskUsecase maintenance *system.MaintenanceUsecase media *system.MediaUsecase - runtime *conf.Runtime + runtime *config.Store } -func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *conf.Runtime) *TaskMethods { +func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store) *TaskMethods { return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime} } @@ -53,12 +53,14 @@ func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) { return errors.New("CleanStaleUploads 任务依赖未就绪") } ttl := 24 - var config *conf.AdminBackend + var admin *config.Admin if methods.runtime != nil { - config = methods.runtime.Admin() + if snapshot := methods.runtime.Snapshot(); snapshot != nil { + admin = snapshot.Admin + } } - if config != nil && config.Media != nil && config.Media.SessionTtl > 0 { - ttl = int(config.Media.SessionTtl) + if admin != nil && admin.Media != nil && admin.Media.SessionTTL > 0 { + ttl = admin.Media.SessionTTL } return methods.media.CleanupStale(ctx, ttl) }, diff --git a/internal/worker/task_runtime_test.go b/internal/worker/task_runtime_test.go index fb37565..c152c7e 100644 --- a/internal/worker/task_runtime_test.go +++ b/internal/worker/task_runtime_test.go @@ -12,6 +12,7 @@ import ( "time" taskbiz "kra/internal/biz/task" + platformtask "kra/pkg/task" ) type workerTaskRepo struct { @@ -63,8 +64,9 @@ func (r *workerTaskRepo) ListTaskLogs(context.Context, int, int, uint, string) ( } func newTestTaskScheduler(repo *workerTaskRepo) *TaskScheduler { - tasks := taskbiz.NewTaskUsecase(repo) - executor := &TaskExecutor{tasks: tasks} + methods := platformtask.NewRegistry() + tasks := taskbiz.NewTaskUsecaseWithRegistry(repo, methods) + executor := &TaskExecutor{tasks: tasks, methods: methods} logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return NewTaskScheduler(tasks, nil, executor, logger) } @@ -136,16 +138,18 @@ func TestManualTriggerSkipsOverlappingExecution(t *testing.T) { started := make(chan struct{}, 2) release := make(chan struct{}) var calls atomic.Int32 - taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { + methods := platformtask.NewRegistry() + methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error { calls.Add(1) started <- struct{}{} <-release return nil - }) + }}) task := &taskbiz.TimedTask{ID: 9, Name: "single", ExecutorType: taskbiz.TaskExecutorMethod, MethodName: methodName} repo := &workerTaskRepo{items: []*taskbiz.TimedTask{task}, logs: make(chan *taskbiz.TimedTaskLog, 2)} - scheduler := newTestTaskScheduler(repo) + tasks := taskbiz.NewTaskUsecaseWithRegistry(repo, methods) + scheduler := NewTaskScheduler(tasks, nil, &TaskExecutor{tasks: tasks, methods: methods}, slog.New(slog.NewTextHandler(io.Discard, nil))) if err := scheduler.TriggerID(context.Background(), task.ID); err != nil { t.Fatalf("first manual trigger failed: %v", err) } @@ -193,16 +197,18 @@ func TestAutomaticAndManualTriggersShareOverlapGate(t *testing.T) { started := make(chan struct{}, 1) release := make(chan struct{}) var calls atomic.Int32 - taskbiz.RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { + methods := platformtask.NewRegistry() + methods.Register(platformtask.Method{Name: methodName, Description: "test", Run: func(context.Context, json.RawMessage) error { calls.Add(1) started <- struct{}{} <-release return nil - }) + }}) task := &taskbiz.TimedTask{ID: 10, Name: "shared-gate", ExecutorType: taskbiz.TaskExecutorMethod, MethodName: methodName} repo := &workerTaskRepo{logs: make(chan *taskbiz.TimedTaskLog, 1)} - scheduler := newTestTaskScheduler(repo) + tasks := taskbiz.NewTaskUsecaseWithRegistry(repo, methods) + scheduler := NewTaskScheduler(tasks, nil, &TaskExecutor{tasks: tasks, methods: methods}, slog.New(slog.NewTextHandler(io.Discard, nil))) if !scheduler.dispatch(task, "auto", true) { t.Fatal("automatic dispatch was rejected while idle") } diff --git a/usage.txt b/usage.txt new file mode 100644 index 0000000..a8c4298 --- /dev/null +++ b/usage.txt @@ -0,0 +1,224 @@ +cmd\main.go:13: "kra/internal/conf" +cmd\main.go:147: runtime := conf.NewRuntime(bc.Data, bc.Admin) +cmd\main.go:148: unsubscribeLogger := runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) { +cmd\main_test.go:6: "kra/internal/conf" +cmd\wire_gen.go:16: "kra/internal/conf" +internal\app\runtime.go:30: return module.NewRuntime(contributions.Routes...) +cmd\wire.go:14: "kra/internal/conf" +pkg\module\module_test.go:54: runtime := NewRuntime(nil, stub) +pkg\mq\mqtt.go:102: return waitToken(ctx, client.Subscribe(topic, qos, func(_ paho.Client, msg paho.Message) { +pkg\module\module.go:84:func NewRuntime(routes ...RouteRegistrar) *Runtime { +internal\config\runtime_test.go:63: runtime := NewRuntime(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "before"}}}) +internal\config\runtime_test.go:65: stop := runtime.Subscribe(func(value *Config) { +internal\config\runtime_test.go:69: runtime.Replace(&Config{Admin: &Admin{JWT: &JWT{SigningKey: "after"}}}) +internal\config\runtime_test.go:72: value := runtime.Snapshot() +internal\config\runtime_test.go:82: runtime := NewRuntime(&Config{Data: &Data{Database: &Database{Name: "initial"}}}) +internal\config\runtime_test.go:89: _ = runtime.Snapshot() +internal\config\runtime_test.go:94: runtime.Replace(&Config{Data: &Data{Database: &Database{Name: "next"}}}) +internal\config\runtime_test.go:111: value := runtime.Snapshot() +internal\config\runtime_test.go:117: t.Fatalf("watcher did not reload configuration: %#v", runtime.Snapshot()) +internal\config\runtime.go:56:func NewRuntime(config *Config) *Runtime { +internal\config\runtime.go:61: runtime.Replace(config) +internal\config\runtime.go:71: runtime := NewRuntime(config) +internal\config\runtime.go:87: config := r.Snapshot() +internal\config\runtime.go:95: config := r.Snapshot() +internal\config\runtime.go:103: config := r.Snapshot() +internal\config\runtime.go:111: config := r.Snapshot() +internal\config\runtime.go:122: config := r.Snapshot() +internal\config\runtime.go:155: if path := r.ConfigPath(); path != "" { +internal\config\runtime.go:200: path := r.ConfigPath() +internal\config\runtime.go:268: r.Replace(config) +internal\global\resources_test.go:61: resources := registry.Snapshot() +internal\global\resources_test.go:79: registry.Replace(Resources{ +internal\global\resources_test.go:84: resources := registry.Snapshot() +internal\global\resources_test.go:124: registry.Replace(Resources{Redis: redisClient, Storage: storage, MQ: mq, WebSocket: webSocket, Scheduler: scheduler}) +internal\global\resources_test.go:125: resources := registry.Snapshot() +internal\global\resources_test.go:144: _ = registry.Snapshot() +internal\global\resources.go:80:func (r *ResourceRegistry) Logger() *slog.Logger { return r.Snapshot().Logger } +internal\global\resources.go:86:func (r *ResourceRegistry) DB() *gorm.DB { return r.Snapshot().DB } +internal\global\resources.go:98: return r.Snapshot().NamedDBs[name] +internal\global\resources.go:106:func (r *ResourceRegistry) Redis() redis.UniversalClient { return r.Snapshot().Redis } +internal\global\resources.go:118: return r.Snapshot().NamedRedis[name] +internal\global\resources.go:126:func (r *ResourceRegistry) Mongo() *mongo.Client { return r.Snapshot().Mongo } +internal\global\resources.go:132:func (r *ResourceRegistry) Storage() FileStorage { return r.Snapshot().Storage } +internal\global\resources.go:138:func (r *ResourceRegistry) MQ() platformmq.Registry { return r.Snapshot().MQ } +internal\global\resources.go:144:func (r *ResourceRegistry) WebSocket() platformws.Hub { return r.Snapshot().WebSocket } +internal\global\resources.go:150:func (r *ResourceRegistry) Scheduler() Scheduler { return r.Snapshot().Scheduler } +internal\global\resources.go:241:func ResourceSnapshot() Resources { return defaultResources.Snapshot() } +internal\global\resources.go:243:func ReplaceResources(resources Resources) { defaultResources.Replace(resources) } +internal\conf\runtime_test.go:9: runtime := NewRuntime(&Data{Database: &Data_Database{Name: "first"}}, &AdminBackend{RouterPrefix: "/api", Jwt: &AdminBackend_JWT{SigningKey: "secret"}}) +internal\conf\runtime_test.go:25: runtime := NewRuntime(&Data{Database: &Data_Database{Name: "initial"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "initial"}}) +internal\conf\runtime_test.go:38: runtime.Replace(&Data{Database: &Data_Database{Name: "next"}}, &AdminBackend{Jwt: &AdminBackend_JWT{SigningKey: "next"}}) +internal\conf\runtime.go:24:func NewRuntime(data *Data, admin *AdminBackend) *Runtime { +internal\conf\runtime.go:26: runtime.Replace(data, admin) +internal\conf\conf.pb.go:2216: " \x01(\tR\taccountIdB\x18Z\x16kra/internal/conf;confb\x06proto3" +internal\biz\task\task.go:236: return uc.runtime.Subscribe(userID) +internal\data\database.go:22: "kra/internal/config" +internal\data\data.go:14: "kra/internal/config" +internal\data\data.go:52: runtime *config.Runtime +internal\data\data.go:79:func (d *Data) Runtime() *config.Runtime { +internal\data\data.go:164:func NewData(runtime *config.Runtime, appLogger *slog.Logger, storageManager *storage.Reloadable, catalog module.Catalog) (*Data, func(), error) { +internal\data\data.go:256: runtime.Replace(c, admin) +internal\data\data.go:265: storageManager.Replace(activeStorage) +internal\data\data.go:327: d.runtime.UpdateDatabase(config) +internal\data\initialization_backend_test.go:10: "kra/internal/config" +internal\data\initialization_backend_test.go:81: runtime := config.NewRuntime(&config.Data{}, &config.Admin{ConfigPath: configPath}) +internal\data\config_watch_test.go:10: "kra/internal/config" +internal\data\config_watch_test.go:20: runtime := config.NewRuntime(&config.Data{}, &config.Admin{ConfigPath: configPath}) +internal\data\config_watch_test.go:24: stopListener := runtime.Subscribe(func(*config.Data, *config.Admin) { +internal\data\initialization_backend.go:9: "kra/internal/config" +internal\data\initialization_backend.go:18:func (d *Data) RuntimeValues() (*config.Data, *config.Admin) { return d.runtime.Values() } +internal\data\initialization_backend.go:61: currentData, currentAdmin := d.runtime.Values() +internal\data\initialization_backend.go:88: d.runtime.Replace(currentData, next) +internal\data\initialization_backend.go:90: d.storage.Replace(candidateStorage) +internal\data\initialization_backend.go:95: currentData, currentAdmin := d.runtime.Values() +internal\data\initialization_backend.go:125: d.runtime.Replace(nextData, nextAdmin) +internal\data\initialization_backend.go:127: d.storage.Replace(candidateStorage) +internal\data\initialization_backend.go:218: currentData, currentAdmin := d.runtime.Values() +internal\data\initialization_backend.go:228: d.runtime.Replace(currentData, currentAdmin) +internal\data\initialization_backend.go:230: d.integrations.Replace(integrationConfigs) +internal\data\integration_config.go:10: "kra/internal/config" +internal\data\integration_config_test.go:11: "kra/internal/config" +internal\data\integration_config_test.go:252: runtime: config.NewRuntime(&config.Data{}, currentAdmin), +internal\data\config_helpers.go:4: "kra/internal/config" +internal\data\config_store.go:12: "kra/internal/config" +internal\data\config_store.go:155: dataConfig, adminConfig := d.runtime.Values() +internal\data\config_store.go:214: configPath := d.runtime.ConfigPath() +internal\data\config_store.go:262: configPath := d.runtime.ConfigPath() +internal\data\config_store.go:313: configPath := d.runtime.ConfigPath() +internal\data\config_store.go:431: d.runtime.Replace(next.Data, next.Admin) +internal\data\config_store.go:433: d.integrations.Replace(integrationConfigs) +internal\data\config_store.go:436: d.storage.Replace(candidateStorage) +internal\data\config_watch.go:16: configPath := d.runtime.ConfigPath() +internal\data\config_watch.go:91: d.runtime.Replace(next.Data, next.Admin) +internal\server\gin.go:9: "kra/internal/config" +internal\server\gin.go:23:func NewGinEngine(runtime *config.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine { +internal\server\gin.go:24: return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, platformmodule.NewRuntime(router.NewRoutes(handlers)), nil) +internal\server\gin.go:27:func NewGinEngineWithRuntime(runtime *config.Runtime, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine { +internal\server\gin.go:29: runtime = config.NewRuntime(nil) +internal\server\gin.go:39: snapshot := runtime.Snapshot() +internal\server\gin_test.go:14: "kra/internal/config" +internal\server\gin_test.go:35: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") +internal\server\gin_test.go:69: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") +internal\server\gin_test.go:89: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, logger, "test") +internal\server\gin_test.go:100: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") +internal\server\gin_test.go:116: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") +internal\server\gin_test.go:131: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") +internal\server\gin_test.go:140: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") +internal\server\gin_test.go:154: engine := NewGinEngine(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") +internal\server\gin_test.go:169: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "local"}}}) +internal\server\gin_test.go:193: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}}}) +internal\server\gin_test.go:216: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "local"}}}) +internal\server\gin_test.go:218: runtime.Replace(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: root, PathPrefix: "files"}, Storage: &config.Storage{Type: "local"}}}) +internal\server\gin_test.go:233: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &config.Storage{Type: "local"}}}) +internal\server\gin_test.go:243: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{Local: &config.Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &config.Storage{Type: "s3"}}}) +internal\data\integration_runtime.go:19: d.integrations.Replace(configs) +internal\data\mongo.go:9: "kra/internal/config" +internal\worker\task_scheduler_test.go:10: first := scheduler.Subscribe(1) +internal\worker\task_scheduler_test.go:11: second := scheduler.Subscribe(1) +internal\worker\task_scheduler_test.go:12: third := scheduler.Subscribe(2) +internal\worker\task_scheduler_test.go:34: channels = append(channels, scheduler.Subscribe(7)) +internal\worker\task_scheduler_test.go:60: ch := scheduler.Subscribe(9) +internal\worker\task_registry.go:10: "kra/internal/config" +internal\worker\task_registry.go:20: runtime *config.Runtime +internal\worker\task_registry.go:23:func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Runtime) *TaskMethods { +internal\worker\task_registry.go:58: if snapshot := methods.runtime.Snapshot(); snapshot != nil { +internal\data\payment\payment.go:411: normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +internal\server\middleware\access_log.go:18: "kra/internal/config" +internal\server\middleware\access_log.go:28:func AccessLog(runtime *config.Runtime, logger *slog.Logger, version string) gin.HandlerFunc { +internal\server\middleware\access_log.go:36: if snapshot := runtime.Snapshot(); snapshot != nil { +internal\server\staticfiles\staticfiles.go:12: "kra/internal/config" +internal\server\staticfiles\staticfiles.go:19:func Register(engine *gin.Engine, runtime *config.Runtime) { +internal\server\staticfiles\staticfiles.go:39:func Serve(c *gin.Context, runtime *config.Runtime) bool { +internal\server\staticfiles\staticfiles.go:48:func localConfig(runtime *config.Runtime) *config.Local { +internal\server\staticfiles\staticfiles.go:52: snapshot := runtime.Snapshot() +internal\server\staticfiles\staticfiles.go:76:func serveAt(c *gin.Context, runtime *config.Runtime, prefix string) bool { +internal\server\middleware\access.go:7: "kra/internal/config" +internal\server\middleware\access.go:18:func AccessControl(runtime *config.Runtime, access accessController) gin.HandlerFunc { +internal\server\middleware\access.go:33: if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil { +internal\server\websocket_auth_test.go:12: "kra/internal/config" +internal\server\websocket_auth_test.go:47: engine := NewGinEngineWithRuntime(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws) +internal\integration\websocket\server.go:40: s.stop = store.Subscribe("websocket", ProviderMelody, func(config runtimeconfig.Config) { s.apply(config) }) +internal\server\middleware\audit.go:15: "kra/internal/config" +internal\server\middleware\audit.go:25:func OperationAudit(runtime *config.Runtime, recorder *service.AuditRecorder) gin.HandlerFunc { +internal\server\middleware\audit.go:42: if snapshot := runtime.Snapshot(); snapshot != nil && snapshot.Admin != nil && snapshot.Admin.Zap != nil && snapshot.Admin.Zap.AccessLogMaxBytes > 0 { +internal\server\middleware\cors_test.go:8: "kra/internal/config" +internal\server\middleware\cors_test.go:16: engine.Use(CORS(config.NewRuntime(&config.Config{Admin: admin}))) +internal\server\middleware\cors.go:7: "kra/internal/config" +internal\server\middleware\cors.go:20:func CORS(runtime *config.Runtime) gin.HandlerFunc { +internal\server\middleware\cors.go:26: snapshot := runtime.Snapshot() +internal\server\middleware\access_test.go:12: "kra/internal/config" +internal\server\middleware\access_test.go:39: engine.Use(AccessControl(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), &accessControllerStub{scopeErr: errors.New("database unavailable")})) +internal\server\middleware\access_log_test.go:12: "kra/internal/config" +internal\server\middleware\access_log_test.go:21: engine.Use(AccessLog(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, "test")) +internal\server\middleware\access_log_test.go:37: engine.Use(AccessLog(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, "test")) +internal\server\middleware\access_log_test.go:54: engine.Use(AccessLog(config.NewRuntime(&config.Config{Admin: &config.Admin{}}), nil, "test")) +internal\server\middleware\access_log_test.go:73: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{Zap: &config.Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}}) +internal\server\middleware\access_log_test.go:104: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{Zap: &config.Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}}}) +internal\server\middleware\access_log_test.go:130: runtime := config.NewRuntime(&config.Config{Admin: &config.Admin{}}) +internal\integration\email\email_test.go:7: "kra/internal/config" +internal\integration\email\email_test.go:11: runtime := config.NewRuntime(&config.Data{}, &config.Admin{Email: &config.Email{To: " "}}) +internal\integration\email\email_test.go:19: runtime := config.NewRuntime(&config.Data{}, &config.Admin{Email: &config.Email{Host: "smtp.example.com", From: "from@example.com", Secret: "secret", Port: 25}}) +internal\integration\email\email.go:16: "kra/internal/config" +internal\integration\email\email.go:19:type emailRepo struct{ runtime *config.Runtime } +internal\integration\email\email.go:21:func NewEmailRepo(runtime *config.Runtime) system.EmailRepo { +internal\integration\email\email.go:64: return strings.NewReplacer("\r", "", "\n", "").Replace(value) +internal\service\task\task.go:81:func (s *TaskService) Subscribe(userID uint) chan []byte { return s.uc.Subscribe(userID) } +internal\server\handler\task.go:138: events := h.service.Subscribe(claims.ID) +internal\initialize\configuration_test.go:7: "kra/internal/conf" +internal\integration\storage\tencent_storage.go:14: "kra/internal/config" +internal\initialize\configuration.go:7: "kra/internal/conf" +internal\integration\storage\s3_storage.go:12: "kra/internal/config" +internal\integration\storage\reloadable_test.go:68: reloadable.Replace(&emptyStorage{}) +internal\initialize\compatibility.go:9: "kra/internal/conf" +internal\initialize\backend.go:7: "kra/internal/conf" +internal\data\system\api_policy_test.go:9: "kra/internal/config" +internal\data\system\api_policy_test.go:23: runtime: config.NewRuntime( +internal\integration\storage\reloadable.go:10: "kra/internal/config" +internal\integration\storage\reloadable.go:18:func NewFileStorage(runtime *config.Runtime) (*Reloadable, error) { +internal\integration\storage\qiniu_storage.go:14: "kra/internal/config" +internal\integration\storage\local.go:14: "kra/internal/config" +internal\integration\storage\huawei_storage.go:11: "kra/internal/config" +internal\data\system\authority_test.go:9: "kra/internal/config" +internal\data\system\authority_test.go:17: currentData, _ := data.runtime.Values() +internal\data\system\authority_test.go:18: data.runtime.Replace(currentData, &config.Admin{System: &config.System{UseStrictAuth: true}}) +internal\integration\storage\aws_storage.go:11: "kra/internal/config" +internal\integration\storage\aliyun_storage.go:11: "kra/internal/config" +internal\integration\payment\alipay.go:439: normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +internal\integration\runtimeconfig\store_test.go:12: stop := store.Subscribe("mq", "rabbitmq", func(config Config) { updates <- config }) +internal\integration\runtimeconfig\store_test.go:36: stop := store.Subscribe("mq", "rabbitmq", func(config Config) { updates <- config }) +internal\integration\runtimeconfig\store_test.go:47: store.Replace([]Config{config}) +internal\integration\runtimeconfig\store_test.go:56: store.Replace([]Config{changed}) +internal\integration\runtimeconfig\store_test.go:70: stop := store.Subscribe("mq", "rabbitmq", func(config Config) { updates <- config }) +internal\integration\payment\allinpay.go:210: normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +internal\integration\mq\emqx.go:71: store.Subscribe("mq", ProviderEMQX, func(config runtimeconfig.Config) { r.apply(ProviderEMQX, config) }), +internal\integration\mq\emqx.go:72: store.Subscribe("mq", ProviderRabbitMQ, func(config runtimeconfig.Config) { r.apply(ProviderRabbitMQ, config) }), +internal\integration\mq\emqx.go:362: if err := client.Subscribe(context.Background(), topic, qos, r.dispatcher(provider, topic)); err != nil { +internal\integration\mq\emqx.go:445: if err := client.Subscribe(context.Background(), topic, qos, r.dispatcher(provider, topic)); err != nil { +internal\integration\payment\lakala.go:74: return strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(method))) +internal\integration\mq\emqx_test.go:57: if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil { +internal\integration\mq\emqx_test.go:204: err := r.Subscribe(context.Background(), "events.offline", platformmq.AtLeastOnce, func(context.Context, platformmq.Message) {}) +internal\integration\payment\douyin.go:113: normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +internal\data\system\log_file_test.go:11: "kra/internal/config" +internal\data\system\log_file_test.go:25: runtime := config.NewRuntime(nil, &config.Admin{Zap: &config.Zap{Director: root}}) +internal\data\system\log_file_test.go:44: runtime := config.NewRuntime(nil, &config.Admin{Zap: &config.Zap{Director: t.TempDir()}}) +internal\integration\payment\saobei.go:92: normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +internal\data\system\menu_test.go:9: "kra/internal/config" +internal\data\system\menu_test.go:23: runtime: config.NewRuntime(&config.Data{Database: &config.Database{Driver: "sqlite"}}, &config.Admin{}), +internal\integration\payment\wechat_v3.go:123: return strings.NewReplacer(".", "", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +internal\integration\payment\wechat_v2.go:189: normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) +internal\data\system\permission_test.go:8: "kra/internal/config" +internal\data\system\permission_test.go:19: data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewRuntime(&config.Data{Database: &config.Database{Driver: "sqlite"}}, &config.Admin{})} +internal\data\system\permission_test.go:39: data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewRuntime(&config.Data{Database: &config.Database{Driver: "sqlite"}}, &config.Admin{})} +internal\data\system\permission_test.go:70: data := &Data{gormDB: newReloadableDB(db, nil), runtime: config.NewRuntime(&config.Data{Database: &config.Database{Driver: "sqlite"}}, &config.Admin{})} +internal\data\system\provider.go:4: "kra/internal/config" +internal\data\system\provider.go:15: Runtime() *config.Runtime +internal\data\system\runtime.go:10: "kra/internal/config" +internal\data\system\runtime.go:16:type runtimeSettings struct{ runtime *config.Runtime } +internal\data\system\runtime.go:18:func NewRuntimeSettings(runtime *config.Runtime) system.RuntimeSettings { +internal\data\system\testing_support_test.go:10: "kra/internal/config" +internal\data\system\testing_support_test.go:19: runtime *config.Runtime +internal\data\system\testing_support_test.go:32:func (d *Data) Runtime() *config.Runtime { +internal\data\system\transactions_test.go:9: "kra/internal/config" +internal\data\system\transactions_test.go:14: name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) +internal\data\system\transactions_test.go:22: data := &Data{gormDB: newReloadableDB(db, nil), redis: newReloadableRedis(nil), runtime: config.NewRuntime(&config.Data{Database: &config.Database{Driver: "sqlite"}}, &config.Admin{})}