Compare commits
2 Commits
05b49cd86a
...
10ccc005be
| Author | SHA1 | Date |
|---|---|---|
|
|
10ccc005be | |
|
|
083cc3a9ea |
|
|
@ -1,235 +1,147 @@
|
|||
server:
|
||||
http:
|
||||
network: tcp
|
||||
addr: 0.0.0.0:8000
|
||||
timeout: 600s
|
||||
http:
|
||||
network: tcp
|
||||
addr: 0.0.0.0:8000
|
||||
timeout: 600s
|
||||
data:
|
||||
database:
|
||||
driver: mysql
|
||||
source: root:root@tcp(127.0.0.1:3306)/kra?timeout=5s&parseTime=True&loc=Local&charset=utf8mb4
|
||||
host: 127.0.0.1
|
||||
port: "3306"
|
||||
user: root
|
||||
password: "12345678"
|
||||
name: kra
|
||||
config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4
|
||||
path: ""
|
||||
alias_name: ""
|
||||
disable: false
|
||||
prefix: ""
|
||||
engine: InnoDB
|
||||
log_mode: info
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
conn_max_lifetime: 3600
|
||||
singular: false
|
||||
redis:
|
||||
network: tcp
|
||||
name: default
|
||||
addr: 127.0.0.1:6379
|
||||
password: ""
|
||||
db: 0
|
||||
use_cluster: false
|
||||
cluster_addrs: []
|
||||
read_timeout: 0.2s
|
||||
write_timeout: 0.2s
|
||||
# Additional databases use alias_name as the lookup key. Disabled entries
|
||||
# remain available as configuration examples without opening connections.
|
||||
database_list: []
|
||||
# Example:
|
||||
# database_list:
|
||||
# - driver: mysql
|
||||
# source: ""
|
||||
# host: 127.0.0.1
|
||||
# port: "3306"
|
||||
# user: root
|
||||
# password: ""
|
||||
# name: business
|
||||
# config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4
|
||||
# path: ""
|
||||
# alias_name: business
|
||||
# disable: true
|
||||
# prefix: ""
|
||||
# engine: InnoDB
|
||||
# log_mode: info
|
||||
# max_idle_conns: 10
|
||||
# max_open_conns: 100
|
||||
# conn_max_lifetime: 3600
|
||||
# singular: false
|
||||
redis_list: []
|
||||
# Example:
|
||||
# redis_list:
|
||||
# - network: tcp
|
||||
# name: cache
|
||||
# addr: 127.0.0.1:6379
|
||||
# password: ""
|
||||
# db: 0
|
||||
# use_cluster: false
|
||||
# cluster_addrs: []
|
||||
# read_timeout: 0.2s
|
||||
# write_timeout: 0.2s
|
||||
mongo:
|
||||
coll: ""
|
||||
options: ""
|
||||
database: ""
|
||||
username: ""
|
||||
password: ""
|
||||
auth_source: ""
|
||||
min_pool_size: 0
|
||||
max_pool_size: 100
|
||||
socket_timeout_ms: 0
|
||||
connect_timeout_ms: 0
|
||||
is_zap: false
|
||||
hosts:
|
||||
- host: ""
|
||||
port: ""
|
||||
admin:
|
||||
# The administration system router prefix maps to this transport prefix.
|
||||
router_prefix: ""
|
||||
jwt:
|
||||
# Production deployments must override this value with a private secret.
|
||||
signing_key: change-me-before-production
|
||||
expires_time: 604800s
|
||||
buffer_time: 86400s
|
||||
issuer: kra
|
||||
captcha:
|
||||
key_long: 6
|
||||
img_width: 240
|
||||
img_height: 80
|
||||
store_expiration: 180s
|
||||
local:
|
||||
store_path: uploads/file
|
||||
path_prefix: uploads/file
|
||||
media:
|
||||
# Upload chunks are stored below .chunks in the selected storage backend.
|
||||
session_ttl: 24
|
||||
max_file_size: 0
|
||||
chunk_dir: uploads/chunks
|
||||
system:
|
||||
use_redis: false
|
||||
use_multipoint: false
|
||||
use_strict_auth: false
|
||||
disable_auto_migrate: false
|
||||
use_mongo: false
|
||||
addr: 8000
|
||||
iplimit_count: 0
|
||||
iplimit_time: 0
|
||||
zap:
|
||||
level: info
|
||||
prefix: "[kra] "
|
||||
format: json
|
||||
director: logs
|
||||
encode_level: LowercaseLevelEncoder
|
||||
stacktrace_key: stacktrace
|
||||
show_line: true
|
||||
log_in_console: true
|
||||
retention_day: 7
|
||||
access_req_body: true
|
||||
access_resp_data: true
|
||||
access_req_headers: true
|
||||
access_log_max_bytes: 1024
|
||||
file_only_modules: []
|
||||
cors:
|
||||
mode: whitelist
|
||||
whitelist: []
|
||||
database:
|
||||
driver: mysql
|
||||
source: root:Xu950329.@tcp(127.0.0.1:3306)/kra?charset=utf8mb4&parseTime=True&loc=Local
|
||||
host: 127.0.0.1
|
||||
port: "3306"
|
||||
user: root
|
||||
password: Xu950329.
|
||||
name: kra
|
||||
config: charset=utf8mb4&parseTime=True&loc=Local
|
||||
path: ""
|
||||
alias_name: ""
|
||||
disable: false
|
||||
prefix: ""
|
||||
engine: InnoDB
|
||||
log_mode: info
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
conn_max_lifetime: 3600
|
||||
singular: false
|
||||
redis:
|
||||
network: tcp
|
||||
name: default
|
||||
addr: 127.0.0.1:6379
|
||||
password: ""
|
||||
db: 0
|
||||
use_cluster: false
|
||||
cluster_addrs: []
|
||||
read_timeout: 0.200s
|
||||
write_timeout: 0.200s
|
||||
# Additional databases use alias_name as the lookup key. Disabled entries
|
||||
# remain available as configuration examples without opening connections.
|
||||
database_list: []
|
||||
# Example:
|
||||
# whitelist:
|
||||
# - allow_origin: https://admin.example.com
|
||||
# allow_headers: Content-Type,Authorization,X-Token,X-User-Id
|
||||
# allow_methods: POST,GET,PUT,DELETE,OPTIONS
|
||||
# expose_headers: Content-Length,Content-Type
|
||||
# allow_credentials: true
|
||||
app:
|
||||
node: ""
|
||||
app_id: kra
|
||||
env: development
|
||||
disk_list:
|
||||
- mount_point: /
|
||||
storage:
|
||||
# local, qiniu, aliyun-oss, huawei-obs, tencent-cos, aws-s3,
|
||||
# cloudflare-r2 or minio
|
||||
type: local
|
||||
qiniu:
|
||||
zone: ZoneHuadong
|
||||
bucket: ""
|
||||
base_url: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
use_https: false
|
||||
use_cdn_domains: false
|
||||
aliyun_oss:
|
||||
endpoint: ""
|
||||
region: ""
|
||||
bucket: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
base_url: ""
|
||||
path_prefix: ""
|
||||
use_ssl: true
|
||||
force_path_style: false
|
||||
account_id: ""
|
||||
huawei_obs:
|
||||
endpoint: ""
|
||||
region: ""
|
||||
bucket: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
base_url: ""
|
||||
path_prefix: ""
|
||||
use_ssl: true
|
||||
force_path_style: false
|
||||
account_id: ""
|
||||
tencent_cos:
|
||||
endpoint: ""
|
||||
region: ""
|
||||
bucket: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
base_url: ""
|
||||
path_prefix: ""
|
||||
use_ssl: true
|
||||
force_path_style: false
|
||||
account_id: ""
|
||||
aws_s3:
|
||||
endpoint: ""
|
||||
region: ""
|
||||
bucket: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
base_url: ""
|
||||
path_prefix: ""
|
||||
use_ssl: true
|
||||
force_path_style: false
|
||||
account_id: ""
|
||||
cloudflare_r2:
|
||||
endpoint: ""
|
||||
region: auto
|
||||
bucket: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
base_url: ""
|
||||
path_prefix: uploads
|
||||
use_ssl: true
|
||||
force_path_style: false
|
||||
account_id: ""
|
||||
minio:
|
||||
endpoint: ""
|
||||
region: ""
|
||||
bucket: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
base_url: ""
|
||||
path_prefix: ""
|
||||
use_ssl: false
|
||||
force_path_style: true
|
||||
account_id: ""
|
||||
email:
|
||||
# Leave host/from/secret empty to disable SMTP error notifications.
|
||||
to: ""
|
||||
from: ""
|
||||
host: ""
|
||||
secret: ""
|
||||
nickname: ""
|
||||
port: 465
|
||||
is_ssl: true
|
||||
is_login_auth: false
|
||||
# database_list:
|
||||
# - driver: mysql
|
||||
# source: ""
|
||||
# host: 127.0.0.1
|
||||
# port: "3306"
|
||||
# user: root
|
||||
# password: ""
|
||||
# name: business
|
||||
# config: timeout=5s&parseTime=True&loc=Local&charset=utf8mb4
|
||||
# path: ""
|
||||
# alias_name: business
|
||||
# disable: true
|
||||
# prefix: ""
|
||||
# engine: InnoDB
|
||||
# log_mode: info
|
||||
# max_idle_conns: 10
|
||||
# max_open_conns: 100
|
||||
# conn_max_lifetime: 3600
|
||||
# singular: false
|
||||
redis_list: []
|
||||
# Example:
|
||||
# redis_list:
|
||||
# - network: tcp
|
||||
# name: cache
|
||||
# addr: 127.0.0.1:6379
|
||||
# password: ""
|
||||
# db: 0
|
||||
# use_cluster: false
|
||||
# cluster_addrs: []
|
||||
# read_timeout: 0.2s
|
||||
# write_timeout: 0.2s
|
||||
mongo:
|
||||
coll: ""
|
||||
options: ""
|
||||
database: ""
|
||||
username: ""
|
||||
password: ""
|
||||
auth_source: ""
|
||||
min_pool_size: "0"
|
||||
max_pool_size: "100"
|
||||
socket_timeout_ms: "0"
|
||||
connect_timeout_ms: "0"
|
||||
is_zap: false
|
||||
hosts:
|
||||
- host: ""
|
||||
port: ""
|
||||
admin:
|
||||
# The administration system router prefix maps to this transport prefix.
|
||||
router_prefix: ""
|
||||
jwt:
|
||||
# Production deployments must override this value with a private secret.
|
||||
signing_key: 86a6eb31-46b3-4da9-ae79-85aeff3e699d
|
||||
expires_time: 604800s
|
||||
buffer_time: 86400s
|
||||
issuer: kra
|
||||
captcha:
|
||||
key_long: 6
|
||||
img_width: 240
|
||||
img_height: 80
|
||||
store_expiration: 180s
|
||||
local:
|
||||
store_path: uploads/file
|
||||
path_prefix: uploads/file
|
||||
media:
|
||||
# Upload chunks are stored below .chunks in the selected storage backend.
|
||||
session_ttl: 24
|
||||
max_file_size: "0"
|
||||
chunk_dir: uploads/chunks
|
||||
system:
|
||||
use_redis: false
|
||||
use_multipoint: false
|
||||
use_strict_auth: false
|
||||
disable_auto_migrate: false
|
||||
use_mongo: false
|
||||
addr: 8000
|
||||
iplimit_count: 0
|
||||
iplimit_time: 0
|
||||
zap:
|
||||
level: info
|
||||
prefix: '[kra] '
|
||||
format: json
|
||||
director: logs
|
||||
encode_level: LowercaseLevelEncoder
|
||||
stacktrace_key: stacktrace
|
||||
show_line: true
|
||||
log_in_console: true
|
||||
retention_day: 7
|
||||
access_req_body: true
|
||||
access_resp_data: true
|
||||
access_req_headers: true
|
||||
access_log_max_bytes: 1024
|
||||
file_only_modules: []
|
||||
cors:
|
||||
mode: whitelist
|
||||
whitelist: []
|
||||
# Example:
|
||||
# whitelist:
|
||||
# - allow_origin: https://admin.example.com
|
||||
# allow_headers: Content-Type,Authorization,X-Token,X-User-Id
|
||||
# allow_methods: POST,GET,PUT,DELETE,OPTIONS
|
||||
# expose_headers: Content-Length,Content-Type
|
||||
# allow_credentials: true
|
||||
app:
|
||||
node: ""
|
||||
app_id: kra
|
||||
env: development
|
||||
disk_list:
|
||||
- mount_point: /
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type APIRepo interface {
|
|||
ListAPIs(context.Context, int, int, *API) ([]*API, int64, error)
|
||||
APIRoleIDs(context.Context, string, string) ([]uint, error)
|
||||
SetAPIRoles(context.Context, string, string, []uint) error
|
||||
CheckPolicyStore(context.Context) error
|
||||
Authorize(context.Context, uint, string, string) (bool, error)
|
||||
PolicyPaths(context.Context, uint) ([]*API, error)
|
||||
SetPolicyPaths(context.Context, uint, []*API) error
|
||||
|
|
@ -38,6 +39,10 @@ type APIUsecase struct{ APIRepo }
|
|||
|
||||
func NewAPIUsecase(repo APIRepo) *APIUsecase { return &APIUsecase{APIRepo: repo} }
|
||||
|
||||
func (uc *APIUsecase) FreshCasbin(ctx context.Context) error {
|
||||
return uc.CheckPolicyStore(ctx)
|
||||
}
|
||||
|
||||
// DeleteAPI preserves the single-delete contract used by the legacy admin:
|
||||
// the target is looked up first, so deleting a missing API returns the
|
||||
// repository's not-found error instead of silently succeeding on an empty
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ func (r *apiRepo) SetAPIRoles(ctx context.Context, path, method string, ids []ui
|
|||
return tx.Create(&rules).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *apiRepo) CheckPolicyStore(ctx context.Context) error {
|
||||
var count int64
|
||||
return r.data.gormDB.WithContext(ctx).Model(&casbinRulePO{}).Count(&count).Error
|
||||
}
|
||||
|
||||
func (r *apiRepo) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) {
|
||||
rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -112,3 +112,17 @@ func TestSetPolicyPathsUsesCompatibleDedupeKey(t *testing.T) {
|
|||
t.Fatalf("deduplicated policies = %#v, want only the first concatenated-key match", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPolicyStore(t *testing.T) {
|
||||
data := newPolicyTestData(t)
|
||||
repo := &apiRepo{data: data}
|
||||
if err := repo.CheckPolicyStore(context.Background()); err != nil {
|
||||
t.Fatalf("CheckPolicyStore returned error for migrated table: %v", err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(context.Background()).Migrator().DropTable(&casbinRulePO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.CheckPolicyStore(context.Background()); err == nil {
|
||||
t.Fatal("CheckPolicyStore succeeded after casbin_rule was dropped")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,11 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
|
|||
admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir}
|
||||
}
|
||||
if adminConfig.Email != nil {
|
||||
email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": "******", "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth}
|
||||
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)
|
||||
|
|
@ -97,7 +101,7 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
|
|||
if safeAdmin.Jwt != nil {
|
||||
safeAdmin.Jwt.SigningKey = "******"
|
||||
}
|
||||
if safeAdmin.Email != nil {
|
||||
if safeAdmin.Email != nil && safeAdmin.Email.Secret != "" {
|
||||
safeAdmin.Email.Secret = "******"
|
||||
}
|
||||
maskStorageSecrets(safeAdmin.Storage)
|
||||
|
|
@ -432,11 +436,11 @@ func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
|
|||
if storage == nil {
|
||||
return
|
||||
}
|
||||
if storage.Qiniu != nil {
|
||||
if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" {
|
||||
storage.Qiniu.SecretKey = "******"
|
||||
}
|
||||
for _, item := range objectStores(storage) {
|
||||
if item != nil {
|
||||
if item != nil && item.SecretKey != "" {
|
||||
item.SecretKey = "******"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,30 @@ func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node {
|
|||
return node
|
||||
}
|
||||
|
||||
func deleteYAMLMapping(node *yaml.Node, keys ...string) {
|
||||
if len(keys) == 0 || node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
node = node.Content[0]
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
key := keys[0]
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value != key {
|
||||
continue
|
||||
}
|
||||
if len(keys) == 1 {
|
||||
node.Content = append(node.Content[:i], node.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
deleteYAMLMapping(node.Content[i+1], keys[1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setServerHTTPPort(document *yaml.Node, port int32) error {
|
||||
if port <= 0 {
|
||||
return nil
|
||||
|
|
@ -154,7 +178,10 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminValue, err := protoMap(adminConfig)
|
||||
fileAdmin := cloneAdminConfig(adminConfig)
|
||||
fileAdmin.Storage = nil
|
||||
fileAdmin.Email = nil
|
||||
adminValue, err := protoMap(fileAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -164,6 +191,8 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
|||
if err = setYAMLMapping(&document, "admin", adminValue); err != nil {
|
||||
return err
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
if adminConfig.System != nil {
|
||||
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
|
||||
return err
|
||||
|
|
@ -221,6 +250,31 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st
|
|||
return err
|
||||
}
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
func (d *Data) removeIntegrationConfigFromFile() error {
|
||||
d.configMu.Lock()
|
||||
defer d.configMu.Unlock()
|
||||
configPath := d.runtime.ConfigPath()
|
||||
if configPath == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document yaml.Node
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil {
|
||||
return nil
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +354,27 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
return fmt.Errorf("reload database migrations: %w", err)
|
||||
}
|
||||
}
|
||||
legacyStorage := next.Admin.Storage
|
||||
legacyEmail := next.Admin.Email
|
||||
currentAdmin := d.runtime.Admin()
|
||||
if legacyStorage == nil {
|
||||
if currentAdmin != nil {
|
||||
legacyStorage = currentAdmin.Storage
|
||||
}
|
||||
}
|
||||
if legacyEmail == nil && currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage configuration: %w", err)
|
||||
}
|
||||
next.Admin.Storage = storageConfig
|
||||
emailConfig, err := resolveEmailIntegrationConfig(candidateDB.WithContext(ctx), legacyEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload email configuration: %w", err)
|
||||
}
|
||||
next.Admin.Email = emailConfig
|
||||
candidateStorage, err := buildFileStorage(next.Admin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage: %w", err)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ func (d *Data) watchConfig() func() {
|
|||
logger.Error("reload changed config: data and admin configuration are required", "mod", "system")
|
||||
return
|
||||
}
|
||||
if current := d.runtime.Admin(); current != nil {
|
||||
next.Admin.Storage = current.Storage
|
||||
next.Admin.Email = current.Email
|
||||
}
|
||||
next.Admin.ConfigPath = absolute
|
||||
d.runtime.Replace(next.Data, next.Admin)
|
||||
logger.Info("config file changed", "mod", "system", "path", absolute)
|
||||
|
|
|
|||
|
|
@ -133,12 +133,33 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
|
|||
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
||||
}
|
||||
admin := runtime.Admin()
|
||||
disableAutoMigrate := admin != nil && admin.System != nil && admin.System.DisableAutoMigrate
|
||||
if admin == nil {
|
||||
admin = &conf.AdminBackend{}
|
||||
}
|
||||
disableAutoMigrate := admin.System != nil && admin.System.DisableAutoMigrate
|
||||
if !usingFallback && !disableAutoMigrate {
|
||||
if err = migrateAll(db); err != nil {
|
||||
return nil, nil, fmt.Errorf("migrate tables: %w", err)
|
||||
}
|
||||
}
|
||||
if !usingFallback {
|
||||
storageConfig, storageErr := resolveStorageIntegrationConfig(db, admin.Storage)
|
||||
if storageErr != nil {
|
||||
return nil, nil, fmt.Errorf("load storage integration configuration: %w", storageErr)
|
||||
}
|
||||
emailConfig, emailErr := resolveEmailIntegrationConfig(db, admin.Email)
|
||||
if emailErr != nil {
|
||||
return nil, nil, fmt.Errorf("load email integration configuration: %w", emailErr)
|
||||
}
|
||||
admin.Storage = storageConfig
|
||||
admin.Email = emailConfig
|
||||
runtime.Replace(c, admin)
|
||||
if db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if removeErr := d.removeIntegrationConfigFromFile(); removeErr != nil {
|
||||
appLogger.Warn("remove legacy integration configuration from file", "mod", "integration", "error", removeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
|
||||
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger))
|
||||
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
integrationKindStorage = "storage"
|
||||
integrationKindEmail = "email"
|
||||
integrationKindPayment = "payment"
|
||||
)
|
||||
|
||||
// integrationConfigPO stores credentials and provider-specific options for
|
||||
// external services. Payment integrations use the same table with kind
|
||||
// "payment", keeping secrets out of the bootstrap configuration file.
|
||||
type integrationConfigPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"`
|
||||
Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"`
|
||||
Enabled bool `gorm:"not null;default:false;index"`
|
||||
Config string `gorm:"type:text;not null"`
|
||||
}
|
||||
|
||||
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
|
||||
|
||||
var storageProviderNames = []string{
|
||||
"local",
|
||||
"qiniu",
|
||||
"aliyun-oss",
|
||||
"huawei-obs",
|
||||
"tencent-cos",
|
||||
"aws-s3",
|
||||
"cloudflare-r2",
|
||||
"minio",
|
||||
}
|
||||
|
||||
func normalizeStorageType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return "local"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func storageProviderMessage(storage *conf.AdminBackend_Storage, provider string) proto.Message {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
switch provider {
|
||||
case "qiniu":
|
||||
if storage.Qiniu == nil {
|
||||
storage.Qiniu = &conf.AdminBackend_Qiniu{}
|
||||
}
|
||||
return storage.Qiniu
|
||||
case "aliyun-oss":
|
||||
return ensureObjectStore(&storage.AliyunOss)
|
||||
case "huawei-obs":
|
||||
return ensureObjectStore(&storage.HuaweiObs)
|
||||
case "tencent-cos":
|
||||
return ensureObjectStore(&storage.TencentCos)
|
||||
case "aws-s3":
|
||||
return ensureObjectStore(&storage.AwsS3)
|
||||
case "cloudflare-r2":
|
||||
return ensureObjectStore(&storage.CloudflareR2)
|
||||
case "minio":
|
||||
return ensureObjectStore(&storage.Minio)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ensureObjectStore(value **conf.AdminBackend_ObjectStore) proto.Message {
|
||||
if *value == nil {
|
||||
*value = &conf.AdminBackend_ObjectStore{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func marshalStorageProvider(storage *conf.AdminBackend_Storage, provider string) (string, error) {
|
||||
message := storageProviderMessage(storage, provider)
|
||||
if message == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func unmarshalStorageProvider(storage *conf.AdminBackend_Storage, provider, value string) error {
|
||||
if provider == "local" || strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
message := storageProviderMessage(storage, provider)
|
||||
if message == 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 {
|
||||
return fmt.Errorf("decode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storage) error {
|
||||
if storage == nil {
|
||||
storage = &conf.AdminBackend_Storage{}
|
||||
}
|
||||
active := normalizeStorageType(storage.Type)
|
||||
known := false
|
||||
for _, provider := range storageProviderNames {
|
||||
if provider == active {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return fmt.Errorf("unsupported storage type %q", active)
|
||||
}
|
||||
|
||||
return db.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error {
|
||||
for _, provider := range storageProviderNames {
|
||||
value, err := marshalStorageProvider(storage, provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s integration configuration: %w", provider, err)
|
||||
}
|
||||
var current integrationConfigPO
|
||||
err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
current = integrationConfigPO{Kind: integrationKindStorage, Provider: provider}
|
||||
current.Enabled, current.Config = provider == active, value
|
||||
if err = tx.Create(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if err = tx.Model(¤t).Updates(map[string]any{"enabled": provider == active, "config": value}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool, error) {
|
||||
var rows []integrationConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ?", integrationKindStorage).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
storage := &conf.AdminBackend_Storage{Type: "local"}
|
||||
for _, row := range rows {
|
||||
if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if row.Enabled {
|
||||
storage.Type = row.Provider
|
||||
}
|
||||
}
|
||||
return storage, true, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return &conf.AdminBackend_Storage{Type: "local"}, nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_Storage), nil
|
||||
}
|
||||
storage, found, err := loadStorageIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return storage, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = &conf.AdminBackend_Storage{Type: "local"}
|
||||
}
|
||||
if err = saveStorageIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage, _, err = loadStorageIntegrationConfig(clean)
|
||||
return storage, err
|
||||
}
|
||||
|
||||
func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *conf.AdminBackend_Storage) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveStorageIntegrationConfig(db, storage)
|
||||
}
|
||||
|
||||
func defaultEmailIntegrationConfig() *conf.AdminBackend_Email {
|
||||
return &conf.AdminBackend_Email{Port: 465, IsSsl: true}
|
||||
}
|
||||
|
||||
func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) error {
|
||||
if email == nil {
|
||||
email = defaultEmailIntegrationConfig()
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode smtp integration configuration: %w", err)
|
||||
}
|
||||
enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
var current integrationConfigPO
|
||||
err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return clean.Create(&integrationConfigPO{
|
||||
Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw),
|
||||
}).Error
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
return clean.Model(¤t).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error
|
||||
}
|
||||
}
|
||||
|
||||
func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, error) {
|
||||
var row integrationConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(row.Config)) {
|
||||
return nil, false, errors.New("invalid smtp integration configuration")
|
||||
}
|
||||
email := defaultEmailIntegrationConfig()
|
||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).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) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return defaultEmailIntegrationConfig(), nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_Email), nil
|
||||
}
|
||||
email, found, err := loadEmailIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return email, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = defaultEmailIntegrationConfig()
|
||||
}
|
||||
if err = saveEmailIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email, _, err = loadEmailIntegrationConfig(clean)
|
||||
return email, err
|
||||
}
|
||||
|
||||
func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *conf.AdminBackend_Email) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveEmailIntegrationConfig(db, email)
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err = db.AutoMigrate(&integrationConfigPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrateAllCreatesIntegrationConfigTable(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err = migrateAll(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
t.Fatal("migrateAll did not create sys_integration_configs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
storage := &conf.AdminBackend_Storage{
|
||||
Type: "aliyun-oss",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{
|
||||
Zone: "ZoneHuadong", Bucket: "qiniu-bucket", AccessKey: "qiniu-key", SecretKey: "qiniu-secret",
|
||||
},
|
||||
AliyunOss: &conf.AdminBackend_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},
|
||||
}
|
||||
if err := saveStorageIntegrationConfig(db, storage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := &conf.AdminBackend_Email{
|
||||
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
|
||||
Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSsl: true,
|
||||
}
|
||||
if err := saveEmailIntegrationConfig(db, email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, found, err := loadStorageIntegrationConfig(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("storage integration configuration was not found")
|
||||
}
|
||||
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.Qiniu == nil || loaded.Qiniu.SecretKey != "qiniu-secret" {
|
||||
t.Fatalf("qiniu configuration = %#v", loaded.Qiniu)
|
||||
}
|
||||
|
||||
loadedEmail, found, err := loadEmailIntegrationConfig(db)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("email configuration found=%v, err=%v", found, err)
|
||||
}
|
||||
if loadedEmail.Host != "smtp.example.com" || loadedEmail.Secret != "smtp-secret" || loadedEmail.Port != 465 {
|
||||
t.Fatalf("email configuration = %#v", loadedEmail)
|
||||
}
|
||||
|
||||
var storageCount, emailCount, paymentCount int64
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&paymentCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storageCount != int64(len(storageProviderNames)) {
|
||||
t.Fatalf("storage row count = %d, want %d", storageCount, len(storageProviderNames))
|
||||
}
|
||||
if paymentCount != 1 {
|
||||
t.Fatalf("payment row count = %d, want 1", paymentCount)
|
||||
}
|
||||
if emailCount != 1 {
|
||||
t.Fatalf("email row count = %d, want 1", emailCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_Storage{
|
||||
Type: "qiniu",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"},
|
||||
}
|
||||
loaded, err := resolveStorageIntegrationConfig(db, legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Type != "qiniu" || loaded.Qiniu.GetBucket() != "legacy" {
|
||||
t.Fatalf("migrated storage = %#v", loaded)
|
||||
}
|
||||
|
||||
other := &conf.AdminBackend_Storage{
|
||||
Type: "minio",
|
||||
Minio: &conf.AdminBackend_ObjectStore{Bucket: "must-not-replace-database"},
|
||||
}
|
||||
loaded, err = resolveStorageIntegrationConfig(db, other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Type != "qiniu" || loaded.Qiniu.GetSecretKey() != "legacy-secret" {
|
||||
t.Fatalf("database configuration was replaced by legacy config: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) {
|
||||
storage := &conf.AdminBackend_Storage{
|
||||
Qiniu: &conf.AdminBackend_Qiniu{},
|
||||
AliyunOss: &conf.AdminBackend_ObjectStore{SecretKey: "configured-secret"},
|
||||
Minio: &conf.AdminBackend_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)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
loaded, err := resolveEmailIntegrationConfig(db, legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret {
|
||||
t.Fatalf("database email was replaced by legacy config: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n extension_key: retained\n")
|
||||
if err := os.WriteFile(path, input, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &Data{}
|
||||
admin := &conf.AdminBackend{
|
||||
ConfigPath: path,
|
||||
RouterPrefix: "/api",
|
||||
Storage: &conf.AdminBackend_Storage{
|
||||
Type: "qiniu",
|
||||
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
|
||||
},
|
||||
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
|
||||
}
|
||||
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document map[string]any
|
||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminValue, ok := document["admin"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("admin config = %#v", document["admin"])
|
||||
}
|
||||
if _, exists := adminValue["storage"]; exists {
|
||||
t.Fatalf("storage remained in YAML: %s", raw)
|
||||
}
|
||||
if _, exists := adminValue["email"]; exists {
|
||||
t.Fatalf("email remained in YAML: %s", raw)
|
||||
}
|
||||
if adminValue["extension_key"] != "retained" {
|
||||
t.Fatalf("extension key was not retained: %#v", adminValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("data: {}\nadmin: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldRoot := filepath.Join(t.TempDir(), "old")
|
||||
newRoot := filepath.Join(t.TempDir(), "new")
|
||||
currentAdmin := &conf.AdminBackend{
|
||||
ConfigPath: configPath,
|
||||
Local: &conf.AdminBackend_Local{StorePath: oldRoot, PathPrefix: "old-files"},
|
||||
Storage: &conf.AdminBackend_Storage{Type: "local"},
|
||||
}
|
||||
currentStorage, err := buildFileStorage(currentAdmin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadableDB := &reloadableDB{}
|
||||
reloadableDB.current.Store(db)
|
||||
d := &Data{
|
||||
runtime: conf.NewRuntime(&conf.Data{}, currentAdmin),
|
||||
gormDB: reloadableDB,
|
||||
storage: &reloadableStorage{current: currentStorage},
|
||||
}
|
||||
d.databaseReady.Store(true)
|
||||
|
||||
nextAdmin := cloneAdminConfig(currentAdmin)
|
||||
nextAdmin.Local = &conf.AdminBackend_Local{StorePath: newRoot, PathPrefix: "new-files"}
|
||||
nextAdmin.Email = &conf.AdminBackend_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{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminRaw, err := protojson.Marshal(nextAdmin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &initializationRepo{data: d}
|
||||
if err = repo.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, err := d.storage.Put(context.Background(), "active.txt", strings.NewReader("active"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.URL != "/new-files/active.txt" {
|
||||
t.Fatalf("active storage URL = %q, want /new-files/active.txt", stored.URL)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(newRoot, "active.txt")); err != nil {
|
||||
t.Fatalf("active storage did not write to the new root: %v", err)
|
||||
}
|
||||
loaded, found, err := loadStorageIntegrationConfig(db)
|
||||
if err != nil || !found || loaded.Type != "local" {
|
||||
t.Fatalf("database storage config = %#v, found=%v, err=%v", loaded, found, err)
|
||||
}
|
||||
loadedEmail, found, err := loadEmailIntegrationConfig(db)
|
||||
if err != nil || !found || loadedEmail.Secret != "runtime-secret" {
|
||||
t.Fatalf("database email config = %#v, found=%v, err=%v", loadedEmail, found, err)
|
||||
}
|
||||
if runtimeEmail := d.runtime.Admin().Email; runtimeEmail == nil || runtimeEmail.Host != "smtp.example.com" {
|
||||
t.Fatalf("runtime email config = %#v", runtimeEmail)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ func migrateAll(db *gorm.DB) error {
|
|||
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
|
||||
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
|
||||
&dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &securityConfigPO{},
|
||||
&integrationConfigPO{},
|
||||
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
|
||||
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
|
||||
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
|
||||
|
|
|
|||
|
|
@ -18,22 +18,41 @@ import (
|
|||
)
|
||||
|
||||
func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() }
|
||||
func (r *initializationRepo) PersistAdminConfig(_ context.Context, raw []byte) error {
|
||||
func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
next := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); 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 := buildFileStorage(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistStorageIntegrationConfig(ctx, next.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistConfigValues(currentData, next); 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.
|
||||
r.data.runtime.Replace(currentData, next)
|
||||
if r.data.storage != nil {
|
||||
r.data.storage.replace(candidateStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *initializationRepo) PersistRuntimeConfig(_ context.Context, dataRaw, adminRaw []byte) error {
|
||||
func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error {
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
nextData := proto.Clone(currentData).(*conf.Data)
|
||||
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend)
|
||||
|
|
@ -44,11 +63,30 @@ func (r *initializationRepo) PersistRuntimeConfig(_ context.Context, dataRaw, ad
|
|||
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 := buildFileStorage(nextAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
r.data.runtime.Replace(nextData, nextAdmin)
|
||||
if r.data.storage != nil {
|
||||
r.data.storage.replace(candidateStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *initializationRepo) ReloadConfig(ctx context.Context) error {
|
||||
|
|
@ -198,7 +236,11 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
|||
return err
|
||||
}
|
||||
}
|
||||
ignoredAPIs := defaultIgnoredAPIs()
|
||||
staticPath := "uploads/file"
|
||||
if admin := r.data.runtime.Admin(); admin != nil && admin.Local != nil && strings.Trim(admin.Local.PathPrefix, "/") != "" {
|
||||
staticPath = strings.Trim(admin.Local.PathPrefix, "/")
|
||||
}
|
||||
ignoredAPIs := defaultIgnoredAPIs(staticPath)
|
||||
for _, ignored := range ignoredAPIs {
|
||||
if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil {
|
||||
return err
|
||||
|
|
@ -232,6 +274,23 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
|||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
currentAdmin := r.data.runtime.Admin()
|
||||
var legacyStorage *conf.AdminBackend_Storage
|
||||
if currentAdmin != nil {
|
||||
legacyStorage = currentAdmin.Storage
|
||||
}
|
||||
storageConfig, err := resolveStorageIntegrationConfig(candidate.WithContext(ctx), legacyStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize storage integration configuration: %w", err)
|
||||
}
|
||||
var legacyEmail *conf.AdminBackend_Email
|
||||
if currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
emailConfig, err := resolveEmailIntegrationConfig(candidate.WithContext(ctx), legacyEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize email integration configuration: %w", err)
|
||||
}
|
||||
signingKey := uuid.NewString()
|
||||
if err := r.data.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
return fmt.Errorf("persist database configuration: %w", err)
|
||||
|
|
@ -245,15 +304,19 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
|||
currentAdmin.Jwt = &conf.AdminBackend_JWT{}
|
||||
}
|
||||
currentAdmin.Jwt.SigningKey = signingKey
|
||||
currentAdmin.Storage = storageConfig
|
||||
currentAdmin.Email = emailConfig
|
||||
r.data.runtime.Replace(currentData, currentAdmin)
|
||||
activated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultIgnoredAPIs() []ignoredAPIPO {
|
||||
func defaultIgnoredAPIs(staticPath string) []ignoredAPIPO {
|
||||
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
|
||||
return []ignoredAPIPO{
|
||||
{Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"},
|
||||
{Method: "GET", Path: "/swagger/*any"},
|
||||
{Method: "GET", Path: staticRoute}, {Method: "HEAD", Path: staticRoute},
|
||||
{Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"},
|
||||
{Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"},
|
||||
{Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"},
|
||||
|
|
|
|||
|
|
@ -3,10 +3,20 @@ package data
|
|||
import "testing"
|
||||
|
||||
func TestDefaultIgnoredAPIsIncludeSwagger(t *testing.T) {
|
||||
for _, api := range defaultIgnoredAPIs() {
|
||||
if api.Method == "GET" && api.Path == "/swagger/*any" {
|
||||
return
|
||||
wants := map[string]bool{
|
||||
"GET /swagger/*any": false,
|
||||
"GET /uploads/file/*filepath": false,
|
||||
"HEAD /uploads/file/*filepath": false,
|
||||
}
|
||||
for _, api := range defaultIgnoredAPIs("uploads/file") {
|
||||
key := api.Method + " " + api.Path
|
||||
if _, ok := wants[key]; ok {
|
||||
wants[key] = true
|
||||
}
|
||||
}
|
||||
for key, found := range wants {
|
||||
if !found {
|
||||
t.Fatalf("default ignored APIs do not include %s", key)
|
||||
}
|
||||
}
|
||||
t.Fatal("default ignored APIs do not include the Swagger handler")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
|
|||
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
|
||||
serverrouter.RegisterEmail(private, handlers.Email)
|
||||
registerSwagger(engine, prefix, version, logger)
|
||||
registerLocalStorage(engine, runtime)
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if serveLocalStorage(c, runtime) {
|
||||
|
|
@ -117,6 +118,50 @@ func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server {
|
|||
// serveLocalStorage resolves the local path for every request so a config
|
||||
// reload takes effect without rebuilding the Gin engine.
|
||||
func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool {
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Local == nil || config.Local.StorePath == "" {
|
||||
return false
|
||||
}
|
||||
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
|
||||
return serveLocalStorageAt(c, runtime, prefix)
|
||||
}
|
||||
|
||||
func registerLocalStorage(engine *gin.Engine, runtime *conf.Runtime) {
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Local == nil || config.Local.StorePath == "" || strings.Trim(config.Local.PathPrefix, "/") == "" {
|
||||
return
|
||||
}
|
||||
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
|
||||
return
|
||||
}
|
||||
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
|
||||
if localStorageRouteConflicts(engine.Routes(), prefix) {
|
||||
return
|
||||
}
|
||||
handler := func(c *gin.Context) {
|
||||
if !serveLocalStorageAt(c, runtime, prefix) {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
engine.GET(prefix+"/*filepath", handler)
|
||||
engine.HEAD(prefix+"/*filepath", handler)
|
||||
}
|
||||
|
||||
func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool {
|
||||
staticRoot := strings.Split(strings.TrimPrefix(prefix, "/"), "/")[0]
|
||||
for _, route := range routes {
|
||||
if route.Method != http.MethodGet && route.Method != http.MethodHead {
|
||||
continue
|
||||
}
|
||||
routeRoot := strings.Split(strings.TrimPrefix(route.Path, "/"), "/")[0]
|
||||
if routeRoot == staticRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func serveLocalStorageAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool {
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Local == nil || config.Local.StorePath == "" {
|
||||
return false
|
||||
|
|
@ -124,7 +169,9 @@ func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool {
|
|||
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
|
||||
return false
|
||||
}
|
||||
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
|
||||
if currentPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/"); currentPrefix != prefix {
|
||||
return false
|
||||
}
|
||||
if prefix == "/" || (c.Request.URL.Path != prefix && !strings.HasPrefix(c.Request.URL.Path, prefix+"/")) {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,15 @@ 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")
|
||||
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":"/"`) {
|
||||
t.Fatalf("swagger basePath mismatch: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageResponseHeaders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, body := range map[string]string{"script.html": "<script>alert(1)</script>", "image.png": "png"} {
|
||||
|
|
@ -120,6 +129,66 @@ 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"}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
seen := map[string]bool{}
|
||||
for _, route := range engine.Routes() {
|
||||
seen[route.Method+" "+route.Path] = true
|
||||
}
|
||||
for _, expected := range []string{"GET /uploads/file/*filepath", "HEAD /uploads/file/*filepath"} {
|
||||
if !seen[expected] {
|
||||
t.Fatalf("missing registered static route %s", expected)
|
||||
}
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil))
|
||||
if strings.Contains(response.Body.String(), "/uploads/file/") {
|
||||
t.Fatal("swagger document should not expose static file wildcard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
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"}})
|
||||
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"}})
|
||||
|
||||
oldResponse := httptest.NewRecorder()
|
||||
engine.ServeHTTP(oldResponse, httptest.NewRequest(http.MethodGet, "/uploads/file/image.png", nil))
|
||||
if oldResponse.Code != http.StatusNotFound {
|
||||
t.Fatalf("old static prefix status = %d, want %d", oldResponse.Code, http.StatusNotFound)
|
||||
}
|
||||
newResponse := httptest.NewRecorder()
|
||||
engine.ServeHTTP(newResponse, httptest.NewRequest(http.MethodGet, "/files/image.png", nil))
|
||||
if newResponse.Code != http.StatusOK {
|
||||
t.Fatalf("new static prefix status = %d, want %d", newResponse.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
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"}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/api/*filepath" {
|
||||
t.Fatal("conflicting static wildcard must not be registered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"}})
|
||||
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/") {
|
||||
t.Fatalf("remote storage registered local route %s %s", route.Method, route.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedGinRouteContract = `DELETE /api/deleteApisByIds
|
||||
DELETE /dataAccessLog/deleteDataAccessLogByIds
|
||||
DELETE /department/deleteDepartment
|
||||
|
|
|
|||
|
|
@ -199,9 +199,10 @@ func (h *API) ApplySync(c *gin.Context) {
|
|||
httpx.OK(c)
|
||||
}
|
||||
func (h *API) FreshCasbin(c *gin.Context) {
|
||||
// Policies are read from casbin_rule on every authorization decision, so
|
||||
// there is no in-memory enforcer cache to reload. Keep the compatible
|
||||
// endpoint and success response.
|
||||
if err := h.service.FreshCasbin(c.Request.Context()); err != nil {
|
||||
httpx.Fail(c, "刷新失败")
|
||||
return
|
||||
}
|
||||
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "刷新成功")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,15 +22,26 @@ func NewExport(service *service.ExportService) *Export {
|
|||
return &Export{service: service}
|
||||
}
|
||||
|
||||
func exportParams(values url.Values) map[string]string {
|
||||
func exportParams(values url.Values) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
nested, _ := url.ParseQuery(values.Get("params"))
|
||||
nested, err := url.ParseQuery(values.Get("params"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, items := range nested {
|
||||
if len(items) > 0 {
|
||||
out[key] = items[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func exportIssueParams(values url.Values, blank bool) (map[string]string, error) {
|
||||
params, err := exportParams(values)
|
||||
if err != nil && blank {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return params, err
|
||||
}
|
||||
|
||||
func (h *Export) Create(c *gin.Context) {
|
||||
|
|
@ -133,7 +144,8 @@ func (h *Export) Preview(c *gin.Context) {
|
|||
httpx.Fail(c, "模板ID不能为空")
|
||||
return
|
||||
}
|
||||
sql, err := h.service.Preview(c.Request.Context(), templateID, exportParams(c.Request.URL.Query()))
|
||||
params, _ := exportParams(c.Request.URL.Query())
|
||||
sql, err := h.service.Preview(c.Request.Context(), templateID, params)
|
||||
if err != nil {
|
||||
httpx.Fail(c, "获取失败")
|
||||
return
|
||||
|
|
@ -148,7 +160,12 @@ func (h *Export) Issue(blank bool) gin.HandlerFunc {
|
|||
httpx.Fail(c, "模板ID不能为空")
|
||||
return
|
||||
}
|
||||
token, err := h.service.IssueToken(c.Request.Context(), templateID, exportParams(c.Request.URL.Query()), blank)
|
||||
params, err := exportIssueParams(c.Request.URL.Query(), blank)
|
||||
if err != nil {
|
||||
httpx.Fail(c, "解析 params 参数失败")
|
||||
return
|
||||
}
|
||||
token, err := h.service.IssueToken(c.Request.Context(), templateID, params, blank)
|
||||
if err != nil {
|
||||
httpx.Fail(c, "导出令牌创建失败")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportParamsRejectsMalformedNestedQuery(t *testing.T) {
|
||||
_, err := exportParams(url.Values{"params": {"name=%zz"}})
|
||||
if err == nil {
|
||||
t.Fatal("exportParams accepted malformed nested query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportParamsUsesFirstValue(t *testing.T) {
|
||||
got, err := exportParams(url.Values{"params": {"name=first&name=second"}})
|
||||
if err != nil {
|
||||
t.Fatalf("exportParams returned error: %v", err)
|
||||
}
|
||||
if got["name"] != "first" {
|
||||
t.Fatalf("exportParams name = %q, want first", got["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportIssueParamsKeepsBlankTemplateCompatible(t *testing.T) {
|
||||
got, err := exportIssueParams(url.Values{"params": {"name=%zz"}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("blank template params returned error: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("blank template params = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportIssueParamsRejectsMalformedExcelParams(t *testing.T) {
|
||||
_, err := exportIssueParams(url.Values{"params": {"name=%zz"}}, false)
|
||||
if err == nil {
|
||||
t.Fatal("Excel export accepted malformed nested query")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/server/httpx"
|
||||
|
|
@ -20,11 +18,8 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService)
|
|||
}
|
||||
path := c.Request.URL.Path
|
||||
policyPath := path
|
||||
if config := runtime.Admin(); config != nil && config.RouterPrefix != "" {
|
||||
policyPath = strings.TrimPrefix(policyPath, strings.TrimSuffix(config.RouterPrefix, "/"))
|
||||
if policyPath == "" {
|
||||
policyPath = "/"
|
||||
}
|
||||
if config := runtime.Admin(); config != nil {
|
||||
policyPath = service.NormalizeRoutePath(policyPath, config.RouterPrefix)
|
||||
}
|
||||
allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method)
|
||||
if err != nil || !allowed {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package middleware
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -17,6 +18,8 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
|
||||
|
||||
func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
|
@ -66,7 +69,12 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
|
|||
responseBody = "[超出记录长度]"
|
||||
}
|
||||
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||
_ = service.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")})
|
||||
if err := service.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
||||
// Preserve the business response, but expose audit persistence failures
|
||||
// to the global access/error logging pipeline.
|
||||
c.Set(ctxOperationAuditPersistFailedKey, true)
|
||||
_ = c.Error(fmt.Errorf("operation audit persist: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,18 @@ func CORS(runtime *conf.Runtime) gin.HandlerFunc {
|
|||
}
|
||||
mode := strings.TrimSpace(config.Cors.Mode)
|
||||
origin := c.GetHeader("Origin")
|
||||
corsHandled := false
|
||||
if mode == "allow-all" {
|
||||
setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true)
|
||||
corsHandled = true
|
||||
} else if rule := matchingCORSRule(config.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 && c.Request.URL.Path == "/health") {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
if corsHandled && c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func runCORSTest(t *testing.T, admin *conf.AdminBackend, method, origin string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
engine := gin.New()
|
||||
engine.Use(CORS(conf.NewRuntime(nil, admin)))
|
||||
engine.Any("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
request := httptest.NewRequest(method, "/test", nil)
|
||||
if origin != "" {
|
||||
request.Header.Set("Origin", origin)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestCORSDoesNotConsumeUnmatchedWhitelistPreflight(t *testing.T) {
|
||||
response := runCORSTest(t, &conf.AdminBackend{Cors: &conf.AdminBackend_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")
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("matched whitelist preflight status = %d, want %d", response.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,13 @@ import (
|
|||
func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
privateErrors := strings.TrimSpace(c.Errors.ByType(gin.ErrorTypePrivate).String())
|
||||
auditPersistFailed, _ := c.Get(ctxOperationAuditPersistFailedKey)
|
||||
// sysError writes must never audit themselves. Log-viewer failures are
|
||||
// already recorded by the handler with the underlying filesystem error;
|
||||
// emitting again from the response envelope would duplicate both the
|
||||
// classified error file and the sys_error row.
|
||||
if strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
||||
if auditPersistFailed != true && (strings.Contains(c.Request.URL.Path, "/sysError/") || strings.Contains(c.Request.URL.Path, "/logViewer/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500) {
|
||||
return
|
||||
}
|
||||
var response httpx.Response
|
||||
|
|
@ -31,12 +33,22 @@ func ErrorAudit(logger *slog.Logger) gin.HandlerFunc {
|
|||
body = buffer.Bytes()
|
||||
}
|
||||
}
|
||||
if json.Unmarshal(body, &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) {
|
||||
if json.Unmarshal(body, &response) != nil && privateErrors == "" {
|
||||
return
|
||||
}
|
||||
if response.Code == httpx.CodeSuccess && privateErrors == "" {
|
||||
return
|
||||
}
|
||||
if privateErrors == "" && expectedClientFailure(response.Msg) {
|
||||
return
|
||||
}
|
||||
errorMessage := response.Msg
|
||||
if privateErrors != "" {
|
||||
errorMessage = privateErrors
|
||||
}
|
||||
requestID, _ := c.Get("request_id")
|
||||
if logger != nil {
|
||||
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", response.Msg, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
|
||||
logger.ErrorContext(c.Request.Context(), "请求处理失败", "mod", failureLogModule(c.Request.URL.Path), "path", c.Request.URL.Path, "method", c.Request.Method, "status", c.Writer.Status(), "error", errorMessage, "request_id", stringValue(requestID), "trace_id", stringValueFromContext(c, "trace_id"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package middleware
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
|
@ -73,3 +74,38 @@ func TestErrorAuditSkipsLogViewerFailureAlreadyLoggedByHandler(t *testing.T) {
|
|||
t.Fatalf("log viewer failure must not be emitted twice, got %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsPrivateMiddlewareErrorOnSuccessResponse(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.GET("/test", func(c *gin.Context) {
|
||||
c.Error(errors.New("operation audit persist: database unavailable"))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/test", nil))
|
||||
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
|
||||
t.Fatalf("private middleware error was not logged: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorAuditEmitsOperationAuditFailureForSysErrorRoute(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.Use(ErrorAudit(logger))
|
||||
engine.PUT("/sysError/updateSysError", func(c *gin.Context) {
|
||||
c.Set(ctxOperationAuditPersistFailedKey, true)
|
||||
c.Error(errors.New("operation audit persist: database unavailable"))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{}, "msg": "成功"})
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/sysError/updateSysError", nil))
|
||||
if !strings.Contains(output.String(), "operation audit persist: database unavailable") {
|
||||
t.Fatalf("sysError operation audit failure was not logged: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string
|
|||
})
|
||||
paths := make(map[string]map[string]any, len(routes))
|
||||
for _, route := range routes {
|
||||
if strings.HasSuffix(route.Path, "/*filepath") {
|
||||
continue
|
||||
}
|
||||
method := strings.ToLower(route.Method)
|
||||
switch method {
|
||||
case "get", "post", "put", "delete", "patch":
|
||||
|
|
|
|||
|
|
@ -18,20 +18,14 @@ func NewAPIService(uc *biz.APIUsecase, settings biz.RuntimeSettings) *APIService
|
|||
}
|
||||
|
||||
func (s *APIService) NormalizeRoutePath(path string) string {
|
||||
routerPrefix := s.settings.RouterPrefix()
|
||||
if routerPrefix == "" {
|
||||
if s.settings == nil {
|
||||
return path
|
||||
}
|
||||
prefix := strings.TrimSuffix(routerPrefix, "/")
|
||||
normalized := strings.TrimPrefix(path, prefix)
|
||||
if normalized == "" {
|
||||
return "/"
|
||||
}
|
||||
return normalized
|
||||
return NormalizeRoutePath(path, s.settings.RouterPrefix())
|
||||
}
|
||||
|
||||
func apiDomain(value *dto.APIRequest) *biz.API {
|
||||
return &biz.API{ID: value.ID, Path: value.Path, Description: value.Description, APIGroup: value.APIGroup, Method: value.Method}
|
||||
func (s *APIService) apiDomain(value *dto.APIRequest) *biz.API {
|
||||
return &biz.API{ID: value.ID, Path: s.NormalizeRoutePath(value.Path), Description: value.Description, APIGroup: value.APIGroup, Method: value.Method}
|
||||
}
|
||||
|
||||
func apiResponse(value *biz.API) *dto.APIResponse {
|
||||
|
|
@ -87,14 +81,14 @@ func (s *APIService) Groups(ctx context.Context) ([]string, map[string]string, e
|
|||
}
|
||||
|
||||
func (s *APIService) CreateAPIRequest(ctx context.Context, req *dto.APIRequest) (*dto.APIResponse, error) {
|
||||
value := apiDomain(req)
|
||||
value := s.apiDomain(req)
|
||||
if err := s.uc.CreateAPI(ctx, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiResponse(value), nil
|
||||
}
|
||||
func (s *APIService) UpdateAPIRequest(ctx context.Context, req *dto.APIRequest) error {
|
||||
return s.uc.UpdateAPI(ctx, apiDomain(req))
|
||||
return s.uc.UpdateAPI(ctx, s.apiDomain(req))
|
||||
}
|
||||
func (s *APIService) FindAPIResponse(ctx context.Context, id uint) (*dto.APIResponse, error) {
|
||||
value, err := s.uc.FindAPI(ctx, id)
|
||||
|
|
@ -107,10 +101,10 @@ func (s *APIService) ApplyAPISyncRequest(ctx context.Context, req *dto.ApplyAPIS
|
|||
added := make([]*biz.API, 0, len(req.NewAPIs))
|
||||
deleted := make([]*biz.API, 0, len(req.DeleteAPIs))
|
||||
for i := range req.NewAPIs {
|
||||
added = append(added, apiDomain(&req.NewAPIs[i]))
|
||||
added = append(added, s.apiDomain(&req.NewAPIs[i]))
|
||||
}
|
||||
for i := range req.DeleteAPIs {
|
||||
deleted = append(deleted, apiDomain(&req.DeleteAPIs[i]))
|
||||
deleted = append(deleted, s.apiDomain(&req.DeleteAPIs[i]))
|
||||
}
|
||||
return s.uc.ApplyAPISync(ctx, added, deleted)
|
||||
}
|
||||
|
|
@ -142,7 +136,7 @@ func (s *APIService) SyncAPIResponses(ctx context.Context, routes []dto.APIReque
|
|||
// The compatible sync endpoint compares Gin's route table directly, so newly
|
||||
// discovered routes carry only path and method. Group/description are
|
||||
// intentionally left empty for the operator to fill in the sync dialog.
|
||||
items = append(items, apiDomain(&routes[i]))
|
||||
items = append(items, s.apiDomain(&routes[i]))
|
||||
}
|
||||
return s.SyncAPIs(ctx, items)
|
||||
}
|
||||
|
|
@ -154,10 +148,13 @@ func (s *APIService) DeleteAPI(ctx context.Context, id uint) error {
|
|||
return s.uc.DeleteAPI(ctx, id)
|
||||
}
|
||||
func (s *APIService) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) {
|
||||
return s.uc.APIRoleIDs(ctx, path, method)
|
||||
return s.uc.APIRoleIDs(ctx, s.NormalizeRoutePath(path), method)
|
||||
}
|
||||
func (s *APIService) SetAPIRoles(ctx context.Context, path, method string, ids []uint) error {
|
||||
return s.uc.SetAPIRoles(ctx, path, method, ids)
|
||||
return s.uc.SetAPIRoles(ctx, s.NormalizeRoutePath(path), method, ids)
|
||||
}
|
||||
func (s *APIService) FreshCasbin(ctx context.Context) error {
|
||||
return s.uc.FreshCasbin(ctx)
|
||||
}
|
||||
func (s *APIService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APISyncResponse, error) {
|
||||
diff, err := s.uc.SyncAPIs(ctx, routes)
|
||||
|
|
@ -167,5 +164,5 @@ func (s *APIService) SyncAPIs(ctx context.Context, routes []*biz.API) (*dto.APIS
|
|||
return &dto.APISyncResponse{NewAPIs: apiResponses(diff.Added), DeleteAPIs: apiResponses(diff.Deleted), IgnoreAPIs: apiResponses(diff.Ignored)}, nil
|
||||
}
|
||||
func (s *APIService) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error {
|
||||
return s.uc.SetAPIIgnored(ctx, path, method, ignored)
|
||||
return s.uc.SetAPIIgnored(ctx, s.NormalizeRoutePath(path), method, ignored)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
// NormalizeRoutePath removes only a complete configured router prefix. Paths
|
||||
// such as /administrator must not be shortened when the prefix is /admin.
|
||||
func NormalizeRoutePath(path, routerPrefix string) string {
|
||||
prefix := strings.TrimSuffix(strings.TrimSpace(routerPrefix), "/")
|
||||
if prefix == "" || prefix == "/" {
|
||||
return path
|
||||
}
|
||||
if path == prefix {
|
||||
return "/"
|
||||
}
|
||||
if strings.HasPrefix(path, prefix+"/") {
|
||||
return strings.TrimPrefix(path, prefix)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeRoutePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
prefix string
|
||||
want string
|
||||
}{
|
||||
{name: "empty prefix", path: "/api/login", prefix: "", want: "/api/login"},
|
||||
{name: "configured prefix", path: "/admin/api/login", prefix: "/admin", want: "/api/login"},
|
||||
{name: "trailing slash prefix", path: "/admin/api/login", prefix: "/admin/", want: "/api/login"},
|
||||
{name: "prefix root", path: "/admin", prefix: "/admin", want: "/"},
|
||||
{name: "prefix boundary", path: "/administrator/api/login", prefix: "/admin", want: "/administrator/api/login"},
|
||||
{name: "already normalized", path: "/api/login", prefix: "/admin", want: "/api/login"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := NormalizeRoutePath(tt.path, tt.prefix); got != tt.want {
|
||||
t.Fatalf("NormalizeRoutePath(%q, %q) = %q, want %q", tt.path, tt.prefix, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package service
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/routeinfo"
|
||||
|
|
@ -29,13 +28,7 @@ func (s *SystemConfigService) Initialize(ctx context.Context, input *dto.Databas
|
|||
func (s *SystemConfigService) InitializeRoutes(ctx context.Context, input *dto.DatabaseInitRequest, routes []dto.Route) error {
|
||||
apis := make([]*biz.API, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
path := route.Path
|
||||
if routerPrefix := s.settings.RouterPrefix(); routerPrefix != "" {
|
||||
path = strings.TrimPrefix(path, strings.TrimSuffix(routerPrefix, "/"))
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
}
|
||||
path := NormalizeRoutePath(route.Path, s.settings.RouterPrefix())
|
||||
group, description := routeinfo.Metadata(route.Method, path)
|
||||
apis = append(apis, &biz.API{Path: path, Method: route.Method, APIGroup: group, Description: description})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,20 @@ export const setSystemConfig = (data) => {
|
|||
})
|
||||
}
|
||||
|
||||
// 对象存储配置以局部 payload 提交,避免连带覆盖其他尚未保存的表单项。
|
||||
export const setStorageConfig = (storage) => {
|
||||
return setSystemConfig({
|
||||
config: {
|
||||
admin: { storage }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 邮件配置同样以局部 payload 提交,SMTP 密钥由后端写入集成配置表。
|
||||
export const setEmailConfig = (email) => {
|
||||
return setSystemConfig({ config: { email } })
|
||||
}
|
||||
|
||||
// @Tags system
|
||||
// @Summary 获取服务器运行状态
|
||||
// @Security ApiKeyAuth
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<warning-bar
|
||||
title="需要提前配置email配置文件,为防止不必要的垃圾邮件,在线体验功能不开放此功能体验。"
|
||||
title="请先在系统设置的邮件设置中完成 SMTP 配置。为防止不必要的垃圾邮件,在线体验不开放此功能。"
|
||||
/>
|
||||
<div class="kra-form-box">
|
||||
<el-form
|
||||
|
|
|
|||
|
|
@ -193,7 +193,9 @@
|
|||
<el-form-item v-if="config.admin.storage.type === 'cloudflare-r2'" label="Account ID"><el-input v-model.trim="currentObjectStorage.account_id" /></el-form-item>
|
||||
<el-form-item label="连接选项"><div class="flex gap-5"><el-switch v-model="currentObjectStorage.use_ssl" active-text="HTTPS" /><el-switch v-model="currentObjectStorage.force_path_style" active-text="Path Style" /></div></el-form-item>
|
||||
</template>
|
||||
<p class="md:col-span-2 text-sm text-gray-500">保存后点击“重载服务”,新的对象存储配置会立即生效。</p>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<el-button type="primary" :loading="storageSaving" @click="saveStorage">保存对象存储</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
|
|
@ -223,8 +225,11 @@
|
|||
<el-switch v-model="config.email['is-loginauth']" active-text="LOGIN 认证" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="连通性测试">
|
||||
<el-button :loading="testing" @click="testEmail">发送测试邮件</el-button>
|
||||
<el-form-item label="配置操作" class="md:col-span-2">
|
||||
<div class="flex gap-2">
|
||||
<el-button type="primary" :loading="emailSaving" @click="saveEmail">保存邮件配置</el-button>
|
||||
<el-button :loading="testing" @click="testEmail">保存并发送测试邮件</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
|
@ -235,7 +240,7 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSystemConfig, reloadSystem, setSystemConfig } from '@/api/system'
|
||||
import { getSystemConfig, reloadSystem, setEmailConfig, setStorageConfig, setSystemConfig } from '@/api/system'
|
||||
import { emailTest } from '@/api/email'
|
||||
import { CreateUUID } from '@/utils/format'
|
||||
|
||||
|
|
@ -243,6 +248,8 @@
|
|||
|
||||
const activeKey = ref('basic')
|
||||
const saving = ref(false)
|
||||
const storageSaving = ref(false)
|
||||
const emailSaving = ref(false)
|
||||
const testing = ref(false)
|
||||
const config = ref({
|
||||
admin: {
|
||||
|
|
@ -256,7 +263,7 @@
|
|||
storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} }
|
||||
},
|
||||
email: {
|
||||
to: '', from: '', host: '', secret: '******', nickname: '', port: 465,
|
||||
to: '', from: '', host: '', secret: '', nickname: '', port: 465,
|
||||
'is-ssl': true, 'is-loginauth': false
|
||||
},
|
||||
data: {
|
||||
|
|
@ -352,6 +359,49 @@
|
|||
config.value.admin.jwt.signingKey = CreateUUID()
|
||||
}
|
||||
|
||||
const storageFieldLabels = {
|
||||
endpoint: 'Endpoint', region: 'Region', bucket: 'Bucket',
|
||||
access_key: 'Access Key', secret_key: 'Secret Key', account_id: 'Account ID'
|
||||
}
|
||||
|
||||
const validateStorage = () => {
|
||||
const storage = config.value.admin.storage
|
||||
if (storage.type === 'local') return true
|
||||
const target = storage.type === 'qiniu' ? storage.qiniu : currentObjectStorage.value
|
||||
const required = {
|
||||
qiniu: ['bucket', 'access_key', 'secret_key'],
|
||||
'aliyun-oss': ['endpoint', 'bucket', 'access_key', 'secret_key'],
|
||||
'huawei-obs': ['endpoint', 'bucket', 'access_key', 'secret_key'],
|
||||
'tencent-cos': ['region', 'bucket', 'access_key', 'secret_key'],
|
||||
'aws-s3': ['region', 'bucket', 'access_key', 'secret_key'],
|
||||
'cloudflare-r2': ['bucket', 'access_key', 'secret_key'],
|
||||
minio: ['endpoint', 'bucket', 'access_key', 'secret_key']
|
||||
}[storage.type] || []
|
||||
const missing = required.filter(key => !String(target?.[key] || '').trim())
|
||||
if (storage.type === 'cloudflare-r2' && !String(target?.endpoint || target?.account_id || '').trim()) {
|
||||
missing.push('account_id')
|
||||
}
|
||||
if (missing.length) {
|
||||
ElMessage.warning(`请填写:${missing.map(key => storageFieldLabels[key]).join('、')}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const saveStorage = async () => {
|
||||
if (!validateStorage()) return
|
||||
storageSaving.value = true
|
||||
try {
|
||||
const res = await setStorageConfig(config.value.admin.storage)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('对象存储配置已保存并生效')
|
||||
await initForm()
|
||||
}
|
||||
} finally {
|
||||
storageSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要重载服务配置吗?', '提示')
|
||||
|
|
@ -363,9 +413,40 @@
|
|||
if (res.code === 0) ElMessage.success('配置已重载')
|
||||
}
|
||||
|
||||
const validateEmail = () => {
|
||||
const email = config.value.email
|
||||
const fields = [
|
||||
['to', '默认收件人'], ['from', '发件人'], ['host', 'SMTP 主机'], ['secret', 'SMTP 密钥']
|
||||
]
|
||||
const missing = fields.filter(([key]) => !String(email[key] || '').trim()).map(([, label]) => label)
|
||||
if (!email.port) missing.push('SMTP 端口')
|
||||
if (missing.length) {
|
||||
ElMessage.warning(`请填写:${missing.join('、')}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const saveEmail = async () => {
|
||||
if (!validateEmail()) return false
|
||||
emailSaving.value = true
|
||||
try {
|
||||
const res = await setEmailConfig(config.value.email)
|
||||
if (res.code !== 0) return false
|
||||
ElMessage.success('邮件配置已保存并生效')
|
||||
await initForm()
|
||||
return true
|
||||
} finally {
|
||||
emailSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testEmail = async () => {
|
||||
if (!validateEmail()) return
|
||||
testing.value = true
|
||||
try {
|
||||
const saved = await setEmailConfig(config.value.email)
|
||||
if (saved.code !== 0) return
|
||||
const res = await emailTest()
|
||||
if (res.code === 0) {
|
||||
ElMessage.success('测试邮件发送成功')
|
||||
|
|
|
|||
|
|
@ -15,14 +15,19 @@ const pathMapPlugin = () => ({
|
|||
const result = {}
|
||||
const walk = (directory) => {
|
||||
if (!fs.existsSync(directory)) return
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
||||
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
for (const entry of entries) {
|
||||
const filename = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(filename)
|
||||
} else if (filename.endsWith('.vue')) {
|
||||
const source = fs.readFileSync(filename, 'utf8')
|
||||
const match = source.match(/defineOptions\s*\(\s*{[\s\S]*?name:\s*['"]([^'"]+)['"]/)
|
||||
if (match) result[`/src/${filename.replace(/^src\//, '')}`] = match[1]
|
||||
if (match) {
|
||||
const relativePath = path.relative('src', filename).split(path.sep).join('/')
|
||||
result[`/src/${relativePath}`] = match[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue