数据源和 oss

This commit is contained in:
2026-08-13 23:17:55 +08:00
parent f1a0b17bb0
commit c4bf7ee03b
40 changed files with 1975 additions and 922 deletions
+20 -5
View File
@@ -35,6 +35,10 @@ type UserListFilter struct {
Desc bool
}
type DatabaseConfig struct {
Driver, Host, Port, User, Password, Name, Path, Config, AdminPassword string
}
type Authority struct {
AuthorityID uint
AuthorityName string
@@ -64,7 +68,7 @@ type Menu struct {
type SystemRepo interface {
IsInitialized(context.Context) (bool, error)
Initialize(context.Context) error
Initialize(context.Context, *DatabaseConfig) error
FindUserByUsername(context.Context, string) (*User, error)
FindUserByID(context.Context, uint) (*User, error)
MenusByAuthority(context.Context, uint) ([]*Menu, error)
@@ -72,11 +76,12 @@ type SystemRepo interface {
CreateUser(context.Context, *User) (*User, error)
UpdateUser(context.Context, *User) error
DeleteUser(context.Context, uint) error
UpdatePassword(context.Context, uint, string) error
UpdatePassword(context.Context, uint, string, bool) error
SetUserAuthorities(context.Context, uint, []uint) error
SetUserAuthority(context.Context, uint, uint) error
SetUserSetting(context.Context, uint, map[string]any) error
ListAuthorities(context.Context) ([]*Authority, error)
PersistConfig(context.Context) error
}
type SystemUsecase struct {
@@ -102,7 +107,13 @@ func (uc *SystemUsecase) CacheDelete(ctx context.Context, key string) error {
func (uc *SystemUsecase) IsInitialized(ctx context.Context) (bool, error) {
return uc.repo.IsInitialized(ctx)
}
func (uc *SystemUsecase) Initialize(ctx context.Context) error { return uc.repo.Initialize(ctx) }
func (uc *SystemUsecase) Initialize(ctx context.Context, config *DatabaseConfig) error {
if config == nil || len(config.AdminPassword) < 6 {
return ErrInvalidCredentials
}
return uc.repo.Initialize(ctx, config)
}
func (uc *SystemUsecase) PersistConfig(ctx context.Context) error { return uc.repo.PersistConfig(ctx) }
func (uc *SystemUsecase) Login(ctx context.Context, username, password string) (*User, error) {
u, err := uc.repo.FindUserByUsername(ctx, username)
@@ -142,7 +153,7 @@ func (uc *SystemUsecase) ResetPassword(ctx context.Context, id uint, password st
if err != nil {
return err
}
return uc.repo.UpdatePassword(ctx, id, string(hash))
return uc.repo.UpdatePassword(ctx, id, string(hash), false)
}
func (uc *SystemUsecase) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error {
user, err := uc.repo.FindUserByID(ctx, id)
@@ -152,7 +163,11 @@ func (uc *SystemUsecase) ChangePassword(ctx context.Context, id uint, oldPasswor
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)) != nil {
return ErrInvalidCredentials
}
return uc.ResetPassword(ctx, id, newPassword)
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return uc.repo.UpdatePassword(ctx, id, string(hash), true)
}
func (uc *SystemUsecase) Authorities(ctx context.Context) ([]*Authority, error) {
return uc.repo.ListAuthorities(ctx)
+482 -40
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc (unknown)
// source: conf/conf.proto
package conf
@@ -188,12 +188,15 @@ func (x *Data) GetRedis() *Data_Redis {
// AdminBackend contains settings for the administration HTTP transport.
type AdminBackend struct {
state protoimpl.MessageState `protogen:"open.v1"`
RouterPrefix string `protobuf:"bytes,1,opt,name=router_prefix,json=routerPrefix,proto3" json:"router_prefix,omitempty"`
Jwt *AdminBackend_JWT `protobuf:"bytes,2,opt,name=jwt,proto3" json:"jwt,omitempty"`
Captcha *AdminBackend_Captcha `protobuf:"bytes,3,opt,name=captcha,proto3" json:"captcha,omitempty"`
Local *AdminBackend_Local `protobuf:"bytes,4,opt,name=local,proto3" json:"local,omitempty"`
Email *AdminBackend_Email `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
RouterPrefix string `protobuf:"bytes,1,opt,name=router_prefix,json=routerPrefix,proto3" json:"router_prefix,omitempty"`
Jwt *AdminBackend_JWT `protobuf:"bytes,2,opt,name=jwt,proto3" json:"jwt,omitempty"`
Captcha *AdminBackend_Captcha `protobuf:"bytes,3,opt,name=captcha,proto3" json:"captcha,omitempty"`
Local *AdminBackend_Local `protobuf:"bytes,4,opt,name=local,proto3" json:"local,omitempty"`
Email *AdminBackend_Email `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"`
Storage *AdminBackend_Storage `protobuf:"bytes,6,opt,name=storage,proto3" json:"storage,omitempty"`
// ConfigPath is populated by the entrypoint and is not required in YAML.
ConfigPath string `protobuf:"bytes,7,opt,name=config_path,json=configPath,proto3" json:"config_path,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -263,6 +266,20 @@ func (x *AdminBackend) GetEmail() *AdminBackend_Email {
return nil
}
func (x *AdminBackend) GetStorage() *AdminBackend_Storage {
if x != nil {
return x.Storage
}
return nil
}
func (x *AdminBackend) GetConfigPath() string {
if x != nil {
return x.ConfigPath
}
return ""
}
type Server_HTTP struct {
state protoimpl.MessageState `protogen:"open.v1"`
Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"`
@@ -387,6 +404,13 @@ type Data_Database struct {
state protoimpl.MessageState `protogen:"open.v1"`
Driver string `protobuf:"bytes,1,opt,name=driver,proto3" json:"driver,omitempty"`
Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"`
Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"`
Port string `protobuf:"bytes,4,opt,name=port,proto3" json:"port,omitempty"`
User string `protobuf:"bytes,5,opt,name=user,proto3" json:"user,omitempty"`
Password string `protobuf:"bytes,6,opt,name=password,proto3" json:"password,omitempty"`
Name string `protobuf:"bytes,7,opt,name=name,proto3" json:"name,omitempty"`
Config string `protobuf:"bytes,8,opt,name=config,proto3" json:"config,omitempty"`
Path string `protobuf:"bytes,9,opt,name=path,proto3" json:"path,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -435,6 +459,55 @@ func (x *Data_Database) GetSource() string {
return ""
}
func (x *Data_Database) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *Data_Database) GetPort() string {
if x != nil {
return x.Port
}
return ""
}
func (x *Data_Database) GetUser() string {
if x != nil {
return x.User
}
return ""
}
func (x *Data_Database) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
func (x *Data_Database) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *Data_Database) GetConfig() string {
if x != nil {
return x.Config
}
return ""
}
func (x *Data_Database) GetPath() string {
if x != nil {
return x.Path
}
return ""
}
type Data_Redis struct {
state protoimpl.MessageState `protogen:"open.v1"`
Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"`
@@ -791,6 +864,316 @@ func (x *AdminBackend_Email) GetIsLoginAuth() bool {
return false
}
type AdminBackend_Storage struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
Qiniu *AdminBackend_Qiniu `protobuf:"bytes,2,opt,name=qiniu,proto3" json:"qiniu,omitempty"`
AliyunOss *AdminBackend_ObjectStore `protobuf:"bytes,3,opt,name=aliyun_oss,json=aliyunOss,proto3" json:"aliyun_oss,omitempty"`
HuaweiObs *AdminBackend_ObjectStore `protobuf:"bytes,4,opt,name=huawei_obs,json=huaweiObs,proto3" json:"huawei_obs,omitempty"`
TencentCos *AdminBackend_ObjectStore `protobuf:"bytes,5,opt,name=tencent_cos,json=tencentCos,proto3" json:"tencent_cos,omitempty"`
AwsS3 *AdminBackend_ObjectStore `protobuf:"bytes,6,opt,name=aws_s3,json=awsS3,proto3" json:"aws_s3,omitempty"`
CloudflareR2 *AdminBackend_ObjectStore `protobuf:"bytes,7,opt,name=cloudflare_r2,json=cloudflareR2,proto3" json:"cloudflare_r2,omitempty"`
Minio *AdminBackend_ObjectStore `protobuf:"bytes,8,opt,name=minio,proto3" json:"minio,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AdminBackend_Storage) Reset() {
*x = AdminBackend_Storage{}
mi := &file_conf_conf_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AdminBackend_Storage) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AdminBackend_Storage) ProtoMessage() {}
func (x *AdminBackend_Storage) ProtoReflect() protoreflect.Message {
mi := &file_conf_conf_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AdminBackend_Storage.ProtoReflect.Descriptor instead.
func (*AdminBackend_Storage) Descriptor() ([]byte, []int) {
return file_conf_conf_proto_rawDescGZIP(), []int{3, 4}
}
func (x *AdminBackend_Storage) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *AdminBackend_Storage) GetQiniu() *AdminBackend_Qiniu {
if x != nil {
return x.Qiniu
}
return nil
}
func (x *AdminBackend_Storage) GetAliyunOss() *AdminBackend_ObjectStore {
if x != nil {
return x.AliyunOss
}
return nil
}
func (x *AdminBackend_Storage) GetHuaweiObs() *AdminBackend_ObjectStore {
if x != nil {
return x.HuaweiObs
}
return nil
}
func (x *AdminBackend_Storage) GetTencentCos() *AdminBackend_ObjectStore {
if x != nil {
return x.TencentCos
}
return nil
}
func (x *AdminBackend_Storage) GetAwsS3() *AdminBackend_ObjectStore {
if x != nil {
return x.AwsS3
}
return nil
}
func (x *AdminBackend_Storage) GetCloudflareR2() *AdminBackend_ObjectStore {
if x != nil {
return x.CloudflareR2
}
return nil
}
func (x *AdminBackend_Storage) GetMinio() *AdminBackend_ObjectStore {
if x != nil {
return x.Minio
}
return nil
}
type AdminBackend_Qiniu struct {
state protoimpl.MessageState `protogen:"open.v1"`
Zone string `protobuf:"bytes,1,opt,name=zone,proto3" json:"zone,omitempty"`
Bucket string `protobuf:"bytes,2,opt,name=bucket,proto3" json:"bucket,omitempty"`
BaseUrl string `protobuf:"bytes,3,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"`
AccessKey string `protobuf:"bytes,4,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"`
SecretKey string `protobuf:"bytes,5,opt,name=secret_key,json=secretKey,proto3" json:"secret_key,omitempty"`
UseHttps bool `protobuf:"varint,6,opt,name=use_https,json=useHttps,proto3" json:"use_https,omitempty"`
UseCdnDomains bool `protobuf:"varint,7,opt,name=use_cdn_domains,json=useCdnDomains,proto3" json:"use_cdn_domains,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AdminBackend_Qiniu) Reset() {
*x = AdminBackend_Qiniu{}
mi := &file_conf_conf_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AdminBackend_Qiniu) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AdminBackend_Qiniu) ProtoMessage() {}
func (x *AdminBackend_Qiniu) ProtoReflect() protoreflect.Message {
mi := &file_conf_conf_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AdminBackend_Qiniu.ProtoReflect.Descriptor instead.
func (*AdminBackend_Qiniu) Descriptor() ([]byte, []int) {
return file_conf_conf_proto_rawDescGZIP(), []int{3, 5}
}
func (x *AdminBackend_Qiniu) GetZone() string {
if x != nil {
return x.Zone
}
return ""
}
func (x *AdminBackend_Qiniu) GetBucket() string {
if x != nil {
return x.Bucket
}
return ""
}
func (x *AdminBackend_Qiniu) GetBaseUrl() string {
if x != nil {
return x.BaseUrl
}
return ""
}
func (x *AdminBackend_Qiniu) GetAccessKey() string {
if x != nil {
return x.AccessKey
}
return ""
}
func (x *AdminBackend_Qiniu) GetSecretKey() string {
if x != nil {
return x.SecretKey
}
return ""
}
func (x *AdminBackend_Qiniu) GetUseHttps() bool {
if x != nil {
return x.UseHttps
}
return false
}
func (x *AdminBackend_Qiniu) GetUseCdnDomains() bool {
if x != nil {
return x.UseCdnDomains
}
return false
}
// ObjectStore covers S3-compatible configuration used by public cloud
// providers and self-hosted MinIO.
type AdminBackend_ObjectStore struct {
state protoimpl.MessageState `protogen:"open.v1"`
Endpoint string `protobuf:"bytes,1,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"`
Bucket string `protobuf:"bytes,3,opt,name=bucket,proto3" json:"bucket,omitempty"`
AccessKey string `protobuf:"bytes,4,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"`
SecretKey string `protobuf:"bytes,5,opt,name=secret_key,json=secretKey,proto3" json:"secret_key,omitempty"`
BaseUrl string `protobuf:"bytes,6,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"`
PathPrefix string `protobuf:"bytes,7,opt,name=path_prefix,json=pathPrefix,proto3" json:"path_prefix,omitempty"`
UseSsl bool `protobuf:"varint,8,opt,name=use_ssl,json=useSsl,proto3" json:"use_ssl,omitempty"`
ForcePathStyle bool `protobuf:"varint,9,opt,name=force_path_style,json=forcePathStyle,proto3" json:"force_path_style,omitempty"`
AccountId string `protobuf:"bytes,10,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AdminBackend_ObjectStore) Reset() {
*x = AdminBackend_ObjectStore{}
mi := &file_conf_conf_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AdminBackend_ObjectStore) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AdminBackend_ObjectStore) ProtoMessage() {}
func (x *AdminBackend_ObjectStore) ProtoReflect() protoreflect.Message {
mi := &file_conf_conf_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AdminBackend_ObjectStore.ProtoReflect.Descriptor instead.
func (*AdminBackend_ObjectStore) Descriptor() ([]byte, []int) {
return file_conf_conf_proto_rawDescGZIP(), []int{3, 6}
}
func (x *AdminBackend_ObjectStore) GetEndpoint() string {
if x != nil {
return x.Endpoint
}
return ""
}
func (x *AdminBackend_ObjectStore) GetRegion() string {
if x != nil {
return x.Region
}
return ""
}
func (x *AdminBackend_ObjectStore) GetBucket() string {
if x != nil {
return x.Bucket
}
return ""
}
func (x *AdminBackend_ObjectStore) GetAccessKey() string {
if x != nil {
return x.AccessKey
}
return ""
}
func (x *AdminBackend_ObjectStore) GetSecretKey() string {
if x != nil {
return x.SecretKey
}
return ""
}
func (x *AdminBackend_ObjectStore) GetBaseUrl() string {
if x != nil {
return x.BaseUrl
}
return ""
}
func (x *AdminBackend_ObjectStore) GetPathPrefix() string {
if x != nil {
return x.PathPrefix
}
return ""
}
func (x *AdminBackend_ObjectStore) GetUseSsl() bool {
if x != nil {
return x.UseSsl
}
return false
}
func (x *AdminBackend_ObjectStore) GetForcePathStyle() bool {
if x != nil {
return x.ForcePathStyle
}
return false
}
func (x *AdminBackend_ObjectStore) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
var File_conf_conf_proto protoreflect.FileDescriptor
const file_conf_conf_proto_rawDesc = "" +
@@ -811,24 +1194,34 @@ const file_conf_conf_proto_rawDesc = "" +
"\x04GRPC\x12\x18\n" +
"\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" +
"\x04addr\x18\x02 \x01(\tR\x04addr\x123\n" +
"\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\xdd\x02\n" +
"\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\xf6\x03\n" +
"\x04Data\x125\n" +
"\bdatabase\x18\x01 \x01(\v2\x19.kratos.api.Data.DatabaseR\bdatabase\x12,\n" +
"\x05redis\x18\x02 \x01(\v2\x16.kratos.api.Data.RedisR\x05redis\x1a:\n" +
"\x05redis\x18\x02 \x01(\v2\x16.kratos.api.Data.RedisR\x05redis\x1a\xd2\x01\n" +
"\bDatabase\x12\x16\n" +
"\x06driver\x18\x01 \x01(\tR\x06driver\x12\x16\n" +
"\x06source\x18\x02 \x01(\tR\x06source\x1a\xb3\x01\n" +
"\x06source\x18\x02 \x01(\tR\x06source\x12\x12\n" +
"\x04host\x18\x03 \x01(\tR\x04host\x12\x12\n" +
"\x04port\x18\x04 \x01(\tR\x04port\x12\x12\n" +
"\x04user\x18\x05 \x01(\tR\x04user\x12\x1a\n" +
"\bpassword\x18\x06 \x01(\tR\bpassword\x12\x12\n" +
"\x04name\x18\a \x01(\tR\x04name\x12\x16\n" +
"\x06config\x18\b \x01(\tR\x06config\x12\x12\n" +
"\x04path\x18\t \x01(\tR\x04path\x1a\xb3\x01\n" +
"\x05Redis\x12\x18\n" +
"\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" +
"\x04addr\x18\x02 \x01(\tR\x04addr\x12<\n" +
"\fread_timeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\vreadTimeout\x12>\n" +
"\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeout\"\xfd\x06\n" +
"\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeout\"\xd1\x0f\n" +
"\fAdminBackend\x12#\n" +
"\rrouter_prefix\x18\x01 \x01(\tR\frouterPrefix\x12.\n" +
"\x03jwt\x18\x02 \x01(\v2\x1c.kratos.api.AdminBackend.JWTR\x03jwt\x12:\n" +
"\acaptcha\x18\x03 \x01(\v2 .kratos.api.AdminBackend.CaptchaR\acaptcha\x124\n" +
"\x05local\x18\x04 \x01(\v2\x1e.kratos.api.AdminBackend.LocalR\x05local\x124\n" +
"\x05email\x18\x05 \x01(\v2\x1e.kratos.api.AdminBackend.EmailR\x05email\x1a\xb8\x01\n" +
"\x05email\x18\x05 \x01(\v2\x1e.kratos.api.AdminBackend.EmailR\x05email\x12:\n" +
"\astorage\x18\x06 \x01(\v2 .kratos.api.AdminBackend.StorageR\astorage\x12\x1f\n" +
"\vconfig_path\x18\a \x01(\tR\n" +
"configPath\x1a\xb8\x01\n" +
"\x03JWT\x12\x1f\n" +
"\vsigning_key\x18\x01 \x01(\tR\n" +
"signingKey\x12<\n" +
@@ -855,7 +1248,45 @@ const file_conf_conf_proto_rawDesc = "" +
"\bnickname\x18\x05 \x01(\tR\bnickname\x12\x12\n" +
"\x04port\x18\x06 \x01(\x05R\x04port\x12\x15\n" +
"\x06is_ssl\x18\a \x01(\bR\x05isSsl\x12\"\n" +
"\ris_login_auth\x18\b \x01(\bR\visLoginAuthB\x18Z\x16kra/internal/conf;confb\x06proto3"
"\ris_login_auth\x18\b \x01(\bR\visLoginAuth\x1a\xe8\x03\n" +
"\aStorage\x12\x12\n" +
"\x04type\x18\x01 \x01(\tR\x04type\x124\n" +
"\x05qiniu\x18\x02 \x01(\v2\x1e.kratos.api.AdminBackend.QiniuR\x05qiniu\x12C\n" +
"\n" +
"aliyun_oss\x18\x03 \x01(\v2$.kratos.api.AdminBackend.ObjectStoreR\taliyunOss\x12C\n" +
"\n" +
"huawei_obs\x18\x04 \x01(\v2$.kratos.api.AdminBackend.ObjectStoreR\thuaweiObs\x12E\n" +
"\vtencent_cos\x18\x05 \x01(\v2$.kratos.api.AdminBackend.ObjectStoreR\n" +
"tencentCos\x12;\n" +
"\x06aws_s3\x18\x06 \x01(\v2$.kratos.api.AdminBackend.ObjectStoreR\x05awsS3\x12I\n" +
"\rcloudflare_r2\x18\a \x01(\v2$.kratos.api.AdminBackend.ObjectStoreR\fcloudflareR2\x12:\n" +
"\x05minio\x18\b \x01(\v2$.kratos.api.AdminBackend.ObjectStoreR\x05minio\x1a\xd1\x01\n" +
"\x05Qiniu\x12\x12\n" +
"\x04zone\x18\x01 \x01(\tR\x04zone\x12\x16\n" +
"\x06bucket\x18\x02 \x01(\tR\x06bucket\x12\x19\n" +
"\bbase_url\x18\x03 \x01(\tR\abaseUrl\x12\x1d\n" +
"\n" +
"access_key\x18\x04 \x01(\tR\taccessKey\x12\x1d\n" +
"\n" +
"secret_key\x18\x05 \x01(\tR\tsecretKey\x12\x1b\n" +
"\tuse_https\x18\x06 \x01(\bR\buseHttps\x12&\n" +
"\x0fuse_cdn_domains\x18\a \x01(\bR\ruseCdnDomains\x1a\xb5\x02\n" +
"\vObjectStore\x12\x1a\n" +
"\bendpoint\x18\x01 \x01(\tR\bendpoint\x12\x16\n" +
"\x06region\x18\x02 \x01(\tR\x06region\x12\x16\n" +
"\x06bucket\x18\x03 \x01(\tR\x06bucket\x12\x1d\n" +
"\n" +
"access_key\x18\x04 \x01(\tR\taccessKey\x12\x1d\n" +
"\n" +
"secret_key\x18\x05 \x01(\tR\tsecretKey\x12\x19\n" +
"\bbase_url\x18\x06 \x01(\tR\abaseUrl\x12\x1f\n" +
"\vpath_prefix\x18\a \x01(\tR\n" +
"pathPrefix\x12\x17\n" +
"\ause_ssl\x18\b \x01(\bR\x06useSsl\x12(\n" +
"\x10force_path_style\x18\t \x01(\bR\x0eforcePathStyle\x12\x1d\n" +
"\n" +
"account_id\x18\n" +
" \x01(\tR\taccountIdB\x18Z\x16kra/internal/conf;confb\x06proto3"
var (
file_conf_conf_proto_rawDescOnce sync.Once
@@ -869,21 +1300,24 @@ func file_conf_conf_proto_rawDescGZIP() []byte {
return file_conf_conf_proto_rawDescData
}
var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_conf_conf_proto_goTypes = []any{
(*Bootstrap)(nil), // 0: kratos.api.Bootstrap
(*Server)(nil), // 1: kratos.api.Server
(*Data)(nil), // 2: kratos.api.Data
(*AdminBackend)(nil), // 3: kratos.api.AdminBackend
(*Server_HTTP)(nil), // 4: kratos.api.Server.HTTP
(*Server_GRPC)(nil), // 5: kratos.api.Server.GRPC
(*Data_Database)(nil), // 6: kratos.api.Data.Database
(*Data_Redis)(nil), // 7: kratos.api.Data.Redis
(*AdminBackend_JWT)(nil), // 8: kratos.api.AdminBackend.JWT
(*AdminBackend_Captcha)(nil), // 9: kratos.api.AdminBackend.Captcha
(*AdminBackend_Local)(nil), // 10: kratos.api.AdminBackend.Local
(*AdminBackend_Email)(nil), // 11: kratos.api.AdminBackend.Email
(*durationpb.Duration)(nil), // 12: google.protobuf.Duration
(*Bootstrap)(nil), // 0: kratos.api.Bootstrap
(*Server)(nil), // 1: kratos.api.Server
(*Data)(nil), // 2: kratos.api.Data
(*AdminBackend)(nil), // 3: kratos.api.AdminBackend
(*Server_HTTP)(nil), // 4: kratos.api.Server.HTTP
(*Server_GRPC)(nil), // 5: kratos.api.Server.GRPC
(*Data_Database)(nil), // 6: kratos.api.Data.Database
(*Data_Redis)(nil), // 7: kratos.api.Data.Redis
(*AdminBackend_JWT)(nil), // 8: kratos.api.AdminBackend.JWT
(*AdminBackend_Captcha)(nil), // 9: kratos.api.AdminBackend.Captcha
(*AdminBackend_Local)(nil), // 10: kratos.api.AdminBackend.Local
(*AdminBackend_Email)(nil), // 11: kratos.api.AdminBackend.Email
(*AdminBackend_Storage)(nil), // 12: kratos.api.AdminBackend.Storage
(*AdminBackend_Qiniu)(nil), // 13: kratos.api.AdminBackend.Qiniu
(*AdminBackend_ObjectStore)(nil), // 14: kratos.api.AdminBackend.ObjectStore
(*durationpb.Duration)(nil), // 15: google.protobuf.Duration
}
var file_conf_conf_proto_depIdxs = []int32{
1, // 0: kratos.api.Bootstrap.server:type_name -> kratos.api.Server
@@ -897,18 +1331,26 @@ var file_conf_conf_proto_depIdxs = []int32{
9, // 8: kratos.api.AdminBackend.captcha:type_name -> kratos.api.AdminBackend.Captcha
10, // 9: kratos.api.AdminBackend.local:type_name -> kratos.api.AdminBackend.Local
11, // 10: kratos.api.AdminBackend.email:type_name -> kratos.api.AdminBackend.Email
12, // 11: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration
12, // 12: kratos.api.Server.GRPC.timeout:type_name -> google.protobuf.Duration
12, // 13: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration
12, // 14: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration
12, // 15: kratos.api.AdminBackend.JWT.expires_time:type_name -> google.protobuf.Duration
12, // 16: kratos.api.AdminBackend.JWT.buffer_time:type_name -> google.protobuf.Duration
12, // 17: kratos.api.AdminBackend.Captcha.store_expiration:type_name -> google.protobuf.Duration
18, // [18:18] is the sub-list for method output_type
18, // [18:18] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
12, // 11: kratos.api.AdminBackend.storage:type_name -> kratos.api.AdminBackend.Storage
15, // 12: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration
15, // 13: kratos.api.Server.GRPC.timeout:type_name -> google.protobuf.Duration
15, // 14: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration
15, // 15: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration
15, // 16: kratos.api.AdminBackend.JWT.expires_time:type_name -> google.protobuf.Duration
15, // 17: kratos.api.AdminBackend.JWT.buffer_time:type_name -> google.protobuf.Duration
15, // 18: kratos.api.AdminBackend.Captcha.store_expiration:type_name -> google.protobuf.Duration
13, // 19: kratos.api.AdminBackend.Storage.qiniu:type_name -> kratos.api.AdminBackend.Qiniu
14, // 20: kratos.api.AdminBackend.Storage.aliyun_oss:type_name -> kratos.api.AdminBackend.ObjectStore
14, // 21: kratos.api.AdminBackend.Storage.huawei_obs:type_name -> kratos.api.AdminBackend.ObjectStore
14, // 22: kratos.api.AdminBackend.Storage.tencent_cos:type_name -> kratos.api.AdminBackend.ObjectStore
14, // 23: kratos.api.AdminBackend.Storage.aws_s3:type_name -> kratos.api.AdminBackend.ObjectStore
14, // 24: kratos.api.AdminBackend.Storage.cloudflare_r2:type_name -> kratos.api.AdminBackend.ObjectStore
14, // 25: kratos.api.AdminBackend.Storage.minio:type_name -> kratos.api.AdminBackend.ObjectStore
26, // [26:26] is the sub-list for method output_type
26, // [26:26] is the sub-list for method input_type
26, // [26:26] is the sub-list for extension type_name
26, // [26:26] is the sub-list for extension extendee
0, // [0:26] is the sub-list for field type_name
}
func init() { file_conf_conf_proto_init() }
@@ -922,7 +1364,7 @@ func file_conf_conf_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_conf_conf_proto_rawDesc), len(file_conf_conf_proto_rawDesc)),
NumEnums: 0,
NumMessages: 12,
NumMessages: 15,
NumExtensions: 0,
NumServices: 0,
},
+46
View File
@@ -30,6 +30,13 @@ message Data {
message Database {
string driver = 1;
string source = 2;
string host = 3;
string port = 4;
string user = 5;
string password = 6;
string name = 7;
string config = 8;
string path = 9;
}
message Redis {
string network = 1;
@@ -48,6 +55,9 @@ message AdminBackend {
Captcha captcha = 3;
Local local = 4;
Email email = 5;
Storage storage = 6;
// ConfigPath is populated by the entrypoint and is not required in YAML.
string config_path = 7;
message JWT {
string signing_key = 1;
@@ -78,4 +88,40 @@ message AdminBackend {
bool is_ssl = 7;
bool is_login_auth = 8;
}
message Storage {
string type = 1;
Qiniu qiniu = 2;
ObjectStore aliyun_oss = 3;
ObjectStore huawei_obs = 4;
ObjectStore tencent_cos = 5;
ObjectStore aws_s3 = 6;
ObjectStore cloudflare_r2 = 7;
ObjectStore minio = 8;
}
message Qiniu {
string zone = 1;
string bucket = 2;
string base_url = 3;
string access_key = 4;
string secret_key = 5;
bool use_https = 6;
bool use_cdn_domains = 7;
}
// ObjectStore covers S3-compatible configuration used by public cloud
// providers and self-hosted MinIO.
message ObjectStore {
string endpoint = 1;
string region = 2;
string bucket = 3;
string access_key = 4;
string secret_key = 5;
string base_url = 6;
string path_prefix = 7;
bool use_ssl = 8;
bool force_path_style = 9;
string account_id = 10;
}
}
+50 -92
View File
@@ -2,126 +2,84 @@ package data
import (
"context"
"errors"
"time"
"github.com/go-kratos/aip-go/ents"
"gorm.io/gorm"
"kra/internal/biz"
"kra/internal/data/ent"
"kra/internal/data/ent/admin"
)
func convertAdmin(po *ent.Admin) *biz.Admin {
return &biz.Admin{
ID: po.ID,
Name: po.Name,
Email: po.Email,
Avatar: po.Avatar,
Access: po.Access,
Password: po.Password,
CreateTime: po.CreateTime,
UpdateTime: po.UpdateTime,
type adminPO struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"uniqueIndex"`
Email string `gorm:"uniqueIndex"`
Password string
Access string
Avatar string
CreateTime time.Time
UpdateTime time.Time
}
func (adminPO) TableName() string { return "admins" }
func convertAdmin(po *adminPO) *biz.Admin {
return &biz.Admin{ID: po.ID, Name: po.Name, Email: po.Email, Password: po.Password, Access: po.Access, Avatar: po.Avatar, CreateTime: po.CreateTime, UpdateTime: po.UpdateTime}
}
type adminRepo struct{ data *Data }
func NewAdminRepo(data *Data) biz.AdminRepo { return &adminRepo{data: data} }
func (r *adminRepo) one(ctx context.Context, query *gorm.DB) (*biz.Admin, error) {
var po adminPO
if err := query.WithContext(ctx).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, biz.ErrAdminNotFound
}
return nil, err
}
return convertAdmin(&po), nil
}
type adminRepo struct {
data *Data
}
// NewAdminRepo creates a new AdminRepo instance.
func NewAdminRepo(data *Data) biz.AdminRepo {
return &adminRepo{
data: data,
}
}
func (r *adminRepo) FindByID(ctx context.Context, id int64) (*biz.Admin, error) {
po, err := r.data.db.Admin.Get(ctx, id)
if err != nil {
if ent.IsNotFound(err) {
return nil, biz.ErrAdminNotFound
}
return nil, err
}
return convertAdmin(po), nil
return r.one(ctx, r.data.gormDB.Where("id = ?", id))
}
func (r *adminRepo) FindByName(ctx context.Context, name string) (*biz.Admin, error) {
po, err := r.data.db.Admin.Query().Where(admin.NameEQ(name)).Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return nil, biz.ErrAdminNotFound
}
return nil, err
}
return convertAdmin(po), nil
return r.one(ctx, r.data.gormDB.Where("name = ?", name))
}
func (r *adminRepo) FindByEmail(ctx context.Context, email string) (*biz.Admin, error) {
po, err := r.data.db.Admin.Query().Where(admin.EmailEQ(email)).Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return nil, biz.ErrAdminNotFound
}
return nil, err
}
return convertAdmin(po), nil
return r.one(ctx, r.data.gormDB.Where("email = ?", email))
}
func (r *adminRepo) ListAdmins(ctx context.Context, opts ...biz.ListOption) ([]*biz.Admin, error) {
o := biz.ListOptions{Limit: 20}
for _, opt := range opts {
opt(&o)
}
pos, err := r.data.db.Admin.Query().
Where(ents.ApplyFilter(o.Filter)).
Order(ents.ApplyOrderBy(o.OrderBy)).
Offset(o.Offset).
Limit(o.Limit).
All(ctx)
if err != nil {
var pos []adminPO
if err := r.data.gormDB.WithContext(ctx).Offset(o.Offset).Limit(o.Limit).Order("id asc").Find(&pos).Error; err != nil {
return nil, err
}
var admins []*biz.Admin
for _, po := range pos {
admins = append(admins, convertAdmin(po))
out := make([]*biz.Admin, 0, len(pos))
for i := range pos {
out = append(out, convertAdmin(&pos[i]))
}
return admins, nil
return out, nil
}
func (r *adminRepo) CreateAdmin(ctx context.Context, admin *biz.Admin) (*biz.Admin, error) {
po, err := r.data.db.Admin.Create().
SetName(admin.Name).
SetEmail(admin.Email).
SetAvatar(admin.Avatar).
SetAccess(admin.Access).
SetPassword(admin.Password).
SetCreateTime(time.Now()).
SetUpdateTime(time.Now()).
Save(ctx)
if err != nil {
func (r *adminRepo) CreateAdmin(ctx context.Context, a *biz.Admin) (*biz.Admin, error) {
now := time.Now()
po := adminPO{Name: a.Name, Email: a.Email, Password: a.Password, Access: a.Access, Avatar: a.Avatar, CreateTime: now, UpdateTime: now}
if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil {
return nil, err
}
return convertAdmin(po), nil
return convertAdmin(&po), nil
}
func (r *adminRepo) UpdateAdmin(ctx context.Context, admin *biz.Admin) (*biz.Admin, error) {
update := r.data.db.Admin.UpdateOneID(admin.ID).
SetName(admin.Name).
SetEmail(admin.Email).
SetAccess(admin.Access).
SetAvatar(admin.Avatar).
SetUpdateTime(time.Now())
// Only update the password if it's not empty
if admin.Password != "" {
update.SetPassword(admin.Password)
func (r *adminRepo) UpdateAdmin(ctx context.Context, a *biz.Admin) (*biz.Admin, error) {
values := map[string]any{"name": a.Name, "email": a.Email, "access": a.Access, "avatar": a.Avatar, "update_time": time.Now()}
if a.Password != "" {
values["password"] = a.Password
}
po, err := update.Save(ctx)
if err != nil {
if err := r.data.gormDB.WithContext(ctx).Model(&adminPO{}).Where("id = ?", a.ID).Updates(values).Error; err != nil {
return nil, err
}
return convertAdmin(po), nil
return r.FindByID(ctx, a.ID)
}
func (r *adminRepo) DeleteAdmin(ctx context.Context, id int64) error {
return r.data.db.Admin.DeleteOneID(id).Exec(ctx)
return r.data.gormDB.WithContext(ctx).Delete(&adminPO{}, id).Error
}
+103
View File
@@ -0,0 +1,103 @@
package data
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"gopkg.in/yaml.v3"
)
func protoMap(message proto.Message) (map[string]any, error) {
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
if err != nil {
return nil, err
}
var value map[string]any
if err = json.Unmarshal(raw, &value); err != nil {
return nil, err
}
delete(value, "config_path")
return value, nil
}
func setYAMLMapping(node *yaml.Node, key string, value any) error {
if node.Kind == yaml.DocumentNode {
node = node.Content[0]
}
if node.Kind != yaml.MappingNode {
return fmt.Errorf("configuration root is not a mapping")
}
raw, err := yaml.Marshal(value)
if err != nil {
return err
}
var replacement yaml.Node
if err = yaml.Unmarshal(raw, &replacement); err != nil {
return err
}
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == key {
node.Content[i+1] = replacement.Content[0]
return nil
}
}
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0])
return nil
}
func (d *Data) persistConfig() error {
if d.admin == nil || d.admin.ConfigPath == "" {
return nil
}
raw, err := os.ReadFile(d.admin.ConfigPath)
if err != nil {
return err
}
var document yaml.Node
if err = yaml.Unmarshal(raw, &document); err != nil {
return err
}
dataValue, err := protoMap(d.config)
if err != nil {
return err
}
adminValue, err := protoMap(d.admin)
if err != nil {
return err
}
if err = setYAMLMapping(&document, "data", dataValue); err != nil {
return err
}
if err = setYAMLMapping(&document, "admin", adminValue); err != nil {
return err
}
output, err := yaml.Marshal(&document)
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(d.admin.ConfigPath), 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(d.admin.ConfigPath), ".kra-config-*.yaml")
if err != nil {
return err
}
tempName := temporary.Name()
defer os.Remove(tempName)
if _, err = temporary.Write(output); err != nil {
_ = temporary.Close()
return err
}
if err = temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if err = temporary.Close(); err != nil {
return err
}
return os.Rename(tempName, d.admin.ConfigPath)
}
+55 -48
View File
@@ -4,55 +4,47 @@ import (
"context"
"fmt"
"log"
"os"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/google/wire"
"github.com/redis/go-redis/v9"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"kra/internal/conf"
"kra/internal/data/ent"
"kra/internal/data/ent/migrate"
)
// ProviderSet is data providers.
var ProviderSet = wire.NewSet(NewData, NewAdminRepo, NewSystemRepo, NewAccessRepo, NewSettingsRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewCache, NewFileStorage)
// Data is a struct that contains the database client.
type Data struct {
db *ent.Client
gormDB *gorm.DB
redis *redis.Client
mu sync.RWMutex
gormDB *gorm.DB
redis *redis.Client
database *conf.Data_Database
config *conf.Data
admin *conf.AdminBackend
}
// NewData creates a new Data instance.
func NewData(c *conf.Data) (*Data, func(), error) {
func NewData(c *conf.Data, admin *conf.AdminBackend) (*Data, func(), error) {
if c == nil || c.Database == nil {
return nil, nil, fmt.Errorf("database configuration is required")
}
db, err := ent.Open(c.Database.Driver, c.Database.Source)
d := &Data{database: c.Database, config: c, admin: admin}
db, err := openDatabase(c.Database, false)
if err != nil {
return nil, nil, fmt.Errorf("open ent database: %w", err)
}
gormDB, err := gorm.Open(mysql.Open(c.Database.Source), &gorm.Config{})
if err != nil {
_ = db.Close()
return nil, nil, err
}
// The announcement module owns its table lifecycle independently of the
// first-run database initializer.
if err = gormDB.AutoMigrate(&announcementPO{}); err != nil {
_ = db.Close()
return nil, nil, fmt.Errorf("migrate announcement table: %w", err)
}
var redisClient *redis.Client
if c.Redis != nil && c.Redis.Addr != "" {
options := &redis.Options{Addr: c.Redis.Addr}
if c.Redis.Network != "" {
options.Network = c.Redis.Network
// The initialization endpoint must remain available when the configured
// target database has not been created yet.
log.Printf("configured database unavailable before initialization: %v", err)
db, err = openFallbackDatabase()
if err != nil {
return nil, nil, fmt.Errorf("open bootstrap database: %w", err)
}
}
d.gormDB = db
if err = db.AutoMigrate(&announcementPO{}, &adminPO{}); err != nil {
return nil, nil, fmt.Errorf("migrate bootstrap tables: %w", err)
}
if c.Redis != nil && c.Redis.Addr != "" {
options := &redis.Options{Addr: c.Redis.Addr, Network: c.Redis.Network}
if c.Redis.ReadTimeout != nil {
options.ReadTimeout = c.Redis.ReadTimeout.AsDuration()
}
@@ -65,27 +57,42 @@ func NewData(c *conf.Data) (*Data, func(), error) {
log.Printf("redis unavailable, using in-memory cache: %v", pingErr)
_ = candidate.Close()
} else {
redisClient = candidate
d.redis = candidate
}
cancel()
}
if os.Getenv("DEPLOY_ENV") == "dev" {
// Enable debug mode for detailed logging.
db = db.Debug()
// Run the auto migration tool.
if err = db.Schema.Create(context.Background(), migrate.WithDropIndex(true)); err != nil {
return nil, nil, err
}
}
cleanup := func() {
_ = db.Close()
if redisClient != nil {
_ = redisClient.Close()
d.mu.RLock()
db := d.gormDB
d.mu.RUnlock()
if sqlDB, closeErr := db.DB(); closeErr == nil {
_ = sqlDB.Close()
}
if d.redis != nil {
_ = d.redis.Close()
}
}
return &Data{
db: db,
gormDB: gormDB,
redis: redisClient,
}, cleanup, nil
return d, cleanup, nil
}
func (d *Data) switchDatabase(config *conf.Data_Database) error {
db, err := openDatabase(config, true)
if err != nil {
return err
}
d.mu.Lock()
old := d.gormDB
d.gormDB = db
d.database = config
d.config.Database = config
d.mu.Unlock()
if err := d.persistConfig(); err != nil {
return fmt.Errorf("persist database configuration: %w", err)
}
if old != nil {
if sqlDB, e := old.DB(); e == nil {
_ = sqlDB.Close()
}
}
return nil
}
+179
View File
@@ -0,0 +1,179 @@
package data
import (
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
oracle "github.com/dzwvip/gorm-oracle"
"github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
"kra/internal/conf"
)
var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`)
func normalizedDriver(driver string) string {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "postgres", "postgresql", "pgsql":
return "pgsql"
case "sqlserver", "mssql":
return "mssql"
case "sqlite", "sqlite3":
return "sqlite"
case "oracle":
return "oracle"
default:
return "mysql"
}
}
func databaseDSN(c *conf.Data_Database, name string) (string, error) {
if c.Source != "" && c.Host == "" && c.Path == "" {
return c.Source, nil
}
driver := normalizedDriver(c.Driver)
if name == "" {
name = c.Name
}
host := c.Host
if host == "" {
host = "127.0.0.1"
}
switch driver {
case "mysql":
port := c.Port
if port == "" {
port = "3306"
}
query := c.Config
if query == "" {
query = "timeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
}
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.User, c.Password, net.JoinHostPort(host, port), name, query), nil
case "pgsql":
port := c.Port
if port == "" {
port = "5432"
}
extra := c.Config
if extra == "" {
extra = "sslmode=disable TimeZone=Local"
}
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s %s", host, port, c.User, c.Password, name, extra), nil
case "mssql":
port := c.Port
if port == "" {
port = "1433"
}
q := url.Values{"database": {name}, "encrypt": {"disable"}}
return (&url.URL{Scheme: "sqlserver", User: url.UserPassword(c.User, c.Password), Host: net.JoinHostPort(host, port), RawQuery: q.Encode()}).String(), nil
case "oracle":
port := c.Port
if port == "" {
port = "1521"
}
return fmt.Sprintf("oracle://%s:%s@%s/%s?%s", url.PathEscape(c.User), url.PathEscape(c.Password), net.JoinHostPort(host, port), url.PathEscape(name), c.Config), nil
case "sqlite":
path := c.Path
if path == "" {
path = "."
}
if name == "" {
name = "kra"
}
if filepath.Ext(name) == "" {
name += ".db"
}
return filepath.Join(path, name), nil
}
return "", fmt.Errorf("unsupported database driver %q", c.Driver)
}
func openWithDriver(driver, dsn string) (*gorm.DB, error) {
switch normalizedDriver(driver) {
case "mysql":
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
case "pgsql":
return gorm.Open(postgres.Open(dsn), &gorm.Config{})
case "mssql":
return gorm.Open(sqlserver.Open(dsn), &gorm.Config{})
case "oracle":
return gorm.Open(oracle.Open(dsn), &gorm.Config{})
case "sqlite":
return gorm.Open(sqlite.Open(dsn), &gorm.Config{})
default:
return nil, fmt.Errorf("unsupported database driver %q", driver)
}
}
func openDatabase(c *conf.Data_Database, create bool) (*gorm.DB, error) {
driver := normalizedDriver(c.Driver)
if driver == "sqlite" {
dsn, err := databaseDSN(c, "")
if err != nil {
return nil, err
}
if err = os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
return nil, err
}
return openWithDriver(driver, dsn)
}
if create && driver != "oracle" {
if !databaseNamePattern.MatchString(c.Name) {
return nil, fmt.Errorf("invalid database name %q", c.Name)
}
bootstrap := ""
switch driver {
case "pgsql":
bootstrap = "postgres"
case "mssql":
bootstrap = "master"
}
dsn, err := databaseDSN(c, bootstrap)
if err != nil {
return nil, err
}
adminDB, err := openWithDriver(driver, dsn)
if err != nil {
return nil, fmt.Errorf("connect database server: %w", err)
}
var statement string
switch driver {
case "mysql":
statement = "CREATE DATABASE IF NOT EXISTS `" + c.Name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
case "pgsql":
var count int64
if err = adminDB.Raw("SELECT count(*) FROM pg_database WHERE datname = ?", c.Name).Scan(&count).Error; err == nil && count == 0 {
statement = `CREATE DATABASE "` + c.Name + `"`
}
case "mssql":
statement = "IF DB_ID(N'" + c.Name + "') IS NULL CREATE DATABASE [" + c.Name + "]"
}
if statement != "" {
err = adminDB.Exec(statement).Error
}
if sqlDB, e := adminDB.DB(); e == nil {
_ = sqlDB.Close()
}
if err != nil {
return nil, fmt.Errorf("create database: %w", err)
}
}
dsn, err := databaseDSN(c, "")
if err != nil {
return nil, err
}
return openWithDriver(driver, dsn)
}
func openFallbackDatabase() (*gorm.DB, error) {
return openWithDriver("sqlite", "file:kra-bootstrap?mode=memory&cache=shared")
}
+65 -34
View File
@@ -21,6 +21,33 @@ type fileStorage struct {
}
func NewFileStorage(config *conf.AdminBackend) (biz.FileStorage, error) {
storageType := "local"
if config != nil && config.Storage != nil && config.Storage.Type != "" {
storageType = strings.ToLower(config.Storage.Type)
}
if storageType == "qiniu" {
return newQiniuStorage(config.Storage.Qiniu)
}
if storageType != "local" {
var object *conf.AdminBackend_ObjectStore
switch storageType {
case "aliyun-oss":
object = config.Storage.AliyunOss
case "huawei-obs":
object = config.Storage.HuaweiObs
case "tencent-cos":
object = config.Storage.TencentCos
case "aws-s3":
object = config.Storage.AwsS3
case "cloudflare-r2":
object = config.Storage.CloudflareR2
case "minio":
object = config.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 != "" {
@@ -37,6 +64,43 @@ func NewFileStorage(config *conf.AdminBackend) (biz.FileStorage, error) {
return &fileStorage{root: root, urlPrefix: "/" + strings.Trim(prefix, "/")}, nil
}
func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if ctx.Err() != nil {
errCh <- ctx.Err()
return
}
file, err := storage.Open(ctx, name)
if err != nil {
errCh <- err
return
}
_, err = io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if err != nil {
errCh <- err
return
}
}
errCh <- nil
}()
stored, err := storage.Put(ctx, destination, reader)
composeErr := <-errCh
if err != nil {
return nil, "", err
}
if composeErr != nil {
_ = storage.Delete(ctx, destination)
return nil, "", composeErr
}
return stored, hex.EncodeToString(hash.Sum(nil)), nil
}
func (s *fileStorage) resolve(name string) (string, error) {
clean := filepath.Clean(strings.TrimPrefix(name, "/"))
if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
@@ -88,40 +152,7 @@ func (s *fileStorage) Delete(ctx context.Context, name string) error {
}
func (s *fileStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if ctx.Err() != nil {
errCh <- ctx.Err()
return
}
file, err := s.Open(ctx, name)
if err != nil {
errCh <- err
return
}
_, err = io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if err != nil {
errCh <- err
return
}
}
errCh <- nil
}()
stored, err := s.Put(ctx, destination, reader)
composeErr := <-errCh
if err != nil {
return nil, "", err
}
if composeErr != nil {
_ = s.Delete(ctx, destination)
return nil, "", composeErr
}
return stored, hex.EncodeToString(hash.Sum(nil)), nil
return composeFiles(ctx, s, names, destination)
}
func (s *fileStorage) DeletePrefix(ctx context.Context, prefix string) error {
path, err := s.resolve(strings.TrimSuffix(prefix, "/") + "/placeholder")
+1 -1
View File
@@ -143,7 +143,7 @@ func (r *mediaRepo) DeleteMedia(ctx context.Context, id uint) error {
}
func (r *mediaRepo) MediaKeyReferences(ctx context.Context, key string) (int64, error) {
var count int64
err := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}).Where("`key` = ?", key).Count(&count).Error
err := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}).Where(map[string]any{"key": key}).Count(&count).Error
return count, err
}
func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*biz.MediaFile) error {
+126
View File
@@ -0,0 +1,126 @@
package data
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/qiniu/go-sdk/v7/auth/qbox"
qstorage "github.com/qiniu/go-sdk/v7/storage"
"kra/internal/biz"
"kra/internal/conf"
)
type qiniuStorage struct {
config *conf.AdminBackend_Qiniu
upload *qstorage.FormUploader
manager *qstorage.BucketManager
mac *qbox.Mac
token string
}
func newQiniuStorage(config *conf.AdminBackend_Qiniu) (biz.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}
switch config.Zone {
case "ZoneHuadong":
cfg.Zone = &qstorage.ZoneHuadong
case "ZoneHuabei":
cfg.Zone = &qstorage.ZoneHuabei
case "ZoneHuanan":
cfg.Zone = &qstorage.ZoneHuanan
case "ZoneBeimei":
cfg.Zone = &qstorage.ZoneBeimei
case "ZoneXinjiapo":
cfg.Zone = &qstorage.ZoneXinjiapo
}
mac := qbox.NewMac(config.AccessKey, config.SecretKey)
policy := qstorage.PutPolicy{Scope: config.Bucket}
token := policy.UploadToken(mac)
return &qiniuStorage{config: config, upload: qstorage.NewFormUploader(&cfg), manager: qstorage.NewBucketManager(mac, &cfg), mac: mac, token: token}, nil
}
func (s *qiniuStorage) file(key string, size int64) *biz.StoredFile {
return &biz.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) (*biz.StoredFile, error) {
temporary, err := os.CreateTemp("", "kra-qiniu-upload-*")
if err != nil {
return nil, err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
size, err := io.Copy(temporary, reader)
if err != nil {
_ = temporary.Close()
return nil, err
}
if _, err = temporary.Seek(0, io.SeekStart); err != nil {
_ = temporary.Close()
return nil, err
}
defer temporary.Close()
ret := qstorage.PutRet{}
if err = s.upload.Put(ctx, &ret, s.token, strings.TrimPrefix(name, "/"), temporary, size, &qstorage.PutExtra{}); err != nil {
return nil, err
}
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())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
_ = resp.Body.Close()
return nil, fmt.Errorf("qiniu download failed: %s", resp.Status)
}
return resp.Body, nil
}
func (s *qiniuStorage) Delete(ctx context.Context, name string) error {
return s.manager.Delete(s.config.Bucket, strings.TrimPrefix(name, "/"))
}
func (s *qiniuStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *qiniuStorage) DeletePrefix(ctx context.Context, prefix string) error {
for {
items, _, more, err := s.List(ctx, prefix, "", 1000)
if err != nil {
return err
}
for _, item := range items {
if err = s.Delete(ctx, item.Path); err != nil {
return err
}
}
if !more {
return nil
}
}
}
func (s *qiniuStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit < 1 || limit > 1000 {
limit = 100
}
entries, _, marker, more, err := s.manager.ListFiles(s.config.Bucket, prefix, "", cursor, limit)
if err != nil {
return nil, "", false, err
}
out := make([]*biz.StoredFile, 0, len(entries))
for _, entry := range entries {
out = append(out, s.file(entry.Key, entry.Fsize))
}
return out, marker, more, nil
}
+138
View File
@@ -0,0 +1,138 @@
package data
import (
"context"
"fmt"
"io"
"net/url"
"path"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"kra/internal/biz"
"kra/internal/conf"
)
type s3Storage struct {
client *minio.Client
bucket, baseURL, prefix string
}
func newS3Storage(provider string, config *conf.AdminBackend_ObjectStore) (biz.FileStorage, error) {
if config == nil || config.Bucket == "" || config.AccessKey == "" || config.SecretKey == "" {
return nil, fmt.Errorf("%s storage configuration is incomplete", provider)
}
endpoint := config.Endpoint
if endpoint == "" {
switch provider {
case "aws-s3":
endpoint = "s3." + config.Region + ".amazonaws.com"
case "cloudflare-r2":
endpoint = config.AccountId + ".r2.cloudflarestorage.com"
case "aliyun-oss":
endpoint = "oss-" + config.Region + ".aliyuncs.com"
case "huawei-obs":
endpoint = "obs." + config.Region + ".myhuaweicloud.com"
case "tencent-cos":
endpoint = "cos." + config.Region + ".myqcloud.com"
}
}
secure := config.UseSsl
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
secure = parsed.Scheme == "https"
endpoint = parsed.Host
}
if endpoint == "" {
return nil, fmt.Errorf("%s endpoint is required", provider)
}
client, err := minio.New(strings.TrimSuffix(endpoint, "/"), &minio.Options{Creds: credentials.NewStaticV4(config.AccessKey, config.SecretKey, ""), Secure: secure, Region: config.Region, BucketLookup: func() minio.BucketLookupType {
if config.ForcePathStyle {
return minio.BucketLookupPath
}
return minio.BucketLookupAuto
}()})
if err != nil {
return nil, err
}
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 == "" {
return strings.TrimPrefix(name, "/")
}
return path.Join(s.prefix, strings.TrimPrefix(name, "/"))
}
func (s *s3Storage) unkey(key string) string {
return strings.TrimPrefix(strings.TrimPrefix(key, s.prefix), "/")
}
func (s *s3Storage) file(key string, size int64) *biz.StoredFile {
name := s.unkey(key)
rawURL := s.baseURL + "/" + key
if s.baseURL == "" {
rawURL = key
}
return &biz.StoredFile{Name: path.Base(name), Path: name, URL: rawURL, Size: size}
}
func (s *s3Storage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) {
key := s.key(name)
info, err := s.client.PutObject(ctx, s.bucket, key, reader, -1, minio.PutObjectOptions{})
if err != nil {
return nil, err
}
return s.file(key, info.Size), nil
}
func (s *s3Storage) Open(ctx context.Context, name string) (io.ReadCloser, error) {
obj, err := s.client.GetObject(ctx, s.bucket, s.key(name), minio.GetObjectOptions{})
if err != nil {
return nil, err
}
if _, err = obj.Stat(); err != nil {
_ = obj.Close()
return nil, err
}
return obj, nil
}
func (s *s3Storage) Delete(ctx context.Context, name string) error {
return s.client.RemoveObject(ctx, s.bucket, s.key(name), minio.RemoveObjectOptions{})
}
func (s *s3Storage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) {
return composeFiles(ctx, s, names, destination)
}
func (s *s3Storage) DeletePrefix(ctx context.Context, prefix string) error {
items := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: s.key(prefix), Recursive: true})
for item := range items {
if item.Err != nil {
return item.Err
}
if err := s.client.RemoveObject(ctx, s.bucket, item.Key, minio.RemoveObjectOptions{}); err != nil {
return err
}
}
return nil
}
func (s *s3Storage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) {
if limit < 1 || limit > 1000 {
limit = 100
}
items := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: s.key(prefix), Recursive: true, StartAfter: s.key(cursor)})
out := make([]*biz.StoredFile, 0, limit+1)
for item := range items {
if item.Err != nil {
return nil, "", false, item.Err
}
out = append(out, s.file(item.Key, item.Size))
if len(out) > limit {
break
}
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if len(out) > 0 {
next = out[len(out)-1].Path
}
return out, next, more, nil
}
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"kra/internal/biz"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type dictionaryPO struct {
@@ -334,7 +335,7 @@ func (r *settingsRepo) FindParameter(ctx context.Context, id uint, key string) (
if id != 0 {
err = db.First(&po, id).Error
} else {
err = db.Where("`key` = ?", key).First(&po).Error
err = db.Where(map[string]any{"key": key}).First(&po).Error
}
if err != nil {
return nil, err
@@ -354,7 +355,7 @@ func (r *settingsRepo) ListParameters(ctx context.Context, page, size int, q *bi
db = db.Where("name LIKE ?", "%"+q.Name+"%")
}
if q.Key != "" {
db = db.Where("`key` LIKE ?", "%"+q.Key+"%")
db = db.Where(clause.Like{Column: clause.Column{Name: "key"}, Value: "%" + q.Key + "%"})
}
}
var total int64
+18 -5
View File
@@ -7,6 +7,7 @@ import (
"time"
"kra/internal/biz"
"kra/internal/conf"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
@@ -87,14 +88,22 @@ func (authorityMenuPO) TableName() string { return "sys_authority_menus" }
type systemRepo struct{ data *Data }
func NewSystemRepo(data *Data) biz.SystemRepo { return &systemRepo{data: data} }
func NewSystemRepo(data *Data) biz.SystemRepo { return &systemRepo{data: data} }
func (r *systemRepo) PersistConfig(context.Context) error { return r.data.persistConfig() }
func (r *systemRepo) IsInitialized(ctx context.Context) (bool, error) {
return r.data.gormDB.WithContext(ctx).Migrator().HasTable(&userPO{}), nil
}
func (r *systemRepo) Initialize(ctx context.Context) error {
func (r *systemRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error {
config := &conf.Data_Database{Driver: input.Driver, Host: input.Host, Port: input.Port, User: input.User, Password: input.Password, Name: input.Name, Path: input.Path, Config: input.Config}
if err := r.data.switchDatabase(config); err != nil {
return err
}
db := r.data.gormDB.WithContext(ctx)
if err := db.AutoMigrate(&adminPO{}); err != nil {
return err
}
if err := db.AutoMigrate(&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &menuButtonPO{}, &authorityButtonPO{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, &parameterPO{}, &apiTokenPO{}, &securityConfigPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, &operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}, &announcementPO{}); err != nil {
return err
}
@@ -140,7 +149,7 @@ func (r *systemRepo) Initialize(ctx context.Context) error {
return err
}
if count == 0 {
hash, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
hash, err := bcrypt.GenerateFromPassword([]byte(input.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
@@ -349,9 +358,13 @@ func (r *systemRepo) DeleteUser(ctx context.Context, id uint) error {
return tx.Delete(&userPO{}, id).Error
})
}
func (r *systemRepo) UpdatePassword(ctx context.Context, id uint, password string) error {
func (r *systemRepo) UpdatePassword(ctx context.Context, id uint, password string, clearMustChange bool) error {
now := time.Now()
return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(map[string]any{"password": password, "password_updated_at": now, "must_change_password": false}).Error
updates := map[string]any{"password": password, "password_updated_at": now}
if clearMustChange {
updates["must_change_password"] = false
}
return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(updates).Error
}
func (r *systemRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) {
var pos []authorityPO
+1 -1
View File
@@ -62,7 +62,7 @@ func redactJSON(raw []byte) string {
}
func operationAudit(svc *service.AuditService) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" {
if isBootstrapPath(c.Request.URL.Path) || c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" {
c.Next()
return
}
+35 -5
View File
@@ -34,7 +34,8 @@ func NewGinServer(c *conf.Server, config *conf.AdminBackend, svc *service.System
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(ginRequestMeta(), emailErrorAlert(emails, logger), gin.Recovery(), securityRateLimit(svc, settings), operationAudit(audit))
if config != nil && config.Local != nil && config.Local.StorePath != "" {
localStorage := config == nil || config.Storage == nil || config.Storage.Type == "" || config.Storage.Type == "local"
if localStorage && config != nil && config.Local != nil && config.Local.StorePath != "" {
pathPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
if pathPrefix != "/" {
engine.StaticFS(pathPrefix, filesOnly{FileSystem: http.Dir(config.Local.StorePath)})
@@ -51,11 +52,11 @@ func NewGinServer(c *conf.Server, config *conf.AdminBackend, svc *service.System
})
registerPublicRoutes(public, engine, config, svc, settings, access, audit)
private := engine.Group(prefix)
private.Use(jwtAuth(config, settings), accessControl(access, audit))
private.Use(jwtAuth(config, settings), mustChangePasswordGuard(), accessControl(access, audit))
registerPrivateRoutes(private, svc)
registerAccessRoutes(private, engine, access)
registerSettingsRoutes(private, public, settings)
registerSystemConfigRoutes(private, config, settings)
registerSystemConfigRoutes(private, config, svc, settings)
registerVersionRoutes(private, versions)
registerExportRoutes(private, public, svc, exports)
registerAuditRoutes(private, public, audit)
@@ -117,6 +118,22 @@ func currentClaims(c *gin.Context) *adminauth.Claims {
return result
}
func mustChangePasswordGuard() gin.HandlerFunc {
return func(c *gin.Context) {
claims := currentClaims(c)
if claims == nil || !claims.MustChangePwd {
c.Next()
return
}
path := strings.TrimSuffix(c.Request.URL.Path, "/")
if strings.HasSuffix(path, "/user/changePassword") || strings.HasSuffix(path, "/user/getUserInfo") || strings.HasSuffix(path, "/jwt/jsonInBlacklist") {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusConflict, response{Code: codePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "请先修改初始密码"})
}
}
func registerPublicRoutes(group *gin.RouterGroup, engine *gin.Engine, config *conf.AdminBackend, svc *service.SystemService, settings *service.SettingsService, access *service.AccessService, audit *service.AuditService) {
expiration := 3 * time.Minute
keyLong, width, height := 6, 240, 80
@@ -221,8 +238,13 @@ func registerPublicRoutes(group *gin.RouterGroup, engine *gin.Engine, config *co
writeResult(c, codeSuccess, gin.H{"needInit": !initialized}, message)
})
initGroup.POST("/initdb", func(c *gin.Context) {
if err := svc.Initialize(c.Request.Context()); err != nil {
fail(c, "自动创建数据库失败")
var input service.DatabaseInit
if err := c.ShouldBindJSON(&input); err != nil {
fail(c, "数据库初始化参数无效")
return
}
if err := svc.Initialize(c.Request.Context(), &input); err != nil {
fail(c, "自动创建数据库失败: "+err.Error())
return
}
routes := engine.Routes()
@@ -261,6 +283,10 @@ func incrementCached(ctx context.Context, svc *service.SystemService, key string
}
func securityRateLimit(svc *service.SystemService, settings *service.SettingsService) gin.HandlerFunc {
return func(c *gin.Context) {
if isBootstrapPath(c.Request.URL.Path) {
c.Next()
return
}
cfg, err := settings.CurrentSecurity(c.Request.Context())
if err != nil || cfg == nil || !cfg.LimitEnable {
c.Next()
@@ -281,6 +307,10 @@ func securityRateLimit(svc *service.SystemService, settings *service.SettingsSer
}
}
func isBootstrapPath(path string) bool {
return strings.HasSuffix(path, "/health") || strings.Contains(path, "/init/")
}
type captchaStore struct {
service *service.SystemService
expiration time.Duration
+61 -6
View File
@@ -3,15 +3,19 @@ package server
import (
"runtime"
"syscall"
"time"
"kra/internal/biz"
"kra/internal/conf"
"kra/internal/service"
"github.com/gin-gonic/gin"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/mem"
"google.golang.org/protobuf/proto"
)
func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBackend, settings *service.SettingsService) {
func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBackend, systemService *service.SystemService, settings *service.SettingsService) {
security := group.Group("/securityConfig")
security.GET("/getSecurityConfig", func(c *gin.Context) {
value, err := settings.Security(c.Request.Context())
@@ -53,6 +57,11 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken
if config.Email != nil {
email = gin.H{"to": config.Email.To, "from": config.Email.From, "host": config.Email.Host, "secret": "******", "nickname": config.Email.Nickname, "port": config.Email.Port, "is-ssl": config.Email.IsSsl, "is-loginauth": config.Email.IsLoginAuth}
}
if config.Storage != nil {
storage := proto.Clone(config.Storage).(*conf.AdminBackend_Storage)
maskStorageSecrets(storage)
admin["storage"] = storage
}
}
writeResult(c, codeSuccess, gin.H{"config": gin.H{"admin": admin, "email": email}}, "获取成功")
})
@@ -69,6 +78,7 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken
StorePath string `json:"storePath"`
PathPrefix string `json:"pathPrefix"`
} `json:"local"`
Storage *conf.AdminBackend_Storage `json:"storage"`
} `json:"admin"`
Email *struct {
To string `json:"to"`
@@ -104,6 +114,10 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken
config.Local.PathPrefix = req.Config.Admin.Local.PathPrefix
}
}
if req.Config.Admin.Storage != nil {
preserveStorageSecrets(req.Config.Admin.Storage, config.Storage)
config.Storage = req.Config.Admin.Storage
}
if config.Email != nil && req.Config.Email != nil {
config.Email.To = req.Config.Email.To
config.Email.From = req.Config.Email.From
@@ -117,12 +131,18 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken
}
}
}
ok(c)
if err := systemService.PersistConfig(c.Request.Context()); err != nil {
fail(c, "配置保存失败: "+err.Error())
return
}
writeResult(c, codeSuccess, gin.H{"restartRequired": true}, "配置已保存,重启服务后完整生效")
})
system.POST("/reloadSystem", func(c *gin.Context) {
writeResult(c, codeSuccess, gin.H{"reload": false, "restartRequired": true}, "配置已在当前进程部分生效;路由与存储配置需重启服务")
})
system.POST("/reloadSystem", func(c *gin.Context) { writeResult(c, codeSuccess, gin.H{"reload": true}, "配置已热更新") })
system.POST("/getServerInfo", func(c *gin.Context) {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
cpuPercent, _ := cpu.Percent(200*time.Millisecond, false)
memory, _ := mem.VirtualMemory()
disk := []gin.H{}
var stat syscall.Statfs_t
if syscall.Statfs(".", &stat) == nil {
@@ -135,7 +155,42 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken
}
disk = append(disk, gin.H{"mountPoint": ".", "usedMb": used / 1024 / 1024, "usedGb": used / 1024 / 1024 / 1024, "totalMb": total / 1024 / 1024, "totalGb": total / 1024 / 1024 / 1024, "usedPercent": percent})
}
server := gin.H{"os": gin.H{"goos": runtime.GOOS, "numCpu": runtime.NumCPU(), "compiler": runtime.Compiler, "goVersion": runtime.Version(), "numGoroutine": runtime.NumGoroutine()}, "cpu": gin.H{"cpus": []float64{}, "cores": runtime.NumCPU()}, "ram": gin.H{"usedMb": mem.Alloc / 1024 / 1024, "totalMb": mem.Sys / 1024 / 1024, "usedPercent": 0}, "disk": disk}
usedMB, totalMB, memoryPercent := uint64(0), uint64(0), float64(0)
if memory != nil {
usedMB, totalMB, memoryPercent = memory.Used/1024/1024, memory.Total/1024/1024, memory.UsedPercent
}
server := gin.H{"os": gin.H{"goos": runtime.GOOS, "numCpu": runtime.NumCPU(), "compiler": runtime.Compiler, "goVersion": runtime.Version(), "numGoroutine": runtime.NumGoroutine()}, "cpu": gin.H{"cpus": cpuPercent, "cores": runtime.NumCPU()}, "ram": gin.H{"usedMb": usedMB, "totalMb": totalMB, "usedPercent": memoryPercent}, "disk": disk}
writeResult(c, codeSuccess, gin.H{"server": server}, "获取成功")
})
}
func objectStores(storage *conf.AdminBackend_Storage) []*conf.AdminBackend_ObjectStore {
if storage == nil {
return nil
}
return []*conf.AdminBackend_ObjectStore{storage.AliyunOss, storage.HuaweiObs, storage.TencentCos, storage.AwsS3, storage.CloudflareR2, storage.Minio}
}
func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
if storage.Qiniu != nil {
storage.Qiniu.SecretKey = "******"
}
for _, item := range objectStores(storage) {
if item != nil {
item.SecretKey = "******"
}
}
}
func preserveStorageSecrets(next, current *conf.AdminBackend_Storage) {
if next == nil || current == nil {
return
}
if next.Qiniu != nil && current.Qiniu != nil && (next.Qiniu.SecretKey == "" || next.Qiniu.SecretKey == "******") {
next.Qiniu.SecretKey = current.Qiniu.SecretKey
}
nextItems, currentItems := objectStores(next), objectStores(current)
for i := range nextItems {
if nextItems[i] != nil && currentItems[i] != nil && (nextItems[i].SecretKey == "" || nextItems[i].SecretKey == "******") {
nextItems[i].SecretKey = currentItems[i].SecretKey
}
}
}
+24 -1
View File
@@ -29,7 +29,30 @@ func NewSystemService(uc *biz.SystemUsecase, config *conf.AdminBackend, settings
func (s *SystemService) IsInitialized(ctx context.Context) (bool, error) {
return s.uc.IsInitialized(ctx)
}
func (s *SystemService) Initialize(ctx context.Context) error { return s.uc.Initialize(ctx) }
func (s *SystemService) PersistConfig(ctx context.Context) error { return s.uc.PersistConfig(ctx) }
type DatabaseInit struct {
DBType string `json:"dbType"`
Host string `json:"host"`
Port string `json:"port"`
UserName string `json:"userName"`
Password string `json:"password"`
DBName string `json:"dbName"`
DBPath string `json:"dbPath"`
Template string `json:"template"`
AdminPassword string `json:"adminPassword"`
}
func (s *SystemService) Initialize(ctx context.Context, input *DatabaseInit) error {
config := ""
switch input.DBType {
case "mysql":
config = "timeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
case "pgsql":
config = "sslmode=disable TimeZone=Local"
}
return s.uc.Initialize(ctx, &biz.DatabaseConfig{Driver: input.DBType, Host: input.Host, Port: input.Port, User: input.UserName, Password: input.Password, Name: input.DBName, Path: input.DBPath, Config: config, AdminPassword: input.AdminPassword})
}
func (s *SystemService) CacheGet(ctx context.Context, key string) (string, bool, error) {
return s.uc.CacheGet(ctx, key)