diff --git a/go.mod b/go.mod index 9d96a737..02a8e543 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/iam v1.52.2 github.com/baidubce/bce-sdk-go v0.9.252 github.com/byteplus-sdk/byteplus-sdk-golang v1.0.59 - github.com/go-acme/lego/v4 v4.28.1 + github.com/go-acme/lego/v4 v4.29.0 github.com/go-cmd/cmd v1.4.3 github.com/go-resty/resty/v2 v2.17.0 github.com/go-viper/mapstructure/v2 v2.4.0 @@ -99,7 +99,7 @@ require ( github.com/alibabacloud-go/alibabacloud-gateway-fc-util v0.0.7 // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect github.com/aws/aws-sdk-go v1.40.45 // indirect - github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.61.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -107,8 +107,8 @@ require ( github.com/djherbis/times v1.6.0 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-acme/alidns-20150109/v4 v4.6.1 // indirect - github.com/go-acme/tencentclouddnspod v1.1.10 // indirect + github.com/go-acme/alidns-20150109/v4 v4.7.0 // indirect + github.com/go-acme/tencentclouddnspod v1.1.25 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -131,7 +131,7 @@ require ( github.com/kong/semver/v4 v4.0.1 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/linode/linodego v1.60.0 // indirect + github.com/linode/linodego v1.61.0 // indirect github.com/magefile/mage v1.14.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -155,7 +155,7 @@ require ( github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect - github.com/vultr/govultr/v3 v3.24.0 // indirect + github.com/vultr/govultr/v3 v3.25.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.mongodb.org/mongo-driver v1.17.2 // indirect go.uber.org/ratelimit v0.3.1 // indirect @@ -165,7 +165,7 @@ require ( golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/ns1/ns1-go.v2 v2.15.1 // indirect + gopkg.in/ns1/ns1-go.v2 v2.15.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect @@ -203,6 +203,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/ganigeorgiev/fexpr v0.5.0 // indirect + github.com/go-acme/esa-20240910/v2 v2.40.1 // indirect github.com/go-acme/tencentedgdeone v1.1.48 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect diff --git a/go.sum b/go.sum index 3fd181b1..c6ba7980 100644 --- a/go.sum +++ b/go.sum @@ -227,8 +227,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/A github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= -github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1 h1:KuoA/cmy/yK8n9v/d6WH36cZwGxFOrn0TmZ4lNN3MKQ= -github.com/aws/aws-sdk-go-v2/service/route53 v1.59.1/go.mod h1:BymbICXBfXQHO6i+yTBhocA9a6DM0uMDQqYelqa9wzs= +github.com/aws/aws-sdk-go-v2/service/route53 v1.61.0 h1:W3+0Cbc9awFBr9Yt7nFUkvB4N4e7vVIGtKD1qDttXn4= +github.com/aws/aws-sdk-go-v2/service/route53 v1.61.0/go.mod h1:Wa3q5R2uwIfIL3HZH+vG1/P9y7CjjfzTgcz5IWXlsZs= github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= @@ -337,12 +337,14 @@ github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNan github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-acme/alidns-20150109/v4 v4.6.1 h1:Dch3aWRcw4U62+jKPjPQN3iW3TPvgIywATbvHzojXeo= -github.com/go-acme/alidns-20150109/v4 v4.6.1/go.mod h1:RBcqBA5IvUWtlpjx6dC6EkPVyBNLQ+mR18XoaP38BFY= -github.com/go-acme/lego/v4 v4.28.1 h1:zt301JYF51UIEkpSXsdeGq9hRePeFzQCq070OdAmP0Q= -github.com/go-acme/lego/v4 v4.28.1/go.mod h1:bzjilr03IgbaOwlH396hq5W56Bi0/uoRwW/JM8hP7m4= -github.com/go-acme/tencentclouddnspod v1.1.10 h1:ERVJ4mc3cT4Nb3+n6H/c1AwZnChGBqLoymE0NVYscKI= -github.com/go-acme/tencentclouddnspod v1.1.10/go.mod h1:Bo/0YQJ/99FM+44HmCQkByuptX1tJsJ9V14MGV/2Qco= +github.com/go-acme/alidns-20150109/v4 v4.7.0 h1:PqJ/wR0JTpL4v0Owu1uM7bPQ1Yww0eQLAuuSdLjjQaQ= +github.com/go-acme/alidns-20150109/v4 v4.7.0/go.mod h1:btQvB6xZoN6ykKB74cPhiR+uvhrEE2AFVXm6RDmCHm0= +github.com/go-acme/esa-20240910/v2 v2.40.1 h1:pog3UlF5d+3LPoo1L8u8PqB189recIXX8T7pGoEz18A= +github.com/go-acme/esa-20240910/v2 v2.40.1/go.mod h1:ZYdN9EN9ikn26SNapxCVjZ65pHT/1qm4fzuJ7QGVX6g= +github.com/go-acme/lego/v4 v4.29.0 h1:vKMEtvoKb0gOO9rWO9zMBwE4CgI5A5CWDsK4QEeBqzo= +github.com/go-acme/lego/v4 v4.29.0/go.mod h1:rnYyDj1NdDd9y1dHkVuUS97j7bfe9I61+oY9odKaHM8= +github.com/go-acme/tencentclouddnspod v1.1.25 h1:7H3ZKshkaHzCXfRpAHVB5nvxeDDl2XLeNZfrNHiZj/s= +github.com/go-acme/tencentclouddnspod v1.1.25/go.mod h1:XXfzp0AYV7UAUsHKT6R0KAUJFhqAUXmWGF07Elpa5cE= github.com/go-acme/tencentedgdeone v1.1.48 h1:WLyLBsRVhSLFmtbEFXk0naLODSQn7X6J0Fc/qR8xVUk= github.com/go-acme/tencentedgdeone v1.1.48/go.mod h1:mu6tA+bPhlSd+CKUfzRikE0mfxmTlBI6dVTn9LY9dRI= github.com/go-cmd/cmd v1.4.3 h1:6y3G+3UqPerXvPcXvj+5QNPHT02BUw7p6PsqRxLNA7Y= @@ -618,8 +620,8 @@ github.com/libdns/dynv6 v1.1.1 h1:X+P/5tNeBo+MLf080kyy1aJG2FPdon0/fVOd58AlfZU= github.com/libdns/dynv6 v1.1.1/go.mod h1:XhpjX1oZ0TdzH0nZaP5hpW3x/HCMB0T7loZw65on5Iw= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= -github.com/linode/linodego v1.60.0 h1:SgsebJFRCi+lSmYy+C40wmKZeJllGGm+W12Qw4+yVdI= -github.com/linode/linodego v1.60.0/go.mod h1:1+Bt0oTz5rBnDOJbGhccxn7LYVytXTIIfAy7QYmijDs= +github.com/linode/linodego v1.61.0 h1:9g20NWl+/SbhDFj6X5EOZXtM2hBm1Mx8I9h8+F3l1LM= +github.com/linode/linodego v1.61.0/go.mod h1:64o30geLNwR0NeYh5HM/WrVCBXcSqkKnRK3x9xoRuJI= github.com/luthermonson/go-proxmox v0.2.3 h1:NAjUJ5Jd1ynIK6UHMGd/VLGgNZWpGXhfL+DBmAVSEaA= github.com/luthermonson/go-proxmox v0.2.3/go.mod h1:oyFgg2WwTEIF0rP6ppjiixOHa5ebK1p8OaRiFhvICBQ= github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= @@ -838,7 +840,7 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.2.3 h1:48Vaw27yh github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.2.3/go.mod h1:20RYaBaXM5Qo/JzYX91x2SsyvVrP/+dVCK7V5IwLJd8= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.2.2 h1:mDg1SYAFF3tiDRFnFDGguCR2Kq35udG07nHa52uTYX0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.2.2/go.mod h1:59a8Y11O2Hqa9cS3mMpsLbTgQsu6+PpOu+ZYWTEtVDE= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.10/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.25/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.45/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.49/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= @@ -883,8 +885,8 @@ github.com/volcengine/volc-sdk-golang v1.0.229 h1:gOkDltTS6Fta8OyfYrbeY9bqCHHyiJ github.com/volcengine/volc-sdk-golang v1.0.229/go.mod h1:zHJlaqiMbIB+0mcrsZPTwOb3FB7S/0MCfqlnO8R7hlM= github.com/volcengine/volcengine-go-sdk v1.1.50 h1:hWJvHKcpcye3tqA1rqQrRLxiyrVB5s9gwhJNs0DjWk8= github.com/volcengine/volcengine-go-sdk v1.1.50/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A= -github.com/vultr/govultr/v3 v3.24.0 h1:fTTTj0VBve+Miy+wGhlb90M2NMDfpGFi6Frlj3HVy6M= -github.com/vultr/govultr/v3 v3.24.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= +github.com/vultr/govultr/v3 v3.25.0 h1:rS8/Vdy8HlHArwmD4MtLY+hbbpYAbcnZueZrE6b0oUg= +github.com/vultr/govultr/v3 v3.25.0/go.mod h1:9WwnWGCKnwDlNjHjtt+j+nP+0QWq6hQXzaHgddqrLWY= github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -1389,8 +1391,8 @@ gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/ns1/ns1-go.v2 v2.15.1 h1:8rri2TzAPYcVbBGXn48+dz1Xg30PzHfZ4k8A9JOS0Z0= -gopkg.in/ns1/ns1-go.v2 v2.15.1/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= +gopkg.in/ns1/ns1-go.v2 v2.15.2 h1:aBVyKeEH3rBFWwX72xPPjEuRL4+Lp5P9GlAcrzu0Y5M= +gopkg.in/ns1/ns1-go.v2 v2.15.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/domain/settings.go b/internal/domain/settings.go index 84dc2000..8c7a4ee4 100644 --- a/internal/domain/settings.go +++ b/internal/domain/settings.go @@ -13,8 +13,11 @@ type Settings struct { } const ( - SettingsNameSSLProvider = "sslProvider" - SettingsNamePersistence = "persistence" + SettingsNameEmails = "emails" + SettingsNameNotificationTemplate = "notifyTemplate" + SettingsNameScriptTemplate = "scriptTemplate" + SettingsNameSSLProvider = "sslProvider" + SettingsNamePersistence = "persistence" ) type SettingsContent map[string]any diff --git a/pkg/core/certifier/challengers/dns01/aliyun-esa/aliyun_esa.go b/pkg/core/certifier/challengers/dns01/aliyun-esa/aliyun_esa.go index b63fa04a..5f29d7bb 100644 --- a/pkg/core/certifier/challengers/dns01/aliyun-esa/aliyun_esa.go +++ b/pkg/core/certifier/challengers/dns01/aliyun-esa/aliyun_esa.go @@ -4,8 +4,9 @@ import ( "errors" "time" + "github.com/go-acme/lego/v4/providers/dns/aliesa" + "github.com/certimate-go/certimate/pkg/core/certifier" - "github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/aliyun-esa/internal" ) type ChallengerConfig struct { @@ -21,8 +22,8 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) { return nil, errors.New("the configuration of the acme challenge provider is nil") } - providerConfig := internal.NewDefaultConfig() - providerConfig.SecretID = config.AccessKeyId + providerConfig := aliesa.NewDefaultConfig() + providerConfig.APIKey = config.AccessKeyId providerConfig.SecretKey = config.AccessKeySecret providerConfig.RegionID = config.Region if config.DnsPropagationTimeout != 0 { @@ -32,7 +33,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) { providerConfig.TTL = config.DnsTTL } - provider, err := internal.NewDNSProviderConfig(providerConfig) + provider, err := aliesa.NewDNSProviderConfig(providerConfig) if err != nil { return nil, err } diff --git a/pkg/core/certifier/challengers/dns01/aliyun-esa/internal/client.go b/pkg/core/certifier/challengers/dns01/aliyun-esa/internal/client.go deleted file mode 100644 index 44418137..00000000 --- a/pkg/core/certifier/challengers/dns01/aliyun-esa/internal/client.go +++ /dev/null @@ -1,225 +0,0 @@ -package internal - -import ( - openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" - openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils" - aliesa "github.com/alibabacloud-go/esa-20240910/v2/client" - "github.com/alibabacloud-go/tea/dara" -) - -// This is a partial copy of https://github.com/alibabacloud-go/esa-20240910/blob/master/client/client.go -// to lightweight the vendor packages in the built binary. -type EsaClient struct { - openapi.Client - DisableSDKError *bool -} - -func NewEsaClient(config *openapiutil.Config) (*EsaClient, error) { - client := new(EsaClient) - err := client.Init(config) - return client, err -} - -func (client *EsaClient) Init(config *openapiutil.Config) (_err error) { - _err = client.Client.Init(config) - if _err != nil { - return _err - } - _err = client.CheckConfig(config) - if _err != nil { - return _err - } - - return nil -} - -func (client *EsaClient) CreateRecordWithOptions(tmpReq *aliesa.CreateRecordRequest, runtime *dara.RuntimeOptions) (_result *aliesa.CreateRecordResponse, _err error) { - _err = tmpReq.Validate() - if _err != nil { - return _result, _err - } - - request := &aliesa.CreateRecordShrinkRequest{} - openapiutil.Convert(tmpReq, request) - if !dara.IsNil(tmpReq.AuthConf) { - request.AuthConfShrink = openapiutil.ArrayToStringWithSpecifiedStyle(tmpReq.AuthConf, dara.String("AuthConf"), dara.String("json")) - } - - if !dara.IsNil(tmpReq.Data) { - request.DataShrink = openapiutil.ArrayToStringWithSpecifiedStyle(tmpReq.Data, dara.String("Data"), dara.String("json")) - } - - query := map[string]interface{}{} - if !dara.IsNil(request.AuthConfShrink) { - query["AuthConf"] = request.AuthConfShrink - } - - if !dara.IsNil(request.BizName) { - query["BizName"] = request.BizName - } - - if !dara.IsNil(request.Comment) { - query["Comment"] = request.Comment - } - - if !dara.IsNil(request.DataShrink) { - query["Data"] = request.DataShrink - } - - if !dara.IsNil(request.HostPolicy) { - query["HostPolicy"] = request.HostPolicy - } - - if !dara.IsNil(request.Proxied) { - query["Proxied"] = request.Proxied - } - - if !dara.IsNil(request.RecordName) { - query["RecordName"] = request.RecordName - } - - if !dara.IsNil(request.SiteId) { - query["SiteId"] = request.SiteId - } - - if !dara.IsNil(request.SourceType) { - query["SourceType"] = request.SourceType - } - - if !dara.IsNil(request.Ttl) { - query["Ttl"] = request.Ttl - } - - if !dara.IsNil(request.Type) { - query["Type"] = request.Type - } - - req := &openapiutil.OpenApiRequest{ - Query: openapiutil.Query(query), - } - params := &openapiutil.Params{ - Action: dara.String("CreateRecord"), - Version: dara.String("2024-09-10"), - Protocol: dara.String("HTTPS"), - Pathname: dara.String("/"), - Method: dara.String("POST"), - AuthType: dara.String("AK"), - Style: dara.String("RPC"), - ReqBodyType: dara.String("formData"), - BodyType: dara.String("json"), - } - _result = &aliesa.CreateRecordResponse{} - _body, _err := client.CallApi(params, req, runtime) - if _err != nil { - return _result, _err - } - _err = dara.Convert(_body, &_result) - return _result, _err -} - -func (client *EsaClient) CreateRecord(request *aliesa.CreateRecordRequest) (_result *aliesa.CreateRecordResponse, _err error) { - runtime := &dara.RuntimeOptions{} - _result = &aliesa.CreateRecordResponse{} - _body, _err := client.CreateRecordWithOptions(request, runtime) - if _err != nil { - return _result, _err - } - _result = _body - return _result, _err -} - -func (client *EsaClient) DeleteRecordWithOptions(request *aliesa.DeleteRecordRequest, runtime *dara.RuntimeOptions) (_result *aliesa.DeleteRecordResponse, _err error) { - _err = request.Validate() - if _err != nil { - return _result, _err - } - - query := map[string]interface{}{} - if !dara.IsNil(request.RecordId) { - query["RecordId"] = request.RecordId - } - - if !dara.IsNil(request.SecurityToken) { - query["SecurityToken"] = request.SecurityToken - } - - req := &openapiutil.OpenApiRequest{ - Query: openapiutil.Query(query), - } - params := &openapiutil.Params{ - Action: dara.String("DeleteRecord"), - Version: dara.String("2024-09-10"), - Protocol: dara.String("HTTPS"), - Pathname: dara.String("/"), - Method: dara.String("POST"), - AuthType: dara.String("AK"), - Style: dara.String("RPC"), - ReqBodyType: dara.String("formData"), - BodyType: dara.String("json"), - } - _result = &aliesa.DeleteRecordResponse{} - _body, _err := client.CallApi(params, req, runtime) - if _err != nil { - return _result, _err - } - _err = dara.Convert(_body, &_result) - return _result, _err -} - -func (client *EsaClient) DeleteRecord(request *aliesa.DeleteRecordRequest) (_result *aliesa.DeleteRecordResponse, _err error) { - runtime := &dara.RuntimeOptions{} - _result = &aliesa.DeleteRecordResponse{} - _body, _err := client.DeleteRecordWithOptions(request, runtime) - if _err != nil { - return _result, _err - } - _result = _body - return _result, _err -} - -func (client *EsaClient) ListSitesWithOptions(tmpReq *aliesa.ListSitesRequest, runtime *dara.RuntimeOptions) (_result *aliesa.ListSitesResponse, _err error) { - _err = tmpReq.Validate() - if _err != nil { - return _result, _err - } - - request := &aliesa.ListSitesShrinkRequest{} - openapiutil.Convert(tmpReq, request) - if !dara.IsNil(tmpReq.TagFilter) { - request.TagFilterShrink = openapiutil.ArrayToStringWithSpecifiedStyle(tmpReq.TagFilter, dara.String("TagFilter"), dara.String("json")) - } - - query := openapiutil.Query(dara.ToMap(request)) - req := &openapiutil.OpenApiRequest{ - Query: openapiutil.Query(query), - } - params := &openapiutil.Params{ - Action: dara.String("ListSites"), - Version: dara.String("2024-09-10"), - Protocol: dara.String("HTTPS"), - Pathname: dara.String("/"), - Method: dara.String("GET"), - AuthType: dara.String("AK"), - Style: dara.String("RPC"), - ReqBodyType: dara.String("formData"), - BodyType: dara.String("json"), - } - _result = &aliesa.ListSitesResponse{} - _body, _err := client.CallApi(params, req, runtime) - if _err != nil { - return _result, _err - } - _err = dara.Convert(_body, &_result) - return _result, _err -} - -func (client *EsaClient) ListSites(request *aliesa.ListSitesRequest) (_result *aliesa.ListSitesResponse, _err error) { - runtime := &dara.RuntimeOptions{} - _result = &aliesa.ListSitesResponse{} - _body, _err := client.ListSitesWithOptions(request, runtime) - if _err != nil { - return _result, _err - } - _result = _body - return _result, _err -} diff --git a/pkg/core/certifier/challengers/dns01/aliyun-esa/internal/lego.go b/pkg/core/certifier/challengers/dns01/aliyun-esa/internal/lego.go deleted file mode 100644 index db385b42..00000000 --- a/pkg/core/certifier/challengers/dns01/aliyun-esa/internal/lego.go +++ /dev/null @@ -1,196 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "sync" - "time" - - aliopen "github.com/alibabacloud-go/darabonba-openapi/v2/client" - aliesa "github.com/alibabacloud-go/esa-20240910/v2/client" - "github.com/alibabacloud-go/tea/tea" - "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/platform/config/env" -) - -const ( - envNamespace = "ALICLOUDESA_" - - EnvAccessKey = envNamespace + "ACCESS_KEY" - EnvSecretKey = envNamespace + "SECRET_KEY" - EnvRegionID = envNamespace + "REGION_ID" - - EnvTTL = envNamespace + "TTL" - EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" - EnvPollingInterval = envNamespace + "POLLING_INTERVAL" - EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" -) - -var _ challenge.ProviderTimeout = (*DNSProvider)(nil) - -type Config struct { - SecretID string - SecretKey string - RegionID string - - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPTimeout time.Duration -} - -type DNSProvider struct { - client *EsaClient - config *Config - - recordIDs map[string]int64 - recordIDsMu sync.Mutex -} - -func NewDefaultConfig() *Config { - return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, 300), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), - HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), - } -} - -func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAccessKey, EnvSecretKey, EnvRegionID) - if err != nil { - return nil, fmt.Errorf("alicloud-esa: %w", err) - } - - config := NewDefaultConfig() - config.SecretID = values[EnvAccessKey] - config.SecretKey = values[EnvSecretKey] - config.RegionID = values[EnvRegionID] - - return NewDNSProviderConfig(config) -} - -func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { - if config == nil { - return nil, errors.New("alicloud-esa: the configuration of the DNS provider is nil") - } - - if config.RegionID == "" { - config.RegionID = "cn-hangzhou" - } - - client, err := NewEsaClient(&aliopen.Config{ - AccessKeyId: tea.String(config.SecretID), - AccessKeySecret: tea.String(config.SecretKey), - Endpoint: tea.String(fmt.Sprintf("esa.%s.aliyuncs.com", config.RegionID)), - }) - if err != nil { - return nil, fmt.Errorf("alicloud-esa: %w", err) - } - - return &DNSProvider{ - client: client, - config: config, - recordIDs: make(map[string]int64), - recordIDsMu: sync.Mutex{}, - }, nil -} - -func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("alicloud-esa: could not find zone for domain %q: %w", domain, err) - } - - siteName := dns01.UnFqdn(authZone) - siteID, err := d.findSiteIdByName(siteName) - if err != nil { - return fmt.Errorf("alicloud-esa: could not find site for zone %q: %w", siteName, err) - } - - // REF: https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord - aliCreateRecordReq := &aliesa.CreateRecordRequest{ - SiteId: tea.Int64(siteID), - Type: tea.String("TXT"), - RecordName: tea.String(dns01.UnFqdn(info.EffectiveFQDN)), - Data: &aliesa.CreateRecordRequestData{ - Value: tea.String(info.Value), - }, - Ttl: tea.Int32(int32(d.config.TTL)), - } - aliCreateRecordResp, err := d.client.CreateRecord(aliCreateRecordReq) - if err != nil { - return fmt.Errorf("alicloud-esa: error when create record: %w", err) - } - - d.recordIDsMu.Lock() - d.recordIDs[token] = *aliCreateRecordResp.Body.GetRecordId() - d.recordIDsMu.Unlock() - - return nil -} - -func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - d.recordIDsMu.Lock() - recordID, ok := d.recordIDs[token] - d.recordIDsMu.Unlock() - if !ok { - return fmt.Errorf("alicloud-esa: unknown record ID for '%s'", info.EffectiveFQDN) - } - - // REF: https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-deleterecord - aliDeleteRecordReq := &aliesa.DeleteRecordRequest{ - RecordId: &recordID, - } - if _, err := d.client.DeleteRecord(aliDeleteRecordReq); err != nil { - return fmt.Errorf("alicloud-esa: error when delete record %w", err) - } - - return nil -} - -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -func (d *DNSProvider) findSiteIdByName(siteName string) (int64, error) { - aliListSitesPageNumber := 1 - aliListSitesPageSize := 500 - for { - // REF: https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-listsites - aliListSitesReq := &aliesa.ListSitesRequest{ - SiteName: tea.String(siteName), - SiteSearchType: tea.String("exact"), - AccessType: tea.String("NS"), - PageNumber: tea.Int32(int32(aliListSitesPageNumber)), - PageSize: tea.Int32(int32(aliListSitesPageSize)), - } - aliListSitesResp, err := d.client.ListSites(aliListSitesReq) - if err != nil { - return 0, err - } - - if aliListSitesResp.Body == nil { - break - } - - for _, siteItem := range aliListSitesResp.Body.Sites { - if *siteItem.GetSiteName() == siteName { - return *siteItem.GetSiteId(), nil - } - } - - if len(aliListSitesResp.Body.Sites) < aliListSitesPageSize { - break - } - - aliListSitesPageNumber++ - } - - return 0, fmt.Errorf("could not find site '%s'", siteName) -} diff --git a/pkg/core/certifier/challengers/dns01/baiducloud/baiducloud.go b/pkg/core/certifier/challengers/dns01/baiducloud/baiducloud.go index eb4ddfc1..160cfd3d 100644 --- a/pkg/core/certifier/challengers/dns01/baiducloud/baiducloud.go +++ b/pkg/core/certifier/challengers/dns01/baiducloud/baiducloud.go @@ -4,8 +4,9 @@ import ( "errors" "time" + "github.com/go-acme/lego/v4/providers/dns/baiducloud" + "github.com/certimate-go/certimate/pkg/core/certifier" - "github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/baiducloud/internal" ) type ChallengerConfig struct { @@ -22,7 +23,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) { // 没有使用 github.com/go-acme/lego/v4/providers/dns/baiducloud // 因为该实现存在一些问题 - providerConfig := internal.NewDefaultConfig() + providerConfig := baiducloud.NewDefaultConfig() providerConfig.AccessKeyID = config.AccessKeyId providerConfig.SecretAccessKey = config.SecretAccessKey if config.DnsPropagationTimeout != 0 { @@ -32,7 +33,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) { providerConfig.TTL = config.DnsTTL } - provider, err := internal.NewDNSProviderConfig(providerConfig) + provider, err := baiducloud.NewDNSProviderConfig(providerConfig) if err != nil { return nil, err } diff --git a/pkg/core/certifier/challengers/dns01/baiducloud/internal/lego.go b/pkg/core/certifier/challengers/dns01/baiducloud/internal/lego.go deleted file mode 100644 index 3fa4039a..00000000 --- a/pkg/core/certifier/challengers/dns01/baiducloud/internal/lego.go +++ /dev/null @@ -1,175 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "time" - - bce "github.com/baidubce/bce-sdk-go/bce" - bcedns "github.com/baidubce/bce-sdk-go/services/dns" - "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/platform/config/env" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/samber/lo" -) - -const ( - envNamespace = "BAIDUCLOUD_" - - EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID" - EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY" - - EnvTTL = envNamespace + "TTL" - EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" - EnvPollingInterval = envNamespace + "POLLING_INTERVAL" - EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" -) - -var _ challenge.ProviderTimeout = (*DNSProvider)(nil) - -type Config struct { - AccessKeyID string - SecretAccessKey string - - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPTimeout time.Duration -} - -type DNSProvider struct { - client *bcedns.Client - config *Config -} - -func NewDefaultConfig() *Config { - return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, 300), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), - HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), - } -} - -func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey) - if err != nil { - return nil, fmt.Errorf("baiducloud: %w", err) - } - - config := NewDefaultConfig() - config.AccessKeyID = values[EnvAccessKeyID] - config.SecretAccessKey = values[EnvSecretAccessKey] - - return NewDNSProviderConfig(config) -} - -func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { - if config == nil { - return nil, errors.New("baiducloud: the configuration of the DNS provider is nil") - } - - client, err := bcedns.NewClient(config.AccessKeyID, config.SecretAccessKey, "") - if err != nil { - return nil, err - } else { - if client.Config == nil { - client.Config = &bce.BceClientConfiguration{} - } - client.Config.HTTPClientTimeout = &config.HTTPTimeout - } - - return &DNSProvider{ - client: client, - config: config, - }, nil -} - -func (d *DNSProvider) Present(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err) - } - - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) - if err != nil { - return fmt.Errorf("baiducloud: %w", err) - } - - // REF: https://cloud.baidu.com/doc/DNS/s/El4s7lssr#%E6%B7%BB%E5%8A%A0%E8%A7%A3%E6%9E%90%E8%AE%B0%E5%BD%95 - bceCreateRecordReq := &bcedns.CreateRecordRequest{ - Type: "TXT", - Rr: subDomain, - Value: info.Value, - Description: lo.ToPtr("certimate acme"), - Ttl: lo.ToPtr(int32(d.config.TTL)), - } - if err := d.client.CreateRecord(dns01.UnFqdn(authZone), bceCreateRecordReq, security.RandomString(32)); err != nil { - return fmt.Errorf("baiducloud: error when create record: %w", err) - } - - return nil -} - -func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - info := dns01.GetChallengeInfo(domain, keyAuth) - - authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) - if err != nil { - return fmt.Errorf("baiducloud: could not find zone for domain %q: %w", domain, err) - } - - subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) - if err != nil { - return fmt.Errorf("baiducloud: %w", err) - } - - record, err := d.findDNSRecord(dns01.UnFqdn(authZone), subDomain, info.Value) - if err != nil { - return fmt.Errorf("baiducloud: error when find record: %q: %w", domain, err) - } - - // REF: https://cloud.baidu.com/doc/DNS/s/El4s7lssr#%E5%88%A0%E9%99%A4%E8%A7%A3%E6%9E%90%E8%AE%B0%E5%BD%95 - if err := d.client.DeleteRecord(dns01.UnFqdn(authZone), record.Id, security.RandomString(32)); err != nil { - return fmt.Errorf("baiducloud: %w", err) - } - - return nil -} - -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -func (d *DNSProvider) findDNSRecord(zoneName, subDomain, tokenValue string) (*bcedns.Record, error) { - bceListRecordPageMarker := "" - for { - // REF: https://cloud.baidu.com/doc/DNS/s/El4s7lssr#%E6%9F%A5%E8%AF%A2%E8%A7%A3%E6%9E%90%E8%AE%B0%E5%BD%95%E5%88%97%E8%A1%A8 - bceListRecordReq := &bcedns.ListRecordRequest{} - bceListRecordReq.Rr = subDomain - bceListRecordReq.Marker = bceListRecordPageMarker - bceListRecordReq.MaxKeys = 1000 - - bceListRecordResp, err := d.client.ListRecord(zoneName, bceListRecordReq) - if err != nil { - return nil, err - } - - for _, record := range bceListRecordResp.Records { - if record.Type == "TXT" && record.Rr == subDomain && record.Value == tokenValue { - return &record, nil - } - } - - if bceListRecordResp.NextMarker == "" { - break - } - - bceListRecordPageMarker = bceListRecordResp.NextMarker - } - - return nil, errors.New("could not find record") -} diff --git a/pkg/core/deployer/providers/local/local.go b/pkg/core/deployer/providers/local/local.go index ef4cca47..f25ab21c 100644 --- a/pkg/core/deployer/providers/local/local.go +++ b/pkg/core/deployer/providers/local/local.go @@ -8,6 +8,7 @@ import ( "log/slog" "os/exec" "runtime" + "strings" "github.com/certimate-go/certimate/pkg/core/deployer" xcert "github.com/certimate-go/certimate/pkg/utils/cert" @@ -83,7 +84,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep // 执行前置命令 if d.config.PreCommand != "" { - stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PreCommand) + command := d.config.PreCommand + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass) + + stdout, stderr, err := execCommand(d.config.ShellEnv, command) d.logger.Debug("run pre-command", slog.String("stdout", stdout), slog.String("stderr", stderr)) if err != nil { return nil, fmt.Errorf("failed to execute pre-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err) @@ -147,7 +158,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep // 执行后置命令 if d.config.PostCommand != "" { - stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PostCommand) + command := d.config.PostCommand + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass) + + stdout, stderr, err := execCommand(d.config.ShellEnv, command) d.logger.Debug("run post-command", slog.String("stdout", stdout), slog.String("stderr", stderr)) if err != nil { return nil, fmt.Errorf("failed to execute post-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err) diff --git a/pkg/core/deployer/providers/ssh/ssh.go b/pkg/core/deployer/providers/ssh/ssh.go index ab5fa41c..05323290 100644 --- a/pkg/core/deployer/providers/ssh/ssh.go +++ b/pkg/core/deployer/providers/ssh/ssh.go @@ -8,6 +8,7 @@ import ( "log/slog" "net" "strconv" + "strings" "golang.org/x/crypto/ssh" @@ -181,7 +182,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep // 执行前置命令 if d.config.PreCommand != "" { - stdout, stderr, err := execSshCommand(client, d.config.PreCommand) + command := d.config.PreCommand + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass) + + stdout, stderr, err := execSshCommand(client, command) d.logger.Debug("run pre-command", slog.String("stdout", stdout), slog.String("stderr", stderr)) if err != nil { return nil, fmt.Errorf("failed to execute pre-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err) @@ -245,7 +256,17 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep // 执行后置命令 if d.config.PostCommand != "" { - stdout, stderr, err := execSshCommand(client, d.config.PostCommand) + command := d.config.PostCommand + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}", d.config.OutputCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}", d.config.OutputServerCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}", d.config.OutputIntermediaCertPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}", d.config.OutputKeyPath) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}", d.config.PfxPassword) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}", d.config.JksAlias) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}", d.config.JksKeypass) + command = strings.ReplaceAll(command, "${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}", d.config.JksStorepass) + + stdout, stderr, err := execSshCommand(client, command) d.logger.Debug("run post-command", slog.String("stdout", stdout), slog.String("stderr", stderr)) if err != nil { return nil, fmt.Errorf("failed to execute post-command (stdout: %s, stderr: %s): %w ", stdout, stderr, err) diff --git a/pkg/core/deployer/providers/webhook/webhook.go b/pkg/core/deployer/providers/webhook/webhook.go index 2bfb3bb2..3ad22aa0 100644 --- a/pkg/core/deployer/providers/webhook/webhook.go +++ b/pkg/core/deployer/providers/webhook/webhook.go @@ -93,8 +93,6 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep return nil, fmt.Errorf("failed to parse webhook url: %w", err) } else if webhookUrl.Scheme != "http" && webhookUrl.Scheme != "https" { return nil, fmt.Errorf("unsupported webhook url scheme '%s'", webhookUrl.Scheme) - } else { - webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${DOMAIN}", url.PathEscape(certX509.Subject.CommonName)) } // 处理 Webhook 请求谓词 @@ -143,13 +141,6 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) } - replaceJsonValueRecursively(webhookData, "${DOMAIN}", certX509.Subject.CommonName) - replaceJsonValueRecursively(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";")) - replaceJsonValueRecursively(webhookData, "${CERTIFICATE}", certPEM) - replaceJsonValueRecursively(webhookData, "${SERVER_CERTIFICATE}", serverCertPEM) - replaceJsonValueRecursively(webhookData, "${INTERMEDIA_CERTIFICATE}", intermediaCertPEM) - replaceJsonValueRecursively(webhookData, "${PRIVATE_KEY}", privkeyPEM) - if webhookMethod == http.MethodGet || webhookContentType == CONTENT_TYPE_FORM || webhookContentType == CONTENT_TYPE_MULTIPART { temp := make(map[string]string) jsonb, err := json.Marshal(webhookData) @@ -163,6 +154,24 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } } + // 替换变量值 + webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${CERTIMATE_DEPLOYER_COMMONNAME}", url.PathEscape(certX509.Subject.CommonName)) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_COMMONNAME}", certX509.Subject.CommonName) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}", strings.Join(certX509.DNSNames, ";")) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE}", certPEM) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}", serverCertPEM) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}", intermediaCertPEM) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_DEPLOYER_PRIVATEKEY}", privkeyPEM) + + // 兼容旧版变量 + webhookUrl.Path = strings.ReplaceAll(webhookUrl.Path, "${DOMAIN}", url.PathEscape(certX509.Subject.CommonName)) + replaceJsonValueRecursively(webhookData, "${DOMAIN}", certX509.Subject.CommonName) + replaceJsonValueRecursively(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";")) + replaceJsonValueRecursively(webhookData, "${CERTIFICATE}", certPEM) + replaceJsonValueRecursively(webhookData, "${SERVER_CERTIFICATE}", serverCertPEM) + replaceJsonValueRecursively(webhookData, "${INTERMEDIA_CERTIFICATE}", intermediaCertPEM) + replaceJsonValueRecursively(webhookData, "${PRIVATE_KEY}", privkeyPEM) + // 生成请求 // 其中 GET 请求需转换为查询参数 req := d.httpClient.R().SetHeaderMultiValues(webhookHeaders) diff --git a/pkg/core/notifier/providers/webhook/webhook.go b/pkg/core/notifier/providers/webhook/webhook.go index 4e065e31..62597483 100644 --- a/pkg/core/notifier/providers/webhook/webhook.go +++ b/pkg/core/notifier/providers/webhook/webhook.go @@ -127,9 +127,6 @@ func (n *Notifier) Notify(ctx context.Context, subject string, message string) ( return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) } - replaceJsonValueRecursively(webhookData, "${SUBJECT}", subject) - replaceJsonValueRecursively(webhookData, "${MESSAGE}", message) - if webhookMethod == http.MethodGet || webhookContentType == CONTENT_TYPE_FORM || webhookContentType == CONTENT_TYPE_MULTIPART { temp := make(map[string]string) jsonb, err := json.Marshal(webhookData) @@ -143,6 +140,14 @@ func (n *Notifier) Notify(ctx context.Context, subject string, message string) ( } } + // 替换变量值 + replaceJsonValueRecursively(webhookData, "${CERTIMATE_NOTIFIER_SUBJECT}", subject) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_NOTIFIER_MESSAGE}", message) + + // 兼容旧版变量 + replaceJsonValueRecursively(webhookData, "${SUBJECT}", subject) + replaceJsonValueRecursively(webhookData, "${MESSAGE}", message) + // 生成请求 // 其中 GET 请求需转换为查询参数 req := n.httpClient.R().SetContext(ctx).SetHeaderMultiValues(webhookHeaders) diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs index 39721b4f..f7a96e16 100644 --- a/ui/eslint.config.mjs +++ b/ui/eslint.config.mjs @@ -125,7 +125,7 @@ export default defineConfig( "react-hooks/exhaustive-deps": ["warn"], "react-hooks/immutability": ["warn"], "react-hooks/refs": ["warn"], - "react-hooks/preserve-manual-memoization": ["warn"], + "react-hooks/preserve-manual-memoization": ["off"], "react-hooks/set-state-in-effect": ["warn"], "react-hooks/set-state-in-render": ["warn"], "react-refresh/only-export-components": [ diff --git a/ui/src/components/DrawerForm.tsx b/ui/src/components/DrawerForm.tsx index 1bcea6b8..6c183f71 100644 --- a/ui/src/components/DrawerForm.tsx +++ b/ui/src/components/DrawerForm.tsx @@ -18,7 +18,6 @@ export interface DrawerFormProps = any> extends O open?: boolean; title?: React.ReactNode; trigger?: React.ReactNode; - onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void | Promise; onFinish?: (values: T) => unknown | Promise; onOpenChange?: (open: boolean) => void; } diff --git a/ui/src/components/ModalForm.tsx b/ui/src/components/ModalForm.tsx index 2e24d8df..d2c9190a 100644 --- a/ui/src/components/ModalForm.tsx +++ b/ui/src/components/ModalForm.tsx @@ -33,7 +33,6 @@ export interface ModalFormProps = any> extends Om title?: ModalProps["title"]; trigger?: React.ReactNode; width?: ModalProps["width"]; - onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void | Promise; onFinish?: (values: T) => unknown | Promise; onOpenChange?: (open: boolean) => void; } diff --git a/ui/src/components/MultipleSplitValueInput.tsx b/ui/src/components/MultipleSplitValueInput.tsx index e09082e2..8055cf08 100644 --- a/ui/src/components/MultipleSplitValueInput.tsx +++ b/ui/src/components/MultipleSplitValueInput.tsx @@ -17,8 +17,8 @@ export interface MultipleSplitValueInputProps extends Omit
- {mode === "modify" - ? data?.id - ? t("access.action.edit.modal.title") + ` #${data.id}` - : t("access.action.edit.modal.title") - : t(`access.action.${mode}.modal.title`)} + {mode === "modify" && !!data?.id ? t("access.action.modify.modal.title") + ` #${data.id}` : t(`access.action.${mode}.modal.title`)}
@@ -268,14 +268,14 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF menu={{ items: ["bark", "ntfy", "gotify", "pushover", "pushplus", "serverchan3", "serverchanturbo", "common"].map((key) => ({ key, - label: , + label: , onClick: () => handlePresetDataForNotificationClick(key), })), }} trigger={["click"]} > @@ -318,14 +318,14 @@ const getInitialValues = ({ usage = "none" }: { usage?: "deployment" | "notifica data: JSON.stringify( usage === "deployment" ? { - name: "${DOMAINS}", - cert: "${CERTIFICATE}", - privkey: "${PRIVATE_KEY}", + name: "${CERTIMATE_DEPLOYER_COMMONNAME}", + cert: "${CERTIMATE_DEPLOYER_CERTIFICATE}", + privkey: "${CERTIMATE_DEPLOYER_PRIVATEKEY}", } : usage === "notification" ? { - subject: "${SUBJECT}", - message: "${MESSAGE}", + subject: "${CERTIMATE_NOTIFIER_SUBJECT}", + message: "${CERTIMATE_NOTIFIER_MESSAGE}", } : {}, null, diff --git a/ui/src/components/preset/PresetNotifyTemplatesPopselect.tsx b/ui/src/components/preset/PresetNotifyTemplatesPopselect.tsx new file mode 100644 index 00000000..bf171a51 --- /dev/null +++ b/ui/src/components/preset/PresetNotifyTemplatesPopselect.tsx @@ -0,0 +1,83 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { IconMoodEmpty } from "@tabler/icons-react"; +import { useMount } from "ahooks"; +import { Dropdown, type DropdownProps } from "antd"; + +import { useZustandShallowSelector } from "@/hooks"; +import { useNotifyTemplatesStore } from "@/stores/settings"; + +type PresetTemplate = { + subject: string; + message: string; +}; + +export interface PresetNotifyTemplatesPopselectProps extends Omit { + options?: NonNullable["items"]; + onSelect?: (value: string, template?: PresetTemplate | undefined) => void; +} + +const PresetNotifyTemplatesPopselect = ({ className, options, onSelect, ...props }: PresetNotifyTemplatesPopselectProps) => { + const { t } = useTranslation(); + + const { templates, fetchTemplates } = useNotifyTemplatesStore(useZustandShallowSelector(["templates", "fetchTemplates"])); + useMount(() => { + fetchTemplates(false); + }); + + const menuItems = useMemo(() => { + type MenuItem = NonNullable["items"]>[number]; + const temp: MenuItem[] = []; + + if (!options?.length && !templates?.length) { + temp.push({ + key: "nodata", + label: t("common.text.nodata"), + icon: ( + + + + ), + disabled: true, + }); + return temp; + } + + if (options?.length) { + temp.push( + ...options.map((option) => { + return { + ...option!, + onClick: (e: any) => { + if ("onClick" in option!) { + option.onClick?.(e); + } + + onSelect?.(String(option!.key!)); + }, + }; + }) + ); + } + + if (templates?.length) { + temp.push({ + key: "custom", + label: t("preset.dropdown.option_group.custom"), + children: templates.map((template) => ({ + key: `custom/${template.name}`, + label: template.name, + onClick: () => { + onSelect?.(template.name, template); + }, + })), + }); + } + + return temp; + }, [options, templates, onSelect]); + + return ; +}; + +export default PresetNotifyTemplatesPopselect; diff --git a/ui/src/components/preset/PresetScriptTemplatesPopselect.tsx b/ui/src/components/preset/PresetScriptTemplatesPopselect.tsx new file mode 100644 index 00000000..cbefe2f0 --- /dev/null +++ b/ui/src/components/preset/PresetScriptTemplatesPopselect.tsx @@ -0,0 +1,82 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { IconMoodEmpty } from "@tabler/icons-react"; +import { useMount } from "ahooks"; +import { Dropdown, type DropdownProps } from "antd"; + +import { useZustandShallowSelector } from "@/hooks"; +import { useScriptTemplatesStore } from "@/stores/settings"; + +type PresetTemplate = { + command: string; +}; + +export interface PresetScriptTemplatesPopselectProps extends Omit { + options?: NonNullable["items"]; + onSelect?: (value: string, template?: PresetTemplate | undefined) => void; +} + +const PresetScriptTemplatesPopselect = ({ className, options, onSelect, ...props }: PresetScriptTemplatesPopselectProps) => { + const { t } = useTranslation(); + + const { templates, fetchTemplates } = useScriptTemplatesStore(useZustandShallowSelector(["templates", "fetchTemplates"])); + useMount(() => { + fetchTemplates(false); + }); + + const menuItems = useMemo(() => { + type MenuItem = NonNullable["items"]>[number]; + const temp: MenuItem[] = []; + + if (!options?.length && !templates?.length) { + temp.push({ + key: "nodata", + label: t("common.text.nodata"), + icon: ( + + + + ), + disabled: true, + }); + return temp; + } + + if (options?.length) { + temp.push( + ...options.map((option) => { + return { + ...option!, + onClick: (e: any) => { + if ("onClick" in option!) { + option.onClick?.(e); + } + + onSelect?.(String(option!.key!)); + }, + }; + }) + ); + } + + if (templates?.length) { + temp.push({ + key: "custom", + label: t("preset.dropdown.option_group.custom"), + children: templates.map((template) => ({ + key: `custom/${template.name}`, + label: template.name, + onClick: () => { + onSelect?.(template.name, template); + }, + })), + }); + } + + return temp; + }, [options, templates, onSelect]); + + return ; +}; + +export default PresetScriptTemplatesPopselect; diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index 87d0eb60..5d1739e4 100644 --- a/ui/src/components/provider/AccessProviderPicker.tsx +++ b/ui/src/components/provider/AccessProviderPicker.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMount } from "ahooks"; -import { Avatar, Card, Empty, Input, type InputRef, Tag, Tooltip, Typography } from "antd"; +import { Avatar, Card, Empty, Input, type InputRef, Tag, Typography } from "antd"; import Show from "@/components/Show"; import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; @@ -63,10 +63,15 @@ const AccessProviderPicker = ({ const renderOption = (provider: AccessProvider) => { return ( -
+
{ if (provider.builtin) { @@ -76,36 +81,44 @@ const AccessProviderPicker = ({ handleProviderTypeSelect(provider.type); }} > -
-
- } shape="square" size={32} /> -
-
-
- - {t(provider.name) || "\u00A0"} - +
+
+
+ {t(provider.name) || "\u00A0"} +
+
+ } shape="square" size={28} />
- -
- - {t("access.props.provider.builtin")} - - - {t("access.props.provider.usage.dns")} - - - {t("access.props.provider.usage.hosting")} - - - {t("access.props.provider.usage.ca")} - - - {t("access.props.provider.usage.notification")} - -
-
+ +
+ + + {t("access.props.provider.builtin")} + + + + + {t("access.props.provider.usage.dns")} + + + + + {t("access.props.provider.usage.hosting")} + + + + + {t("access.props.provider.usage.ca")} + + + + + {t("access.props.provider.usage.notification")} + + +
+
diff --git a/ui/src/components/provider/AccessProviderSelect.tsx b/ui/src/components/provider/AccessProviderSelect.tsx index 934015ab..1bc23feb 100644 --- a/ui/src/components/provider/AccessProviderSelect.tsx +++ b/ui/src/components/provider/AccessProviderSelect.tsx @@ -60,21 +60,21 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv {t(provider.name)}
-
+
- {t("access.props.provider.builtin")} + {t("access.props.provider.builtin")} - {t("access.props.provider.usage.dns")} + {t("access.props.provider.usage.dns")} - {t("access.props.provider.usage.hosting")} + {t("access.props.provider.usage.hosting")} - {t("access.props.provider.usage.ca")} + {t("access.props.provider.usage.ca")} - {t("access.props.provider.usage.notification")} + {t("access.props.provider.usage.notification")}
diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index 55aebd28..ca81940e 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -67,7 +67,9 @@ const DeploymentProviderPicker = ({ >
- } shape="square" size={28} /> +
+ } shape="square" size={28} /> +
diff --git a/ui/src/components/provider/NotificationProviderPicker.tsx b/ui/src/components/provider/NotificationProviderPicker.tsx index 7a46c388..eb1f7636 100644 --- a/ui/src/components/provider/NotificationProviderPicker.tsx +++ b/ui/src/components/provider/NotificationProviderPicker.tsx @@ -55,7 +55,9 @@ const NotificationProviderPicker = ({ >
- } shape="square" size={28} /> +
+ } shape="square" size={28} /> +
diff --git a/ui/src/components/provider/_shared.ts b/ui/src/components/provider/_shared.ts index ec72f09c..f0e3b55c 100644 --- a/ui/src/components/provider/_shared.ts +++ b/ui/src/components/provider/_shared.ts @@ -26,7 +26,9 @@ export const useSelectDataSource = ({ deps?: React.DependencyList; }) => { const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); - useMount(() => fetchAccesses(false)); + useMount(() => { + fetchAccesses(false); + }); const filteredDataSource = useMemo(() => { return dataSource.filter((provider) => { @@ -82,7 +84,7 @@ export const usePickerWrapperCols = (width: number) => { const wWidth = wrapperSize?.width ?? document.body.clientWidth - 256; const wCols = Math.floor(wWidth / width); return Math.min(9, Math.max(1, wCols)); - }, [wrapperSize, width]); + }, [wrapperSize?.width, width]); return { wrapperElRef, @@ -104,7 +106,9 @@ export const usePickerDataSource = ({ const { t } = useTranslation(); const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); - useMount(() => fetchAccesses(false)); + useMount(() => { + fetchAccesses(false); + }); const filteredDataSource = useMemo(() => { return dataSource diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index ee4eb2a8..fba34b19 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -22,7 +22,7 @@ import { acmeDns01ProvidersMap, acmeHttp01ProvidersMap, caProvidersMap } from "@ import { type WorkflowNodeConfigForBizApply, defaultNodeConfigForBizApply } from "@/domain/workflow"; import { useAntdForm, useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; -import { useContactEmailsStore } from "@/stores/contact"; +import { useContactEmailsStore } from "@/stores/settings"; import { getErrMsg } from "@/utils/error"; import { validDomainName, validIPv4Address, validIPv6Address } from "@/utils/validators"; @@ -653,7 +653,9 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const InternalEmailInput = memo( ({ disabled, placeholder, ...props }: { disabled?: boolean; placeholder?: string; value?: string; onChange?: (value: string) => void }) => { const { emails, fetchEmails, removeEmail } = useContactEmailsStore(); - useMount(() => fetchEmails(false)); + useMount(() => { + fetchEmails(false); + }); const [value, setValue] = useControllableValue(props, { valuePropName: "value", diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx index 20ce8049..39a9d142 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx @@ -1,10 +1,11 @@ import { getI18n, useTranslation } from "react-i18next"; -import { IconChevronDown } from "@tabler/icons-react"; -import { Button, Dropdown, Form, Input, Select } from "antd"; +import { IconBulb, IconChevronDown } from "@tabler/icons-react"; +import { Button, Divider, Form, Input, Popover, Select, Space } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; import CodeInput from "@/components/CodeInput"; +import PresetScriptTemplatesPopselect from "@/components/preset/PresetScriptTemplatesPopselect"; import Show from "@/components/Show"; import Tips from "@/components/Tips"; import { CERTIFICATE_FORMATS } from "@/domain/certificate"; @@ -59,8 +60,8 @@ sudo service nginx reload return `# *** 需要管理员权限 *** # 请将以下变量替换为实际值 -$pfxPath = "${params?.certPath || ""}" # PFX 文件路径(与表单中保持一致) -$pfxPassword = "${params?.pfxPassword || ""}" # PFX 密码(与表单中保持一致) +$pfxPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # PFX 文件路径(与表单中保持一致) +$pfxPassword = "\${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}" # PFX 密码(与表单中保持一致) $siteName = "" # IIS 网站名称 $domain = "" # 域名 $ipaddr = "" # 绑定 IP,“*”表示所有 IP 绑定 @@ -90,8 +91,8 @@ Remove-Item -Path "$pfxPath" -Force return `# *** 需要管理员权限 *** # 请将以下变量替换为实际值 -$pfxPath = "${params?.certPath || ""}" # PFX 文件路径(与表单中保持一致) -$pfxPassword = "${params?.pfxPassword || ""}" # PFX 密码(与表单中保持一致) +$pfxPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # PFX 文件路径(与表单中保持一致) +$pfxPassword = "\${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}" # PFX 密码(与表单中保持一致) $ipaddr = "" # 绑定 IP,“0.0.0.0”表示所有 IP 绑定,可填入域名 $port = "" # 绑定端口 @@ -113,8 +114,8 @@ Remove-Item -Path "$pfxPath" -Force return `# *** 需要管理员权限 *** # 请将以下变量替换为实际值 -$pfxPath = "${params?.certPath || ""}" # PFX 文件路径(与表单中保持一致) -$pfxPassword = "${params?.pfxPassword || ""}" # PFX 密码(与表单中保持一致) +$pfxPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # PFX 文件路径(与表单中保持一致) +$pfxPassword = "\${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}" # PFX 密码(与表单中保持一致) # 导入证书到本地计算机的个人存储区 $cert = Import-PfxCertificate -FilePath "$pfxPath" -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String "$pfxPassword" -AsPlainText -Force) -Exportable @@ -339,21 +340,25 @@ const BizDeployNodeConfigFieldsProviderLocal = () => {
- ({ - key, - label: t(`workflow_node.deploy.form.local_preset_scripts.option.${key}.label`), - onClick: () => handlePresetPreScriptClick(key), - })), - }} + ({ + key, + label: t(`workflow_node.deploy.form.local_preset_scripts.${key}`), + }))} trigger={["click"]} + onSelect={(key, template) => { + if (template) { + formInst.setFieldValue([parentNamePath, "preCommand"], template.command); + } else { + handlePresetPreScriptClick(key); + } + }} > - +
{
- ({ + } size={0}> + } mouseEnterDelay={1}> + + + ({ key, - label: t(`workflow_node.deploy.form.local_preset_scripts.option.${key}.label`), + label: t(`workflow_node.deploy.form.local_preset_scripts.${key}`), onClick: () => handlePresetPostScriptClick(key), - })), - }} - trigger={["click"]} - > - - + }))} + trigger={["click"]} + onSelect={(key, template) => { + if (template) { + formInst.setFieldValue([parentNamePath, "postCommand"], template.command); + } else { + handlePresetPostScriptClick(key); + } + }} + > + + +
"}" # 证书文件路径(与表单中保持一致) -$tmpCertPath = "${params?.certPathForServerOnly || ""}" # 服务器证书文件路径(与表单中保持一致) -$tmpKeyPath = "${params?.keyPath || ""}" # 私钥文件路径(与表单中保持一致) +$tmpFullchainPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # 证书文件路径(与表单中保持一致) +$tmpCertPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}" # 服务器证书文件路径(与表单中保持一致) +$tmpKeyPath = "\${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}" # 私钥文件路径(与表单中保持一致) DEBUG=1 error_exit() { echo "[ERROR] $1"; exit 1; } @@ -105,9 +106,9 @@ info "Completed" # 请将以下变量替换为实际值 # 飞牛证书实际存放路径请在 \`/usr/trim/etc/network_cert_all.conf\` 中查看,注意不要修改文件名 -$tmpFullchainPath = "${params?.certPath || ""}" # 证书文件路径(与表单中保持一致) -$tmpCertPath = "${params?.certPathForServerOnly || ""}" # 服务器证书文件路径(与表单中保持一致) -$tmpKeyPath = "${params?.keyPath || ""}" # 私钥文件路径(与表单中保持一致) +$tmpFullchainPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}" # 证书文件路径(与表单中保持一致) +$tmpCertPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}" # 服务器证书文件路径(与表单中保持一致) +$tmpKeyPath = "\${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}" # 私钥文件路径(与表单中保持一致) $fnFullchainPath = "/usr/trim/var/trim_connect/ssls/example.com/1234567890/fullchain.crt" # 飞牛证书文件路径 $fnCertPath = "/usr/trim/var/trim_connect/ssls/example.com/1234567890/example.com.crt" # 飞牛服务器证书文件路径 $fnKeyPath = "/usr/trim/var/trim_connect/ssls/example.com/1234567890/example.com.key" # 飞牛私钥文件路径 @@ -139,8 +140,8 @@ systemctl restart trim_nginx.service # 注意仅支持替换证书,需本身已开启过一次 HTTPS # 请将以下变量替换为实际值 -$tmpFullchainPath = "${params?.certPath || ""}" # 证书文件路径(与表单中保持一致) -$tmpKeyPath = "${params?.keyPath || ""}" # 私钥文件路径(与表单中保持一致) +$tmpFullchainPath = "\${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}"}" # 证书文件路径(与表单中保持一致) +$tmpKeyPath = "\${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}" # 私钥文件路径(与表单中保持一致) # 复制文件 cp -rf "$tmpFullchainPath" /etc/stunnel/backup.cert @@ -257,6 +258,16 @@ const BizDeployNodeConfigFieldsProviderSSH = () => { return ( <> + } + > + + + {
- ({ - key, - label: t(`workflow_node.deploy.form.ssh_preset_scripts.option.${key}.label`), - onClick: () => handlePresetPreScriptClick(key), - })), - }} + ({ + key, + label: t(`workflow_node.deploy.form.ssh_preset_scripts.${key}`), + }))} trigger={["click"]} + onSelect={(key, template) => { + if (template) { + formInst.setFieldValue([parentNamePath, "preCommand"], template.command); + } else { + handlePresetPreScriptClick(key); + } + }} > - +
{
- } size={0}> + } mouseEnterDelay={1}> + + + { "ps_binding_rdp", ].map((key) => ({ key, - label: t(`workflow_node.deploy.form.ssh_preset_scripts.option.${key}.label`), - onClick: () => handlePresetPostScriptClick(key), - })), - }} - trigger={["click"]} - > - - + label: t(`workflow_node.deploy.form.ssh_preset_scripts.${key}`), + }))} + trigger={["click"]} + onSelect={(key, template) => { + if (template) { + formInst.setFieldValue([parentNamePath, "postCommand"], template.command); + } else { + handlePresetPostScriptClick(key); + } + }} + > + + +
{ />
- - } - > - - ); }; @@ -454,6 +470,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) return z .object({ + useSCP: z.boolean().nullish(), format: z.literal([FORMAT_PEM, FORMAT_PFX, FORMAT_JKS], t("workflow_node.deploy.form.ssh_format.placeholder")), keyPath: z .string() @@ -483,7 +500,6 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) .string() .max(20480, t("common.errmsg.string_max", { max: 20480 })) .nullish(), - useSCP: z.boolean().nullish(), }) .superRefine((values, ctx) => { switch (values.format) { diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx index 18563668..ca1d2dd0 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderWebhook.tsx @@ -1,5 +1,6 @@ import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; +import { IconBulb } from "@tabler/icons-react"; +import { Button, Form, Input, Popover } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -30,21 +31,24 @@ const BizDeployNodeConfigFieldsProviderWebhook = () => { return ( <> - - + +
+ } mouseEnterDelay={1}> + + +
+ + +
diff --git a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigFieldsProviderWebhook.tsx b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigFieldsProviderWebhook.tsx index 41ea5b64..11915d4f 100644 --- a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigFieldsProviderWebhook.tsx +++ b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigFieldsProviderWebhook.tsx @@ -1,5 +1,6 @@ import { getI18n, useTranslation } from "react-i18next"; -import { Form, Input } from "antd"; +import { IconBulb } from "@tabler/icons-react"; +import { Button, Form, Input, Popover } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -30,21 +31,24 @@ const BizNotifyNodeConfigFieldsProviderWebhook = () => { return ( <> - - + +
+ } mouseEnterDelay={1}> + + +
+ + +
diff --git a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx index a8873652..bc2ad189 100644 --- a/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizNotifyNodeConfigForm.tsx @@ -1,13 +1,14 @@ import { useEffect, useMemo } from "react"; import { getI18n, useTranslation } from "react-i18next"; import { type FlowNodeEntity } from "@flowgram.ai/fixed-layout-editor"; -import { IconPlus } from "@tabler/icons-react"; +import { IconChevronDown, IconPlus } from "@tabler/icons-react"; import { type AnchorProps, Button, Divider, Form, type FormInstance, Input, Switch, Typography } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; import AccessEditDrawer from "@/components/access/AccessEditDrawer"; import AccessSelect from "@/components/access/AccessSelect"; +import PresetNotifyTemplatesPopselect from "@/components/preset/PresetNotifyTemplatesPopselect"; import NotificationProviderPicker from "@/components/provider/NotificationProviderPicker"; import NotificationProviderSelect from "@/components/provider/NotificationProviderSelect"; import Show from "@/components/Show"; @@ -111,8 +112,26 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp - - + +
+ { + if (template) { + formInst.setFieldValue("subject", template.subject); + formInst.setFieldValue("message", template.message); + } + }} + > + + +
+ + +
diff --git a/ui/src/domain/settings.ts b/ui/src/domain/settings.ts index d83a9088..a1d34796 100644 --- a/ui/src/domain/settings.ts +++ b/ui/src/domain/settings.ts @@ -2,6 +2,8 @@ import { type CAProviderType } from "./provider"; export const SETTINGS_NAMES = Object.freeze({ EMAILS: "emails", + NOTIFY_TEMPLATE: "notifyTemplate", + SCRIPT_TEMPLATE: "scriptTemplate", SSL_PROVIDER: "sslProvider", PERSISTENCE: "persistence", } as const); @@ -19,6 +21,25 @@ export type EmailsSettingsContent = { }; // #endregion +// #region Settings: NotifyTemplate +export type NotifyTemplateContent = { + templates: Array<{ + name: string; + subject: string; + message: string; + }>; +}; +// #endregion + +// #region Settings: ScriptTemplate +export type ScriptTemplateContent = { + templates: Array<{ + name: string; + command: string; + }>; +}; +// #endregion + // #region Settings: SSLProvider export type SSLProviderSettingsContent = { provider: CAProviderType; diff --git a/ui/src/i18n/locales/en/index.ts b/ui/src/i18n/locales/en/index.ts index 4eaeced5..b430e3d4 100644 --- a/ui/src/i18n/locales/en/index.ts +++ b/ui/src/i18n/locales/en/index.ts @@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json"; import nlsCommon from "./nls.common.json"; import nlsDashboard from "./nls.dashboard.json"; import nlsLogin from "./nls.login.json"; +import nlsPreset from "./nls.preset.json"; import nlsProvider from "./nls.provider.json"; import nlsSettings from "./nls.settings.json"; import nlsWorkflow from "./nls.workflow.json"; @@ -17,6 +18,7 @@ export default Object.freeze({ ...nlsSettings, ...nlsProvider, ...nlsAccess, + ...nlsPreset, ...nlsCertificate, ...nlsWorkflow, ...nlsWorkflowNodes, diff --git a/ui/src/i18n/locales/en/nls.access.json b/ui/src/i18n/locales/en/nls.access.json index 86f6efe6..40530e5c 100644 --- a/ui/src/i18n/locales/en/nls.access.json +++ b/ui/src/i18n/locales/en/nls.access.json @@ -2,7 +2,6 @@ "access.page.title": "Credentials", "access.page.subtitle": "Credentials store authentication information (username and password, API key, tokens, etc.) to connect with specific third-party apps and services.", - "access.nodata": "No credentials", "access.nodata.title": "No credentials", "access.nodata.description": "It looks like you don't have any credentials. Get started by adding one.", "access.nodata.button": "Create credential", @@ -11,8 +10,8 @@ "access.action.create.button": "Create credential", "access.action.create.modal.title": "Create credential", - "access.action.edit.menu": "Edit", - "access.action.edit.modal.title": "Edit credential", + "access.action.modify.menu": "Edit", + "access.action.modify.modal.title": "Edit credential", "access.action.duplicate.menu": "Duplicate", "access.action.duplicate.modal.title": "Duplicate credential", "access.action.delete.menu": "Delete", @@ -627,17 +626,17 @@ "access.form.webhook_data.label": "Webhook data (Optional)", "access.form.webhook_data.placeholder": "Please enter the default Webhook data", "access.form.webhook_data.help": "Notes: It can be overrided in the workflows.", - "access.form.webhook_data.guide_for_deployment": "The Webhook data should be in JSON format.

The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables:
  1. ${DOMAIN}: The primary domain of the certificate (a.k.a. CommonName).
  2. ${DOMAINS}: The domains list of the certificate (a.k.a. SubjectAltNames).
  3. ${CERTIFICATE}: The PEM format content of the certificate file.
  4. ${SERVER_CERTIFICATE}: The PEM format content of the server certificate file.
  5. ${INTERMEDIA_CERTIFICATE}: The PEM format content of the intermediate CA certificate file.
  6. ${PRIVATE_KEY}: The PEM format content of the private key file.

When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats:
  1. application/json (default).
  2. application/x-www-form-urlencoded: Nested data is not supported.
  3. multipart/form-data: Nested data is not supported.
  4. ", - "access.form.webhook_data.guide_for_notification": "The Webhook data should be in JSON format.

    The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables:
    1. ${SUBJECT}: The subject of notification.
    2. ${MESSAGE}: The message of notification.

    When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats:
    1. application/json (default).
    2. application/x-www-form-urlencoded: Nested data is not supported.
    3. multipart/form-data: Nested data is not supported.
    4. ", - "access.form.webhook_preset_data.button": "Use preset template", - "access.form.webhook_preset_data.option.bark.label": "Bark", - "access.form.webhook_preset_data.option.gotify.label": "Gotify", - "access.form.webhook_preset_data.option.ntfy.label": "ntfy", - "access.form.webhook_preset_data.option.pushover.label": "Pushover", - "access.form.webhook_preset_data.option.pushplus.label": "PushPlus", - "access.form.webhook_preset_data.option.serverchan3.label": "ServerChan3", - "access.form.webhook_preset_data.option.serverchanturbo.label": "ServerChanTurbo", - "access.form.webhook_preset_data.option.common.label": "General template", + "access.form.webhook_data.guide_for_deployment": "The Webhook data should be in JSON format.

      The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables:
      1. ${CERTIMATE_DEPLOYER_COMMONNAME}: The primary domain of the certificate (CommonName).
      2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}: The domains of the certificate, separated by semicolons (SubjectAltNames).
      3. ${CERTIMATE_DEPLOYER_CERTIFICATE}: The PEM format content of the certificate file.
      4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}: The PEM format content of the server certificate file.
      5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}: The PEM format content of the intermediate CA certificate file.
      6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}: The PEM format content of the private key file.

      When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats:
      1. application/json (default).
      2. application/x-www-form-urlencoded: Nested data is not supported.
      3. multipart/form-data: Nested data is not supported.
      4. ", + "access.form.webhook_data.guide_for_notification": "The Webhook data should be in JSON format.

        The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL. Supported variables:
        1. ${CERTIMATE_NOTIFIER_SUBJECT}: The subject of notification.
        2. ${CERTIMATE_NOTIFIER_MESSAGE}: The message of notification.

        When the request method is GET, the data will be passed as query string. Otherwise, the data will be encoded in the format indicated by the Content-Type in the request headers. Supported formats:
        1. application/json (default).
        2. application/x-www-form-urlencoded: Nested data is not supported.
        3. multipart/form-data: Nested data is not supported.
        4. ", + "access.form.webhook_preset_data": "Use preset Webhook", + "access.form.webhook_preset_data.bark": "Bark", + "access.form.webhook_preset_data.gotify": "Gotify", + "access.form.webhook_preset_data.ntfy": "ntfy", + "access.form.webhook_preset_data.pushover": "Pushover", + "access.form.webhook_preset_data.pushplus": "PushPlus", + "access.form.webhook_preset_data.serverchan3": "ServerChan3", + "access.form.webhook_preset_data.serverchanturbo": "ServerChanTurbo", + "access.form.webhook_preset_data.common": "General data", "access.form.wecombot_webhook_url.label": "WeCom bot Webhook URL", "access.form.wecombot_webhook_url.placeholder": "Please enter WeCom bot Webhook URL", "access.form.wecombot_webhook_url.tooltip": "For more information, see https://www.west.cn/CustomerCenter/doc/apiv2.html", diff --git a/ui/src/i18n/locales/en/nls.preset.json b/ui/src/i18n/locales/en/nls.preset.json new file mode 100644 index 00000000..b0499105 --- /dev/null +++ b/ui/src/i18n/locales/en/nls.preset.json @@ -0,0 +1,34 @@ +{ + "preset.page.title": "Presets", + "preset.page.subtitle": "Presets are a set of reusable data snippets, for filling forms conveniently.", + + "preset.action.create.button": "Create preset", + "preset.action.create.modal.title": "Create preset", + "preset.action.modify.menu": "Edit", + "preset.action.modify.modal.title": "Edit preset", + "preset.action.delete.menu": "Delete", + "preset.action.delete.modal.title": "Delete \"{{name}}\"", + "preset.action.delete.modal.content": "Are you sure want to delete this preset?
          This action cannot be undone.", + + "preset.props.name": "Name", + "preset.props.usage.notification": "Notification template", + "preset.props.usage.notification.tips": "You can use these preset notification subjects and messages in workflow notification nodes.", + "preset.props.usage.script": "Script template", + "preset.props.usage.script.tips": "You can use these preset script commands in workflow deployment nodes (for Local host, Remote host, etc.).", + + "preset.warning.excceeded": "The maximum number of presets has been reached", + "preset.form.name.label": "Preset name", + "preset.form.name.placeholder": "Please enter preset name", + "preset.form.name.errmsg.duplicated": "The name already exists, please use a different one.", + "preset.form.notification_subject.label": "Notification subject", + "preset.form.notification_subject.placeholder": "Please enter notification subject", + "preset.form.notification_message.label": "Notification message", + "preset.form.notification_message.placeholder": "Please enter notification message", + "preset.form.script_command.label": "Script command", + "preset.form.script_command.placeholder": "Please enter script command", + + "preset.dropdown.notification.button": "Use preset notification", + "preset.dropdown.script.button": "Use preset script", + "preset.dropdown.option_group.builtin": "Built-in templates", + "preset.dropdown.option_group.custom": "Customized templates" +} diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index 38057854..e9f9fda3 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -9,7 +9,7 @@ "workflow.search.placeholder": "Search by workflow name ...", "workflow.action.create.button": "Create workflow", - "workflow.action.edit.menu": "Edit", + "workflow.action.modify.menu": "Edit", "workflow.action.duplicate.menu": "Duplicate", "workflow.action.duplicate.modal.title": "Duplicate \"{{name}}\"", "workflow.action.duplicate.modal.content": "Are you sure to duplicate this workflow?", diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 61c36063..00af59eb 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -198,6 +198,7 @@ "workflow_node.deploy.form.shared_domain_match_pattern.option.wildcard.label": "Wildcard matches", "workflow_node.deploy.form.shared_domain_match_pattern.option.certsan.label": "via Certificate", "workflow_node.deploy.form.shared_domain_match_pattern.help_wildcard": "Notes: For the sites which support wildcard resolution, an exact match of a wildcard domain only includes the site itself, does not include its subdomains.", + "workflow_node.deploy.form.shared_script_command.vartips": "Supported variables:
          1. ${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}:
            The path of the certificate file, same as the value of the form related field.
          2. ${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}:
            The path of the server certificate file, same as the value of the form related field.
          3. ${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}:
            The path of the intermediate CA certificate file, same as the value of the form related field.
          4. ${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}:
            The path of the private key file, same as the value of the form related field.
          5. ${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}:
            The PFX password, same as the value of the form related field.
          6. ${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}:
            The JKS alias, same as the value of the form related field.
          7. ${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}:
            The JKS key password, same as the value of the form related field.
          8. ${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}:
            The JKS store password, same as the value of the form related field.
          ", "workflow_node.deploy.form.1panel_console_auto_restart.label": "Auto restart 1Panel after deployment", "workflow_node.deploy.form.1panel_site_node_name.label": "1Panel node name (Optional)", "workflow_node.deploy.form.1panel_site_node_name.placeholder": "Please enter 1Panel node name", @@ -675,13 +676,12 @@ "workflow_node.deploy.form.local_pre_command.placeholder": "Please enter command to be executed before saving files", "workflow_node.deploy.form.local_post_command.label": "Post-command (Optional)", "workflow_node.deploy.form.local_post_command.placeholder": "Please enter command to be executed after saving files", - "workflow_node.deploy.form.local_preset_scripts.button": "Use preset scripts", - "workflow_node.deploy.form.local_preset_scripts.option.sh_backup_files.label": "POSIX Bash - Backup certificate files", - "workflow_node.deploy.form.local_preset_scripts.option.ps_backup_files.label": "PowerShell - Backup certificate files", - "workflow_node.deploy.form.local_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - Reload nginx", - "workflow_node.deploy.form.local_preset_scripts.option.ps_binding_iis.label": "PowerShell - Binding IIS", - "workflow_node.deploy.form.local_preset_scripts.option.ps_binding_netsh.label": "PowerShell - Binding netsh", - "workflow_node.deploy.form.local_preset_scripts.option.ps_.label": "PowerShell - Binding RDP", + "workflow_node.deploy.form.local_preset_scripts.sh_backup_files": "POSIX Bash - Backup certificate files", + "workflow_node.deploy.form.local_preset_scripts.ps_backup_files": "PowerShell - Backup certificate files", + "workflow_node.deploy.form.local_preset_scripts.sh_reload_nginx": "POSIX Bash - Reload nginx", + "workflow_node.deploy.form.local_preset_scripts.ps_binding_iis": "PowerShell - Binding IIS", + "workflow_node.deploy.form.local_preset_scripts.ps_binding_netsh": "PowerShell - Binding netsh", + "workflow_node.deploy.form.local_preset_scripts.ps_binding_rdp": "PowerShell - Binding RDP", "workflow_node.deploy.form.netlify_site_id.label": "Netlify site ID", "workflow_node.deploy.form.netlify_site_id.placeholder": "Please enter Netlify site ID", "workflow_node.deploy.form.netlify_site_id.tooltip": "For more information, see https://docs.netlify.com/api/get-started/#get-site", @@ -755,16 +755,15 @@ "workflow_node.deploy.form.ssh_pre_command.placeholder": "Please enter command to be executed before uploading files", "workflow_node.deploy.form.ssh_post_command.label": "Post-command (Optional)", "workflow_node.deploy.form.ssh_post_command.placeholder": "Please enter command to be executed after uploading files", - "workflow_node.deploy.form.ssh_preset_scripts.button": "Use preset scripts", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_backup_files.label": "POSIX Bash - Backup certificate files", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_backup_files.label": "PowerShell - Backup certificate files", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - Reload nginx", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_synologydsm_ssl.label": "POSIX Bash - Replace SynologyDSM SSL certificate", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_fnos_ssl.label": "POSIX Bash - Replace fnOS SSL certificate", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_qnap_ssl.label": "POSIX Bash - Replace QNAP SSL certificate", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_iis.label": "PowerShell - Binding IIS", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_netsh.label": "PowerShell - Binding netsh", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_rdp.label": "PowerShell - Binding RDP", + "workflow_node.deploy.form.ssh_preset_scripts.sh_backup_files": "POSIX Bash - Backup certificate files", + "workflow_node.deploy.form.ssh_preset_scripts.ps_backup_files": "PowerShell - Backup certificate files", + "workflow_node.deploy.form.ssh_preset_scripts.sh_reload_nginx": "POSIX Bash - Reload nginx", + "workflow_node.deploy.form.ssh_preset_scripts.sh_replace_synologydsm_ssl": "POSIX Bash - Replace SynologyDSM SSL certificate", + "workflow_node.deploy.form.ssh_preset_scripts.sh_replace_fnos_ssl": "POSIX Bash - Replace fnOS SSL certificate", + "workflow_node.deploy.form.ssh_preset_scripts.sh_replace_qnap_ssl": "POSIX Bash - Replace QNAP SSL certificate", + "workflow_node.deploy.form.ssh_preset_scripts.ps_binding_iis": "PowerShell - Binding IIS", + "workflow_node.deploy.form.ssh_preset_scripts.ps_binding_netsh": "PowerShell - Binding netsh", + "workflow_node.deploy.form.ssh_preset_scripts.ps_binding_rdp": "PowerShell - Binding RDP", "workflow_node.deploy.form.ssh_use_scp.label": "Fallback to use SCP", "workflow_node.deploy.form.ssh_use_scp.tooltip": "If the remote server does not support SFTP, please check this option to fallback to SCP.", "workflow_node.deploy.form.tencentcloud_cdn_endpoint.label": "Tencent Cloud API endpoint (Optional)", @@ -1005,6 +1004,7 @@ "workflow_node.deploy.form.webhook_data.placeholder": "Please enter Webhook data", "workflow_node.deploy.form.webhook_data.help": "Notes: Leave it blank to use the default Webhook data provided by the credential.", "workflow_node.deploy.form.webhook_data.errmsg.json_invalid": "Please enter a valid JSON string", + "workflow_node.deploy.form.webhook_data.vartips": "Supported variables:
          1. ${CERTIMATE_DEPLOYER_COMMONNAME}:
            The primary domain of the certificate (CommonName).
          2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}:
            The domains of the certificate, separated by semicolons (SubjectAltNames).
          3. ${CERTIMATE_DEPLOYER_CERTIFICATE}:
            The PEM format content of the certificate file.
          4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}:
            The PEM format content of the server certificate file.
          5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}:
            The PEM format content of the intermediate CA certificate file.
          6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}:
            The PEM format content of the private key file.
          ", "workflow_node.deploy.form.webhook_timeout.label": "Webhook timeout (Optional)", "workflow_node.deploy.form.webhook_timeout.placeholder": "Please enter Webhook timeout", "workflow_node.deploy.form.webhook_timeout.unit": "seconds", @@ -1025,7 +1025,7 @@ "workflow_node.notify.form.subject.placeholder": "Please enter subject", "workflow_node.notify.form.message.label": "Message", "workflow_node.notify.form.message.placeholder": "Please enter message", - "workflow_node.notify.form.template.guide": "
          The content using the \"Mustache\" syntax (double curly braces) and preceded by \"$\" in the subject or message are text interpolations. They will be replaced by the actual values. (Expand to see more)
          Supported text interpolations:
          1. workflow.id: The ID of the workflow.
          2. workflow.name: The name of the workflow.
          3. run.id: The ID of the workflow run.
          4. error.nodeId: The node ID that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.
          5. error.nodeName: The node name that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.
          6. error.message: The error message that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.
          7. certificate.domain: The primary domain of the certificate (a.k.a. CommonName). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          8. certificate.domains: The domains list of the certificate (a.k.a. SubjectAltNames). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          9. certificate.notBefore: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          10. certificate.notAfter: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          11. certificate.hoursLeft: The left hours of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          12. certificate.daysLeft: The left days of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          13. certificate.validity: The validity of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          14. now: The current time on the server, formatted in RFC3339.

          Example:
          Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.

          Please visit the documentation for more details.
          ", + "workflow_node.notify.form.template.guide": "
          The content using the \"Mustache\" syntax (double curly braces) and preceded by \"$\" in the subject or message are text interpolations. They will be replaced by the actual values.
          Supported text interpolations:
          1. workflow.id: The ID of the workflow.
          2. workflow.name: The name of the workflow.
          3. run.id: The ID of the workflow run.
          4. error.nodeId: The node ID that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.
          5. error.nodeName: The node name that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.
          6. error.message: The error message that execution failed. If there are multiple nodes that have failed before this, it always indicate the nearest one.
          7. certificate.domain: The primary domain of the certificate (a.k.a. CommonName). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          8. certificate.domains: The domains list of the certificate (a.k.a. SubjectAltNames). If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          9. certificate.notBefore: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          10. certificate.notAfter: The effect time of the certificate, formatted in RFC3339. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          11. certificate.hoursLeft: The left hours of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          12. certificate.daysLeft: The left days of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          13. certificate.validity: The validity of the certificate. If there are multiple nodes outputting a certificate before this, it always indicate the nearest one.
          14. now: The current time on the server, formatted in RFC3339.

          Example:
          Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.

          Please visit the documentation for more details.
          ", "workflow_node.notify.form.provider.label": "Notification channel", "workflow_node.notify.form.provider.placeholder": "Please select notification channel", "workflow_node.notify.form.provider.search.placeholder": "Search notification channel ...", @@ -1052,6 +1052,7 @@ "workflow_node.notify.form.webhook_data.placeholder": "Please enter Webhook data", "workflow_node.notify.form.webhook_data.help": "Notes: Leave it blank to use the default Webhook data provided by the credential.", "workflow_node.notify.form.webhook_data.errmsg.json_invalid": "Please enter a valid JSON string", + "workflow_node.notify.form.webhook_data.vartips": "Supported variables:
          1. ${CERTIMATE_NOTIFIER_SUBJECT}:
            The subject of notification.
          2. ${CERTIMATE_NOTIFIER_MESSAGE}:
            The message of notification.
          ", "workflow_node.notify.form.webhook_timeout.label": "Webhook timeout (Optional)", "workflow_node.notify.form.webhook_timeout.placeholder": "Please enter Webhook timeout", "workflow_node.notify.form.webhook_timeout.unit": "seconds", diff --git a/ui/src/i18n/locales/zh/index.ts b/ui/src/i18n/locales/zh/index.ts index 4eaeced5..b430e3d4 100644 --- a/ui/src/i18n/locales/zh/index.ts +++ b/ui/src/i18n/locales/zh/index.ts @@ -3,6 +3,7 @@ import nlsCertificate from "./nls.certificate.json"; import nlsCommon from "./nls.common.json"; import nlsDashboard from "./nls.dashboard.json"; import nlsLogin from "./nls.login.json"; +import nlsPreset from "./nls.preset.json"; import nlsProvider from "./nls.provider.json"; import nlsSettings from "./nls.settings.json"; import nlsWorkflow from "./nls.workflow.json"; @@ -17,6 +18,7 @@ export default Object.freeze({ ...nlsSettings, ...nlsProvider, ...nlsAccess, + ...nlsPreset, ...nlsCertificate, ...nlsWorkflow, ...nlsWorkflowNodes, diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index b3ff6ee8..c35d96b7 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -10,8 +10,8 @@ "access.action.create.button": "新建授权", "access.action.create.modal.title": "新建授权", - "access.action.edit.menu": "编辑授权", - "access.action.edit.modal.title": "编辑授权", + "access.action.modify.menu": "编辑授权", + "access.action.modify.modal.title": "编辑授权", "access.action.duplicate.menu": "复制授权", "access.action.duplicate.modal.title": "复制授权", "access.action.delete.menu": "删除授权", @@ -626,17 +626,17 @@ "access.form.webhook_data.label": "Webhook 回调数据(可选)", "access.form.webhook_data.placeholder": "请输入默认的 Webhook 回调数据", "access.form.webhook_data.help": "提示:可在工作流中覆盖此设置。", - "access.form.webhook_data.guide_for_deployment": "回调数据是一个 JSON 格式的数据。

          其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:
          1. ${DOMAIN}:证书的主域名(即 CommonName)。
          2. ${DOMAINS}:证书的多域名列表(即 SubjectAltNames)。
          3. ${CERTIFICATE}:证书文件 PEM 格式内容。
          4. ${SERVER_CERTIFICATE}:证书文件(仅含服务器证书)PEM 格式内容。
          5. ${INTERMEDIA_CERTIFICATE}:证书文件(仅含中间证书)PEM 格式内容。
          6. ${PRIVATE_KEY}:私钥文件 PEM 格式内容。

          当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:
          1. application/json(默认)。
          2. application/x-www-form-urlencoded:不支持嵌套数据。
          3. multipart/form-data:不支持嵌套数据。
          4. ", - "access.form.webhook_data.guide_for_notification": "回调数据是一个 JSON 格式的数据。

            其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:
            1. ${SUBJECT}:通知主题。
            2. ${MESSAGE}:通知内容。

            当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:
            1. application/json(默认)。
            2. application/x-www-form-urlencoded:不支持嵌套数据。
            3. multipart/form-data:不支持嵌套数据。
            4. ", - "access.form.webhook_preset_data.button": "使用预设模板", - "access.form.webhook_preset_data.option.bark.label": "Bark", - "access.form.webhook_preset_data.option.gotify.label": "Gotify", - "access.form.webhook_preset_data.option.ntfy.label": "ntfy", - "access.form.webhook_preset_data.option.pushover.label": "Pushover", - "access.form.webhook_preset_data.option.pushplus.label": "PushPlus 推送加", - "access.form.webhook_preset_data.option.serverchan3.label": "Server 酱 3", - "access.form.webhook_preset_data.option.serverchanturbo.label": "Server酱 Turbo", - "access.form.webhook_preset_data.option.common.label": "通用模板", + "access.form.webhook_data.guide_for_deployment": "回调数据是一个 JSON 格式的数据。

              其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:
              1. ${CERTIMATE_DEPLOYER_COMMONNAME}:证书的主域名(即 CommonName)。
              2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}:证书的多域名,以半角分号隔开(即 SubjectAltNames)。
              3. ${CERTIMATE_DEPLOYER_CERTIFICATE}:证书文件 PEM 格式内容。
              4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}:证书文件(仅含服务器证书)PEM 格式内容。
              5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}:证书文件(仅含中间证书)PEM 格式内容。
              6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}:私钥文件 PEM 格式内容。

              当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:
              1. application/json(默认)。
              2. application/x-www-form-urlencoded:不支持嵌套数据。
              3. multipart/form-data:不支持嵌套数据。
              4. ", + "access.form.webhook_data.guide_for_notification": "回调数据是一个 JSON 格式的数据。

                其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。支持的变量:
                1. ${CERTIMATE_NOTIFIER_SUBJECT}:通知主题。
                2. ${CERTIMATE_NOTIFIER_MESSAGE}:通知内容。

                当请求谓词为 GET 时,回调数据将作为查询参数;否则,回调数据将按照请求标头中 Content-Type 所指示的格式进行编码。支持的格式:
                1. application/json(默认)。
                2. application/x-www-form-urlencoded:不支持嵌套数据。
                3. multipart/form-data:不支持嵌套数据。
                4. ", + "access.form.webhook_preset_data": "使用预设回调", + "access.form.webhook_preset_data.bark": "Bark", + "access.form.webhook_preset_data.gotify": "Gotify", + "access.form.webhook_preset_data.ntfy": "ntfy", + "access.form.webhook_preset_data.pushover": "Pushover", + "access.form.webhook_preset_data.pushplus": "PushPlus 推送加", + "access.form.webhook_preset_data.serverchan3": "Server 酱 3", + "access.form.webhook_preset_data.serverchanturbo": "Server酱 Turbo", + "access.form.webhook_preset_data.common": "通用内容", "access.form.wecombot_webhook_url.label": "企业微信群机器人 Webhook 地址", "access.form.wecombot_webhook_url.placeholder": "请输入企业微信群机器人 Webhook 地址", "access.form.wecombot_webhook_url.tooltip": "这是什么?请参阅 https://open.work.weixin.qq.com/help2/pc/18401", diff --git a/ui/src/i18n/locales/zh/nls.preset.json b/ui/src/i18n/locales/zh/nls.preset.json new file mode 100644 index 00000000..19f87ca2 --- /dev/null +++ b/ui/src/i18n/locales/zh/nls.preset.json @@ -0,0 +1,34 @@ +{ + "preset.page.title": "预设模板", + "preset.page.subtitle": "预设模板是一组可复用的数据片段,可以快速填充特定表单。", + + "preset.action.create.button": "新建模板", + "preset.action.create.modal.title": "新建模板", + "preset.action.modify.menu": "编辑模板", + "preset.action.modify.modal.title": "编辑模板", + "preset.action.delete.menu": "删除模板", + "preset.action.delete.modal.title": "删除「{{name}}」", + "preset.action.delete.modal.content": "确定要删除该模板吗?
                  注意此操作不可撤销,请谨慎操作。", + + "preset.props.name": "名称", + "preset.props.usage.notification": "通知模板", + "preset.props.usage.notification.tips": "你可以在工作流的通知节点中使用这些预设的通知主题和内容。", + "preset.props.usage.script": "脚本模板", + "preset.props.usage.script.tips": "你可以在工作流的部署节点(如本地主机、SSH 远程主机等)中使用这些预设的脚本命令。", + + "preset.warning.excceeded": "可创建的模板数量已达上限", + "preset.form.name.label": "模板名称", + "preset.form.name.placeholder": "请输入模板名称", + "preset.form.name.errmsg.duplicated": "该名称已存在,请使用其他名称。", + "preset.form.notification_subject.label": "通知主题", + "preset.form.notification_subject.placeholder": "请输入通知主题", + "preset.form.notification_message.label": "通知内容", + "preset.form.notification_message.placeholder": "请输入通知内容", + "preset.form.script_command.label": "脚本命令", + "preset.form.script_command.placeholder": "请输入脚本命令", + + "preset.dropdown.notification.button": "使用预设通知", + "preset.dropdown.script.button": "使用预设脚本", + "preset.dropdown.option_group.builtin": "内置模板", + "preset.dropdown.option_group.custom": "自定义模板" +} diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index e0e0f12d..2af48783 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -5,7 +5,7 @@ "provider.35cn": "三五互联", "provider.acmeca": "ACME 自定义 CA 端点", "provider.acmedns": "ACME-DNS", - "provider.acmehttpreq": "ACME 自定义 HTTP 端点", + "provider.acmehttpreq": "ACME 自定义端点", "provider.actalisssl": "Actalis SSL", "provider.akamai": "Akamai", "provider.akamai.cdn": "Akamai - 内容分发网络 CDN", diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index fe1532a5..62205e55 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -9,7 +9,7 @@ "workflow.search.placeholder": "按工作流名称搜索……", "workflow.action.create.button": "新建工作流", - "workflow.action.edit.menu": "编辑工作流", + "workflow.action.modify.menu": "编辑工作流", "workflow.action.duplicate.menu": "复制工作流", "workflow.action.duplicate.modal.title": "复制「{{name}}」", "workflow.action.duplicate.modal.content": "确定要复制该工作流吗?", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 29cfc013..9281f370 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -197,6 +197,7 @@ "workflow_node.deploy.form.shared_domain_match_pattern.option.wildcard.label": "通配符匹配(泛域名)", "workflow_node.deploy.form.shared_domain_match_pattern.option.certsan.label": "根据证书自动匹配", "workflow_node.deploy.form.shared_domain_match_pattern.help_wildcard": "注意:对于支持泛解析的站点,精确匹配一个泛域名仅包含该站点本身、不包括相关子域名站点。", + "workflow_node.deploy.form.shared_script_command.vartips": "支持的变量:
                  1. ${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_PATH}
                    证书文件路径,等同于表单中相应字段的值。
                  2. ${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_SERVER_PATH}
                    证书文件(仅含服务器证书)路径,等同于表单中相应字段的值。
                  3. ${CERTIMATE_DEPLOYER_CMDVAR_CERTIFICATE_INTERMEDIA_PATH}
                    证书文件(仅含中间证书)路径,等同于表单中相应字段的值。
                  4. ${CERTIMATE_DEPLOYER_CMDVAR_PRIVATEKEY_PATH}
                    私钥文件路径,等同于表单中相应字段的值。
                  5. ${CERTIMATE_DEPLOYER_CMDVAR_PFX_PASSWORD}
                    PFX 导出密码,等同于表单中相应字段的值。
                  6. ${CERTIMATE_DEPLOYER_CMDVAR_JKS_ALIAS}
                    JKS 别名,等同于表单中相应字段的值。
                  7. ${CERTIMATE_DEPLOYER_CMDVAR_JKS_KEYPASS}
                    JKS 私钥访问口令,等同于表单中相应字段的值。
                  8. ${CERTIMATE_DEPLOYER_CMDVAR_JKS_STOREPASS}
                    JKS 密钥库存储口令,等同于表单中相应字段的值。
                  ", "workflow_node.deploy.form.1panel_console_auto_restart.label": "部署后自动重启 1Panel 服务", "workflow_node.deploy.form.1panel_site_node_name.label": "1Panel 子节点名称(可选)", "workflow_node.deploy.form.1panel_site_node_name.placeholder": "请输入 1Panel 子节点名称", @@ -638,18 +639,18 @@ "workflow_node.deploy.form.local_format.option.pem.label": "PEM 格式(*.pem, *.crt, *.key)", "workflow_node.deploy.form.local_format.option.pfx.label": "PFX 格式(*.pfx, *.p12)", "workflow_node.deploy.form.local_format.option.jks.label": "JKS 格式(*.jks)", - "workflow_node.deploy.form.local_key_path.label": "私钥文件路径", + "workflow_node.deploy.form.local_key_path.label": "私钥文件保存路径", "workflow_node.deploy.form.local_key_path.placeholder": "请输入私钥文件本地路径", "workflow_node.deploy.form.local_key_path.help": "注意:路径需包含完整的文件名,而不是只有目录。", - "workflow_node.deploy.form.local_cert_path.label": "证书文件路径", + "workflow_node.deploy.form.local_cert_path.label": "证书文件保存路径", "workflow_node.deploy.form.local_cert_path.placeholder": "请输入证书文件本地路径", "workflow_node.deploy.form.local_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。", - "workflow_node.deploy.form.local_fullchaincert_path.label": "证书链文件路径", + "workflow_node.deploy.form.local_fullchaincert_path.label": "证书链文件保存路径", "workflow_node.deploy.form.local_fullchaincert_path.placeholder": "请输入证书链文件本地路径", - "workflow_node.deploy.form.local_servercert_path.label": "服务器证书文件路径(可选)", + "workflow_node.deploy.form.local_servercert_path.label": "服务器证书文件保存路径(可选)", "workflow_node.deploy.form.local_servercert_path.placeholder": "请输入服务器证书文件本地路径", "workflow_node.deploy.form.local_servercert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不会保存服务器证书。", - "workflow_node.deploy.form.local_intermediacert_path.label": "中间证书文件路径(可选)", + "workflow_node.deploy.form.local_intermediacert_path.label": "中间证书文件保存路径(可选)", "workflow_node.deploy.form.local_intermediacert_path.placeholder": "请输入中间证书文件本地路径", "workflow_node.deploy.form.local_intermediacert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不会保存中间证书。", "workflow_node.deploy.form.local_pfx_password.label": "PFX 导出密码", @@ -673,13 +674,12 @@ "workflow_node.deploy.form.local_pre_command.placeholder": "请输入保存文件前执行的命令", "workflow_node.deploy.form.local_post_command.label": "后置命令(可选)", "workflow_node.deploy.form.local_post_command.placeholder": "请输入保存文件后执行的命令", - "workflow_node.deploy.form.local_preset_scripts.button": "使用预设脚本", - "workflow_node.deploy.form.local_preset_scripts.option.sh_backup_files.label": "POSIX Bash - 备份原证书文件", - "workflow_node.deploy.form.local_preset_scripts.option.ps_backup_files.label": "PowerShell - 备份原证书文件", - "workflow_node.deploy.form.local_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - 重启 nginx 进程", - "workflow_node.deploy.form.local_preset_scripts.option.ps_binding_iis.label": "PowerShell - 导入并绑定到 IIS", - "workflow_node.deploy.form.local_preset_scripts.option.ps_binding_netsh.label": "PowerShell - 导入并绑定到 netsh", - "workflow_node.deploy.form.local_preset_scripts.option.ps_binding_rdp.label": "PowerShell - 导入并绑定到 RDP", + "workflow_node.deploy.form.local_preset_scripts.sh_backup_files": "POSIX Bash - 备份原证书文件", + "workflow_node.deploy.form.local_preset_scripts.ps_backup_files": "PowerShell - 备份原证书文件", + "workflow_node.deploy.form.local_preset_scripts.sh_reload_nginx": "POSIX Bash - 重启 nginx 进程", + "workflow_node.deploy.form.local_preset_scripts.ps_binding_iis": "PowerShell - 导入并绑定到 IIS", + "workflow_node.deploy.form.local_preset_scripts.ps_binding_netsh": "PowerShell - 导入并绑定到 netsh", + "workflow_node.deploy.form.local_preset_scripts.ps_binding_rdp": "PowerShell - 导入并绑定到 RDP", "workflow_node.deploy.form.netlify_site_id.label": "Netlify 网站 ID", "workflow_node.deploy.form.netlify_site_id.placeholder": "请输入 netlify 网站 ID", "workflow_node.deploy.form.netlify_site_id.tooltip": "这是什么?请参阅 https://docs.netlify.com/api/get-started/#get-site", @@ -723,18 +723,18 @@ "workflow_node.deploy.form.ssh_format.option.pem.label": "PEM 格式(*.pem, *.crt, *.key)", "workflow_node.deploy.form.ssh_format.option.pfx.label": "PFX 格式(*.pfx, *.p12)", "workflow_node.deploy.form.ssh_format.option.jks.label": "JKS 格式(*.jks)", - "workflow_node.deploy.form.ssh_key_path.label": "私钥文件路径", + "workflow_node.deploy.form.ssh_key_path.label": "私钥文件保存路径", "workflow_node.deploy.form.ssh_key_path.placeholder": "请输入私钥文件远程路径", "workflow_node.deploy.form.ssh_key_path.help": "注意:路径需包含完整的文件名,而不是只有目录。", - "workflow_node.deploy.form.ssh_cert_path.label": "证书文件路径", + "workflow_node.deploy.form.ssh_cert_path.label": "证书文件保存路径", "workflow_node.deploy.form.ssh_cert_path.placeholder": "请输入证书文件远程路径", "workflow_node.deploy.form.ssh_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。", - "workflow_node.deploy.form.ssh_fullchaincert_path.label": "证书链文件路径", + "workflow_node.deploy.form.ssh_fullchaincert_path.label": "证书链文件保存路径", "workflow_node.deploy.form.ssh_fullchaincert_path.placeholder": "请输入证书链文件远程路径", - "workflow_node.deploy.form.ssh_servercert_path.label": "服务器证书文件路径(可选)", + "workflow_node.deploy.form.ssh_servercert_path.label": "服务器证书文件保存路径(可选)", "workflow_node.deploy.form.ssh_servercert_path.placeholder": "请输入服务器证书文件远程路径", "workflow_node.deploy.form.ssh_servercert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传服务器证书。", - "workflow_node.deploy.form.ssh_intermediacert_path.label": "中间证书文件路径(可选)", + "workflow_node.deploy.form.ssh_intermediacert_path.label": "中间证书文件保存路径(可选)", "workflow_node.deploy.form.ssh_intermediacert_path.placeholder": "请输入中间证书文件远程路径", "workflow_node.deploy.form.ssh_intermediacert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传中间证书。", "workflow_node.deploy.form.ssh_pfx_password.label": "PFX 导出密码", @@ -753,16 +753,15 @@ "workflow_node.deploy.form.ssh_pre_command.placeholder": "请输入上传文件前执行的命令", "workflow_node.deploy.form.ssh_post_command.label": "后置命令(可选)", "workflow_node.deploy.form.ssh_post_command.placeholder": "请输入上传文件后执行的命令", - "workflow_node.deploy.form.ssh_preset_scripts.button": "使用预设脚本", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_backup_files.label": "POSIX Bash - 备份原证书文件", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_backup_files.label": "PowerShell - 备份原证书文件", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_reload_nginx.label": "POSIX Bash - 重启 nginx 进程", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_synologydsm_ssl.label": "POSIX Bash - 替换群晖 DSM 证书", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_fnos_ssl.label": "POSIX Bash - 替换飞牛 fnOS 证书", - "workflow_node.deploy.form.ssh_preset_scripts.option.sh_replace_qnap_ssl.label": "POSIX Bash - 替换威联通 QNAP 证书", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_iis.label": "PowerShell - 导入并绑定到 IIS", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_netsh.label": "PowerShell - 导入并绑定到 netsh", - "workflow_node.deploy.form.ssh_preset_scripts.option.ps_binding_rdp.label": "PowerShell - 导入并绑定到 RDP", + "workflow_node.deploy.form.ssh_preset_scripts.sh_backup_files": "POSIX Bash - 备份原证书文件", + "workflow_node.deploy.form.ssh_preset_scripts.ps_backup_files": "PowerShell - 备份原证书文件", + "workflow_node.deploy.form.ssh_preset_scripts.sh_reload_nginx": "POSIX Bash - 重启 nginx 进程", + "workflow_node.deploy.form.ssh_preset_scripts.sh_replace_synologydsm_ssl": "POSIX Bash - 替换群晖 DSM 证书", + "workflow_node.deploy.form.ssh_preset_scripts.sh_replace_fnos_ssl": "POSIX Bash - 替换飞牛 fnOS 证书", + "workflow_node.deploy.form.ssh_preset_scripts.sh_replace_qnap_ssl": "POSIX Bash - 替换威联通 QNAP 证书", + "workflow_node.deploy.form.ssh_preset_scripts.ps_binding_iis": "PowerShell - 导入并绑定到 IIS", + "workflow_node.deploy.form.ssh_preset_scripts.ps_binding_netsh": "PowerShell - 导入并绑定到 netsh", + "workflow_node.deploy.form.ssh_preset_scripts.ps_binding_rdp": "PowerShell - 导入并绑定到 RDP", "workflow_node.deploy.form.ssh_use_scp.label": "回退使用 SCP", "workflow_node.deploy.form.ssh_use_scp.tooltip": "如果你的远程服务器不支持 SFTP,请开启此选项回退为 SCP。", "workflow_node.deploy.form.tencentcloud_cdn_endpoint.label": "腾讯云接口端点(可选)", @@ -1003,6 +1002,7 @@ "workflow_node.deploy.form.webhook_data.placeholder": "请输入 Webhook 回调数据以覆盖默认值", "workflow_node.deploy.form.webhook_data.help": "提示:不填写时,将使用所选部署目标授权的默认 Webhook 回调数据。", "workflow_node.deploy.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串", + "workflow_node.deploy.form.webhook_data.vartips": "支持的变量:
                  1. ${CERTIMATE_DEPLOYER_COMMONNAME}
                    证书的主域名(即 CommonName)。
                  2. ${CERTIMATE_DEPLOYER_SUBJECTALTNAMES}
                    证书的多域名,以半角分号隔开(即 SubjectAltNames)。
                  3. ${CERTIMATE_DEPLOYER_CERTIFICATE}
                    证书文件 PEM 格式内容。
                  4. ${CERTIMATE_DEPLOYER_CERTIFICATE_SERVER}
                    证书文件(仅含服务器证书)PEM 格式内容。
                  5. ${CERTIMATE_DEPLOYER_CERTIFICATE_INTERMEDIA}
                    证书文件(仅含中间证书)PEM 格式内容。
                  6. ${CERTIMATE_DEPLOYER_PRIVATEKEY}
                    私钥文件 PEM 格式内容。
                  ", "workflow_node.deploy.form.webhook_timeout.label": "Webhook 超时时间(可选)", "workflow_node.deploy.form.webhook_timeout.placeholder": "请输入 Webhook 超时时间", "workflow_node.deploy.form.webhook_timeout.unit": "秒", @@ -1022,7 +1022,8 @@ "workflow_node.notify.form.subject.label": "通知主题", "workflow_node.notify.form.subject.placeholder": "请输入通知主题", "workflow_node.notify.form.message.label": "通知内容", - "workflow_node.notify.form.template.guide": "
                  通知主题或内容中使用「Mustache」语法(即双大括号)包裹、并以「$」符号开头的文本会被视为模板插值,将在推送时被替换为实际值。(展开查看更多)
                  支持的模板插值:
                  1. workflow.id:工作流 ID。
                  2. workflow.name:工作流名称。
                  3. run.id:运行 ID。
                  4. error.nodeId:执行失败时的节点 ID。如果在此之前有多个执行失败的节点,始终表示最近的一个。
                  5. error.nodeName:执行失败时的节点名称。如果在此之前有多个执行失败的节点,始终表示最近的一个。
                  6. error.message:执行失败时的错误信息。如果在此之前有多个执行失败的节点,始终表示最近的一个。
                  7. certificate.domain:证书主域名(即 CommonName)。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  8. certificate.domains:证书多域名列表(即 SubjectAltNames)。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  9. certificate.notBefore:证书生效时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  10. certificate.notAfter:证书过期时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  11. certificate.hoursLeft:证书剩余小时数。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  12. certificate.daysLeft:证书剩余天数。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  13. certificate.validity:证书是否有效。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  14. now:服务器当前时间,以 RFC3339 格式化。

                  示例:
                  Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.

                  更多内容请查看文档。
                  ", + "workflow_node.notify.form.message.placeholder": "请输入通知内容", + "workflow_node.notify.form.template.guide": "
                  通知主题或内容中使用「Mustache」语法(即双大括号)包裹、并以「$」符号开头的文本会被视为模板插值,将在推送时被替换为实际值。
                  支持的模板插值:
                  1. workflow.id:工作流 ID。
                  2. workflow.name:工作流名称。
                  3. run.id:运行 ID。
                  4. error.nodeId:执行失败时的节点 ID。如果在此之前有多个执行失败的节点,始终表示最近的一个。
                  5. error.nodeName:执行失败时的节点名称。如果在此之前有多个执行失败的节点,始终表示最近的一个。
                  6. error.message:执行失败时的错误信息。如果在此之前有多个执行失败的节点,始终表示最近的一个。
                  7. certificate.domain:证书主域名(即 CommonName)。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  8. certificate.domains:证书多域名列表(即 SubjectAltNames)。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  9. certificate.notBefore:证书生效时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  10. certificate.notAfter:证书过期时间,以 RFC3339 格式化。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  11. certificate.hoursLeft:证书剩余小时数。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  12. certificate.daysLeft:证书剩余天数。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  13. certificate.validity:证书是否有效。如果在此之前有多个输出证书的节点,始终表示最近的一个。
                  14. now:服务器当前时间,以 RFC3339 格式化。

                  示例:
                  Your workflow {{ $workflow.name }} has failed on node {{ $error.nodeName }} at {{ $now }}.

                  更多内容请查看文档。
                  ", "workflow_node.notify.form.provider.label": "通知渠道", "workflow_node.notify.form.provider.placeholder": "请选择通知渠道", "workflow_node.notify.form.provider.search.placeholder": "搜索通知渠道……", @@ -1049,6 +1050,7 @@ "workflow_node.notify.form.webhook_data.placeholder": "请输入 Webhook 回调数据以覆盖默认值", "workflow_node.notify.form.webhook_data.help": "提示:不填写时,将使用所选通知渠道授权的默认 Webhook 回调数据。", "workflow_node.notify.form.webhook_data.errmsg.json_invalid": "请输入有效的 JSON 格式字符串", + "workflow_node.notify.form.webhook_data.vartips": "支持的变量:
                  1. ${CERTIMATE_NOTIFIER_SUBJECT}
                    通知主题。
                  2. ${CERTIMATE_NOTIFIER_MESSAGE}
                    通知内容。
                  ", "workflow_node.notify.form.webhook_timeout.label": "Webhook 超时时间(可选)", "workflow_node.notify.form.webhook_timeout.placeholder": "请输入 Webhook 超时时间", "workflow_node.notify.form.webhook_timeout.unit": "秒", diff --git a/ui/src/pages/ConsoleLayout.tsx b/ui/src/pages/ConsoleLayout.tsx index 8947e04f..4ec3a7a7 100644 --- a/ui/src/pages/ConsoleLayout.tsx +++ b/ui/src/pages/ConsoleLayout.tsx @@ -4,6 +4,7 @@ import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"; import { IconBrandGithub, IconCertificate, + IconCodeDots, IconFingerprint, IconHelpCircle, IconHierarchy3, @@ -176,6 +177,7 @@ const SiderMenu = memo(({ collapsed, onSelect }: { collapsed?: boolean; onSelect const MENU_KEY_WORKFLOWS = "/workflows"; const MENU_KEY_CERTIFICATES = "/certificates"; const MENU_KEY_ACCESSES = "/accesses"; + const MENU_KEY_PRESETS = "/presets"; const MENU_KEY_SETTINGS = "/settings"; const menuItems: Required["items"] = ( [ @@ -183,6 +185,7 @@ const SiderMenu = memo(({ collapsed, onSelect }: { collapsed?: boolean; onSelect [MENU_KEY_WORKFLOWS, "workflow.page.title", ], [MENU_KEY_CERTIFICATES, "certificate.page.title", ], [MENU_KEY_ACCESSES, "access.page.title", ], + [MENU_KEY_PRESETS, "preset.page.title", ], [MENU_KEY_SETTINGS, "settings.page.title", ], ] satisfies Array<[string, string, React.ReactNode]> ).map(([key, label, icon]) => { diff --git a/ui/src/pages/accesses/AccessList.tsx b/ui/src/pages/accesses/AccessList.tsx index af20d3be..cdc9920c 100644 --- a/ui/src/pages/accesses/AccessList.tsx +++ b/ui/src/pages/accesses/AccessList.tsx @@ -33,9 +33,7 @@ const AccessList = () => { const { appSettings: globalAppSettings } = useAppSettings(); - const { accesses, loadedAtOnce, fetchAccesses, deleteAccess } = useAccessesStore( - useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses", "deleteAccess"]) - ); + const { loadedAtOnce, fetchAccesses, deleteAccess } = useAccessesStore(useZustandShallowSelector(["loadedAtOnce", "fetchAccesses", "deleteAccess"])); useMount(() => { fetchAccesses().catch((err) => { if (err instanceof ClientResponseError && err.isAbort) { @@ -118,7 +116,7 @@ const AccessList = () => { items: [ { key: "edit", - label: t("access.action.edit.menu"), + label: t("access.action.modify.menu"), icon: ( @@ -199,10 +197,11 @@ const AccessList = () => { }; const { loading, run: refreshData } = useRequest( - () => { - const startIndex = (page - 1) * pageSize; - const endIndex = startIndex + pageSize; - const list = accesses + async () => { + const list = await fetchAccesses(); + const startIdx = (page - 1) * pageSize; + const endIdx = startIdx + pageSize; + const items = list .filter((e) => { const keyword = (filters["keyword"] as string | undefined)?.trim(); if (keyword) { @@ -227,12 +226,12 @@ const AccessList = () => { } }); return Promise.resolve({ - items: list.slice(startIndex, endIndex), - totalItems: list.length, + items: items.slice(startIdx, endIdx), + totalItems: items.length, }); }, { - refreshDeps: [accesses, filters, page, pageSize], + refreshDeps: [filters, page, pageSize], onBefore: () => { setSearchParams((prev) => { if (filters["keyword"]) { @@ -270,7 +269,7 @@ const AccessList = () => { const handleReloadClick = () => { if (loading) return; - fetchAccesses(); + refreshData(); }; const handlePaginationChange = (page: number, pageSize: number) => { diff --git a/ui/src/pages/certificates/CertificateList.tsx b/ui/src/pages/certificates/CertificateList.tsx index 3522924c..115532c5 100644 --- a/ui/src/pages/certificates/CertificateList.tsx +++ b/ui/src/pages/certificates/CertificateList.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { IconBrowserShare, IconCertificate, IconDots, IconExternalLink, IconReload, IconShieldCancel, IconTrash } from "@tabler/icons-react"; -import { useRequest } from "ahooks"; +import { useMount, useRequest } from "ahooks"; import { App, Button, Dropdown, Input, Segmented, Skeleton, Table, type TableProps, Typography, theme } from "antd"; import dayjs from "dayjs"; import { ClientResponseError } from "pocketbase"; @@ -12,10 +12,9 @@ import CertificateDetailDrawer from "@/components/certificate/CertificateDetailD import Empty from "@/components/Empty"; import Show from "@/components/Show"; import { CERTIFICATE_SOURCES, type CertificateModel } from "@/domain/certificate"; -import { SETTINGS_NAMES } from "@/domain/settings"; -import { useAppSettings } from "@/hooks"; +import { useAppSettings, useZustandShallowSelector } from "@/hooks"; import { get as getCertificate, list as listCertificates, remove as removeCertificate } from "@/repository/certificate"; -import { get as getSettings } from "@/repository/settings"; +import { usePersistenceSettingsStore } from "@/stores/settings"; import { getErrMsg } from "@/utils/error"; const CertificateList = () => { @@ -30,7 +29,15 @@ const CertificateList = () => { const { appSettings: globalAppSettings } = useAppSettings(); - const [expiryThreshold, setExpiryThreshold] = useState(0); + const { settings: persistenceSettings, loadSettings: loadPersistenceSettings } = usePersistenceSettingsStore( + useZustandShallowSelector(["settings", "loadSettings"]) + ); + useMount(() => loadPersistenceSettings(false)); + + const [expiryThreshold, setExpiryThreshold] = useState(() => persistenceSettings.certificatesWarningDaysBeforeExpire || 0); + useEffect(() => { + setExpiryThreshold(persistenceSettings.certificatesWarningDaysBeforeExpire || 0); + }, [persistenceSettings.certificatesWarningDaysBeforeExpire]); const [filters, setFilters] = useState>(() => { return { @@ -271,14 +278,6 @@ const CertificateList = () => { return prev; }); - - if (expiryThreshold === 0) { - const settings = await getSettings(SETTINGS_NAMES.PERSISTENCE); - const threshold = settings?.content?.certificatesWarningDaysBeforeExpire ?? 0; - if (threshold !== expiryThreshold) { - setExpiryThreshold(threshold); - } - } }, onSuccess: (res) => { setTableData(res.items); diff --git a/ui/src/pages/presets/PresetList.tsx b/ui/src/pages/presets/PresetList.tsx new file mode 100644 index 00000000..b284740c --- /dev/null +++ b/ui/src/pages/presets/PresetList.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Tabs } from "antd"; + +import Show from "@/components/Show"; + +import PresetListNotifyTemplates from "./PresetListNotifyTemplates"; +import PresetListScriptTemplates from "./PresetListScriptTemplates"; + +type PresetUsages = "notification" | "script"; + +const PresetList = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const { t } = useTranslation(); + + const [tabKey, setTabKey] = useState(() => { + return (searchParams.get("usage") || "notification") as PresetUsages; + }); + + const handleTabChange = (key: string) => { + setTabKey(key as PresetUsages); + setSearchParams((prev) => { + prev.set("usage", key); + return prev; + }); + }; + + return ( +
                  +
                  +

                  {t("preset.page.title")}

                  +

                  {t("preset.page.subtitle")}

                  +
                  + +
                  + handleTabChange(key)} + /> + +
                  + + + + + + + + +
                  +
                  +
                  + ); +}; + +export default PresetList; diff --git a/ui/src/pages/presets/PresetListNotifyTemplates.tsx b/ui/src/pages/presets/PresetListNotifyTemplates.tsx new file mode 100644 index 00000000..6f0cbdc8 --- /dev/null +++ b/ui/src/pages/presets/PresetListNotifyTemplates.tsx @@ -0,0 +1,341 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconDots, IconEdit, IconPlus, IconTrash } from "@tabler/icons-react"; +import { useControllableValue, useMount } from "ahooks"; +import { App, Button, Card, Dropdown, Form, Input, Typography } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { nanoid } from "nanoid/non-secure"; +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import DrawerForm from "@/components/DrawerForm"; +import Tips from "@/components/Tips"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { useNotifyTemplatesStore } from "@/stores/settings"; +import { getErrMsg } from "@/utils/error"; + +const MAX_TEMPLATE_COUNT = 99; + +type PresetTemplate = { + name: string; + subject: string; + message: string; +}; + +const PresetListNotifyTemplates = () => { + const { t } = useTranslation(); + + const { message, modal, notification } = App.useApp(); + + const { templates, loading, loadedAtOnce, fetchTemplates, setTemplates, addTemplate, removeTemplateByIndex } = useNotifyTemplatesStore(); + useMount(() => { + fetchTemplates().catch((err) => { + if (err instanceof ClientResponseError && err.isAbort) { + return; + } + + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + }); + }); + + const [createDrawerOpen, setCreateDrawerOpen] = useState(false); + const [detailDrawerOpen, setDetailDrawerOpen] = useState(false); + const [detailDrawerRecord, setDetailDrawerRecord] = useState(); + const [detailDrawerIndex, setDetailDrawerIndex] = useState(); + + const handleCreateClick = () => { + if (!loadedAtOnce) return; + + if (templates.length >= MAX_TEMPLATE_COUNT) { + message.warning(t("preset.warning.excceeded")); + return; + } + + setCreateDrawerOpen(true); + }; + + const handleRecordDetailClick = (template: PresetTemplate, index: number) => { + setDetailDrawerIndex(index); + setDetailDrawerRecord({ ...template }); + setDetailDrawerOpen(true); + }; + + const handleRecordDeleteClick = (template: PresetTemplate, index: number) => { + modal.confirm({ + title: {t("preset.action.delete.modal.title", { name: template.name })}, + content: , + icon: ( + + + + ), + okText: t("common.button.confirm"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await removeTemplateByIndex(index); + } catch (err) { + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + } + }, + }); + }; + + const handleCreateDrawerSubmit = async (values: PresetTemplate) => { + try { + await addTemplate(values); + + setCreateDrawerOpen(false); + } catch (err) { + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + } + }; + + const handleModifyDrawerSubmit = async (values: PresetTemplate) => { + try { + const newTemplates = [...templates]; + newTemplates[detailDrawerIndex!] = values; + await setTemplates(newTemplates); + + setDetailDrawerIndex(void 0); + setDetailDrawerRecord(void 0); + setDetailDrawerOpen(false); + } catch (err) { + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + } + }; + + return ( + <> + } /> + +
                  +
                  + +
                  + +
                  {t("preset.action.create.button")}
                  +
                  +
                  +
                  + + {templates.map((template, index) => ( +
                  + + + + ), + onClick: (e) => { + e.domEvent.stopPropagation(); + handleRecordDetailClick(template, index); + }, + }, + { + type: "divider", + }, + { + key: "delete", + label: t("preset.action.delete.menu"), + danger: true, + icon: ( + + + + ), + onClick: (e) => { + e.domEvent.stopPropagation(); + handleRecordDeleteClick(template, index); + }, + }, + ], + }} + trigger={["click"]} + > +
                  + ))} + + {loading && !loadedAtOnce && ( +
                  + +
                  + )} +
                  + + setCreateDrawerOpen(false)} + onOpenChange={(open) => setCreateDrawerOpen(open)} + onSubmit={handleCreateDrawerSubmit} + /> + setDetailDrawerOpen(false)} + onOpenChange={(open) => setDetailDrawerOpen(open)} + onSubmit={handleModifyDrawerSubmit} + /> + + ); +}; + +const InternalEditDrawer = ({ + mode, + data, + onSubmit, + ...props +}: { + afterClose?: () => void; + mode: "create" | "modify"; + data?: Nullish; + open: boolean; + onOpenChange?: (open: boolean) => void; + onSubmit?: (record: PresetTemplate) => void; +}) => { + const { t } = useTranslation(); + + const { templates } = useNotifyTemplatesStore(useZustandShallowSelector(["templates"])); + + const [open, setOpen] = useControllableValue(props, { + valuePropName: "open", + defaultValuePropName: "defaultOpen", + trigger: "onOpenChange", + }); + + const afterClose = () => { + formInst.resetFields(); + props.afterClose?.(); + }; + + const formSchema = z + .object({ + name: z.string().nonempty(t("preset.form.name.placeholder")), + subject: z.string().nonempty(t("preset.form.notification_subject.placeholder")), + message: z.string().nonempty(t("preset.form.notification_message.placeholder")), + }) + .superRefine((values, ctx) => { + if (values.name) { + const name = String(values.name).trim(); + const duplicatedCount = templates.filter((t) => String(t.name).trim() === name).length; + if (duplicatedCount > (mode === "create" ? 0 : 1)) { + ctx.addIssue({ + code: "custom", + message: t("preset.form.name.errmsg.duplicated"), + path: ["name"], + }); + } + } + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm>({ + name: "viewPresetListNotifyTemplates_InternalDrawerForm_" + nanoid(), + initialValues: data, + }); + + const handleFormFinish = async (values: z.infer) => { + switch (mode) { + case "create": + case "modify": + { + onSubmit?.(values); + } + break; + + default: + throw "Invalid props: `mode`"; + } + + setOpen(false); + }; + + return ( + !open && afterClose?.() }} + form={formInst} + layout="vertical" + okText={mode === "create" ? t("common.button.create") : mode === "modify" ? t("common.button.save") : void 0} + open={open} + preserve={false} + title={mode === "create" ? t("preset.action.create.modal.title") : mode === "modify" ? t("preset.action.modify.modal.title") : void 0} + validateTrigger="onSubmit" + onFinish={handleFormFinish} + onOpenChange={props.onOpenChange} + > + + + + + + + + + + + + + ); +}; + +export default PresetListNotifyTemplates; diff --git a/ui/src/pages/presets/PresetListScriptTemplates.tsx b/ui/src/pages/presets/PresetListScriptTemplates.tsx new file mode 100644 index 00000000..0c4bc82a --- /dev/null +++ b/ui/src/pages/presets/PresetListScriptTemplates.tsx @@ -0,0 +1,333 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconDots, IconEdit, IconPlus, IconTrash } from "@tabler/icons-react"; +import { useControllableValue, useMount } from "ahooks"; +import { App, Button, Card, Dropdown, Form, Input, Typography } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { nanoid } from "nanoid/non-secure"; +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import CodeInput from "@/components/CodeInput"; +import DrawerForm from "@/components/DrawerForm"; +import Tips from "@/components/Tips"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { useScriptTemplatesStore } from "@/stores/settings"; +import { getErrMsg } from "@/utils/error"; + +const MAX_TEMPLATE_COUNT = 99; + +type PresetTemplate = { + name: string; + command: string; +}; + +const PresetListScriptTemplates = () => { + const { t } = useTranslation(); + + const { message, modal, notification } = App.useApp(); + + const { templates, loading, loadedAtOnce, fetchTemplates, setTemplates, addTemplate, removeTemplateByIndex } = useScriptTemplatesStore(); + useMount(() => { + fetchTemplates().catch((err) => { + if (err instanceof ClientResponseError && err.isAbort) { + return; + } + + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + }); + }); + + const [createDrawerOpen, setCreateDrawerOpen] = useState(false); + const [detailDrawerOpen, setDetailDrawerOpen] = useState(false); + const [detailDrawerRecord, setDetailDrawerRecord] = useState(); + const [detailDrawerIndex, setDetailDrawerIndex] = useState(); + + const handleCreateClick = () => { + if (!loadedAtOnce) return; + + if (templates.length >= MAX_TEMPLATE_COUNT) { + message.warning(t("preset.warning.excceeded")); + return; + } + + setCreateDrawerOpen(true); + }; + + const handleRecordDetailClick = (template: PresetTemplate, index: number) => { + setDetailDrawerIndex(index); + setDetailDrawerRecord({ ...template }); + setDetailDrawerOpen(true); + }; + + const handleRecordDeleteClick = (template: PresetTemplate, index: number) => { + modal.confirm({ + title: {t("preset.action.delete.modal.title", { name: template.name })}, + content: , + icon: ( + + + + ), + okText: t("common.button.confirm"), + okButtonProps: { danger: true }, + onOk: async () => { + try { + await removeTemplateByIndex(index); + } catch (err) { + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + } + }, + }); + }; + + const handleCreateDrawerSubmit = async (values: PresetTemplate) => { + try { + await addTemplate(values); + + setCreateDrawerOpen(false); + } catch (err) { + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + } + }; + + const handleModifyDrawerSubmit = async (values: PresetTemplate) => { + try { + const newTemplates = [...templates]; + newTemplates[detailDrawerIndex!] = values; + await setTemplates(newTemplates); + + setDetailDrawerIndex(void 0); + setDetailDrawerRecord(void 0); + setDetailDrawerOpen(false); + } catch (err) { + console.error(err); + notification.error({ title: t("common.text.request_error"), description: getErrMsg(err) }); + } + }; + + return ( + <> + } /> + +
                  +
                  + +
                  + +
                  {t("preset.action.create.button")}
                  +
                  +
                  +
                  + + {templates.map((template, index) => ( +
                  + + + + ), + onClick: (e) => { + e.domEvent.stopPropagation(); + handleRecordDetailClick(template, index); + }, + }, + { + type: "divider", + }, + { + key: "delete", + label: t("preset.action.delete.menu"), + danger: true, + icon: ( + + + + ), + onClick: (e) => { + e.domEvent.stopPropagation(); + handleRecordDeleteClick(template, index); + }, + }, + ], + }} + trigger={["click"]} + > +
                  + ))} + + {loading && !loadedAtOnce && ( +
                  + +
                  + )} +
                  + + setCreateDrawerOpen(false)} + onOpenChange={(open) => setCreateDrawerOpen(open)} + onSubmit={handleCreateDrawerSubmit} + /> + setDetailDrawerOpen(false)} + onOpenChange={(open) => setDetailDrawerOpen(open)} + onSubmit={handleModifyDrawerSubmit} + /> + + ); +}; + +const InternalEditDrawer = ({ + mode, + data, + onSubmit, + ...props +}: { + afterClose?: () => void; + mode: "create" | "modify"; + data?: Nullish; + open: boolean; + onOpenChange?: (open: boolean) => void; + onSubmit?: (record: PresetTemplate) => void; +}) => { + const { t } = useTranslation(); + + const { templates } = useScriptTemplatesStore(useZustandShallowSelector(["templates"])); + + const [open, setOpen] = useControllableValue(props, { + valuePropName: "open", + defaultValuePropName: "defaultOpen", + trigger: "onOpenChange", + }); + + const afterClose = () => { + formInst.resetFields(); + props.afterClose?.(); + }; + + const formSchema = z + .object({ + name: z.string().nonempty(t("preset.form.name.placeholder")), + command: z.string().nonempty(t("preset.form.script_command.placeholder")), + }) + .superRefine((values, ctx) => { + if (values.name) { + const name = String(values.name).trim(); + const duplicatedCount = templates.filter((t) => String(t.name).trim() === name).length; + if (duplicatedCount > (mode === "create" ? 0 : 1)) { + ctx.addIssue({ + code: "custom", + message: t("preset.form.name.errmsg.duplicated"), + path: ["name"], + }); + } + } + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm>({ + name: "viewPresetListScriptTemplates_InternalDrawerForm_" + nanoid(), + initialValues: data, + }); + + const handleFormFinish = async (values: z.infer) => { + switch (mode) { + case "create": + case "modify": + { + onSubmit?.(values); + } + break; + + default: + throw "Invalid props: `mode`"; + } + + setOpen(false); + }; + + return ( + !open && afterClose?.() }} + form={formInst} + layout="vertical" + okText={mode === "create" ? t("common.button.create") : mode === "modify" ? t("common.button.save") : void 0} + open={open} + preserve={false} + title={mode === "create" ? t("preset.action.create.modal.title") : mode === "modify" ? t("preset.action.modify.modal.title") : void 0} + validateTrigger="onSubmit" + onFinish={handleFormFinish} + onOpenChange={props.onOpenChange} + > + + + + + + + + + ); +}; + +export default PresetListScriptTemplates; diff --git a/ui/src/pages/settings/Settings.tsx b/ui/src/pages/settings/Settings.tsx index 5c22351e..9a302fd6 100644 --- a/ui/src/pages/settings/Settings.tsx +++ b/ui/src/pages/settings/Settings.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; -import { IconBracketsAngle, IconDatabaseCog, IconInfoCircle, IconPalette, IconPlugConnected, IconUserShield } from "@tabler/icons-react"; +import { IconDatabaseCog, IconHeartRateMonitor, IconInfoCircle, IconPalette, IconPlugConnected, IconUserShield } from "@tabler/icons-react"; import { Menu } from "antd"; const Settings = () => { @@ -15,7 +15,7 @@ const Settings = () => { ["appearance", "settings.appearance.tab", ], ["ssl-provider", "settings.sslprovider.tab", ], ["persistence", "settings.persistence.tab", ], - ["diagnostics", "settings.diagnostics.tab", ], + ["diagnostics", "settings.diagnostics.tab", ], ["about", "settings.about.tab", ], ] satisfies [string, string, React.ReactElement][]; const [menuKey, setMenuKey] = useState(() => location.pathname.split("/")[2]); diff --git a/ui/src/pages/settings/SettingsPersistence.tsx b/ui/src/pages/settings/SettingsPersistence.tsx index b7c500ee..5fabb7c6 100644 --- a/ui/src/pages/settings/SettingsPersistence.tsx +++ b/ui/src/pages/settings/SettingsPersistence.tsx @@ -1,14 +1,15 @@ -import { createContext, useContext, useEffect, useState } from "react"; +import { createContext, useContext, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useMount } from "ahooks"; import { App, Button, Divider, Form, InputNumber, Skeleton } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { produce } from "immer"; import { z } from "zod"; import Show from "@/components/Show"; -import { type PersistenceSettingsContent, SETTINGS_NAMES, type SettingsModel } from "@/domain/settings"; -import { useAntdForm } from "@/hooks"; -import { get as getSettings, save as saveSettings } from "@/repository/settings"; +import { type PersistenceSettingsContent } from "@/domain/settings"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { usePersistenceSettingsStore } from "@/stores/settings"; import { getErrMsg } from "@/utils/error"; const SettingsPersistence = () => { @@ -16,26 +17,14 @@ const SettingsPersistence = () => { const { message, notification } = App.useApp(); - const [settings, setSettings] = useState>(); - const [loading, setLoading] = useState(true); + const { settings, loading, loadSettings, saveSettings } = usePersistenceSettingsStore( + useZustandShallowSelector(["settings", "loading", "loadSettings", "saveSettings"]) + ); + useMount(() => loadSettings()); - useEffect(() => { - const fetchData = async () => { - setLoading(true); - - const settings = await getSettings(SETTINGS_NAMES.PERSISTENCE); - setSettings(settings); - - setLoading(false); - }; - - fetchData(); - }, []); - - const updateContextSettings = async (settings: MaybeModelRecordWithId>) => { + const updateContextSettings = async (settings: PersistenceSettingsContent) => { try { - const resp = await saveSettings(settings); - setSettings(resp); + await saveSettings(settings); message.success(t("common.text.operation_succeeded")); } catch (err) { @@ -77,13 +66,12 @@ const SettingsPersistenceAlerting = ({ className, style }: { className?: string; formProps, } = useAntdForm>({ initialValues: { - certificatesWarningDaysBeforeExpire: settings?.content?.certificatesWarningDaysBeforeExpire, + certificatesWarningDaysBeforeExpire: settings?.certificatesWarningDaysBeforeExpire, }, onSubmit: async (values) => { updateSettings( produce(settings!, (draft) => { - draft.content ??= {} as PersistenceSettingsContent; - draft.content.certificatesWarningDaysBeforeExpire = values.certificatesWarningDaysBeforeExpire; + draft.certificatesWarningDaysBeforeExpire = values.certificatesWarningDaysBeforeExpire; }) ); }, @@ -144,15 +132,14 @@ const SettingsPersistenceDataRetention = ({ className, style }: { className?: st formProps, } = useAntdForm>({ initialValues: { - certificatesRetentionMaxDays: settings?.content?.certificatesRetentionMaxDays, - workflowRunsRetentionMaxDays: settings?.content?.workflowRunsRetentionMaxDays, + certificatesRetentionMaxDays: settings?.certificatesRetentionMaxDays, + workflowRunsRetentionMaxDays: settings?.workflowRunsRetentionMaxDays, }, onSubmit: async (values) => { updateSettings( produce(settings!, (draft) => { - draft.content ??= {} as PersistenceSettingsContent; - draft.content.certificatesRetentionMaxDays = values.certificatesRetentionMaxDays; - draft.content.workflowRunsRetentionMaxDays = values.workflowRunsRetentionMaxDays; + draft.certificatesRetentionMaxDays = values.certificatesRetentionMaxDays; + draft.workflowRunsRetentionMaxDays = values.workflowRunsRetentionMaxDays; }) ); }, @@ -218,8 +205,8 @@ const SettingsPersistenceDataRetention = ({ className, style }: { className?: st const InternalSettingsContext = createContext( {} as { loading: boolean; - settings: SettingsModel; - updateSettings: (settings: MaybeModelRecordWithId>) => Promise; + settings: PersistenceSettingsContent; + updateSettings: (settings: PersistenceSettingsContent) => Promise; } ); diff --git a/ui/src/pages/settings/SettingsSSLProvider.tsx b/ui/src/pages/settings/SettingsSSLProvider.tsx index 8bc6faa7..1bf03bae 100644 --- a/ui/src/pages/settings/SettingsSSLProvider.tsx +++ b/ui/src/pages/settings/SettingsSSLProvider.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useMount } from "ahooks"; import { App, Button, Card, Form, Input, Select, Skeleton } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { produce } from "immer"; @@ -8,9 +9,9 @@ import { z } from "zod"; import Show from "@/components/Show"; import Tips from "@/components/Tips"; import { type CAProviderType, CA_PROVIDERS } from "@/domain/provider"; -import { SETTINGS_NAMES, type SSLProviderSettingsContent, type SettingsModel } from "@/domain/settings"; -import { useAntdForm } from "@/hooks"; -import { get as getSettings, save as saveSettings } from "@/repository/settings"; +import { type SSLProviderSettingsContent } from "@/domain/settings"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { useSSLProviderSettingsStore } from "@/stores/settings"; import { mergeCls } from "@/utils/css"; import { getErrMsg } from "@/utils/error"; @@ -19,26 +20,14 @@ const SettingsSSLProvider = () => { const { message, notification } = App.useApp(); - const [settings, setSettings] = useState>(); - const [loading, setLoading] = useState(true); + const { settings, loading, loadSettings, saveSettings } = useSSLProviderSettingsStore( + useZustandShallowSelector(["settings", "loading", "loadSettings", "saveSettings"]) + ); + useMount(() => loadSettings()); const [formInst] = Form.useForm<{ provider?: string }>(); const [formPending, setFormPending] = useState(false); - useEffect(() => { - const fetchData = async () => { - setLoading(true); - - const settings = await getSettings(SETTINGS_NAMES.SSL_PROVIDER); - setSettings(settings); - setProviderValue(settings.content?.provider || CA_PROVIDERS.LETSENCRYPT); - - setLoading(false); - }; - - fetchData(); - }, []); - const providers = [ [CA_PROVIDERS.LETSENCRYPT, "provider.letsencrypt", "letsencrypt.org", "/imgs/providers/letsencrypt.svg"], [CA_PROVIDERS.LETSENCRYPTSTAGING, "provider.letsencryptstaging", "letsencrypt.org", "/imgs/providers/letsencrypt.svg"], @@ -84,13 +73,16 @@ const SettingsSSLProvider = () => { } }, [providerValue]); - const updateContextSettings = async (settings: MaybeModelRecordWithId>) => { + useEffect(() => { + setProviderValue(settings.provider || CA_PROVIDERS.LETSENCRYPT); + }, [settings.provider]); + + const updateContextSettings = async (settings: SSLProviderSettingsContent) => { setFormPending(true); try { - const resp = await saveSettings(settings); - setSettings(resp); - setProviderValue(resp.content?.provider); + await saveSettings(settings); + setProviderValue(settings.provider); message.success(t("common.text.operation_succeeded")); } catch (err) { @@ -157,8 +149,8 @@ const InternalSettingsContext = createContext( {} as { loading: boolean; pending: boolean; - settings: SettingsModel; - updateSettings: (settings: MaybeModelRecordWithId>) => Promise; + settings: SSLProviderSettingsContent; + updateSettings: (settings: SSLProviderSettingsContent) => Promise; } ); @@ -168,14 +160,12 @@ const InternalSharedForm = ({ children, provider }: { children?: React.ReactNode const { pending, settings, updateSettings } = useContext(InternalSettingsContext); const { form: formInst, formProps } = useAntdForm>({ - initialValues: settings?.content?.config?.[provider], + initialValues: settings?.config?.[provider], onSubmit: async (values) => { const newSettings = produce(settings, (draft) => { - draft.content ??= {} as SSLProviderSettingsContent; - draft.content.provider = provider; - - draft.content.config ??= {} as SSLProviderSettingsContent["config"]; - draft.content.config[provider] = values; + draft.provider = provider; + draft.config ??= {} as SSLProviderSettingsContent["config"]; + draft.config[provider] = values; }); await updateSettings(newSettings); @@ -185,8 +175,8 @@ const InternalSharedForm = ({ children, provider }: { children?: React.ReactNode const [formChanged, setFormChanged] = useState(false); useEffect(() => { - setFormChanged(provider !== settings?.content?.provider); - }, [provider, settings?.content?.provider]); + setFormChanged(provider !== settings?.provider); + }, [provider, settings?.provider]); const handleFormChange = () => { setFormChanged(true); diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index f985a2a2..0254686f 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -133,8 +133,8 @@ const WorkflowList = () => { menu={{ items: [ { - key: "edit", - label: t("workflow.action.edit.menu"), + key: "modify", + label: t("workflow.action.modify.menu"), icon: ( diff --git a/ui/src/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx index 034e3908..3b3f9505 100644 --- a/ui/src/pages/workflows/WorkflowNew.tsx +++ b/ui/src/pages/workflows/WorkflowNew.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { IconArrowRight, IconCode, IconSquarePlus2 } from "@tabler/icons-react"; +import { IconArrowRight, IconSquarePlus2, IconUpload } from "@tabler/icons-react"; import { App, Button, Card, Spin, Typography } from "antd"; import Show from "@/components/Show"; @@ -285,18 +285,12 @@ const WorkflowNew = () => {
                  - +
                  - -
                  diff --git a/ui/src/repository/settings.ts b/ui/src/repository/settings.ts index d0be509d..89cd9a6b 100644 --- a/ui/src/repository/settings.ts +++ b/ui/src/repository/settings.ts @@ -3,9 +3,11 @@ import { ClientResponseError } from "pocketbase"; import { CA_PROVIDERS } from "@/domain/provider"; import { type EmailsSettingsContent, + type NotifyTemplateContent, type PersistenceSettingsContent, SETTINGS_NAMES, type SSLProviderSettingsContent, + type ScriptTemplateContent, type SettingsModel, type SettingsNames, } from "@/domain/settings"; @@ -14,6 +16,8 @@ import { COLLECTION_NAME_SETTINGS, getPocketBase } from "./_pocketbase"; interface SettingsContentMap { [SETTINGS_NAMES.EMAILS]: EmailsSettingsContent; + [SETTINGS_NAMES.NOTIFY_TEMPLATE]: NotifyTemplateContent; + [SETTINGS_NAMES.SCRIPT_TEMPLATE]: ScriptTemplateContent; [SETTINGS_NAMES.SSL_PROVIDER]: SSLProviderSettingsContent; [SETTINGS_NAMES.PERSISTENCE]: PersistenceSettingsContent; } @@ -47,6 +51,20 @@ export const get = async , + }, { path: "/settings", element: , diff --git a/ui/src/stores/access/index.ts b/ui/src/stores/access/index.ts index 2ee1bd5f..63186978 100644 --- a/ui/src/stores/access/index.ts +++ b/ui/src/stores/access/index.ts @@ -17,7 +17,7 @@ export const useAccessesStore = create((set, get) => { fetchAccesses: async (refresh = true) => { if (!refresh) { if (get().loadedAtOnce) { - return; + return get().accesses; } } @@ -31,6 +31,8 @@ export const useAccessesStore = create((set, get) => { fetcher = null; set({ loading: false }); } + + return get().accesses; }, createAccess: async (access) => { diff --git a/ui/src/stores/access/types.ts b/ui/src/stores/access/types.ts index 943966f6..e11ae4cb 100644 --- a/ui/src/stores/access/types.ts +++ b/ui/src/stores/access/types.ts @@ -7,7 +7,7 @@ export interface AccessesState { } export interface AccessesActions { - fetchAccesses: (refresh?: boolean) => Promise; + fetchAccesses: (refresh?: boolean) => Promise; createAccess: (access: MaybeModelRecord) => Promise; updateAccess: (access: MaybeModelRecordWithId) => Promise; deleteAccess: (access: MaybeModelRecordWithId | MaybeModelRecordWithId[]) => Promise; diff --git a/ui/src/stores/contact/index.ts b/ui/src/stores/settings/contact/index.ts similarity index 75% rename from ui/src/stores/contact/index.ts rename to ui/src/stores/settings/contact/index.ts index 886169ea..922bcfcc 100644 --- a/ui/src/stores/contact/index.ts +++ b/ui/src/stores/settings/contact/index.ts @@ -8,7 +8,7 @@ import { type ContactEmailsState, type ContactEmailsStore } from "./types"; export const useContactEmailsStore = create((set, get) => { let fetcher: Promise> | null = null; // 防止多次重复请求 - let settings: SettingsModel; // 记录当前设置的其他字段,保存回数据库时用 + let model: SettingsModel; // 记录当前设置的其他字段,保存回数据库时用 return { emails: [], @@ -18,7 +18,7 @@ export const useContactEmailsStore = create((set, get) => { fetchEmails: async (refresh = true) => { if (!refresh) { if (get().loadedAtOnce) { - return; + return get().emails; } } @@ -26,27 +26,29 @@ export const useContactEmailsStore = create((set, get) => { try { set({ loading: true }); - settings = await fetcher; - set({ emails: settings.content.emails?.filter((s) => !!s)?.sort() ?? [], loadedAtOnce: true }); + model = await fetcher; + set({ emails: model.content.emails?.filter((s) => !!s)?.sort() ?? [], loadedAtOnce: true }); } finally { fetcher = null; set({ loading: false }); } + + return get().emails; }, setEmails: async (emails) => { - settings ??= await getSettings(SETTINGS_NAMES.EMAILS); - settings = await saveSettings({ - ...settings, + model ??= await getSettings(SETTINGS_NAMES.EMAILS); + model = await saveSettings({ + ...model, content: { - ...settings.content, + ...model.content, emails: emails, }, }); set( produce((state: ContactEmailsState) => { - state.emails = settings.content.emails?.sort() ?? []; + state.emails = model.content.emails?.sort() ?? []; state.loadedAtOnce = true; }) ); diff --git a/ui/src/stores/contact/types.ts b/ui/src/stores/settings/contact/types.ts similarity index 87% rename from ui/src/stores/contact/types.ts rename to ui/src/stores/settings/contact/types.ts index af1e6950..38fd7556 100644 --- a/ui/src/stores/contact/types.ts +++ b/ui/src/stores/settings/contact/types.ts @@ -5,7 +5,7 @@ } export interface ContactEmailsActions { - fetchEmails: (refresh?: boolean) => Promise; + fetchEmails: (refresh?: boolean) => Promise; setEmails: (emails: string[]) => Promise; addEmail: (email: string) => Promise; removeEmail: (email: string) => Promise; diff --git a/ui/src/stores/settings/index.ts b/ui/src/stores/settings/index.ts new file mode 100644 index 00000000..ca9b1742 --- /dev/null +++ b/ui/src/stores/settings/index.ts @@ -0,0 +1,4 @@ +export { useContactEmailsStore } from "./contact"; +export { usePersistenceSettingsStore } from "./persistence"; +export { useSSLProviderSettingsStore } from "./sslprovider"; +export { useNotifyTemplatesStore, useScriptTemplatesStore } from "./template"; diff --git a/ui/src/stores/settings/persistence/index.ts b/ui/src/stores/settings/persistence/index.ts new file mode 100644 index 00000000..f323fe06 --- /dev/null +++ b/ui/src/stores/settings/persistence/index.ts @@ -0,0 +1,52 @@ +import { produce } from "immer"; +import { create } from "zustand"; + +import { type PersistenceSettingsContent, SETTINGS_NAMES, type SettingsModel } from "@/domain/settings"; +import { get as getSettings, save as saveSettings } from "@/repository/settings"; + +import { type PersistenceSettingsState, type PersistenceSettingsStore } from "./types"; + +export const usePersistenceSettingsStore = create((set, get) => { + let fetcher: Promise> | null = null; // 防止多次重复请求 + let model: SettingsModel; // 记录当前设置的其他字段,保存回数据库时用 + + return { + settings: {} as PersistenceSettingsContent, + loading: false, + loadedAtOnce: false, + + loadSettings: async (refresh = true) => { + if (!refresh) { + if (get().loadedAtOnce) { + return; + } + } + + fetcher ??= getSettings(SETTINGS_NAMES.PERSISTENCE); + + try { + set({ loading: true }); + model = await fetcher; + set({ settings: model.content, loadedAtOnce: true }); + } finally { + fetcher = null; + set({ loading: false }); + } + }, + + saveSettings: async (settings) => { + model ??= await getSettings(SETTINGS_NAMES.PERSISTENCE); + model = await saveSettings({ + ...model, + content: settings, + }); + + set( + produce((state: PersistenceSettingsState) => { + state.settings = model.content; + state.loadedAtOnce = true; + }) + ); + }, + }; +}); diff --git a/ui/src/stores/settings/persistence/types.ts b/ui/src/stores/settings/persistence/types.ts new file mode 100644 index 00000000..139e3b76 --- /dev/null +++ b/ui/src/stores/settings/persistence/types.ts @@ -0,0 +1,14 @@ +import { type PersistenceSettingsContent } from "@/domain/settings"; + +export interface PersistenceSettingsState { + settings: PersistenceSettingsContent; + loading: boolean; + loadedAtOnce: boolean; +} + +export interface PersistenceSettingsActions { + loadSettings: (refresh?: boolean) => Promise; + saveSettings: (settings: PersistenceSettingsContent) => Promise; +} + +export interface PersistenceSettingsStore extends PersistenceSettingsState, PersistenceSettingsActions {} diff --git a/ui/src/stores/settings/sslprovider/index.ts b/ui/src/stores/settings/sslprovider/index.ts new file mode 100644 index 00000000..c8f69782 --- /dev/null +++ b/ui/src/stores/settings/sslprovider/index.ts @@ -0,0 +1,52 @@ +import { produce } from "immer"; +import { create } from "zustand"; + +import { SETTINGS_NAMES, type SSLProviderSettingsContent, type SettingsModel } from "@/domain/settings"; +import { get as getSettings, save as saveSettings } from "@/repository/settings"; + +import { type SSLProviderSettingsState, type SSLProviderSettingsStore } from "./types"; + +export const useSSLProviderSettingsStore = create((set, get) => { + let fetcher: Promise> | null = null; // 防止多次重复请求 + let model: SettingsModel; // 记录当前设置的其他字段,保存回数据库时用 + + return { + settings: {} as SSLProviderSettingsContent, + loading: false, + loadedAtOnce: false, + + loadSettings: async (refresh = true) => { + if (!refresh) { + if (get().loadedAtOnce) { + return; + } + } + + fetcher ??= getSettings(SETTINGS_NAMES.SSL_PROVIDER); + + try { + set({ loading: true }); + model = await fetcher; + set({ settings: model.content, loadedAtOnce: true }); + } finally { + fetcher = null; + set({ loading: false }); + } + }, + + saveSettings: async (settings) => { + model ??= await getSettings(SETTINGS_NAMES.SSL_PROVIDER); + model = await saveSettings({ + ...model, + content: settings, + }); + + set( + produce((state: SSLProviderSettingsState) => { + state.settings = model.content; + state.loadedAtOnce = true; + }) + ); + }, + }; +}); diff --git a/ui/src/stores/settings/sslprovider/types.ts b/ui/src/stores/settings/sslprovider/types.ts new file mode 100644 index 00000000..3780a68c --- /dev/null +++ b/ui/src/stores/settings/sslprovider/types.ts @@ -0,0 +1,14 @@ +import { type SSLProviderSettingsContent } from "@/domain/settings"; + +export interface SSLProviderSettingsState { + settings: SSLProviderSettingsContent; + loading: boolean; + loadedAtOnce: boolean; +} + +export interface SSLProviderSettingsActions { + loadSettings: (refresh?: boolean) => Promise; + saveSettings: (settings: SSLProviderSettingsContent) => Promise; +} + +export interface SSLProviderSettingsStore extends SSLProviderSettingsState, SSLProviderSettingsActions {} diff --git a/ui/src/stores/settings/template/index.ts b/ui/src/stores/settings/template/index.ts new file mode 100644 index 00000000..b3ddffdf --- /dev/null +++ b/ui/src/stores/settings/template/index.ts @@ -0,0 +1,163 @@ +import { produce } from "immer"; +import { create } from "zustand"; + +import { type NotifyTemplateContent, SETTINGS_NAMES, type ScriptTemplateContent, type SettingsModel } from "@/domain/settings"; +import { get as getSettings, save as saveSettings } from "@/repository/settings"; + +import { type NotifyTemplatesState, type NotifyTemplatesStore, type ScriptTemplatesState, type ScriptTemplatesStore } from "./types"; + +export const useNotifyTemplatesStore = create((set, get) => { + let fetcher: Promise> | null = null; // 防止多次重复请求 + let model: SettingsModel; // 记录当前设置的其他字段,保存回数据库时用 + + return { + templates: [], + loading: false, + loadedAtOnce: false, + + fetchTemplates: async (refresh = true) => { + if (!refresh) { + if (get().loadedAtOnce) { + return; + } + } + + fetcher ??= getSettings(SETTINGS_NAMES.NOTIFY_TEMPLATE); + + try { + set({ loading: true }); + model = await fetcher; + set({ templates: model.content.templates ?? [], loadedAtOnce: true }); + } finally { + fetcher = null; + set({ loading: false }); + } + }, + + setTemplates: async (templates) => { + model ??= await getSettings(SETTINGS_NAMES.NOTIFY_TEMPLATE); + model = await saveSettings({ + ...model, + content: { + ...model.content, + templates: templates, + }, + }); + + set( + produce((state: NotifyTemplatesState) => { + state.templates = model.content.templates ?? []; + state.loadedAtOnce = true; + }) + ); + }, + + addTemplate: async (template) => { + const templates = produce(get().templates, (draft) => { + const index = draft.findIndex((t) => t.name === template.name); + if (index !== -1) { + draft[index] = template; + } else { + draft.push(template); + } + + return draft; + }); + get().setTemplates(templates); + }, + + removeTemplateByIndex: async (index) => { + const templates = produce(get().templates, (draft) => { + draft = draft.filter((_, i) => i !== index); + return draft; + }); + get().setTemplates(templates); + }, + + removeTemplateByName: async (name) => { + const templates = produce(get().templates, (draft) => { + draft = draft.filter((e) => e.name !== name); + return draft; + }); + get().setTemplates(templates); + }, + }; +}); + +export const useScriptTemplatesStore = create((set, get) => { + let fetcher: Promise> | null = null; // 防止多次重复请求 + let model: SettingsModel; // 记录当前设置的其他字段,保存回数据库时用 + + return { + templates: [], + loading: false, + loadedAtOnce: false, + + fetchTemplates: async (refresh = true) => { + if (!refresh) { + if (get().loadedAtOnce) { + return; + } + } + + fetcher ??= getSettings(SETTINGS_NAMES.SCRIPT_TEMPLATE); + + try { + set({ loading: true }); + model = await fetcher; + set({ templates: model.content.templates ?? [], loadedAtOnce: true }); + } finally { + fetcher = null; + set({ loading: false }); + } + }, + + setTemplates: async (templates) => { + model ??= await getSettings(SETTINGS_NAMES.SCRIPT_TEMPLATE); + model = await saveSettings({ + ...model, + content: { + ...model.content, + templates: templates, + }, + }); + + set( + produce((state: ScriptTemplatesState) => { + state.templates = model.content.templates ?? []; + state.loadedAtOnce = true; + }) + ); + }, + + addTemplate: async (template) => { + const templates = produce(get().templates, (draft) => { + const index = draft.findIndex((t) => t.name === template.name); + if (index !== -1) { + draft[index] = template; + } else { + draft.push(template); + } + + return draft; + }); + get().setTemplates(templates); + }, + + removeTemplateByIndex: async (index) => { + const templates = produce(get().templates, (draft) => { + draft = draft.filter((_, i) => i !== index); + return draft; + }); + get().setTemplates(templates); + }, + + removeTemplateByName: async (name) => { + const templates = produce(get().templates, (draft) => { + draft = draft.filter((e) => e.name !== name); + return draft; + }); + get().setTemplates(templates); + }, + }; +}); diff --git a/ui/src/stores/settings/template/types.ts b/ui/src/stores/settings/template/types.ts new file mode 100644 index 00000000..b7761c85 --- /dev/null +++ b/ui/src/stores/settings/template/types.ts @@ -0,0 +1,42 @@ +type NotifyTemplate = { + name: string; + subject: string; + message: string; +}; + +export interface NotifyTemplatesState { + templates: NotifyTemplate[]; + loading: boolean; + loadedAtOnce: boolean; +} + +export interface NotifyTemplatesActions { + fetchTemplates: (refresh?: boolean) => Promise; + setTemplates: (templates: NotifyTemplate[]) => Promise; + addTemplate: (template: NotifyTemplate) => Promise; + removeTemplateByIndex: (index: number) => Promise; + removeTemplateByName: (name: string) => Promise; +} + +export interface NotifyTemplatesStore extends NotifyTemplatesState, NotifyTemplatesActions {} + +type ScriptTemplate = { + name: string; + command: string; +}; + +export interface ScriptTemplatesState { + templates: ScriptTemplate[]; + loading: boolean; + loadedAtOnce: boolean; +} + +export interface ScriptTemplatesActions { + fetchTemplates: (refresh?: boolean) => Promise; + setTemplates: (templates: ScriptTemplate[]) => Promise; + addTemplate: (template: ScriptTemplate) => Promise; + removeTemplateByIndex: (index: number) => Promise; + removeTemplateByName: (name: string) => Promise; +} + +export interface ScriptTemplatesStore extends ScriptTemplatesState, ScriptTemplatesActions {}