package clients import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "sort" "strings" "time" ) const ( volcesAssetDefaultEndpoint = "https://ark.cn-beijing.volcengineapi.com" volcesAssetRegion = "cn-beijing" volcesAssetService = "ark" volcesAssetVersion = "2024-01-01" ) type VolcesAssetClient struct { HTTPClient *http.Client Now func() time.Time } type VolcesAssetCredentials struct { AccessKey string SecretKey string Endpoint string } type VolcesAssetResult struct { ID string `json:"Id"` Name string `json:"Name,omitempty"` URL string `json:"URL,omitempty"` AssetType string `json:"AssetType,omitempty"` GroupID string `json:"GroupId,omitempty"` Status string `json:"Status,omitempty"` Error map[string]any `json:"Error,omitempty"` ProjectName string `json:"ProjectName,omitempty"` CreateTime string `json:"CreateTime,omitempty"` UpdateTime string `json:"UpdateTime,omitempty"` } func (c VolcesAssetClient) CreateAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) { var result struct { ID string `json:"Id"` } requestID, err := c.call(ctx, credentials, "CreateAsset", body, &result) return VolcesAssetResult{ID: result.ID}, requestID, err } func (c VolcesAssetClient) GetAsset(ctx context.Context, credentials VolcesAssetCredentials, body map[string]any) (VolcesAssetResult, string, error) { var result VolcesAssetResult requestID, err := c.call(ctx, credentials, "GetAsset", body, &result) return result, requestID, err } func (c VolcesAssetClient) call(ctx context.Context, credentials VolcesAssetCredentials, action string, body map[string]any, target any) (string, error) { accessKey := strings.TrimSpace(credentials.AccessKey) secretKey := strings.TrimSpace(credentials.SecretKey) if accessKey == "" || secretKey == "" { return "", &ClientError{Code: "missing_credentials", Message: "volces portrait asset accessKey and secretKey are required", Retryable: false} } endpoint := strings.TrimRight(strings.TrimSpace(credentials.Endpoint), "/") if endpoint == "" { endpoint = volcesAssetDefaultEndpoint } baseURL, err := url.Parse(endpoint) if err != nil || baseURL.Scheme == "" || baseURL.Host == "" { return "", &ClientError{Code: "invalid_configuration", Message: "invalid volces portrait asset endpoint", Retryable: false} } bodyJSON, err := json.Marshal(body) if err != nil { return "", fmt.Errorf("marshal volces asset request: %w", err) } now := time.Now().UTC() if c.Now != nil { now = c.Now().UTC() } xDate := now.Format("20060102T150405Z") contentSHA := sha256HexBytes(bodyJSON) requestURL := *baseURL requestURL.Path = "/" requestURL.RawPath = "" requestURL.RawQuery = canonicalVolcesAssetQuery(map[string]string{"Action": action, "Version": volcesAssetVersion}) headers := map[string]string{ "content-type": "application/json", "host": baseURL.Host, "x-content-sha256": contentSHA, "x-date": xDate, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(bodyJSON)) if err != nil { return "", err } req.Host = baseURL.Host req.Header.Set("Content-Type", headers["content-type"]) req.Header.Set("X-Content-Sha256", headers["x-content-sha256"]) req.Header.Set("X-Date", headers["x-date"]) req.Header.Set("Authorization", volcesAssetAuthorization(accessKey, secretKey, http.MethodPost, "/", requestURL.RawQuery, headers, contentSHA, xDate)) response, err := httpClient(nil, c.HTTPClient).Do(req) if err != nil { return "", &ClientError{Code: "network", Message: err.Error(), Retryable: true} } defer response.Body.Close() var envelope struct { ResponseMetadata struct { RequestID string `json:"RequestId"` Error struct { Code string `json:"Code"` Message string `json:"Message"` } `json:"Error"` } `json:"ResponseMetadata"` Result json.RawMessage `json:"Result"` } if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil { return requestIDFromHTTPResponse(response), &ClientError{Code: "invalid_response", Message: "decode volces portrait asset response: " + err.Error(), Retryable: HTTPRetryable(response.StatusCode), StatusCode: response.StatusCode} } requestID := firstNonEmpty(requestIDFromHTTPResponse(response), envelope.ResponseMetadata.RequestID) if envelope.ResponseMetadata.Error.Code != "" || response.StatusCode >= http.StatusBadRequest { message := strings.TrimSpace(envelope.ResponseMetadata.Error.Message) if message == "" { message = strings.TrimSpace(envelope.ResponseMetadata.Error.Code) } if message == "" { message = fmt.Sprintf("volces %s failed with status %d", action, response.StatusCode) } return requestID, &ClientError{Code: firstNonEmpty(envelope.ResponseMetadata.Error.Code, "volces_asset_error"), Message: message, RequestID: requestID, StatusCode: response.StatusCode, Retryable: HTTPRetryable(response.StatusCode)} } if len(envelope.Result) == 0 || string(envelope.Result) == "null" { return requestID, &ClientError{Code: "invalid_response", Message: "volces " + action + " returned empty result", RequestID: requestID, Retryable: false} } if err := json.Unmarshal(envelope.Result, target); err != nil { return requestID, &ClientError{Code: "invalid_response", Message: "decode volces " + action + " result: " + err.Error(), RequestID: requestID, Retryable: false} } return requestID, nil } func volcesAssetAuthorization(accessKey string, secretKey string, method string, path string, canonicalQuery string, headers map[string]string, bodySHA string, xDate string) string { signedHeaders := []string{"content-type", "host", "x-content-sha256", "x-date"} canonicalHeaderLines := make([]string, 0, len(signedHeaders)) for _, key := range signedHeaders { canonicalHeaderLines = append(canonicalHeaderLines, key+":"+strings.TrimSpace(headers[key])) } canonicalRequest := strings.Join([]string{ strings.ToUpper(method), path, canonicalQuery, strings.Join(canonicalHeaderLines, "\n") + "\n", strings.Join(signedHeaders, ";"), bodySHA, }, "\n") date := xDate if len(date) >= 8 { date = date[:8] } scope := strings.Join([]string{date, volcesAssetRegion, volcesAssetService, "request"}, "/") stringToSign := strings.Join([]string{"HMAC-SHA256", xDate, scope, sha256HexString(canonicalRequest)}, "\n") kDate := hmacSHA256([]byte(secretKey), date) kRegion := hmacSHA256(kDate, volcesAssetRegion) kService := hmacSHA256(kRegion, volcesAssetService) kSigning := hmacSHA256(kService, "request") signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign)) return "HMAC-SHA256 Credential=" + accessKey + "/" + scope + ", SignedHeaders=" + strings.Join(signedHeaders, ";") + ", Signature=" + signature } func canonicalVolcesAssetQuery(values map[string]string) string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(values[key])) } return strings.ReplaceAll(strings.Join(parts, "&"), "+", "%20") } func sha256HexBytes(value []byte) string { digest := sha256.Sum256(value) return hex.EncodeToString(digest[:]) } func sha256HexString(value string) string { return sha256HexBytes([]byte(value)) } func hmacSHA256(key []byte, value string) []byte { mac := hmac.New(sha256.New, key) _, _ = mac.Write([]byte(value)) return mac.Sum(nil) }