diff --git a/README.md b/README.md index 2ab2fb7b..bc2596af 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Certimate 旨在为用户提供一个安全、简便的 SSL 证书管理解决 - 支持单域名、多域名、泛域名证书,可选 RSA、ECC 签名算法; - 支持 PEM、PFX、JKS 等多种格式输出证书; - 支持 30+ 域名托管商(如阿里云、腾讯云、Cloudflare 等,[点此查看完整清单](https://docs.certimate.me/docs/reference/providers#supported-dns-providers)); -- 支持 80+ 部署目标(如 Kubernetes、CDN、WAF、负载均衡等,[点此查看完整清单](https://docs.certimate.me/docs/reference/providers#supported-hosting-providers)); +- 支持 90+ 部署目标(如 Kubernetes、CDN、WAF、负载均衡等,[点此查看完整清单](https://docs.certimate.me/docs/reference/providers#supported-hosting-providers)); - 支持邮件、钉钉、飞书、企业微信、Webhook 等多种通知渠道; - 支持 Let's Encrypt、Buypass、Google Trust Services、SSL.com、ZeroSSL 等多种 ACME 证书颁发机构; - 更多特性等待探索。 diff --git a/README_EN.md b/README_EN.md index f94020a2..67bab154 100644 --- a/README_EN.md +++ b/README_EN.md @@ -39,7 +39,7 @@ Certimate aims to provide users with a secure and user-friendly SSL certificate - Supports single-domain, multi-domain, wildcard certificates, with options for RSA or ECC. - Supports various certificate formats such as PEM, PFX, JKS. - Supports more than 30+ domain registrars (e.g., Alibaba Cloud, Tencent Cloud, Cloudflare, etc. [Check out this link](https://docs.certimate.me/en/docs/reference/providers#supported-dns-providers)); -- Supports more than 80+ deployment targets (e.g., Kubernetes, CDN, WAF, load balancers, etc. [Check out this link](https://docs.certimate.me/en/docs/reference/providers#supported-hosting-providers)); +- Supports more than 90+ deployment targets (e.g., Kubernetes, CDN, WAF, load balancers, etc. [Check out this link](https://docs.certimate.me/en/docs/reference/providers#supported-hosting-providers)); - Supports multiple notification channels including email, DingTalk, Feishu, WeCom, Webhook, and more; - Supports multiple ACME CAs including Let's Encrypt, Buypass, Google Trust Services,SSL.com, ZeroSSL, and more; - More features waiting to be discovered. diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go index e8dbaf1a..7f9bae91 100644 --- a/internal/deployer/providers.go +++ b/internal/deployer/providers.go @@ -79,6 +79,7 @@ import ( pTencentCloudWAF "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/tencentcloud-waf" pUCloudUCDN "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ucloud-ucdn" pUCloudUS3 "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/ucloud-us3" + pUniCloudWebHost "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/unicloud-webhost" pUpyunCDN "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/upyun-cdn" pVolcEngineALB "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-alb" pVolcEngineCDN "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-cdn" @@ -1144,6 +1145,23 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer } } + case domain.DeploymentProviderTypeUniCloudWebHost: + { + access := domain.AccessConfigForUniCloud{} + if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + deployer, err := pUniCloudWebHost.NewDeployer(&pUniCloudWebHost.DeployerConfig{ + Username: access.Username, + Password: access.Password, + SpaceProvider: maputil.GetString(options.ProviderServiceConfig, "spaceProvider"), + SpaceId: maputil.GetString(options.ProviderServiceConfig, "spaceId"), + Domain: maputil.GetString(options.ProviderServiceConfig, "domain"), + }) + return deployer, err + } + case domain.DeploymentProviderTypeUpyunCDN, domain.DeploymentProviderTypeUpyunFile: { access := domain.AccessConfigForUpyun{} diff --git a/internal/domain/access.go b/internal/domain/access.go index 482f753a..e31bb1a0 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -343,6 +343,11 @@ type AccessConfigForUCloud struct { ProjectId string `json:"projectId,omitempty"` } +type AccessConfigForUniCloud struct { + Username string `json:"username"` + Password string `json:"password"` +} + type AccessConfigForUpyun struct { Username string `json:"username"` Password string `json:"password"` diff --git a/internal/domain/provider.go b/internal/domain/provider.go index c8cb37d5..55f8b2af 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -77,6 +77,7 @@ const ( AccessProviderTypeTelegramBot = AccessProviderType("telegrambot") AccessProviderTypeTencentCloud = AccessProviderType("tencentcloud") AccessProviderTypeUCloud = AccessProviderType("ucloud") + AccessProviderTypeUniCloud = AccessProviderType("unicloud") AccessProviderTypeUpyun = AccessProviderType("upyun") AccessProviderTypeVercel = AccessProviderType("vercel") AccessProviderTypeVolcEngine = AccessProviderType("volcengine") @@ -244,6 +245,7 @@ const ( DeploymentProviderTypeTencentCloudWAF = DeploymentProviderType(AccessProviderTypeTencentCloud + "-waf") DeploymentProviderTypeUCloudUCDN = DeploymentProviderType(AccessProviderTypeUCloud + "-ucdn") DeploymentProviderTypeUCloudUS3 = DeploymentProviderType(AccessProviderTypeUCloud + "-us3") + DeploymentProviderTypeUniCloudWebHost = DeploymentProviderType(AccessProviderTypeUniCloud + "-webhost") DeploymentProviderTypeUpyunCDN = DeploymentProviderType(AccessProviderTypeUpyun + "-cdn") DeploymentProviderTypeUpyunFile = DeploymentProviderType(AccessProviderTypeUpyun + "-file") DeploymentProviderTypeVolcEngineALB = DeploymentProviderType(AccessProviderTypeVolcEngine + "-alb") diff --git a/internal/pkg/core/deployer/providers/unicloud-webhost/unicloud_webhost.go b/internal/pkg/core/deployer/providers/unicloud-webhost/unicloud_webhost.go new file mode 100644 index 00000000..e24708bd --- /dev/null +++ b/internal/pkg/core/deployer/providers/unicloud-webhost/unicloud_webhost.go @@ -0,0 +1,101 @@ +package unicloudwebhost + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + unisdk "github.com/usual2970/certimate/internal/pkg/sdk3rd/dcloud/unicloud" +) + +type DeployerConfig struct { + // uniCloud 控制台账号。 + Username string `json:"username"` + // uniCloud 控制台密码。 + Password string `json:"password"` + // 服务空间提供商。 + // 可取值 "aliyun"、"tencent"。 + SpaceProvider string `json:"spaceProvider"` + // 服务空间 ID。 + SpaceId string `json:"spaceId"` + // 托管网站域名(不支持泛域名)。 + Domain string `json:"domain"` +} + +type DeployerProvider struct { + config *DeployerConfig + logger *slog.Logger + sdkClient *unisdk.Client +} + +var _ deployer.Deployer = (*DeployerProvider)(nil) + +func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) { + if config == nil { + panic("config is nil") + } + + client, err := createSdkClient(config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("failed to create sdk client: %w", err) + } + + return &DeployerProvider{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer { + if logger == nil { + d.logger = slog.Default() + } else { + d.logger = logger + } + return d +} + +func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) { + if d.config.SpaceProvider == "" { + return nil, errors.New("config `spaceProvider` is required") + } + if d.config.SpaceId == "" { + return nil, errors.New("config `spaceId` is required") + } + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + // 变更网站证书 + createDomainWithCertReq := &unisdk.CreateDomainWithCertRequest{ + Provider: d.config.SpaceProvider, + SpaceId: d.config.SpaceId, + Domain: d.config.Domain, + Cert: url.QueryEscape(certPEM), + Key: url.QueryEscape(privkeyPEM), + } + createDomainWithCertResp, err := d.sdkClient.CreateDomainWithCert(createDomainWithCertReq) + d.logger.Debug("sdk request 'unicloud.host.CreateDomainWithCert'", slog.Any("request", createDomainWithCertReq), slog.Any("response", createDomainWithCertResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'unicloud.host.CreateDomainWithCert': %w", err) + } + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(username, password string) (*unisdk.Client, error) { + if username == "" { + return nil, errors.New("invalid unicloud username") + } + + if password == "" { + return nil, errors.New("invalid unicloud password") + } + + client := unisdk.NewClient(username, password) + return client, nil +} diff --git a/internal/pkg/core/deployer/providers/unicloud-webhost/unicloud_webhost_test.go b/internal/pkg/core/deployer/providers/unicloud-webhost/unicloud_webhost_test.go new file mode 100644 index 00000000..1e47ba24 --- /dev/null +++ b/internal/pkg/core/deployer/providers/unicloud-webhost/unicloud_webhost_test.go @@ -0,0 +1,85 @@ +package unicloudwebhost_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/unicloud-webhost" +) + +var ( + fInputCertPath string + fInputKeyPath string + fUsername string + fPassword string + fSpaceProvider string + fSpaceId string + fDomain string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fUsername, argsPrefix+"USERNAME", "", "") + flag.StringVar(&fPassword, argsPrefix+"PASSWORD", "", "") + flag.StringVar(&fSpaceProvider, argsPrefix+"SPACEPROVIDER", "", "") + flag.StringVar(&fSpaceId, argsPrefix+"SPACEID", "", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") +} + +/* +Shell command to run this test: + + go test -v ./unicloud_webhost_test.go -args \ + --CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_USERNAME="your-username" \ + --CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_PASSWORD="your-password" \ + --CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_SPACEPROVIDER="aliyun/tencent" \ + --CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_SPACEID="your-space-id" \ + --CERTIMATE_DEPLOYER_UNICLOUDWEBHOST_DOMAIN="example.com" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("USERNAME: %v", fUsername), + fmt.Sprintf("PASSWORD: %v", fPassword), + fmt.Sprintf("SPACEPROVIDER: %v", fSpaceProvider), + fmt.Sprintf("SPACEID: %v", fSpaceId), + fmt.Sprintf("DOMAIN: %v", fDomain), + }, "\n")) + + deployer, err := provider.NewDeployer(&provider.DeployerConfig{ + Username: fUsername, + Password: fPassword, + SpaceProvider: fSpaceProvider, + SpaceId: fSpaceId, + Domain: fDomain, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/sdk3rd/dcloud/unicloud/api.go b/internal/pkg/sdk3rd/dcloud/unicloud/api.go new file mode 100644 index 00000000..1cd90b15 --- /dev/null +++ b/internal/pkg/sdk3rd/dcloud/unicloud/api.go @@ -0,0 +1,78 @@ +package unicloud + +import ( + "fmt" + "net/http" + "regexp" + "time" +) + +func (c *Client) ensureServerlessJwtTokenExists() error { + c.serverlessJwtTokenMtx.Lock() + defer c.serverlessJwtTokenMtx.Unlock() + if c.serverlessJwtToken != "" && c.serverlessJwtTokenExp.After(time.Now()) { + return nil + } + + params := &loginParams{ + Password: c.password, + } + if regexp.MustCompile("^1\\d{10}$").MatchString(c.username) { + params.Mobile = c.username + } else if regexp.MustCompile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$").MatchString(c.username) { + params.Email = c.username + } else { + params.Username = c.username + } + + resp := &loginResponse{} + if err := c.invokeServerlessWithResult( + uniIdentityEndpoint, uniIdentityClientSecret, uniIdentityAppId, uniIdentitySpaceId, + "uni-id-co", "login", "", params, nil, + resp); err != nil { + return err + } else if resp.Data == nil || resp.Data.NewToken == nil || resp.Data.NewToken.Token == "" { + return fmt.Errorf("unicloud api error: received empty token") + } + + c.serverlessJwtToken = resp.Data.NewToken.Token + c.serverlessJwtTokenExp = time.UnixMilli(resp.Data.NewToken.TokenExpired) + + return nil +} + +func (c *Client) ensureApiUserTokenExists() error { + if err := c.ensureServerlessJwtTokenExists(); err != nil { + return err + } + + c.apiUserTokenMtx.Lock() + defer c.apiUserTokenMtx.Unlock() + if c.apiUserToken != "" { + return nil + } + + resp := &getUserTokenResponse{} + if err := c.invokeServerlessWithResult( + uniConsoleEndpoint, uniConsoleClientSecret, uniConsoleAppId, uniConsoleSpaceId, + "uni-cloud-kernel", "", "user/getUserToken", nil, map[string]any{"isLogin": true}, + resp); err != nil { + return err + } else if resp.Data == nil || resp.Data.Data == nil || resp.Data.Data.Data == nil || resp.Data.Data.Data.Token == "" { + return fmt.Errorf("unicloud api error: received empty user token") + } + + c.apiUserToken = resp.Data.Data.Data.Token + + return nil +} + +func (c *Client) CreateDomainWithCert(req *CreateDomainWithCertRequest) (*CreateDomainWithCertResponse, error) { + if err := c.ensureApiUserTokenExists(); err != nil { + return nil, err + } + + resp := &CreateDomainWithCertResponse{} + err := c.sendRequestWithResult(http.MethodPost, "/host/create-domain-with-cert", req, resp) + return resp, err +} diff --git a/internal/pkg/sdk3rd/dcloud/unicloud/client.go b/internal/pkg/sdk3rd/dcloud/unicloud/client.go new file mode 100644 index 00000000..1e0f3728 --- /dev/null +++ b/internal/pkg/sdk3rd/dcloud/unicloud/client.go @@ -0,0 +1,257 @@ +package unicloud + +import ( + "crypto/hmac" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" +) + +type Client struct { + username string + password string + + serverlessJwtToken string + serverlessJwtTokenExp time.Time + serverlessJwtTokenMtx sync.Mutex + + serverlessClient *resty.Client + + apiUserToken string + apiUserTokenMtx sync.Mutex + + apiClient *resty.Client +} + +const ( + uniIdentityEndpoint = "https://account.dcloud.net.cn/client" + uniIdentityClientSecret = "ba461799-fde8-429f-8cc4-4b6d306e2339" + uniIdentityAppId = "__UNI__uniid_server" + uniIdentitySpaceId = "uni-id-server" + uniConsoleEndpoint = "https://unicloud.dcloud.net.cn/client" + uniConsoleClientSecret = "4c1f7fbf-c732-42b0-ab10-4634a8bbe834" + uniConsoleAppId = "__UNI__unicloud_console" + uniConsoleSpaceId = "dc-6nfabcn6ada8d3dd" +) + +func NewClient(username, password string) *Client { + client := &Client{ + username: username, + password: password, + } + client.serverlessClient = resty.New() + client.apiClient = resty.New(). + SetBaseURL("https://unicloud-api.dcloud.net.cn/unicloud/api"). + SetPreRequestHook(func(c *resty.Client, req *http.Request) error { + if client.apiUserToken != "" { + req.Header.Set("Token", client.apiUserToken) + } + + return nil + }) + + return client +} + +func (c *Client) WithTimeout(timeout time.Duration) *Client { + c.serverlessClient.SetTimeout(timeout) + return c +} + +func (c *Client) generateSignature(params map[string]any, secret string) string { + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + canonicalStr := "" + for i, k := range keys { + if i > 0 { + canonicalStr += "&" + } + canonicalStr += k + "=" + fmt.Sprintf("%v", params[k]) + } + + mac := hmac.New(md5.New, []byte(secret)) + mac.Write([]byte(canonicalStr)) + sign := mac.Sum(nil) + signHex := hex.EncodeToString(sign) + + return signHex +} + +func (c *Client) buildServerlessClientInfo(appId string) (_clientInfo map[string]any, _err error) { + return map[string]any{ + "PLATFORM": "web", + "OS": strings.ToUpper(runtime.GOOS), + "APPID": appId, + "DEVICEID": "certimate", + "LOCALE": "zh-Hans", + "osName": runtime.GOOS, + "appId": appId, + "appName": "uniCloud", + "deviceId": "certimate", + "deviceType": "pc", + "uniPlatform": "web", + "uniCompilerVersion": "4.45", + "uniRuntimeVersion": "4.45", + }, nil +} + +func (c *Client) buildServerlessPayloadInfo(appId, spaceId, target, method, action string, params, data interface{}) (map[string]any, error) { + clientInfo, err := c.buildServerlessClientInfo(appId) + if err != nil { + return nil, err + } + + functionArgsParams := make([]any, 0) + if params != nil { + functionArgsParams = append(functionArgsParams, params) + } + + functionArgs := map[string]any{ + "clientInfo": clientInfo, + "uniIdToken": c.serverlessJwtToken, + } + if method != "" { + functionArgs["method"] = method + functionArgs["params"] = make([]any, 0) + } + if action != "" { + type _obj struct{} + functionArgs["action"] = action + functionArgs["data"] = &_obj{} + } + if params != nil { + functionArgs["params"] = []any{params} + } + if data != nil { + functionArgs["data"] = data + } + + jsonb, err := json.Marshal(map[string]any{ + "functionTarget": target, + "functionArgs": functionArgs, + }) + if err != nil { + return nil, err + } + + payload := map[string]any{ + "method": "serverless.function.runtime.invoke", + "params": string(jsonb), + "spaceId": spaceId, + "timestamp": time.Now().UnixMilli(), + } + + return payload, nil +} + +func (c *Client) invokeServerless(endpoint, clientSecret, appId, spaceId, target, method, action string, params, data interface{}) (*resty.Response, error) { + if endpoint == "" { + return nil, fmt.Errorf("unicloud api error: endpoint cannot be empty") + } + + payload, err := c.buildServerlessPayloadInfo(appId, spaceId, target, method, action, params, data) + if err != nil { + return nil, fmt.Errorf("unicloud api error: failed to build request: %w", err) + } + + clientInfo, _ := c.buildServerlessClientInfo(appId) + clientInfoJsonb, _ := json.Marshal(clientInfo) + + sign := c.generateSignature(payload, clientSecret) + + req := c.serverlessClient.R(). + SetHeader("Origin", "https://unicloud.dcloud.net.cn"). + SetHeader("Referer", "https://unicloud.dcloud.net.cn"). + SetHeader("Content-Type", "application/json"). + SetHeader("X-Client-Info", string(clientInfoJsonb)). + SetHeader("X-Client-Token", c.serverlessJwtToken). + SetHeader("X-Serverless-Sign", sign). + SetBody(payload) + resp, err := req.Post(endpoint) + if err != nil { + return resp, fmt.Errorf("unicloud api error: failed to send request: %w", err) + } else if resp.IsError() { + return resp, fmt.Errorf("unicloud api error: unexpected status code: %d, resp: %s", resp.StatusCode(), resp.String()) + } + + return resp, nil +} + +func (c *Client) invokeServerlessWithResult(endpoint, clientSecret, appId, spaceId, target, method, action string, params, data interface{}, result BaseResponse) error { + resp, err := c.invokeServerless(endpoint, clientSecret, appId, spaceId, target, method, action, params, data) + if err != nil { + if resp != nil { + json.Unmarshal(resp.Body(), &result) + } + return err + } + + if err := json.Unmarshal(resp.Body(), &result); err != nil { + return fmt.Errorf("unicloud api error: failed to unmarshal response: %w", err) + } else if success := result.GetSuccess(); !success { + return fmt.Errorf("unicloud api error: code='%s', message='%s'", result.GetErrorCode(), result.GetErrorMessage()) + } + + return nil +} + +func (c *Client) sendRequest(method string, path string, params interface{}) (*resty.Response, error) { + req := c.apiClient.R() + if strings.EqualFold(method, http.MethodGet) { + qs := make(map[string]string) + if params != nil { + temp := make(map[string]any) + jsonb, _ := json.Marshal(params) + json.Unmarshal(jsonb, &temp) + for k, v := range temp { + if v != nil { + qs[k] = fmt.Sprintf("%v", v) + } + } + } + + req = req.SetQueryParams(qs) + } else { + req = req.SetHeader("Content-Type", "application/json").SetBody(params) + } + + resp, err := req.Execute(method, path) + if err != nil { + return resp, fmt.Errorf("unicloud api error: failed to send request: %w", err) + } else if resp.IsError() { + return resp, fmt.Errorf("unicloud api error: unexpected status code: %d, resp: %s", resp.StatusCode(), resp.String()) + } + + return resp, nil +} + +func (c *Client) sendRequestWithResult(method string, path string, params interface{}, result BaseResponse) error { + resp, err := c.sendRequest(method, path, params) + if err != nil { + if resp != nil { + json.Unmarshal(resp.Body(), &result) + } + return err + } + + if err := json.Unmarshal(resp.Body(), &result); err != nil { + return fmt.Errorf("unicloud api error: failed to unmarshal response: %w", err) + } else if retcode := result.GetReturnCode(); retcode != 0 { + return fmt.Errorf("unicloud api error: ret='%d', desc='%s'", retcode, result.GetReturnDesc()) + } + + return nil +} diff --git a/internal/pkg/sdk3rd/dcloud/unicloud/models.go b/internal/pkg/sdk3rd/dcloud/unicloud/models.go new file mode 100644 index 00000000..05b02db6 --- /dev/null +++ b/internal/pkg/sdk3rd/dcloud/unicloud/models.go @@ -0,0 +1,103 @@ +package unicloud + +type BaseResponse interface { + GetSuccess() bool + GetErrorCode() string + GetErrorMessage() string + + GetReturnCode() int32 + GetReturnDesc() string +} + +type baseResponse struct { + Success *bool `json:"success,omitempty"` + Header *map[string]string `json:"header,omitempty"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` + + ReturnCode *int32 `json:"ret,omitempty"` + ReturnDesc *string `json:"desc,omitempty"` +} + +func (r *baseResponse) GetReturnCode() int32 { + if r.ReturnCode != nil { + return *r.ReturnCode + } + return 0 +} + +func (r *baseResponse) GetReturnDesc() string { + if r.ReturnDesc != nil { + return *r.ReturnDesc + } + return "" +} + +func (r *baseResponse) GetSuccess() bool { + if r.Success != nil { + return *r.Success + } + return false +} + +func (r *baseResponse) GetErrorCode() string { + if r.Error != nil { + return r.Error.Code + } + return "" +} + +func (r *baseResponse) GetErrorMessage() string { + if r.Error != nil { + return r.Error.Message + } + return "" +} + +type loginParams struct { + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password"` +} + +type loginResponse struct { + baseResponse + Data *struct { + Code int32 `json:"errCode"` + UID string `json:"uid"` + NewToken *struct { + Token string `json:"token"` + TokenExpired int64 `json:"tokenExpired"` + } `json:"newToken,omitempty"` + } `json:"data,omitempty"` +} + +type getUserTokenResponse struct { + baseResponse + Data *struct { + Code int32 `json:"code"` + Data *struct { + Result int32 `json:"ret"` + Description string `json:"desc"` + Data *struct { + Email string `json:"email"` + Token string `json:"token"` + } `json:"data,omitempty"` + } `json:"data,omitempty"` + } `json:"data,omitempty"` +} + +type CreateDomainWithCertRequest struct { + Provider string `json:"provider"` + SpaceId string `json:"spaceId"` + Domain string `json:"domain"` + Cert string `json:"cert"` + Key string `json:"key"` +} + +type CreateDomainWithCertResponse struct { + baseResponse +} diff --git a/ui/public/imgs/providers/unicloud.png b/ui/public/imgs/providers/unicloud.png new file mode 100644 index 00000000..a6b0ca9b Binary files /dev/null and b/ui/public/imgs/providers/unicloud.png differ diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index c8c03290..44e389ed 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -70,6 +70,7 @@ import AccessFormSSLComConfig from "./AccessFormSSLComConfig"; import AccessFormTelegramBotConfig from "./AccessFormTelegramBotConfig"; import AccessFormTencentCloudConfig from "./AccessFormTencentCloudConfig"; import AccessFormUCloudConfig from "./AccessFormUCloudConfig"; +import AccessFormUniCloudConfig from "./AccessFormUniCloudConfig"; import AccessFormUpyunConfig from "./AccessFormUpyunConfig"; import AccessFormVercelConfig from "./AccessFormVercelConfig"; import AccessFormVolcEngineConfig from "./AccessFormVolcEngineConfig"; @@ -105,9 +106,9 @@ const AccessForm = forwardRef(({ className, const formSchema = z.object({ name: z .string({ message: t("access.form.name.placeholder") }) + .trim() .min(1, t("access.form.name.placeholder")) - .max(64, t("common.errmsg.string_max", { max: 64 })) - .trim(), + .max(64, t("common.errmsg.string_max", { max: 64 })), provider: z.nativeEnum(ACCESS_PROVIDERS, { message: usage === "ca-only" @@ -302,6 +303,8 @@ const AccessForm = forwardRef(({ className, return ; case ACCESS_PROVIDERS.UCLOUD: return ; + case ACCESS_PROVIDERS.UNICLOUD: + return ; case ACCESS_PROVIDERS.UPYUN: return ; case ACCESS_PROVIDERS.VERCEL: diff --git a/ui/src/components/access/AccessFormUniCloudConfig.tsx b/ui/src/components/access/AccessFormUniCloudConfig.tsx new file mode 100644 index 00000000..d281f1fe --- /dev/null +++ b/ui/src/components/access/AccessFormUniCloudConfig.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { type AccessConfigForUniCloud } from "@/domain/access"; + +type AccessFormUniCloudConfigFieldValues = Nullish; + +export type AccessFormUniCloudConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: AccessFormUniCloudConfigFieldValues; + onValuesChange?: (values: AccessFormUniCloudConfigFieldValues) => void; +}; + +const initFormModel = (): AccessFormUniCloudConfigFieldValues => { + return { + username: "", + password: "", + }; +}; + +const AccessFormUniCloudConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: AccessFormUniCloudConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + username: z.string().trim().nonempty(t("access.form.unicloud_username.placeholder")), + password: z.string().trim().nonempty(t("access.form.unicloud_password.placeholder")), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + +
+ ); +}; + +export default AccessFormUniCloudConfig; diff --git a/ui/src/components/access/AccessFormUpyunConfig.tsx b/ui/src/components/access/AccessFormUpyunConfig.tsx index 8cc06d97..665c50cf 100644 --- a/ui/src/components/access/AccessFormUpyunConfig.tsx +++ b/ui/src/components/access/AccessFormUpyunConfig.tsx @@ -33,9 +33,9 @@ const AccessFormUpyunConfig = ({ form: formInst, formName, disabled, initialValu .max(64, t("common.errmsg.string_max", { max: 64 })), password: z .string() + .trim() .min(1, t("access.form.upyun_password.placeholder")) - .max(64, t("common.errmsg.string_max", { max: 64 })) - .trim(), + .max(64, t("common.errmsg.string_max", { max: 64 })), }); const formRule = createSchemaFieldRule(formSchema); diff --git a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx index 49ed12ec..0443327e 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx @@ -82,6 +82,7 @@ import DeployNodeConfigFormTencentCloudVODConfig from "./DeployNodeConfigFormTen import DeployNodeConfigFormTencentCloudWAFConfig from "./DeployNodeConfigFormTencentCloudWAFConfig"; import DeployNodeConfigFormUCloudUCDNConfig from "./DeployNodeConfigFormUCloudUCDNConfig.tsx"; import DeployNodeConfigFormUCloudUS3Config from "./DeployNodeConfigFormUCloudUS3Config.tsx"; +import DeployNodeConfigFormUniCloudWebHostConfig from "./DeployNodeConfigFormUniCloudWebHostConfig.tsx"; import DeployNodeConfigFormUpyunCDNConfig from "./DeployNodeConfigFormUpyunCDNConfig.tsx"; import DeployNodeConfigFormUpyunFileConfig from "./DeployNodeConfigFormUpyunFileConfig.tsx"; import DeployNodeConfigFormVolcEngineALBConfig from "./DeployNodeConfigFormVolcEngineALBConfig.tsx"; @@ -318,6 +319,8 @@ const DeployNodeConfigForm = forwardRef; case DEPLOYMENT_PROVIDERS.UCLOUD_US3: return ; + case DEPLOYMENT_PROVIDERS.UNICLOUD_WEBHOST: + return ; case DEPLOYMENT_PROVIDERS.UPYUN_CDN: return ; case DEPLOYMENT_PROVIDERS.UPYUN_FILE: diff --git a/ui/src/components/workflow/node/DeployNodeConfigFormUniCloudWebHostConfig.tsx b/ui/src/components/workflow/node/DeployNodeConfigFormUniCloudWebHostConfig.tsx new file mode 100644 index 00000000..b16a7a39 --- /dev/null +++ b/ui/src/components/workflow/node/DeployNodeConfigFormUniCloudWebHostConfig.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input, Select } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { validDomainName } from "@/utils/validators"; + +type DeployNodeConfigFormUniCloudWebHostConfigFieldValues = Nullish<{ + spaceProvider: string; + spaceId: string; + domain: string; +}>; + +export type DeployNodeConfigFormUniCloudWebHostConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: DeployNodeConfigFormUniCloudWebHostConfigFieldValues; + onValuesChange?: (values: DeployNodeConfigFormUniCloudWebHostConfigFieldValues) => void; +}; + +const initFormModel = (): DeployNodeConfigFormUniCloudWebHostConfigFieldValues => { + return { + spaceProvider: "tencent", + spaceId: "", + domain: "", + }; +}; + +const DeployNodeConfigFormUniCloudWebHostConfig = ({ + form: formInst, + formName, + disabled, + initialValues, + onValuesChange, +}: DeployNodeConfigFormUniCloudWebHostConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + spaceProvider: z.string().trim().nonempty(t("workflow_node.deploy.form.unicloud_webhost_space_provider.placeholder")), + spaceId: z.string().trim().nonempty(t("workflow_node.deploy.form.unicloud_webhost_space_id.placeholder")), + domain: z.string().refine((v) => validDomainName(v), t("common.errmsg.domain_invalid")), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ + + + + + + +
+ ); +}; + +export default DeployNodeConfigFormUniCloudWebHostConfig; diff --git a/ui/src/components/workflow/node/DeployNodeConfigFormUpyunCDNConfig.tsx b/ui/src/components/workflow/node/DeployNodeConfigFormUpyunCDNConfig.tsx index e09f5266..c18d570c 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigFormUpyunCDNConfig.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigFormUpyunCDNConfig.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Form, type FormInstance, Input } from "antd"; +import { Alert, Form, type FormInstance, Input } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -44,6 +44,10 @@ const DeployNodeConfigFormUpyunCDNConfig = ({ form: formInst, formName, disabled name={formName} onValuesChange={handleFormChange} > + + } /> + + + + } /> + + [ type, diff --git a/ui/src/i18n/locales/en/nls.access.json b/ui/src/i18n/locales/en/nls.access.json index 98717976..bf453f1d 100644 --- a/ui/src/i18n/locales/en/nls.access.json +++ b/ui/src/i18n/locales/en/nls.access.json @@ -96,12 +96,6 @@ "access.form.bunny_api_key.label": "Bunny API key", "access.form.bunny_api_key.placeholder": "Please enter Bunny API key", "access.form.bunny_api_key.tooltip": "For more information, see https://docs.bunny.net/reference/bunnynet-api-overview", - "access.form.upyun_username.label": "UPYUN subaccount username", - "access.form.upyun_username.placeholder": "Please enter UPYUN subaccount username", - "access.form.upyun_username.tooltip": "For more information, see https://console.upyun.com/account/subaccount/", - "access.form.upyun_password.label": "UPYUN subaccount password", - "access.form.upyun_password.placeholder": "Please enter UPYUN subaccount password", - "access.form.upyun_password.tooltip": "For more information, see https://console.upyun.com/account/subaccount/", "access.form.baishan_api_token.label": "Baishan Cloud API token", "access.form.baishan_api_token.placeholder": "Please enter Baishan Cloud API token", "access.form.baotapanel_server_url.label": "aaPanel server URL", @@ -413,6 +407,16 @@ "access.form.ucloud_project_id.label": "UCloud project ID (Optional)", "access.form.ucloud_project_id.placeholder": "Please enter UCloud project ID", "access.form.ucloud_project_id.tooltip": "For more information, see https://console.ucloud-global.com/uaccount/iam/project_manage", + "access.form.unicloud_username.label": "uniCloud username", + "access.form.unicloud_username.placeholder": "Please enter uniCloud username", + "access.form.unicloud_password.label": "uniCloud password", + "access.form.unicloud_password.placeholder": "Please enter uniCloud password", + "access.form.upyun_username.label": "UPYUN subaccount username", + "access.form.upyun_username.placeholder": "Please enter UPYUN subaccount username", + "access.form.upyun_username.tooltip": "For more information, see https://console.upyun.com/account/subaccount/", + "access.form.upyun_password.label": "UPYUN subaccount password", + "access.form.upyun_password.placeholder": "Please enter UPYUN subaccount password", + "access.form.upyun_password.tooltip": "For more information, see https://console.upyun.com/account/subaccount/", "access.form.vercel_api_access_token.label": "Vercel API access token", "access.form.vercel_api_access_token.placeholder": "Please enter Vercel API access token", "access.form.vercel_api_access_token.tooltip": "For more information, see https://vercel.com/guides/how-do-i-use-a-vercel-api-access-token", diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 85966786..9e59d9d0 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -138,6 +138,8 @@ "provider.ucloud": "UCloud", "provider.ucloud.ucdn": "UCloud - UCDN (UCloud Content Delivery Network)", "provider.ucloud.us3": "UCloud - US3 (UCloud Object-based Storage)", + "provider.unicloud": "uniCloud (DCloud)", + "provider.unicloud.webhost": "uniCloud (DCloud) - Web Host", "provider.upyun": "UPYUN", "provider.upyun.cdn": "UPYUN - CDN (Content Delivery Network)", "provider.upyun.file": "UPYUN - USS (Storage Service)", diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 92989ac2..6de6eda8 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -696,9 +696,21 @@ "workflow_node.deploy.form.ucloud_us3_domain.label": "UCloud US3 domain", "workflow_node.deploy.form.ucloud_us3_domain.placeholder": "Please enter UCloud US3 domain name", "workflow_node.deploy.form.ucloud_us3_domain.tooltip": "For more information, see https://console.ucloud-global.com/ufile", + "workflow_node.deploy.form.unicloud_webhost.guide": "Tips: This uses webpage simulator login and does not guarantee stability. If there are any changes to the uniCloud, please create a GitHub Issue.", + "workflow_node.deploy.form.unicloud_webhost_space_provider.label": "uniCloud space provider", + "workflow_node.deploy.form.unicloud_webhost_space_provider.placeholder": "Please select uniCloud space provider", + "workflow_node.deploy.form.unicloud_webhost_space_provider.option.aliyun.label": "Alibaba Cloud", + "workflow_node.deploy.form.unicloud_webhost_space_provider.option.tencent.label": "Tencent Cloud", + "workflow_node.deploy.form.unicloud_webhost_space_id.label": "uniCloud space ID", + "workflow_node.deploy.form.unicloud_webhost_space_id.placeholder": "uniCloud space ID", + "workflow_node.deploy.form.unicloud_webhost_space_id.tooltip": "For more information, see https://doc.dcloud.net.cn/uniCloud/concepts/space.html", + "workflow_node.deploy.form.unicloud_webhost_domain.label": "uniCloud Web host domain", + "workflow_node.deploy.form.unicloud_webhost_domain.placeholder": "uniCloud Web host domain", + "workflow_node.deploy.form.upyun_cdn.guide": "Tips: This uses webpage simulator login and does not guarantee stability. If there are any changes to the UPYUN, please create a GitHub Issue.", "workflow_node.deploy.form.upyun_cdn_domain.label": "UPYUN CDN domain", "workflow_node.deploy.form.upyun_cdn_domain.placeholder": "Please enter UPYUN CDN domain name", "workflow_node.deploy.form.upyun_cdn_domain.tooltip": "For more information, see https://console.upyun.com/services/cdn/", + "workflow_node.deploy.form.upyun_file.guide": "Tips: This uses webpage simulator login and does not guarantee stability. If there are any changes to the UPYUN, please create a GitHub Issue.", "workflow_node.deploy.form.upyun_file_domain.label": "UPYUN bucket domain", "workflow_node.deploy.form.upyun_file_domain.placeholder": "Please enter UPYUN bucket domain name", "workflow_node.deploy.form.upyun_file_domain.tooltip": "For more information, see https://console.upyun.com/services/file/", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 7e184d55..fb51668f 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -407,6 +407,10 @@ "access.form.ucloud_project_id.label": "优刻得项目 ID(可选)", "access.form.ucloud_project_id.placeholder": "请输入优刻得项目 ID", "access.form.ucloud_project_id.tooltip": "这是什么?请参阅 https://console.ucloud.cn/uaccount/iam/project_manage", + "access.form.unicloud_username.label": "uniCloud 控制台账号", + "access.form.unicloud_username.placeholder": "请输入 uniCloud 控制台账号", + "access.form.unicloud_password.label": "uniCloud 控制台密码", + "access.form.unicloud_password.placeholder": "请输入 uniCloud 控制台密码", "access.form.upyun_username.label": "又拍云子账号用户名", "access.form.upyun_username.placeholder": "请输入又拍云子账号用户名", "access.form.upyun_username.tooltip": "这是什么?请参阅 https://console.upyun.com/account/subaccount/

请关闭该账号的二次登录验证。", diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index c4e126e0..27aa8d0e 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -138,6 +138,8 @@ "provider.ucloud": "优刻得", "provider.ucloud.ucdn": "优刻得 - 内容分发 UCDN", "provider.ucloud.us3": "优刻得 - 对象存储 US3", + "provider.unicloud": "uniCloud (DCloud)", + "provider.unicloud.webhost": "uniCloud (DCloud) - 前端网页托管", "provider.upyun": "又拍云", "provider.upyun.cdn": "又拍云 - 云分发 CDN", "provider.upyun.file": "又拍云 - 云存储 USS", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 87f0076b..6f8f09a6 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -695,9 +695,21 @@ "workflow_node.deploy.form.ucloud_us3_domain.label": "优刻得 US3 自定义域名", "workflow_node.deploy.form.ucloud_us3_domain.placeholder": "请输入优刻得 US3 自定义域名", "workflow_node.deploy.form.ucloud_us3_domain.tooltip": "这是什么?请参阅 https://console.ucloud.cn/ufile", + "workflow_node.deploy.form.unicloud_webhost.guide": "小贴士:由于 uniCloud 未提供相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇 uniCloud 接口变更,请到 GitHub 发起 Issue 告知。", + "workflow_node.deploy.form.unicloud_webhost_space_provider.label": "uniCloud 服务空间提供商", + "workflow_node.deploy.form.unicloud_webhost_space_provider.placeholder": "请选择 uniCloud 服务空间提供商", + "workflow_node.deploy.form.unicloud_webhost_space_provider.option.aliyun.label": "阿里云", + "workflow_node.deploy.form.unicloud_webhost_space_provider.option.tencent.label": "腾讯云", + "workflow_node.deploy.form.unicloud_webhost_space_id.label": "uniCloud 服务空间 ID", + "workflow_node.deploy.form.unicloud_webhost_space_id.placeholder": "请输入 uniCloud 服务空间 ID", + "workflow_node.deploy.form.unicloud_webhost_space_id.tooltip": "这是什么?请参阅 https://doc.dcloud.net.cn/uniCloud/concepts/space.html", + "workflow_node.deploy.form.unicloud_webhost_domain.label": "uniCloud 前端网页托管网站域名", + "workflow_node.deploy.form.unicloud_webhost_domain.placeholder": "请输入 uniCloud 前端网页托管网站域名", + "workflow_node.deploy.form.upyun_cdn.guide": "小贴士:由于又拍云未提供相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。", "workflow_node.deploy.form.upyun_cdn_domain.label": "又拍云 CDN 加速域名", "workflow_node.deploy.form.upyun_cdn_domain.placeholder": "请输入又拍云 CDN 加速域名(支持泛域名)", "workflow_node.deploy.form.upyun_cdn_domain.tooltip": "这是什么?请参阅 https://console.upyun.com/services/cdn/", + "workflow_node.deploy.form.upyun_file.guide": "小贴士:由于又拍云未提供相关 API,这里将使用网页模拟登录方式部署,但无法保证稳定性。如遇又拍云接口变更,请到 GitHub 发起 Issue 告知。", "workflow_node.deploy.form.upyun_file_domain.label": "又拍云云存储加速域名", "workflow_node.deploy.form.upyun_file_domain.placeholder": "请输入又拍云云存储加速域名", "workflow_node.deploy.form.upyun_file_domain.tooltip": "这是什么?请参阅 https://console.upyun.com/services/file/",