diff --git a/go.mod b/go.mod index e5ef76c1..a567360f 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.114 github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 github.com/nikoksr/notify v1.0.0 + github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 github.com/pkg/sftp v1.13.6 github.com/pocketbase/dbx v1.10.1 github.com/pocketbase/pocketbase v0.22.18 @@ -26,10 +27,13 @@ require ( github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.0.992 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030 golang.org/x/crypto v0.28.0 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 k8s.io/api v0.31.1 k8s.io/apimachinery v0.31.1 k8s.io/client-go v0.31.1 + software.sslmate.com/src/go-pkcs12 v0.5.0 ) require ( @@ -58,7 +62,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030 // indirect github.com/x448/float16 v0.8.4 // indirect go.mongodb.org/mongo-driver v1.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect @@ -111,7 +114,7 @@ require ( github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.17.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/ganigeorgiev/fexpr v0.4.1 // indirect github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect diff --git a/go.sum b/go.sum index 69a4a09c..08ae184b 100644 --- a/go.sum +++ b/go.sum @@ -206,6 +206,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= +github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= +github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw= github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k= github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= @@ -398,6 +400,8 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -458,7 +462,6 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017 h1:Oymmfm github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017/go.mod h1:gnLxGXlLmF+jDqWR1/RVoF/UUwxQxomQhkc0oN7KeuI= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.992/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1002/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017 h1:SXrldOXwgomYuATVAuz5ofpTjB+99qVELgdy5R5kMgI= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030 h1:kwiUoCkooUgy7iPyhEEbio7WT21kGJUeZ5JeJfb/dYk= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= @@ -531,6 +534,8 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -776,3 +781,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+s sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index eae80d5a..100f6753 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -1,16 +1,21 @@ package deployer import ( + "bytes" "context" "encoding/json" + "encoding/pem" "errors" "fmt" - "strings" + "time" + "github.com/pavlo-v-chernykh/keystore-go/v4" "github.com/pocketbase/pocketbase/models" + "software.sslmate.com/src/go-pkcs12" "github.com/usual2970/certimate/internal/applicant" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/x509" "github.com/usual2970/certimate/internal/utils/app" ) @@ -38,7 +43,6 @@ const ( type DeployerOption struct { DomainId string `json:"domainId"` Domain string `json:"domain"` - Product string `json:"product"` Access string `json:"access"` AccessRecord *models.Record `json:"-"` DeployConfig domain.DeployConfig `json:"deployConfig"` @@ -90,7 +94,6 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep option := &DeployerOption{ DomainId: record.Id, Domain: record.GetString("domain"), - Product: getProduct(deployConfig.Type), Access: access.GetString("config"), AccessRecord: access, DeployConfig: deployConfig, @@ -145,14 +148,6 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep return nil, errors.New("unsupported deploy target") } -func getProduct(t string) string { - rs := strings.Split(t, "-") - if len(rs) < 2 { - return "" - } - return rs[1] -} - func toStr(tag string, data any) string { if data == nil { return tag @@ -195,3 +190,57 @@ func getDeployVariables(conf domain.DeployConfig) map[string]string { return rs } + +func convertPEMToPFX(certificate string, privateKey string, password string) ([]byte, error) { + cert, err := x509.ParseCertificateFromPEM(certificate) + if err != nil { + return nil, err + } + + privkey, err := x509.ParsePKCS1PrivateKeyFromPEM(privateKey) + if err != nil { + return nil, err + } + + pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, password) + if err != nil { + return nil, fmt.Errorf("failed to encode as pfx %w", err) + } + + return pfxData, nil +} + +func convertPEMToJKS(certificate string, privateKey string, alias string, keypass string, storepass string) ([]byte, error) { + certBlock, _ := pem.Decode([]byte(certificate)) + if certBlock == nil { + return nil, errors.New("failed to decode certificate PEM") + } + + privkeyBlock, _ := pem.Decode([]byte(privateKey)) + if privkeyBlock == nil { + return nil, errors.New("failed to decode private key PEM") + } + + ks := keystore.New() + entry := keystore.PrivateKeyEntry{ + CreationTime: time.Now(), + PrivateKey: privkeyBlock.Bytes, + CertificateChain: []keystore.Certificate{ + { + Type: "X509", + Content: certBlock.Bytes, + }, + }, + } + + if err := ks.SetPrivateKeyEntry(alias, entry, []byte(keypass)); err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := ks.Store(&buf, []byte(storepass)); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/internal/deployer/k8s_secret.go b/internal/deployer/k8s_secret.go index 6468a0d2..85471ef3 100644 --- a/internal/deployer/k8s_secret.go +++ b/internal/deployer/k8s_secret.go @@ -108,7 +108,7 @@ func (d *K8sSecretDeployer) Deploy(ctx context.Context) error { } // 更新 Secret 实例 - _, err = client.CoreV1().Secrets(namespace).Update(ctx, &secretPayload, k8sMeta.UpdateOptions{}) + _, err = client.CoreV1().Secrets(namespace).Update(context.TODO(), &secretPayload, k8sMetaV1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed to update k8s secret: %w", err) } diff --git a/internal/deployer/local.go b/internal/deployer/local.go index 784660b6..0c839fa4 100644 --- a/internal/deployer/local.go +++ b/internal/deployer/local.go @@ -1,15 +1,15 @@ package deployer import ( + "bytes" "context" "encoding/json" "fmt" - "os" "os/exec" - "path/filepath" "runtime" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/fs" ) type LocalDeployer struct { @@ -17,6 +17,18 @@ type LocalDeployer struct { infos []string } +const ( + certFormatPEM = "pem" + certFormatPFX = "pfx" + certFormatJKS = "jks" +) + +const ( + shellEnvSh = "sh" + shellEnvCmd = "cmd" + shellEnvPowershell = "powershell" +) + func NewLocalDeployer(option *DeployerOption) (Deployer, error) { return &LocalDeployer{ option: option, @@ -38,74 +50,114 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error { return err } - preCommand := getDeployString(d.option.DeployConfig, "preCommand") - + // 执行前置命令 + preCommand := d.option.DeployConfig.GetConfigAsString("preCommand") if preCommand != "" { - if err := execCmd(preCommand); err != nil { - return fmt.Errorf("执行前置命令失败: %w", err) + stdout, stderr, err := d.execCommand(preCommand) + if err != nil { + return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr) } + + d.infos = append(d.infos, toStr("执行前置命令成功", stdout)) } - // 复制证书文件 - if err := copyFile(getDeployString(d.option.DeployConfig, "certPath"), d.option.Certificate.Certificate); err != nil { - return fmt.Errorf("复制证书失败: %w", err) - } + // 写入证书和私钥文件 + switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) { + case certFormatPEM: + if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } - // 复制私钥文件 - if err := copyFile(getDeployString(d.option.DeployConfig, "keyPath"), d.option.Certificate.PrivateKey); err != nil { - return fmt.Errorf("复制私钥失败: %w", err) + d.infos = append(d.infos, toStr("保存证书成功", nil)) + + if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil { + return fmt.Errorf("failed to save private key file: %w", err) + } + + d.infos = append(d.infos, toStr("保存私钥成功", nil)) + + case certFormatPFX: + pfxData, err := convertPEMToPFX( + d.option.Certificate.Certificate, + d.option.Certificate.PrivateKey, + d.option.DeployConfig.GetConfigAsString("pfxPassword"), + ) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("保存证书成功", nil)) + + case certFormatJKS: + jksData, err := convertPEMToJKS( + d.option.Certificate.Certificate, + d.option.Certificate.PrivateKey, + d.option.DeployConfig.GetConfigAsString("jksAlias"), + d.option.DeployConfig.GetConfigAsString("jksKeypass"), + d.option.DeployConfig.GetConfigAsString("jksStorepass"), + ) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("保存证书成功", nil)) } // 执行命令 - if err := execCmd(getDeployString(d.option.DeployConfig, "command")); err != nil { - return fmt.Errorf("执行命令失败: %w", err) + command := d.option.DeployConfig.GetConfigAsString("command") + if command != "" { + stdout, stderr, err := d.execCommand(command) + if err != nil { + return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr) + } + + d.infos = append(d.infos, toStr("执行命令成功", stdout)) } return nil } -func execCmd(command string) error { - // 执行命令 +func (d *LocalDeployer) execCommand(command string) (string, string, error) { var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/C", command) - } else { + switch d.option.DeployConfig.GetConfigAsString("shell") { + case shellEnvSh: cmd = exec.Command("sh", "-c", command) + + case shellEnvCmd: + cmd = exec.Command("cmd", "/C", command) + + case shellEnvPowershell: + cmd = exec.Command("powershell", "-Command", command) + + case "": + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", command) + } else { + cmd = exec.Command("sh", "-c", command) + } + + default: + return "", "", fmt.Errorf("unsupported shell") } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + var stdoutBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf err := cmd.Run() if err != nil { - return fmt.Errorf("执行命令失败: %w", err) + return "", "", fmt.Errorf("failed to execute script: %w", err) } - return nil -} - -func copyFile(path string, content string) error { - dir := filepath.Dir(path) - - // 如果目录不存在,创建目录 - err := os.MkdirAll(dir, os.ModePerm) - if err != nil { - return fmt.Errorf("创建目录失败: %w", err) - } - - // 创建或打开文件 - file, err := os.Create(path) - if err != nil { - return fmt.Errorf("创建文件失败: %w", err) - } - defer file.Close() - - // 写入内容到文件 - _, err = file.Write([]byte(content)) - if err != nil { - return fmt.Errorf("写入文件失败: %w", err) - } - - return nil + return stdoutBuf.String(), stderrBuf.String(), err } diff --git a/internal/deployer/qiniu_cdn_test.go b/internal/deployer/qiniu_cdn_test.go index ca236550..67012bfc 100644 --- a/internal/deployer/qiniu_cdn_test.go +++ b/internal/deployer/qiniu_cdn_test.go @@ -24,7 +24,6 @@ func Test_qiuniu_uploadCert(t *testing.T) { option: &DeployerOption{ DomainId: "1", Domain: "example.com", - Product: "test", Access: `{"bucket":"test","accessKey":"","secretKey":""}`, Certificate: applicant.Certificate{ Certificate: "", @@ -70,7 +69,6 @@ func Test_qiuniu_modifyDomainCert(t *testing.T) { option: &DeployerOption{ DomainId: "1", Domain: "jt1.ikit.fun", - Product: "test", Access: `{"bucket":"test","accessKey":"","secretKey":""}`, }, }, diff --git a/internal/deployer/ssh.go b/internal/deployer/ssh.go index 551e8634..98c4de48 100644 --- a/internal/deployer/ssh.go +++ b/internal/deployer/ssh.go @@ -6,12 +6,13 @@ import ( "encoding/json" "fmt" "os" - xpath "path" + "path/filepath" "github.com/pkg/sftp" - sshPkg "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh" "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/fs" ) type SSHDeployer struct { @@ -41,49 +42,120 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error { } // 连接 - client, err := d.createClient(access) + client, err := d.createSshClient(access) if err != nil { return err } defer client.Close() - d.infos = append(d.infos, toStr("ssh连接成功", nil)) + d.infos = append(d.infos, toStr("SSH 连接成功", nil)) // 执行前置命令 - preCommand := getDeployString(d.option.DeployConfig, "preCommand") + preCommand := d.option.DeployConfig.GetConfigAsString("preCommand") if preCommand != "" { stdout, stderr, err := d.sshExecCommand(client, preCommand) if err != nil { return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr) } + + d.infos = append(d.infos, toStr("SSH 执行前置命令成功", stdout)) } - // 上传证书 - if err := d.upload(client, d.option.Certificate.Certificate, getDeployString(d.option.DeployConfig, "certPath")); err != nil { - return fmt.Errorf("failed to upload certificate: %w", err) + // 上传证书和私钥文件 + switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) { + case certFormatPEM: + if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil { + return fmt.Errorf("failed to upload certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) + + if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil { + return fmt.Errorf("failed to upload private key file: %w", err) + } + + d.infos = append(d.infos, toStr("SSH 上传私钥成功", nil)) + + case certFormatPFX: + pfxData, err := convertPEMToPFX( + d.option.Certificate.Certificate, + d.option.Certificate.PrivateKey, + d.option.DeployConfig.GetConfigAsString("pfxPassword"), + ) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := d.writeSftpFile(client, d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil { + return fmt.Errorf("failed to upload certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("SSH 上传证书成功", nil)) + + case certFormatJKS: + jksData, err := convertPEMToJKS( + d.option.Certificate.Certificate, + d.option.Certificate.PrivateKey, + d.option.DeployConfig.GetConfigAsString("jksAlias"), + d.option.DeployConfig.GetConfigAsString("jksKeypass"), + d.option.DeployConfig.GetConfigAsString("jksStorepass"), + ) + if err != nil { + return fmt.Errorf("failed to convert pem to pfx %w", err) + } + + if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil { + return fmt.Errorf("failed to save certificate file: %w", err) + } + + d.infos = append(d.infos, toStr("保存证书成功", nil)) } - d.infos = append(d.infos, toStr("ssh上传证书成功", nil)) - - // 上传私钥 - if err := d.upload(client, d.option.Certificate.PrivateKey, getDeployString(d.option.DeployConfig, "keyPath")); err != nil { - return fmt.Errorf("failed to upload private key: %w", err) - } - - d.infos = append(d.infos, toStr("ssh上传私钥成功", nil)) - // 执行命令 - stdout, stderr, err := d.sshExecCommand(client, getDeployString(d.option.DeployConfig, "command")) - if err != nil { - return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr) - } + command := d.option.DeployConfig.GetConfigAsString("command") + if command != "" { + stdout, stderr, err := d.sshExecCommand(client, command) + if err != nil { + return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr) + } - d.infos = append(d.infos, toStr("ssh执行命令成功", stdout)) + d.infos = append(d.infos, toStr("SSH 执行命令成功", stdout)) + } return nil } -func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (string, string, error) { +func (d *SSHDeployer) createSshClient(access *domain.SSHAccess) (*ssh.Client, error) { + var authMethod ssh.AuthMethod + + if access.Key != "" { + var signer ssh.Signer + var err error + + if access.KeyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(access.Key)) + } + + if err != nil { + return nil, err + } + authMethod = ssh.PublicKeys(signer) + } else { + authMethod = ssh.Password(access.Password) + } + + return ssh.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &ssh.ClientConfig{ + User: access.Username, + Auth: []ssh.AuthMethod{ + authMethod, + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) +} + +func (d *SSHDeployer) sshExecCommand(client *ssh.Client, command string) (string, string, error) { session, err := client.NewSession() if err != nil { return "", "", fmt.Errorf("failed to create ssh session: %w", err) @@ -98,14 +170,18 @@ func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (str return stdoutBuf.String(), stderrBuf.String(), err } -func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error { +func (d *SSHDeployer) writeSftpFileString(client *ssh.Client, path string, content string) error { + return d.writeSftpFile(client, path, []byte(content)) +} + +func (d *SSHDeployer) writeSftpFile(client *ssh.Client, path string, data []byte) error { sftpCli, err := sftp.NewClient(client) if err != nil { return fmt.Errorf("failed to create sftp client: %w", err) } defer sftpCli.Close() - if err := sftpCli.MkdirAll(xpath.Dir(path)); err != nil { + if err := sftpCli.MkdirAll(filepath.Dir(path)); err != nil { return fmt.Errorf("failed to create remote directory: %w", err) } @@ -115,40 +191,10 @@ func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error } defer file.Close() - _, err = file.Write([]byte(content)) + _, err = file.Write(data) if err != nil { return fmt.Errorf("failed to write to remote file: %w", err) } return nil } - -func (d *SSHDeployer) createClient(access *domain.SSHAccess) (*sshPkg.Client, error) { - var authMethod sshPkg.AuthMethod - - if access.Key != "" { - var signer sshPkg.Signer - var err error - - if access.KeyPassphrase != "" { - signer, err = sshPkg.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase)) - } else { - signer, err = sshPkg.ParsePrivateKey([]byte(access.Key)) - } - - if err != nil { - return nil, err - } - authMethod = sshPkg.PublicKeys(signer) - } else { - authMethod = sshPkg.Password(access.Password) - } - - return sshPkg.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &sshPkg.ClientConfig{ - User: access.Username, - Auth: []sshPkg.AuthMethod{ - authMethod, - }, - HostKeyCallback: sshPkg.InsecureIgnoreHostKey(), - }) -} diff --git a/internal/deployer/tencent_cdn.go b/internal/deployer/tencent_cdn.go index aa315466..26c9c252 100644 --- a/internal/deployer/tencent_cdn.go +++ b/internal/deployer/tencent_cdn.go @@ -2,10 +2,10 @@ package deployer import ( "context" - "encoding/base64" "encoding/json" "fmt" "strings" + "golang.org/x/exp/slices" cdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606" "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" @@ -100,16 +100,23 @@ func (d *TencentCDNDeployer) deploy(certId string) error { // 如果是泛域名就从cdn列表下获取SSL证书中的可用域名 domain := getDeployString(d.option.DeployConfig, "domain") if strings.Contains(domain, "*") { - list, errGetList := d.getDomainList() + list, errGetList := d.getDomainList(certId) if errGetList != nil { return fmt.Errorf("failed to get certificate domain list: %w", errGetList) } - if list == nil || len(list) == 0 { - return fmt.Errorf("failed to get certificate domain list: empty list.") + if len(list) == 0 { + d.infos = append(d.infos, "没有需要部署的实例") + return nil } request.InstanceIdList = common.StringPtrs(list) } else { // 否则直接使用传入的域名 - request.InstanceIdList = common.StringPtrs([]string{domain}) + deployed, _ := d.isDomainDeployed(certId, domain) + if(deployed){ + d.infos = append(d.infos, "域名已部署") + return nil + }else{ + request.InstanceIdList = common.StringPtrs([]string{domain}) + } } // 返回的resp是一个DeployCertificateInstanceResponse的实例,与请求对象对应 @@ -121,23 +128,61 @@ func (d *TencentCDNDeployer) deploy(certId string) error { return nil } -func (d *TencentCDNDeployer) getDomainList() ([]string, error) { +func (d *TencentCDNDeployer) getDomainList(certId string) ([]string, error) { cpf := profile.NewClientProfile() cpf.HttpProfile.Endpoint = "cdn.tencentcloudapi.com" client, _ := cdn.NewClient(d.credential, "", cpf) request := cdn.NewDescribeCertDomainsRequest() - cert := base64.StdEncoding.EncodeToString([]byte(d.option.Certificate.Certificate)) - request.Cert = &cert + request.CertId = common.StringPtr(certId) response, err := client.DescribeCertDomains(request) if err != nil { return nil, fmt.Errorf("failed to get domain list: %w", err) } + deployedDomains, err := d.getDeployedDomainList(certId) + if err != nil { + return nil, fmt.Errorf("failed to get deployed domain list: %w", err) + } + domains := make([]string, 0) for _, domain := range response.Response.Domains { + domainStr := *domain + if(slices.Contains(deployedDomains, domainStr)){ + domains = append(domains, domainStr) + } + } + + return domains, nil +} + +func (d *TencentCDNDeployer) isDomainDeployed(certId, domain string) (bool, error) { + deployedDomains, err := d.getDeployedDomainList(certId) + if(err != nil){ + return false, err + } + + return slices.Contains(deployedDomains, domain), nil +} + +func (d *TencentCDNDeployer) getDeployedDomainList(certId string) ([]string, error) { + cpf := profile.NewClientProfile() + cpf.HttpProfile.Endpoint = "ssl.tencentcloudapi.com" + client, _ := ssl.NewClient(d.credential, "", cpf) + + request := ssl.NewDescribeDeployedResourcesRequest() + request.CertificateIds = common.StringPtrs([]string{certId}) + request.ResourceType = common.StringPtr("cdn") + + response, err := client.DescribeDeployedResources(request) + if err != nil { + return nil, fmt.Errorf("failed to get deployed domain list: %w", err) + } + + domains := make([]string, 0) + for _, domain := range response.Response.DeployedResources[0].Resources { domains = append(domains, *domain) } diff --git a/internal/domain/notify.go b/internal/domain/notify.go index de9cf6cf..534d8b02 100644 --- a/internal/domain/notify.go +++ b/internal/domain/notify.go @@ -6,6 +6,7 @@ const ( NotifyChannelTelegram = "telegram" NotifyChannelLark = "lark" NotifyChannelServerChan = "serverchan" + NotifyChannelMail = "mail" ) type NotifyTestPushReq struct { diff --git a/internal/notify/mail.go b/internal/notify/mail.go new file mode 100644 index 00000000..ece00ad2 --- /dev/null +++ b/internal/notify/mail.go @@ -0,0 +1,58 @@ +package notify + +import ( + "context" + "net/smtp" +) + +type Mail struct { + senderAddress string + smtpHostAddr string + smtpHostPort string + smtpAuth smtp.Auth + receiverAddresses string +} + +func NewMail(senderAddress, receiverAddresses, smtpHostAddr, smtpHostPort string) *Mail { + if(smtpHostPort == "") { + smtpHostPort = "25" + } + + return &Mail{ + senderAddress: senderAddress, + smtpHostAddr: smtpHostAddr, + smtpHostPort: smtpHostPort, + receiverAddresses: receiverAddresses, + } +} + +func (m *Mail) SetAuth(username, password string) { + m.smtpAuth = smtp.PlainAuth("", username, password, m.smtpHostAddr) +} + +func (m *Mail) Send(ctx context.Context, subject, message string) error { + // 构建邮件 + from := m.senderAddress + to := []string{m.receiverAddresses} + msg := []byte( + "From: " + from + "\r\n" + + "To: " + m.receiverAddresses + "\r\n" + + "Subject: " + subject + "\r\n" + + "\r\n" + + message + "\r\n") + + var smtpAddress string + // 组装邮箱服务器地址 + if(m.smtpHostPort == "25"){ + smtpAddress = m.smtpHostAddr + }else{ + smtpAddress = m.smtpHostAddr + ":" + m.smtpHostPort + } + + err := smtp.SendMail(smtpAddress, m.smtpAuth, from, to, msg) + if err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 3dfa643b..4b91cdbd 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -106,6 +106,8 @@ func getNotifier(channel string, conf map[string]any) (notifyPackage.Notifier, e return getWebhookNotifier(conf), nil case domain.NotifyChannelServerChan: return getServerChanNotifier(conf), nil + case domain.NotifyChannelMail: + return getMailNotifier(conf), nil } return nil, fmt.Errorf("notifier not found") @@ -166,6 +168,14 @@ func getLarkNotifier(conf map[string]any) notifyPackage.Notifier { return lark.NewWebhookService(getString(conf, "webhookUrl")) } +func getMailNotifier(conf map[string]any) notifyPackage.Notifier { + rs := NewMail(getString(conf, "senderAddress"),getString(conf,"receiverAddress"), getString(conf, "smtpHostAddr"), getString(conf, "smtpHostPort")) + + rs.SetAuth(getString(conf, "username"), getString(conf, "password")) + + return rs +} + func getString(conf map[string]any, key string) string { if _, ok := conf[key]; !ok { return "" diff --git a/internal/pkg/utils/fs/fs.go b/internal/pkg/utils/fs/fs.go new file mode 100644 index 00000000..3ae82060 --- /dev/null +++ b/internal/pkg/utils/fs/fs.go @@ -0,0 +1,51 @@ +package fs + +import ( + "fmt" + "os" + "path/filepath" +) + +// 与 [WriteFile] 类似,但写入的是字符串内容。 +// +// 入参: +// - path: 文件路径。 +// - content: 文件内容。 +// +// 出参: +// - 错误。 +func WriteFileString(path string, content string) error { + return WriteFile(path, []byte(content)) +} + +// 将数据写入指定路径的文件。 +// 如果目录不存在,将会递归创建目录。 +// 如果文件不存在,将会创建该文件;如果文件已存在,将会覆盖原有内容。 +// +// 入参: +// - path: 文件路径。 +// - data: 文件数据字节数组。 +// +// 出参: +// - 错误。 +func WriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + + err := os.MkdirAll(dir, os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} diff --git a/internal/pkg/utils/x509/x509.go b/internal/pkg/utils/x509/x509.go index 09d67d3a..40cc39d6 100644 --- a/internal/pkg/utils/x509/x509.go +++ b/internal/pkg/utils/x509/x509.go @@ -2,6 +2,7 @@ import ( "crypto/ecdsa" + "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" @@ -48,7 +49,7 @@ func ParseCertificateFromPEM(certPem string) (cert *x509.Certificate, err error) return cert, nil } -// 从 PEM 编码的私钥字符串解析并返回一个 ECDSA 私钥对象。 +// 从 PEM 编码的私钥字符串解析并返回一个 ecdsa.PrivateKey 对象。 // // 入参: // - privkeyPem: 私钥 PEM 内容。 @@ -72,7 +73,31 @@ func ParseECPrivateKeyFromPEM(privkeyPem string) (privkey *ecdsa.PrivateKey, err return privkey, nil } -// 将 ECDSA 私钥转换为 PEM 编码的字符串。 +// 从 PEM 编码的私钥字符串解析并返回一个 rsa.PrivateKey 对象。 +// +// 入参: +// - privkeyPem: 私钥 PEM 内容。 +// +// 出参: +// - privkey: rsa.PrivateKey 对象。 +// - err: 错误。 +func ParsePKCS1PrivateKeyFromPEM(privkeyPem string) (privkey *rsa.PrivateKey, err error) { + pemData := []byte(privkeyPem) + + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + privkey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + return privkey, nil +} + +// 将 ecdsa.PrivateKey 对象转换为 PEM 编码的字符串。 // // 入参: // - privkey: ecdsa.PrivateKey 对象。 diff --git a/ui/package-lock.json b/ui/package-lock.json index c0516a33..97fbdc1e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,10 +12,11 @@ "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", @@ -28,6 +29,7 @@ "@radix-ui/react-tooltip": "^1.1.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.1", @@ -1243,6 +1245,41 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz", + "integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.0", + "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.1", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", @@ -1385,24 +1422,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz", - "integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", + "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.0", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" + "react-remove-scroll": "2.6.0" }, "peerDependencies": { "@types/react": "*", @@ -1419,6 +1456,130 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1638,6 +1799,166 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", + "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", @@ -3039,6 +3360,366 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", diff --git a/ui/package.json b/ui/package.json index 4f1e92d1..1c1768ac 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,10 +14,11 @@ "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", @@ -30,6 +31,7 @@ "@radix-ui/react-tooltip": "^1.1.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "i18next": "^23.15.1", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.1", diff --git a/ui/src/components/certimate/AccessEditDialog.tsx b/ui/src/components/certimate/AccessEditDialog.tsx index 326a97f2..afa41da8 100644 --- a/ui/src/components/certimate/AccessEditDialog.tsx +++ b/ui/src/components/certimate/AccessEditDialog.tsx @@ -5,7 +5,6 @@ import { cn } from "@/lib/utils"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; import AccessAliyunForm from "./AccessAliyunForm"; import AccessTencentForm from "./AccessTencentForm"; import AccessHuaweiCloudForm from "./AccessHuaweicloudForm"; @@ -20,7 +19,8 @@ import AccessLocalForm from "./AccessLocalForm"; import AccessSSHForm from "./AccessSSHForm"; import AccessWebhookForm from "./AccessWebhookForm"; import AccessKubernetesForm from "./AccessKubernetesForm"; -import { Access, accessProvidersMap } from "@/domain/access"; +import { Access } from "@/domain/access"; +import { AccessTypeSelect } from "./AccessTypeSelect"; type AccessEditProps = { op: "add" | "edit" | "copy"; @@ -194,16 +194,17 @@ const AccessEditDialog = ({ trigger, op, data, className }: AccessEditProps) => break; } - const getOptionCls = (val: string) => { - return val == configType ? "border-primary" : ""; - }; - return ( - + {trigger} - + { + event.preventDefault(); + }} + > { @@ -219,29 +220,15 @@ const AccessEditDialog = ({ trigger, op, data, className }: AccessEditProps) =>
- + className="w-full mt-3" + placeholder={t("access.authorization.form.type.placeholder")} + searchPlaceholder={t("access.authorization.form.type.search.placeholder")} + />
{childComponent}
diff --git a/ui/src/components/certimate/AccessTypeSelect.tsx b/ui/src/components/certimate/AccessTypeSelect.tsx new file mode 100644 index 00000000..a2e7944c --- /dev/null +++ b/ui/src/components/certimate/AccessTypeSelect.tsx @@ -0,0 +1,80 @@ +import { Check, ChevronsUpDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { accessProvidersMap } from "@/domain/access"; +import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; + +type AccessTypeSelectProps = { + value: string; + onChange: (value: string) => void; + placeholder: string; + searchPlaceholder: string; + className?: string; +}; + +export function AccessTypeSelect({ value, onChange, placeholder, searchPlaceholder, className }: AccessTypeSelectProps) { + const [open, setOpen] = useState(false); + const [locValue, setLocValue] = useState(""); + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const filteredProviders = Array.from(accessProvidersMap.entries()); + + useEffect(() => { + setLocValue(value); + }, [value]); + + const handleOnSelect = (currentValue: string) => { + const newValue = currentValue === locValue ? "" : currentValue; + setLocValue(newValue); + setSearch(""); + setOpen(false); + onChange(newValue); + }; + + return ( + + + + + + + { + setSearch(val); + }} + /> + + {t("access.authorization.form.type.search.notfound")} + + {filteredProviders.map(([key, provider]) => ( + + +
+ +
{t(provider.name)}
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/ui/src/components/certimate/DeployEditDialog.tsx b/ui/src/components/certimate/DeployEditDialog.tsx index 87a63673..3e4804b9 100644 --- a/ui/src/components/certimate/DeployEditDialog.tsx +++ b/ui/src/components/certimate/DeployEditDialog.tsx @@ -21,6 +21,7 @@ import DeployToTencentTEO from "./DeployToTencentTEO"; import DeployToHuaweiCloudCDN from "./DeployToHuaweiCloudCDN"; import DeployToHuaweiCloudELB from "./DeployToHuaweiCloudELB"; import DeployToQiniuCDN from "./DeployToQiniuCDN"; +import DeployToLocal from "./DeployToLocal"; import DeployToSSH from "./DeployToSSH"; import DeployToWebhook from "./DeployToWebhook"; import DeployToKubernetesSecret from "./DeployToKubernetesSecret"; @@ -153,8 +154,10 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro case "qiniu-cdn": childComponent = ; break; - case "ssh": case "local": + childComponent = ; + break; + case "ssh": childComponent = ; break; case "webhook": diff --git a/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx b/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx index b85203e8..e7a7fc4a 100644 --- a/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx +++ b/ui/src/components/certimate/DeployToHuaweiCloudELB.tsx @@ -102,7 +102,7 @@ const DeployToHuaweiCloudCDN = () => { onValueChange={(value) => { const newData = produce(data, (draft) => { draft.config ??= {}; - draft.config.resourceType = value?.trim(); + draft.config.resourceType = value; }); setDeploy(newData); }} diff --git a/ui/src/components/certimate/DeployToLocal.tsx b/ui/src/components/certimate/DeployToLocal.tsx new file mode 100644 index 00000000..672cf52c --- /dev/null +++ b/ui/src/components/certimate/DeployToLocal.tsx @@ -0,0 +1,450 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { produce } from "immer"; + +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useDeployEditContext } from "./DeployEdit"; +import { cn } from "@/lib/utils"; + +const DeployToLocal = () => { + const { t } = useTranslation(); + + const { deploy: data, setDeploy, error, setError } = useDeployEditContext(); + + useEffect(() => { + if (!data.id) { + setDeploy({ + ...data, + config: { + format: "pem", + certPath: "/etc/nginx/ssl/nginx.crt", + keyPath: "/etc/nginx/ssl/nginx.key", + pfxPassword: "", + jksAlias: "", + jksKeypass: "", + jksStorepass: "", + shell: "sh", + preCommand: "", + command: "sudo service nginx reload", + }, + }); + } + }, []); + + useEffect(() => { + setError({}); + }, []); + + const formSchema = z + .object({ + format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], { + message: t("domain.deployment.form.file_format.placeholder"), + }), + certPath: z + .string() + .min(1, t("domain.deployment.form.file_cert_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + keyPath: z + .string() + .min(0, t("domain.deployment.form.file_key_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + pfxPassword: z.string().optional(), + jksAlias: z.string().optional(), + jksKeypass: z.string().optional(), + jksStorepass: z.string().optional(), + shell: z.union([z.literal("sh"), z.literal("cmd"), z.literal("powershell")], { + message: t("domain.deployment.form.shell.placeholder"), + }), + preCommand: z.string().optional(), + command: z.string().optional(), + }) + .refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), { + message: t("domain.deployment.form.file_key_path.placeholder"), + path: ["keyPath"], + }) + .refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), { + message: t("domain.deployment.form.file_pfx_password.placeholder"), + path: ["pfxPassword"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), { + message: t("domain.deployment.form.file_jks_alias.placeholder"), + path: ["jksAlias"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), { + message: t("domain.deployment.form.file_jks_keypass.placeholder"), + path: ["jksKeypass"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), { + message: t("domain.deployment.form.file_jks_storepass.placeholder"), + path: ["jksStorepass"], + }); + + useEffect(() => { + const res = formSchema.safeParse(data.config); + if (!res.success) { + setError({ + ...error, + format: res.error.errors.find((e) => e.path[0] === "format")?.message, + certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message, + keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message, + pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message, + jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message, + jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message, + jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.message, + shell: res.error.errors.find((e) => e.path[0] === "shell")?.message, + preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message, + command: res.error.errors.find((e) => e.path[0] === "command")?.message, + }); + } else { + setError({ + ...error, + format: undefined, + certPath: undefined, + keyPath: undefined, + pfxPassword: undefined, + jksAlias: undefined, + jksKeypass: undefined, + jksStorepass: undefined, + shell: undefined, + preCommand: undefined, + command: undefined, + }); + } + }, [data]); + + useEffect(() => { + if (data.config?.format === "pem") { + if (/(.pfx|.jks)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "pfx") { + if (/(.crt|.jks)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "jks") { + if (/(.crt|.pfx)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks"); + }); + setDeploy(newData); + } + } + }, [data.config?.format]); + + const getOptionCls = (val: string) => { + if (data.config?.shell === val) { + return "border-primary dark:border-primary"; + } + + return ""; + }; + + const handleUsePresetScript = (key: string) => { + switch (key) { + case "reload_nginx": + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.shell = "sh"; + draft.config.command = "sudo service nginx reload"; + }); + setDeploy(newData); + } + break; + + case "binding_iis": + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.shell = "powershell"; + draft.config.command = ` +# 请将以下变量替换为实际值 +$pfxPath = "" # PFX 文件路径 +$pfxPassword = "" # PFX 密码 +$siteName = "" # IIS 网站名称 +$domain = "" # 域名 +$ipaddr = "" # 绑定 IP,“*”表示所有 IP 绑定 +$port = "" # 绑定端口 + + +# 导入证书到本地计算机的个人存储区 +$cert = Import-PfxCertificate -FilePath "$pfxPath" -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String "$pfxPassword" -AsPlainText -Force) -Exportable +# 获取 Thumbprint +$thumbprint = $cert.Thumbprint +# 导入 WebAdministration 模块 +Import-Module WebAdministration +# 检查是否已存在 HTTPS 绑定 +$existingBinding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -HostHeader "$domain" -ErrorAction SilentlyContinue +if (!$existingBinding) { + # 添加新的 HTTPS 绑定 + New-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain" +} +# 获取绑定对象 +$binding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain" +# 绑定 SSL 证书 +$binding.AddSslCertificate($thumbprint, "My") +# 删除目录下的证书文件 +Remove-Item -Path "$pfxPath" -Force + `.trim(); + }); + setDeploy(newData); + } + break; + } + }; + + return ( + <> +
+
+ + +
{error?.format}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.certPath}
+
+ + {data.config?.format === "pem" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.keyPath = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.keyPath}
+
+ ) : ( + <> + )} + + {data.config?.format === "pfx" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.pfxPassword = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.pfxPassword}
+
+ ) : ( + <> + )} + + {data.config?.format === "jks" ? ( + <> +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksAlias = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksAlias}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksKeypass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksKeypass}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksStorepass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksStorepass}
+
+ + ) : ( + <> + )} + +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.shell = val; + }); + setDeploy(newData); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
{error?.shell}
+
+ +
+ + +
{error?.preCommand}
+
+ +
+
+ + + + {t("domain.deployment.form.shell_preset_scripts.trigger")} + + + handleUsePresetScript("reload_nginx")}> + {t("domain.deployment.form.shell_preset_scripts.option.reload_nginx.label")} + + handleUsePresetScript("binding_iis")}> + {t("domain.deployment.form.shell_preset_scripts.option.binding_iis.label")} + + + +
+ +
{error?.command}
+
+
+ + ); +}; + +export default DeployToLocal; diff --git a/ui/src/components/certimate/DeployToSSH.tsx b/ui/src/components/certimate/DeployToSSH.tsx index 80eb15fb..b59eeb72 100644 --- a/ui/src/components/certimate/DeployToSSH.tsx +++ b/ui/src/components/certimate/DeployToSSH.tsx @@ -1,29 +1,31 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { z } from "zod"; import { produce } from "immer"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { useDeployEditContext } from "./DeployEdit"; const DeployToSSH = () => { const { t } = useTranslation(); - const { setError } = useDeployEditContext(); - useEffect(() => { - setError({}); - }, []); - - const { deploy: data, setDeploy } = useDeployEditContext(); + const { deploy: data, setDeploy, error, setError } = useDeployEditContext(); useEffect(() => { if (!data.id) { setDeploy({ ...data, config: { + format: "pem", certPath: "/etc/nginx/ssl/nginx.crt", keyPath: "/etc/nginx/ssl/nginx.key", + pfxPassword: "", + jksAlias: "", + jksKeypass: "", + jksStorepass: "", preCommand: "", command: "sudo service nginx reload", }, @@ -31,79 +33,287 @@ const DeployToSSH = () => { } }, []); + useEffect(() => { + setError({}); + }, []); + + const formSchema = z + .object({ + format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], { + message: t("domain.deployment.form.file_format.placeholder"), + }), + certPath: z + .string() + .min(1, t("domain.deployment.form.file_cert_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + keyPath: z + .string() + .min(0, t("domain.deployment.form.file_key_path.placeholder")) + .max(255, t("common.errmsg.string_max", { max: 255 })), + pfxPassword: z.string().optional(), + jksAlias: z.string().optional(), + jksKeypass: z.string().optional(), + jksStorepass: z.string().optional(), + preCommand: z.string().optional(), + command: z.string().optional(), + }) + .refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), { + message: t("domain.deployment.form.file_key_path.placeholder"), + path: ["keyPath"], + }) + .refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), { + message: t("domain.deployment.form.file_pfx_password.placeholder"), + path: ["pfxPassword"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), { + message: t("domain.deployment.form.file_jks_alias.placeholder"), + path: ["jksAlias"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), { + message: t("domain.deployment.form.file_jks_keypass.placeholder"), + path: ["jksKeypass"], + }) + .refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), { + message: t("domain.deployment.form.file_jks_storepass.placeholder"), + path: ["jksStorepass"], + }); + + useEffect(() => { + const res = formSchema.safeParse(data.config); + if (!res.success) { + setError({ + ...error, + format: res.error.errors.find((e) => e.path[0] === "format")?.message, + certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message, + keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message, + pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message, + jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message, + jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message, + jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.message, + preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message, + command: res.error.errors.find((e) => e.path[0] === "command")?.message, + }); + } else { + setError({ + ...error, + format: undefined, + certPath: undefined, + keyPath: undefined, + pfxPassword: undefined, + jksAlias: undefined, + jksKeypass: undefined, + jksStorepass: undefined, + preCommand: undefined, + command: undefined, + }); + } + }, [data]); + + useEffect(() => { + if (data.config?.format === "pem") { + if (/(.pfx|.jks)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "pfx") { + if (/(.crt|.jks)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx"); + }); + setDeploy(newData); + } + } else if (data.config?.format === "jks") { + if (/(.crt|.pfx)$/.test(data.config.certPath)) { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks"); + }); + setDeploy(newData); + } + } + }, [data.config?.format]); + return ( <>
- + + +
{error?.format}
+
+ +
+ { const newData = produce(data, (draft) => { - if (!draft.config) { - draft.config = {}; - } - draft.config.certPath = e.target.value; + draft.config ??= {}; + draft.config.certPath = e.target.value?.trim(); }); setDeploy(newData); }} /> +
{error?.certPath}
-
- - { - const newData = produce(data, (draft) => { - if (!draft.config) { - draft.config = {}; - } - draft.config.keyPath = e.target.value; - }); - setDeploy(newData); - }} - /> -
+ {data.config?.format === "pem" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.keyPath = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.keyPath}
+
+ ) : ( + <> + )} + + {data.config?.format === "pfx" ? ( +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.pfxPassword = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.pfxPassword}
+
+ ) : ( + <> + )} + + {data.config?.format === "jks" ? ( + <> +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksAlias = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksAlias}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksKeypass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksKeypass}
+
+ +
+ + { + const newData = produce(data, (draft) => { + draft.config ??= {}; + draft.config.jksStorepass = e.target.value?.trim(); + }); + setDeploy(newData); + }} + /> +
{error?.jksStorepass}
+
+ + ) : ( + <> + )}
- + +
{error?.preCommand}
- + +
{error?.command}
diff --git a/ui/src/components/notify/Mail.tsx b/ui/src/components/notify/Mail.tsx new file mode 100644 index 00000000..7b6195fb --- /dev/null +++ b/ui/src/components/notify/Mail.tsx @@ -0,0 +1,319 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/components/ui/use-toast"; +import { getErrMessage } from "@/lib/error"; +import { NotifyChannelMail, NotifyChannels } from "@/domain/settings"; +import { useNotifyContext } from "@/providers/notify"; +import { update } from "@/repository/settings"; +import Show from "@/components/Show"; +import { notifyTest } from "@/api/notify"; + +type MailSetting = { + id: string; + name: string; + data: NotifyChannelMail; +}; + +const Mail = () => { + const { config, setChannels } = useNotifyContext(); + const { t } = useTranslation(); + + const [changed, setChanged] = useState(false); + + const [mail, setmail] = useState({ + id: config.id ?? "", + name: "notifyChannels", + data: { + senderAddress: "", + receiverAddresses: "", + smtpHostAddr: "", + smtpHostPort: "25", + username: "", + password: "", + enabled: false, + }, + }); + + const [originMail, setoriginMail] = useState({ + id: config.id ?? "", + name: "notifyChannels", + data: { + senderAddress: "", + receiverAddresses: "", + smtpHostAddr: "", + smtpHostPort: "25", + username: "", + password: "", + enabled: false, + }, + }); + + useEffect(() => { + setChanged(false); + }, [config]); + + useEffect(() => { + const data = getDetailMail(); + setoriginMail({ + id: config.id ?? "", + name: "mail", + data, + }); + }, [config]); + + useEffect(() => { + const data = getDetailMail(); + setmail({ + id: config.id ?? "", + name: "mail", + data, + }); + }, [config]); + + const { toast } = useToast(); + + const getDetailMail = () => { + const df: NotifyChannelMail = { + senderAddress: "", + receiverAddresses: "", + smtpHostAddr: "", + smtpHostPort: "25", + username: "", + password: "", + enabled: false, + }; + if (!config.content) { + return df; + } + const chanels = config.content as NotifyChannels; + if (!chanels.mail) { + return df; + } + + return chanels.mail as NotifyChannelMail; + }; + + const checkChanged = (data: NotifyChannelMail) => { + if (data.senderAddress !== originMail.data.senderAddress || data.receiverAddresses !== originMail.data.receiverAddresses || data.smtpHostAddr !== originMail.data.smtpHostAddr || data.smtpHostPort !== originMail.data.smtpHostPort || data.username !== originMail.data.username || data.password !== originMail.data.password) { + setChanged(true); + } else { + setChanged(false); + } + }; + + const handleSaveClick = async () => { + try { + const resp = await update({ + ...config, + name: "notifyChannels", + content: { + ...config.content, + mail: { + ...mail.data, + }, + }, + }); + + setChannels(resp); + toast({ + title: t("common.save.succeeded.message"), + description: t("settings.notification.config.saved.message"), + }); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("common.save.failed.message"), + description: `${t("settings.notification.config.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + const handlePushTestClick = async () => { + try { + await notifyTest("mail"); + + toast({ + title: t("settings.notification.config.push.test.message.success.message"), + description: t("settings.notification.config.push.test.message.success.message"), + }); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("settings.notification.config.push.test.message.failed.message"), + description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + const handleSwitchChange = async () => { + const newData = { + ...mail, + data: { + ...mail.data, + enabled: !mail.data.enabled, + }, + }; + setmail(newData); + + try { + const resp = await update({ + ...config, + name: "notifyChannels", + content: { + ...config.content, + mail: { + ...newData.data, + }, + }, + }); + + setChannels(resp); + } catch (e) { + const msg = getErrMessage(e); + + toast({ + title: t("common.save.failed.message"), + description: `${t("settings.notification.config.failed.message")}: ${msg}`, + variant: "destructive", + }); + } + }; + + return ( +
+ { + const newData = { + ...mail, + data: { + ...mail.data, + senderAddress: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + receiverAddresses: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + smtpHostAddr: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + smtpHostPort: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + username: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> + { + const newData = { + ...mail, + data: { + ...mail.data, + password: e.target.value, + }, + }; + checkChanged(newData.data); + setmail(newData); + }} + /> +
+ + +
+ +
+ + + + + + + +
+
+ ); +}; + +export default Mail; diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx new file mode 100644 index 00000000..727aba4d --- /dev/null +++ b/ui/src/components/ui/command.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +const Command = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ) +); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( +
+ + +
+ ) +); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => +); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef, React.ComponentPropsWithoutRef>( + (props, ref) => +); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ) +); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ) +); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator }; diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx index 919a43c2..01ff19c7 100644 --- a/ui/src/components/ui/dialog.tsx +++ b/ui/src/components/ui/dialog.tsx @@ -1,75 +1,122 @@ -import * as React from "react"; -import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { X } from "lucide-react"; +"use client" -import { cn } from "@/lib/utils"; +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" -const Dialog = DialogPrimitive.Root; +import { cn } from "@/lib/utils" -const DialogTrigger = DialogPrimitive.Trigger; +const Dialog = DialogPrimitive.Root -const DialogPortal = DialogPrimitive.Portal; +const DialogTrigger = DialogPrimitive.Trigger -const DialogClose = DialogPrimitive.Close; +const DialogPortal = DialogPrimitive.Portal -const DialogOverlay = React.forwardRef, React.ComponentPropsWithoutRef>( - ({ className, ...props }, ref) => ( - , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + - ) -); -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + > + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName -const DialogContent = React.forwardRef, React.ComponentPropsWithoutRef>( - ({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - - ) -); -DialogContent.displayName = DialogPrimitive.Content.displayName; +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -DialogHeader.displayName = "DialogHeader"; +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -DialogFooter.displayName = "DialogFooter"; - -const DialogTitle = React.forwardRef, React.ComponentPropsWithoutRef>( - ({ className, ...props }, ref) => ( - - ) -); -DialogTitle.displayName = DialogPrimitive.Title.displayName; +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName const DialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ); -DialogDescription.displayName = DialogPrimitive.Description.displayName; +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName -export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx new file mode 100644 index 00000000..bbba7e0e --- /dev/null +++ b/ui/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/ui/src/domain/access.ts b/ui/src/domain/access.ts index bc7733bc..b62d48ed 100644 --- a/ui/src/domain/access.ts +++ b/ui/src/domain/access.ts @@ -7,25 +7,26 @@ type AccessProvider = { name: string; icon: string; usage: AccessUsages; + searchContent: string; }; export const accessProvidersMap: Map = new Map( [ - ["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all"], - ["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all"], - ["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all"], - ["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy"], - ["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply"], - ["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply"], - ["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply"], - ["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply"], - ["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply"], - ["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply"], - ["local", "common.provider.local", "/imgs/providers/local.svg", "deploy"], - ["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy"], - ["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy"], - ["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy"], - ].map(([type, name, icon, usage]) => [type, { type, name, icon, usage: usage as AccessUsages }]) + ["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all", "阿里云:alibaba cloud"], + ["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all", "腾讯云:tencent cloud"], + ["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all", "华为云:huawei cloud"], + ["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy", "七牛:qiniu"], + ["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply", "亚马逊:amazon:aws"], + ["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply", "cloudflare:cf:cloud flare"], + ["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply", "namesilo"], + ["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply", "godaddy"], + ["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply", "powerdns:pdns"], + ["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply", "httpreq"], + ["local", "common.provider.local", "/imgs/providers/local.svg", "deploy", "local:bendi:本地"], + ["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy", "ssh"], + ["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy", "webhook"], + ["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy", "k8s:kubernetes"], + ].map(([type, name, icon, usage, searchContent]) => [type, { type, name, icon, usage: usage as AccessUsages, searchContent: searchContent }]) ); export const accessTypeFormSchema = z.union( diff --git a/ui/src/domain/settings.ts b/ui/src/domain/settings.ts index af77ea98..798c4e86 100644 --- a/ui/src/domain/settings.ts +++ b/ui/src/domain/settings.ts @@ -23,9 +23,10 @@ export type NotifyChannels = { telegram?: NotifyChannel; webhook?: NotifyChannel; serverchan?: NotifyChannel; + mail?: NotifyChannelMail; }; -export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan; +export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan | NotifyChannelMail; export type NotifyChannelDingTalk = { accessToken: string; @@ -54,6 +55,16 @@ export type NotifyChannelServerChan = { enabled: boolean; }; +export type NotifyChannelMail = { + senderAddress: string; + receiverAddresses: string; + smtpHostAddr: string; + smtpHostPort: string; + username:string; + password:string; + enabled: boolean; +}; + export const defaultNotifyTemplate: NotifyTemplate = { title: "您有 {COUNT} 张证书即将过期", content: "有 {COUNT} 张证书即将过期,域名分别为 {DOMAINS},请保持关注!", diff --git a/ui/src/i18n/locales/en/nls.access.json b/ui/src/i18n/locales/en/nls.access.json index cd56f3b9..13201a8e 100644 --- a/ui/src/i18n/locales/en/nls.access.json +++ b/ui/src/i18n/locales/en/nls.access.json @@ -12,6 +12,8 @@ "access.authorization.form.type.label": "Provider", "access.authorization.form.type.placeholder": "Please select a provider", + "access.authorization.form.type.search.placeholder": "Search provider ...", + "access.authorization.form.type.search.notfound": "Provider not found", "access.authorization.form.type.list": "Authorization List", "access.authorization.form.name.label": "Name", "access.authorization.form.name.placeholder": "Please enter authorization name", @@ -88,3 +90,4 @@ "access.group.domains": "All Authorizations", "access.group.domains.nodata": "Please add a domain to start deploying the certificate." } + diff --git a/ui/src/i18n/locales/en/nls.common.json b/ui/src/i18n/locales/en/nls.common.json index d90008ac..6870c616 100644 --- a/ui/src/i18n/locales/en/nls.common.json +++ b/ui/src/i18n/locales/en/nls.common.json @@ -85,5 +85,6 @@ "common.provider.kubernetes.secret": "Kubernetes - Secret", "common.provider.dingtalk": "DingTalk", "common.provider.telegram": "Telegram", - "common.provider.lark": "Lark" + "common.provider.lark": "Lark", + "common.provider.mail": "Mail" } diff --git a/ui/src/i18n/locales/en/nls.domain.json b/ui/src/i18n/locales/en/nls.domain.json index c3a5a4b1..06e78c6d 100644 --- a/ui/src/i18n/locales/en/nls.domain.json +++ b/ui/src/i18n/locales/en/nls.domain.json @@ -120,14 +120,32 @@ "domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "Please enter ELB loadbalancer ID", "domain.deployment.form.huaweicloud_elb_listener_id.label": "Listener ID", "domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "Please enter ELB listener ID", - "domain.deployment.form.ssh_key_path.label": "Private Key Save Path", - "domain.deployment.form.ssh_key_path.placeholder": "Please enter private key save path", - "domain.deployment.form.ssh_cert_path.label": "Certificate Save Path", - "domain.deployment.form.ssh_cert_path.placeholder": "Please enter certificate save path", - "domain.deployment.form.ssh_pre_command.label": "Pre-deployment Command", - "domain.deployment.form.ssh_pre_command.placeholder": "Command to be executed before deploying the certificate", - "domain.deployment.form.ssh_command.label": "Command", - "domain.deployment.form.ssh_command.placeholder": "Please enter command", + "domain.deployment.form.file_format.label": "Certificate Format", + "domain.deployment.form.file_format.placeholder": "Please select certificate format", + "domain.deployment.form.file_cert_path.label": "Certificate Save Path", + "domain.deployment.form.file_cert_path.placeholder": "Please enter certificate save path", + "domain.deployment.form.file_key_path.label": "Private Key Save Path", + "domain.deployment.form.file_key_path.placeholder": "Please enter private key save path", + "domain.deployment.form.file_pfx_password.label": "PFX Output Password", + "domain.deployment.form.file_pfx_password.placeholder": "Please enter PFX output password", + "domain.deployment.form.file_jks_alias.label": "JKS Alias (KeyStore Alias)", + "domain.deployment.form.file_jks_alias.placeholder": "Please enter JKS alias", + "domain.deployment.form.file_jks_keypass.label": "JKS Key Password (KeyStore Keypass)", + "domain.deployment.form.file_jks_keypass.placeholder": "Please enter JKS key password", + "domain.deployment.form.file_jks_storepass.label": "JKS Store Password (KeyStore Storepass)", + "domain.deployment.form.file_jks_storepass.placeholder": "Please enter JKS store password", + "domain.deployment.form.shell.label": "Shell", + "domain.deployment.form.shell.placeholder": "Please select shell environment", + "domain.deployment.form.shell.option.sh.label": "POSIX Bash (Linux)", + "domain.deployment.form.shell.option.cmd.label": "CMD (Windows)", + "domain.deployment.form.shell.option.powershell.label": "PowerShell (Windows)", + "domain.deployment.form.shell_pre_command.label": "Pre-deployment Command", + "domain.deployment.form.shell_pre_command.placeholder": "Command to be executed before deploying the certificate", + "domain.deployment.form.shell_command.label": "Command", + "domain.deployment.form.shell_command.placeholder": "Please enter command", + "domain.deployment.form.shell_preset_scripts.trigger": "Use Preset Scripts", + "domain.deployment.form.shell_preset_scripts.option.reload_nginx.label": "Bash - Reload Nginx", + "domain.deployment.form.shell_preset_scripts.option.binding_iis.label": "PowerShell - Binding IIS", "domain.deployment.form.k8s_namespace.label": "Namespace", "domain.deployment.form.k8s_namespace.placeholder": "Please enter namespace", "domain.deployment.form.k8s_secret_name.label": "Secret Name", diff --git a/ui/src/i18n/locales/en/nls.settings.json b/ui/src/i18n/locales/en/nls.settings.json index b23f6ee0..fd84b888 100644 --- a/ui/src/i18n/locales/en/nls.settings.json +++ b/ui/src/i18n/locales/en/nls.settings.json @@ -36,6 +36,12 @@ "settings.notification.dingtalk.secret.placeholder": "Signature for signed addition", "settings.notification.url.errmsg.invalid": "Invalid Url format", "settings.notification.serverchan.url.placeholder": "Url, e.g. https://sctapi.ftqq.com/****************.send", + "settings.notification.mail.sender_address.placeholder": "Sender email address", + "settings.notification.mail.receiver_address.placeholder": "Receiver email address", + "settings.notification.mail.smtp_host.placeholder": "SMTP server address", + "settings.notification.mail.smtp_port.placeholder": "SMTP server port, if not set, default is 25", + "settings.notification.mail.username.placeholder": "username", + "settings.notification.mail.password.placeholder": "password", "settings.ca.tab": "Certificate Authority", "settings.ca.provider.errmsg.empty": "Please select a Certificate Authority", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 64bfa84a..ed3021fd 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -12,6 +12,8 @@ "access.authorization.form.type.label": "服务商", "access.authorization.form.type.placeholder": "请选择服务商", + "access.authorization.form.type.search.placeholder": "搜索服务商", + "access.authorization.form.type.search.notfound": "未找到服务商", "access.authorization.form.type.list": "服务商列表", "access.authorization.form.name.label": "名称", "access.authorization.form.name.placeholder": "请输入授权名称", @@ -88,3 +90,4 @@ "access.group.domains": "所有授权", "access.group.domains.nodata": "请添加域名开始部署证书吧。" } + diff --git a/ui/src/i18n/locales/zh/nls.common.json b/ui/src/i18n/locales/zh/nls.common.json index d1144d79..12fcd58a 100644 --- a/ui/src/i18n/locales/zh/nls.common.json +++ b/ui/src/i18n/locales/zh/nls.common.json @@ -85,6 +85,7 @@ "common.provider.kubernetes.secret": "Kubernetes - Secret", "common.provider.dingtalk": "钉钉", "common.provider.telegram": "Telegram", - "common.provider.lark": "飞书" + "common.provider.lark": "飞书", + "common.provider.mail": "电子邮件" } diff --git a/ui/src/i18n/locales/zh/nls.domain.json b/ui/src/i18n/locales/zh/nls.domain.json index f6433bf4..7ccf94ba 100644 --- a/ui/src/i18n/locales/zh/nls.domain.json +++ b/ui/src/i18n/locales/zh/nls.domain.json @@ -104,8 +104,8 @@ "domain.deployment.form.tencent_clb_domain.label": "部署到域名(支持泛域名)", "domain.deployment.form.tencent_clb_domain.placeholder": "请输入部署到的域名, 如未开启 SNI, 可置空忽略此项", "domain.deployment.form.tencent_teo_zone_id.label": "Zone ID", - "domain.deployment.form.tencent_teo_zone_id.placeholder": "请输入zoneid, 形如: zone-xxxxxxxxx", - "domain.deployment.form.tencent_teo_domain.label": "部署到域名(支持泛域名, 应与服务器上配置的域名完全一致, 每行一个域名)", + "domain.deployment.form.tencent_teo_zone_id.placeholder": "请输入 Zone ID", + "domain.deployment.form.tencent_teo_domain.label": "部署到域名(支持泛域名, 应与服务器上配置的域名完全一致, 每行一个域名)", "domain.deployment.form.tencent_teo_domain.placeholder": "请输入部署到的域名", "domain.deployment.form.huaweicloud_elb_region.label": "地域", "domain.deployment.form.huaweicloud_elb_region.placeholder": "请输入地域(如 cn-north-1)", @@ -120,14 +120,29 @@ "domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "请输入负载均衡器 ID", "domain.deployment.form.huaweicloud_elb_listener_id.label": "监听器 ID", "domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "请输入监听器 ID", - "domain.deployment.form.ssh_key_path.label": "私钥保存路径", - "domain.deployment.form.ssh_key_path.placeholder": "请输入私钥保存路径", - "domain.deployment.form.ssh_cert_path.label": "证书保存路径", - "domain.deployment.form.ssh_cert_path.placeholder": "请输入证书保存路径", - "domain.deployment.form.ssh_pre_command.label": "前置命令", - "domain.deployment.form.ssh_pre_command.placeholder": "在部署证书前执行的命令", - "domain.deployment.form.ssh_command.label": "命令", - "domain.deployment.form.ssh_command.placeholder": "请输入要执行的命令", + "domain.deployment.form.file_format.label": "证书格式", + "domain.deployment.form.file_format.placeholder": "请选择证书格式", + "domain.deployment.form.file_cert_path.label": "证书保存路径", + "domain.deployment.form.file_cert_path.placeholder": "请输入证书保存路径", + "domain.deployment.form.file_key_path.label": "私钥保存路径", + "domain.deployment.form.file_key_path.placeholder": "请输入私钥保存路径", + "domain.deployment.form.file_pfx_password.label": "PFX 导出密码", + "domain.deployment.form.file_pfx_password.placeholder": "请输入 PFX 导出密码", + "domain.deployment.form.file_jks_alias.label": "JKS 别名(KeyStore Alias)", + "domain.deployment.form.file_jks_alias.placeholder": "请输入 JKS 别名", + "domain.deployment.form.file_jks_keypass.label": "JKS 私钥访问口令(KeyStore Keypass)", + "domain.deployment.form.file_jks_keypass.placeholder": "请输入 JKS 私钥访问口令", + "domain.deployment.form.file_jks_storepass.label": "JKS 密钥库存储口令(KeyStore Storepass)", + "domain.deployment.form.file_jks_storepass.placeholder": "请输入 JKS 密钥库存储口令", + "domain.deployment.form.shell.label": "Shell", + "domain.deployment.form.shell.placeholder": "请选择命令执行环境", + "domain.deployment.form.shell_pre_command.label": "前置命令", + "domain.deployment.form.shell_pre_command.placeholder": "在部署证书前执行的命令", + "domain.deployment.form.shell_command.label": "命令", + "domain.deployment.form.shell_command.placeholder": "请输入要执行的命令", + "domain.deployment.form.shell_preset_scripts.trigger": "使用预设脚本", + "domain.deployment.form.shell_preset_scripts.option.reload_nginx.label": "Bash - 重启 nginx", + "domain.deployment.form.shell_preset_scripts.option.binding_iis.label": "PowerShell - 导入并绑定到 IIS(需管理员权限)", "domain.deployment.form.k8s_namespace.label": "命名空间", "domain.deployment.form.k8s_namespace.placeholder": "请输入 K8S 命名空间", "domain.deployment.form.k8s_secret_name.label": "Secret 名称", diff --git a/ui/src/i18n/locales/zh/nls.settings.json b/ui/src/i18n/locales/zh/nls.settings.json index f3dedaa9..2c0a807f 100644 --- a/ui/src/i18n/locales/zh/nls.settings.json +++ b/ui/src/i18n/locales/zh/nls.settings.json @@ -36,6 +36,12 @@ "settings.notification.dingtalk.secret.placeholder": "加签的签名", "settings.notification.url.errmsg.invalid": "URL 格式不正确", "settings.notification.serverchan.url.placeholder": "Url, 形如: https://sctapi.ftqq.com/****************.send", + "settings.notification.mail.sender_address.placeholder": "发送邮箱地址", + "settings.notification.mail.receiver_address.placeholder": "接收邮箱地址", + "settings.notification.mail.smtp_host.placeholder": "SMTP服务器地址", + "settings.notification.mail.smtp_port.placeholder": "SMTP服务器端口, 如果未设置, 默认为25", + "settings.notification.mail.username.placeholder": "用于登录到邮件服务器的用户名", + "settings.notification.mail.password.placeholder": "用于登录到邮件服务器的密码", "settings.ca.tab": "证书颁发机构(CA)", "settings.ca.provider.errmsg.empty": "请选择证书分发机构", diff --git a/ui/src/pages/DashboardLayout.tsx b/ui/src/pages/DashboardLayout.tsx index b769d8f3..269b04a7 100644 --- a/ui/src/pages/DashboardLayout.tsx +++ b/ui/src/pages/DashboardLayout.tsx @@ -151,4 +151,3 @@ export default function Dashboard() { ); } - diff --git a/ui/src/pages/dashboard/Dashboard.tsx b/ui/src/pages/dashboard/Dashboard.tsx index a792e756..ff655b98 100644 --- a/ui/src/pages/dashboard/Dashboard.tsx +++ b/ui/src/pages/dashboard/Dashboard.tsx @@ -209,7 +209,7 @@ const Dashboard = () => { {t("history.log")} -
+
{deployment.log.check && ( <> {deployment.log.check.map((item: Log) => { diff --git a/ui/src/pages/history/History.tsx b/ui/src/pages/history/History.tsx index 24de0c7b..e0a47186 100644 --- a/ui/src/pages/history/History.tsx +++ b/ui/src/pages/history/History.tsx @@ -104,7 +104,7 @@ const History = () => { {t("history.log")} -
+
{deployment.log.check && ( <> {deployment.log.check.map((item: Log) => { diff --git a/ui/src/pages/setting/Notify.tsx b/ui/src/pages/setting/Notify.tsx index ad06a082..350e4b8b 100644 --- a/ui/src/pages/setting/Notify.tsx +++ b/ui/src/pages/setting/Notify.tsx @@ -7,6 +7,7 @@ import NotifyTemplate from "@/components/notify/NotifyTemplate"; import Telegram from "@/components/notify/Telegram"; import Webhook from "@/components/notify/Webhook"; import ServerChan from "@/components/notify/ServerChan"; +import Mail from "@/components/notify/Mail"; import { NotifyProvider } from "@/providers/notify"; const Notify = () => { @@ -61,6 +62,12 @@ const Notify = () => { + + {t("common.provider.mail")} + + + +