From 564e6909d8f0bf9686420a276f655c592221e45d Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Mon, 1 Dec 2025 10:01:27 +0800 Subject: [PATCH 1/7] chore(deps): upgrade lego --- go.mod | 15 +- go.sum | 32 +-- .../dns01/aliyun-esa/aliyun_esa.go | 9 +- .../dns01/aliyun-esa/internal/client.go | 225 ------------------ .../dns01/aliyun-esa/internal/lego.go | 196 --------------- .../dns01/baiducloud/baiducloud.go | 7 +- .../dns01/baiducloud/internal/lego.go | 175 -------------- 7 files changed, 34 insertions(+), 625 deletions(-) delete mode 100644 pkg/core/certifier/challengers/dns01/aliyun-esa/internal/client.go delete mode 100644 pkg/core/certifier/challengers/dns01/aliyun-esa/internal/lego.go delete mode 100644 pkg/core/certifier/challengers/dns01/baiducloud/internal/lego.go 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/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") -} From af023073d7ef3a8bb9da956a825d44029a0b4f24 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Mon, 1 Dec 2025 18:16:31 +0800 Subject: [PATCH 2/7] refactor(ui): make settings be storable --- .../designer/forms/BizApplyNodeConfigForm.tsx | 2 +- ui/src/pages/certificates/CertificateList.tsx | 27 +++++---- ui/src/pages/settings/SettingsPersistence.tsx | 51 +++++++---------- ui/src/pages/settings/SettingsSSLProvider.tsx | 56 ++++++++----------- ui/src/repository/settings.ts | 2 +- ui/src/stores/{ => settings}/contact/index.ts | 16 +++--- ui/src/stores/{ => settings}/contact/types.ts | 0 ui/src/stores/settings/index.ts | 3 + ui/src/stores/settings/persistence/index.ts | 52 +++++++++++++++++ ui/src/stores/settings/persistence/types.ts | 14 +++++ ui/src/stores/settings/sslprovider/index.ts | 52 +++++++++++++++++ ui/src/stores/settings/sslprovider/types.ts | 14 +++++ 12 files changed, 200 insertions(+), 89 deletions(-) rename ui/src/stores/{ => settings}/contact/index.ts (76%) rename ui/src/stores/{ => settings}/contact/types.ts (100%) create mode 100644 ui/src/stores/settings/index.ts create mode 100644 ui/src/stores/settings/persistence/index.ts create mode 100644 ui/src/stores/settings/persistence/types.ts create mode 100644 ui/src/stores/settings/sslprovider/index.ts create mode 100644 ui/src/stores/settings/sslprovider/types.ts diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index ee4eb2a8..be6b99f3 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"; 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/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/repository/settings.ts b/ui/src/repository/settings.ts index d0be509d..88f6bf58 100644 --- a/ui/src/repository/settings.ts +++ b/ui/src/repository/settings.ts @@ -14,8 +14,8 @@ import { COLLECTION_NAME_SETTINGS, getPocketBase } from "./_pocketbase"; interface SettingsContentMap { [SETTINGS_NAMES.EMAILS]: EmailsSettingsContent; - [SETTINGS_NAMES.SSL_PROVIDER]: SSLProviderSettingsContent; [SETTINGS_NAMES.PERSISTENCE]: PersistenceSettingsContent; + [SETTINGS_NAMES.SSL_PROVIDER]: SSLProviderSettingsContent; } export const get = async >( diff --git a/ui/src/stores/contact/index.ts b/ui/src/stores/settings/contact/index.ts similarity index 76% rename from ui/src/stores/contact/index.ts rename to ui/src/stores/settings/contact/index.ts index 886169ea..6e98d87f 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: [], @@ -26,8 +26,8 @@ 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 }); @@ -35,18 +35,18 @@ export const useContactEmailsStore = create((set, get) => { }, 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 100% rename from ui/src/stores/contact/types.ts rename to ui/src/stores/settings/contact/types.ts diff --git a/ui/src/stores/settings/index.ts b/ui/src/stores/settings/index.ts new file mode 100644 index 00000000..eb2b1495 --- /dev/null +++ b/ui/src/stores/settings/index.ts @@ -0,0 +1,3 @@ +export { useContactEmailsStore } from "./contact"; +export { usePersistenceSettingsStore } from "./persistence"; +export { useSSLProviderSettingsStore } from "./sslprovider"; 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 {} From e8905e4b578ba9134f9d9ef4c046b139961415bb Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Dec 2025 17:03:39 +0800 Subject: [PATCH 3/7] feat(ui): improve provider pickers --- .../provider/AccessProviderPicker.tsx | 77 +++++++++++-------- .../provider/AccessProviderSelect.tsx | 12 +-- .../provider/DeploymentProviderPicker.tsx | 4 +- .../provider/NotificationProviderPicker.tsx | 4 +- ui/src/components/provider/_shared.ts | 2 +- ui/src/i18n/locales/zh/nls.provider.json | 2 +- ui/src/pages/workflows/WorkflowNew.tsx | 12 +-- 7 files changed, 62 insertions(+), 51 deletions(-) diff --git a/ui/src/components/provider/AccessProviderPicker.tsx b/ui/src/components/provider/AccessProviderPicker.tsx index 87d0eb60..61dd91be 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"} - +
+
+
+ } shape="square" size={28} /> +
+
+ {t(provider.name) || "\u00A0"}
- -
- - {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..de00d8ed 100644 --- a/ui/src/components/provider/_shared.ts +++ b/ui/src/components/provider/_shared.ts @@ -82,7 +82,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, 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/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx index 034e3908..6245ffe5 100644 --- a/ui/src/pages/workflows/WorkflowNew.tsx +++ b/ui/src/pages/workflows/WorkflowNew.tsx @@ -285,18 +285,12 @@ const WorkflowNew = () => {
- +
- -
From 98bad3d3a0dc3fd38a645c9e11440ac27f43a552 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Dec 2025 17:03:40 +0800 Subject: [PATCH 4/7] feat: preset templates management --- internal/domain/settings.go | 7 +- ui/src/components/DrawerForm.tsx | 1 - ui/src/components/ModalForm.tsx | 1 - ui/src/components/MultipleSplitValueInput.tsx | 6 +- ui/src/components/access/AccessEditDrawer.tsx | 39 +- ui/src/components/access/AccessSelect.tsx | 4 +- .../provider/AccessProviderPicker.tsx | 12 +- ui/src/components/provider/_shared.ts | 8 +- .../designer/forms/BizApplyNodeConfigForm.tsx | 4 +- ui/src/domain/settings.ts | 21 ++ ui/src/i18n/locales/en/index.ts | 2 + ui/src/i18n/locales/en/nls.access.json | 9 +- ui/src/i18n/locales/en/nls.preset.json | 29 ++ ui/src/i18n/locales/en/nls.workflow.json | 2 +- .../i18n/locales/en/nls.workflow.nodes.json | 2 +- ui/src/i18n/locales/zh/index.ts | 2 + ui/src/i18n/locales/zh/nls.access.json | 8 +- ui/src/i18n/locales/zh/nls.preset.json | 29 ++ ui/src/i18n/locales/zh/nls.workflow.json | 2 +- ui/src/pages/ConsoleLayout.tsx | 3 + ui/src/pages/accesses/AccessList.tsx | 23 +- ui/src/pages/presets/PresetList.tsx | 70 ++++ .../presets/PresetListNotifyTemplates.tsx | 341 ++++++++++++++++++ .../presets/PresetListScriptTemplates.tsx | 333 +++++++++++++++++ ui/src/pages/settings/Settings.tsx | 4 +- ui/src/pages/workflows/WorkflowList.tsx | 4 +- ui/src/repository/settings.ts | 20 +- ui/src/routers/index.tsx | 5 + ui/src/stores/access/index.ts | 4 +- ui/src/stores/access/types.ts | 2 +- ui/src/stores/settings/contact/index.ts | 4 +- ui/src/stores/settings/contact/types.ts | 2 +- ui/src/stores/settings/index.ts | 1 + ui/src/stores/settings/template/index.ts | 163 +++++++++ ui/src/stores/settings/template/types.ts | 42 +++ 35 files changed, 1142 insertions(+), 67 deletions(-) create mode 100644 ui/src/i18n/locales/en/nls.preset.json create mode 100644 ui/src/i18n/locales/zh/nls.preset.json create mode 100644 ui/src/pages/presets/PresetList.tsx create mode 100644 ui/src/pages/presets/PresetListNotifyTemplates.tsx create mode 100644 ui/src/pages/presets/PresetListScriptTemplates.tsx create mode 100644 ui/src/stores/settings/template/index.ts create mode 100644 ui/src/stores/settings/template/types.ts 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/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`)}
+ ))} + + {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/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/repository/settings.ts b/ui/src/repository/settings.ts index 88f6bf58..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,8 +16,10 @@ import { COLLECTION_NAME_SETTINGS, getPocketBase } from "./_pocketbase"; interface SettingsContentMap { [SETTINGS_NAMES.EMAILS]: EmailsSettingsContent; - [SETTINGS_NAMES.PERSISTENCE]: PersistenceSettingsContent; + [SETTINGS_NAMES.NOTIFY_TEMPLATE]: NotifyTemplateContent; + [SETTINGS_NAMES.SCRIPT_TEMPLATE]: ScriptTemplateContent; [SETTINGS_NAMES.SSL_PROVIDER]: SSLProviderSettingsContent; + [SETTINGS_NAMES.PERSISTENCE]: PersistenceSettingsContent; } export const get = async >( @@ -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/settings/contact/index.ts b/ui/src/stores/settings/contact/index.ts index 6e98d87f..922bcfcc 100644 --- a/ui/src/stores/settings/contact/index.ts +++ b/ui/src/stores/settings/contact/index.ts @@ -18,7 +18,7 @@ export const useContactEmailsStore = create((set, get) => { fetchEmails: async (refresh = true) => { if (!refresh) { if (get().loadedAtOnce) { - return; + return get().emails; } } @@ -32,6 +32,8 @@ export const useContactEmailsStore = create((set, get) => { fetcher = null; set({ loading: false }); } + + return get().emails; }, setEmails: async (emails) => { diff --git a/ui/src/stores/settings/contact/types.ts b/ui/src/stores/settings/contact/types.ts index af1e6950..38fd7556 100644 --- a/ui/src/stores/settings/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 index eb2b1495..ca9b1742 100644 --- a/ui/src/stores/settings/index.ts +++ b/ui/src/stores/settings/index.ts @@ -1,3 +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/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 {} From c2427697def4f7dba76367aa6761988d04198d5f Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Dec 2025 17:03:40 +0800 Subject: [PATCH 5/7] feat: preset notify templates in workflow notification nodes --- .../notifier/providers/webhook/webhook.go | 4 + ui/eslint.config.mjs | 2 +- .../AccessConfigFieldsProviderWebhook.tsx | 32 +++---- .../preset/PresetNotifyTemplatesPopselect.tsx | 83 +++++++++++++++++++ .../forms/BizNotifyNodeConfigForm.tsx | 25 +++++- ui/src/i18n/locales/en/nls.access.json | 2 +- ui/src/i18n/locales/en/nls.preset.json | 7 +- .../i18n/locales/en/nls.workflow.nodes.json | 2 +- ui/src/i18n/locales/zh/nls.access.json | 4 +- ui/src/i18n/locales/zh/nls.preset.json | 7 +- .../i18n/locales/zh/nls.workflow.nodes.json | 3 +- ui/src/pages/workflows/WorkflowNew.tsx | 4 +- 12 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 ui/src/components/preset/PresetNotifyTemplatesPopselect.tsx diff --git a/pkg/core/notifier/providers/webhook/webhook.go b/pkg/core/notifier/providers/webhook/webhook.go index 4e065e31..9c182273 100644 --- a/pkg/core/notifier/providers/webhook/webhook.go +++ b/pkg/core/notifier/providers/webhook/webhook.go @@ -127,6 +127,10 @@ func (n *Notifier) Notify(ctx context.Context, subject string, message string) ( return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) } + replaceJsonValueRecursively(webhookData, "${CERTIMATE_NOTIFIER_SUBJECT}", subject) + replaceJsonValueRecursively(webhookData, "${CERTIMATE_NOTIFIER_MESSAGE}", message) + + // 兼容旧版变量 replaceJsonValueRecursively(webhookData, "${SUBJECT}", subject) replaceJsonValueRecursively(webhookData, "${MESSAGE}", message) 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/access/forms/AccessConfigFieldsProviderWebhook.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderWebhook.tsx index eeaf6298..de3c671a 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderWebhook.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderWebhook.tsx @@ -68,8 +68,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF [parentNamePath, "data"], JSON.stringify( { - title: "${SUBJECT}", - body: "${MESSAGE}", + title: "${CERTIMATE_NOTIFIER_SUBJECT}", + body: "${CERTIMATE_NOTIFIER_MESSAGE}", device_key: "", }, null, @@ -86,8 +86,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF [parentNamePath, "data"], JSON.stringify( { - title: "${SUBJECT}", - message: "${MESSAGE}", + title: "${CERTIMATE_NOTIFIER_SUBJECT}", + message: "${CERTIMATE_NOTIFIER_MESSAGE}", priority: 1, }, null, @@ -105,8 +105,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF JSON.stringify( { topic: "", - title: "${SUBJECT}", - message: "${MESSAGE}", + title: "${CERTIMATE_NOTIFIER_SUBJECT}", + message: "${CERTIMATE_NOTIFIER_MESSAGE}", priority: 1, }, null, @@ -125,8 +125,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF { token: "", user: "", - title: "${SUBJECT}", - message: "${MESSAGE}", + title: "${CERTIMATE_NOTIFIER_SUBJECT}", + message: "${CERTIMATE_NOTIFIER_MESSAGE}", }, null, 2 @@ -143,8 +143,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF JSON.stringify( { token: "", - title: "${SUBJECT}", - content: "${MESSAGE}", + title: "${CERTIMATE_NOTIFIER_SUBJECT}", + content: "${CERTIMATE_NOTIFIER_MESSAGE}", }, null, 2 @@ -160,8 +160,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF [parentNamePath, "data"], JSON.stringify( { - title: "${SUBJECT}", - desp: "${MESSAGE}", + title: "${CERTIMATE_NOTIFIER_SUBJECT}", + desp: "${CERTIMATE_NOTIFIER_MESSAGE}", }, null, 2 @@ -177,8 +177,8 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF [parentNamePath, "data"], JSON.stringify( { - title: "${SUBJECT}", - desp: "${MESSAGE}", + title: "${CERTIMATE_NOTIFIER_SUBJECT}", + desp: "${CERTIMATE_NOTIFIER_MESSAGE}", }, null, 2 @@ -324,8 +324,8 @@ const getInitialValues = ({ usage = "none" }: { usage?: "deployment" | "notifica } : 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/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/i18n/locales/en/nls.access.json b/ui/src/i18n/locales/en/nls.access.json index e118a7cc..cb43649d 100644 --- a/ui/src/i18n/locales/en/nls.access.json +++ b/ui/src/i18n/locales/en/nls.access.json @@ -627,7 +627,7 @@ "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_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.button": "Use preset data", "access.form.webhook_preset_data.option.bark.label": "Bark", "access.form.webhook_preset_data.option.gotify.label": "Gotify", diff --git a/ui/src/i18n/locales/en/nls.preset.json b/ui/src/i18n/locales/en/nls.preset.json index aa9ef623..b0499105 100644 --- a/ui/src/i18n/locales/en/nls.preset.json +++ b/ui/src/i18n/locales/en/nls.preset.json @@ -25,5 +25,10 @@ "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.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.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index c7c8bb04..85704148 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -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 ...", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 7c624491..43ff809b 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -627,8 +627,8 @@ "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_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.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", diff --git a/ui/src/i18n/locales/zh/nls.preset.json b/ui/src/i18n/locales/zh/nls.preset.json index 0c5f2217..19f87ca2 100644 --- a/ui/src/i18n/locales/zh/nls.preset.json +++ b/ui/src/i18n/locales/zh/nls.preset.json @@ -25,5 +25,10 @@ "preset.form.notification_message.label": "通知内容", "preset.form.notification_message.placeholder": "请输入通知内容", "preset.form.script_command.label": "脚本命令", - "preset.form.script_command.placeholder": "请输入脚本命令" + "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.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 29cfc013..481e4bcf 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -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": "搜索通知渠道……", diff --git a/ui/src/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx index 6245ffe5..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"; @@ -290,7 +290,7 @@ const WorkflowNew = () => { -
From c8ff00d10cdd445015f899bb69c01fd7021ef072 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Dec 2025 17:03:40 +0800 Subject: [PATCH 6/7] feat: preset script templates in workflow deployment nodes --- .../deployer/providers/webhook/webhook.go | 8 ++ .../AccessConfigFieldsProviderWebhook.tsx | 14 ++-- .../preset/PresetScriptTemplatesPopselect.tsx | 82 +++++++++++++++++++ ...BizDeployNodeConfigFieldsProviderLocal.tsx | 52 +++++++----- .../BizDeployNodeConfigFieldsProviderSSH.tsx | 67 ++++++++------- ui/src/i18n/locales/en/nls.access.json | 20 ++--- .../i18n/locales/en/nls.workflow.nodes.json | 32 ++++---- ui/src/i18n/locales/zh/nls.access.json | 20 ++--- .../i18n/locales/zh/nls.workflow.nodes.json | 32 ++++---- 9 files changed, 216 insertions(+), 111 deletions(-) create mode 100644 ui/src/components/preset/PresetScriptTemplatesPopselect.tsx diff --git a/pkg/core/deployer/providers/webhook/webhook.go b/pkg/core/deployer/providers/webhook/webhook.go index 2bfb3bb2..8361f542 100644 --- a/pkg/core/deployer/providers/webhook/webhook.go +++ b/pkg/core/deployer/providers/webhook/webhook.go @@ -143,6 +143,14 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep return nil, fmt.Errorf("failed to unmarshal webhook data: %w", err) } + 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) + + // 兼容旧版变量 replaceJsonValueRecursively(webhookData, "${DOMAIN}", certX509.Subject.CommonName) replaceJsonValueRecursively(webhookData, "${DOMAINS}", strings.Join(certX509.DNSNames, ";")) replaceJsonValueRecursively(webhookData, "${CERTIFICATE}", certPEM) diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderWebhook.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderWebhook.tsx index de3c671a..93e29191 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderWebhook.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderWebhook.tsx @@ -231,7 +231,7 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF items: [ { key: "certimate", - label: "Certimate", + label: t("access.form.webhook_preset_data.common"), onClick: handlePresetDataForDeploymentClick, }, ], @@ -239,7 +239,7 @@ const AccessConfigFormFieldsProviderWebhook = ({ usage = "none" }: AccessConfigF trigger={["click"]} > @@ -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,9 +318,9 @@ 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" ? { 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/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx index 20ce8049..25240e6e 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 { Button, Form, Input, Select } 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"; @@ -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); + } + }} > - +
{
- ({ - key, - label: t(`workflow_node.deploy.form.local_preset_scripts.option.${key}.label`), - onClick: () => handlePresetPostScriptClick(key), - })), - }} + ({ + key, + label: t(`workflow_node.deploy.form.local_preset_scripts.${key}`), + onClick: () => handlePresetPostScriptClick(key), + }))} trigger={["click"]} + onSelect={(key, template) => { + if (template) { + formInst.setFieldValue([parentNamePath, "postCommand"], template.command); + } else { + handlePresetPostScriptClick(key); + } + }} > - +
{
- ({ - 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); + } + }} > - +
{
- ({ - key, - label: t(`workflow_node.deploy.form.ssh_preset_scripts.option.${key}.label`), - onClick: () => handlePresetPostScriptClick(key), - })), - }} + ({ + key, + 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); + } + }} > - +

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_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.button": "Use preset data", - "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 data", + "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.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 85704148..8a947ce8 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -675,13 +675,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_binding_rdp.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 +754,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)", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 43ff809b..c35d96b7 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -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_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.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_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.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 481e4bcf..73dce687 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -673,13 +673,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", @@ -753,16 +752,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": "腾讯云接口端点(可选)", From 11bf8442c3b3cc5737a8f6d83b22a66451092001 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Dec 2025 17:03:40 +0800 Subject: [PATCH 7/7] feat: support command variables in deployment to local or ssh --- pkg/core/deployer/providers/local/local.go | 25 ++++- pkg/core/deployer/providers/ssh/ssh.go | 25 ++++- .../deployer/providers/webhook/webhook.go | 35 +++--- .../notifier/providers/webhook/webhook.go | 15 +-- ...BizDeployNodeConfigFieldsProviderLocal.tsx | 63 ++++++----- .../BizDeployNodeConfigFieldsProviderSSH.tsx | 103 ++++++++++-------- ...zDeployNodeConfigFieldsProviderWebhook.tsx | 36 +++--- ...zNotifyNodeConfigFieldsProviderWebhook.tsx | 36 +++--- .../i18n/locales/en/nls.workflow.nodes.json | 3 + .../i18n/locales/zh/nls.workflow.nodes.json | 23 ++-- 10 files changed, 218 insertions(+), 146 deletions(-) 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 8361f542..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,21 +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, "${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) - - // 兼容旧版变量 - 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) @@ -171,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 9c182273..62597483 100644 --- a/pkg/core/notifier/providers/webhook/webhook.go +++ b/pkg/core/notifier/providers/webhook/webhook.go @@ -127,13 +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, "${CERTIMATE_NOTIFIER_SUBJECT}", subject) - replaceJsonValueRecursively(webhookData, "${CERTIMATE_NOTIFIER_MESSAGE}", message) - - // 兼容旧版变量 - 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) @@ -147,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/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx index 25240e6e..39a9d142 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx @@ -1,6 +1,6 @@ import { getI18n, useTranslation } from "react-i18next"; -import { IconChevronDown } from "@tabler/icons-react"; -import { Button, 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"; @@ -60,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 绑定 @@ -91,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 = "" # 绑定端口 @@ -114,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 @@ -373,26 +373,33 @@ const BizDeployNodeConfigFieldsProviderLocal = () => {
              - ({ - key, - label: t(`workflow_node.deploy.form.local_preset_scripts.${key}`), - onClick: () => handlePresetPostScriptClick(key), - }))} - trigger={["click"]} - onSelect={(key, template) => { - if (template) { - formInst.setFieldValue([parentNamePath, "postCommand"], template.command); - } else { - handlePresetPostScriptClick(key); - } - }} - > - - + } size={0}> + } mouseEnterDelay={1}> + + + ({ + key, + label: t(`workflow_node.deploy.form.local_preset_scripts.${key}`), + onClick: () => handlePresetPostScriptClick(key), + }))} + 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; } @@ -106,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" # 飞牛私钥文件路径 @@ -140,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 @@ -258,6 +258,16 @@ const BizDeployNodeConfigFieldsProviderSSH = () => { return ( <> + } + > + + + {
              - ({ - key, - 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); - } - }} - > - - + } size={0}> + } mouseEnterDelay={1}> + + + ({ + key, + 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); + } + }} + > + + +
              { />
              - - } - > - - ); }; @@ -463,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() @@ -492,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/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 8a947ce8..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", @@ -1003,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", @@ -1050,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/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 73dce687..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 导出密码", @@ -722,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 导出密码", @@ -1001,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": "秒", @@ -1048,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": "秒",