优化结构

This commit is contained in:
Yvan
2026-08-24 13:25:16 +08:00
parent 7a9a738c6a
commit 0b6b8ddf1a
72 changed files with 2475 additions and 1443 deletions
+5 -5
View File
@@ -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...)
}
+1 -1
View File
@@ -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)
+3 -40
View File
@@ -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 {
+4 -7
View File
@@ -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 {
+2 -5
View File
@@ -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}
}
-17
View File
@@ -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 }
+9 -6
View File
@@ -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")
}
+41
View File
@@ -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
}
+502
View File
@@ -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 &copy
}
func cloneDatabase(value *Database) *Database {
if value == nil {
return nil
}
copy := *value
return &copy
}
func cloneRedis(value *Redis) *Redis {
if value == nil {
return nil
}
copy := *value
copy.ClusterAddrs = append([]string(nil), value.ClusterAddrs...)
return &copy
}
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 &copy
}
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 &copy
}
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 &copy
}
func cloneObjectStore(value *ObjectStore) *ObjectStore {
if value == nil {
return nil
}
copy := *value
return &copy
}
+118
View File
@@ -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())
}
+226
View File
@@ -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"`
}
+10 -12
View File
@@ -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,
} {
+19 -45
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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
})
+16 -16
View File
@@ -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)
+7 -7
View File
@@ -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)
+71 -88
View File
@@ -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)
}
+8 -8
View File
@@ -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)
+36 -38
View File
@@ -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")
}
+32 -32
View File
@@ -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)
}
+3 -3
View File
@@ -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
}
+6 -4
View File
@@ -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() })
+2 -2
View File
@@ -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) {
+3 -3
View File
@@ -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) {
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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}
+2 -2
View File
@@ -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
}
+17 -17
View File
@@ -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 {
+3 -3
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+283
View File
@@ -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) }
+153
View File
@@ -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")
}
}
+3 -7
View File
@@ -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
}
-559
View File
@@ -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
}
-38
View File
@@ -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)
}
})
}
}
+221 -219
View File
@@ -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
}
+59 -14
View File
@@ -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)
}
}
+3 -6
View File
@@ -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 {
+6 -8
View File
@@ -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)
+3 -3
View File
@@ -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")
@@ -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 == "" {
+6 -6
View File
@@ -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 {
@@ -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 == "" {
+17 -17
View File
@@ -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)
@@ -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
+4 -4
View File
@@ -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
+5 -5
View File
@@ -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 == "" {
@@ -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 == "" {
+15 -15
View File
@@ -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)
+15 -15
View File
@@ -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/") {
+6 -4
View File
@@ -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 {
+13 -11
View File
@@ -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 != "" {
@@ -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) })
+2 -2
View File
@@ -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 })
+4 -4
View File
@@ -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)
+8 -7
View File
@@ -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
+5 -5
View File
@@ -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)
}
+11 -21
View File
@@ -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) {
+2 -2
View File
@@ -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))
+74
View File
@@ -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 ""
}
}
@@ -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)
}
}
}
-5
View File
@@ -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)
+17 -12
View File
@@ -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) {
+9 -7
View File
@@ -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)
},
+14 -8
View File
@@ -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")
}