package service import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting/system_setting" ) // DoubaoVideoAssetAdapter serves the Ark-compatible asset API on official // Volcengine (DoubaoVideo) channels. Unlike the aiping adapter (Bearer key), // the official endpoint authenticates every request with a Volcengine V4 // HMAC-SHA256 signature built from an AccessKey/SecretKey pair stored in // channel_asset_credentials (PoolID holds the optional project code). type DoubaoVideoAssetAdapter struct { operation map[AssetOperation]struct{} } func NewDoubaoVideoAssetAdapter() AssetAdapter { operations := []AssetOperation{ AssetOperationAssetCreate, AssetOperationAssetList, AssetOperationAssetGet, AssetOperationAssetUpdate, AssetOperationAssetDelete, AssetOperationAssetGroupCreate, AssetOperationAssetGroupList, AssetOperationAssetGroupGet, AssetOperationAssetGroupUpdate, AssetOperationAssetGroupDelete, } supported := make(map[AssetOperation]struct{}, len(operations)) for _, op := range operations { supported[op] = struct{}{} } return &DoubaoVideoAssetAdapter{operation: supported} } func (a *DoubaoVideoAssetAdapter) Name() string { return "doubao_video_asset" } func (a *DoubaoVideoAssetAdapter) Supports(operation AssetOperation) bool { _, ok := a.operation[operation] return ok } func (a *DoubaoVideoAssetAdapter) DoAssetRequest(ctx context.Context, channel *model.Channel, req AssetRequest) (*AssetUpstreamResponse, *AssetError) { if !a.Supports(req.Action.Operation) { return nil, newAssetError(AssetErrorOperationNotSupported, fmt.Sprintf("asset operation %s is not supported", req.Action.Operation), http.StatusBadRequest) } credential, err := model.GetChannelAssetCredential(channel.Id) if err != nil { return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) } if credential == nil || strings.TrimSpace(credential.AccessKey) == "" || strings.TrimSpace(credential.SecretKey) == "" { return nil, newAssetError(AssetErrorServer, "Volcengine asset AccessKey and SecretKey are required (configure the channel asset credential)", http.StatusBadRequest) } upstreamURL, rawQuery, err := buildDoubaoVideoAssetURL(channel, req.Action.Action, req.Version) if err != nil { return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) } fetchSetting := system_setting.GetFetchSetting() if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return nil, newAssetError(AssetErrorServer, fmt.Sprintf("request blocked: %v", err), http.StatusForbidden) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(req.RawBody)) if err != nil { return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) } signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, req.RawBody, rawQuery) if projectCode := strings.TrimSpace(credential.PoolID); projectCode != "" { httpReq.Header.Set("X-Project-Code", projectCode) } client, err := GetHttpClientWithProxy(channel.GetSetting().Proxy) if err != nil { return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) } if client == nil { client = http.DefaultClient } resp, err := client.Do(httpReq) if err != nil { return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, newAssetError(AssetErrorUpstream, string(data), http.StatusBadGateway) } return &AssetUpstreamResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: data}, nil } func buildDoubaoVideoAssetURL(channel *model.Channel, action string, version string) (string, string, error) { baseURL := strings.TrimSpace(channel.GetBaseURL()) if baseURL == "" { baseURL = "https://ark.cn-beijing.volces.com" } u, err := url.Parse(baseURL) if err != nil { return "", "", err } u.Path = strings.TrimRight(u.Path, "/") + "/openApi/portrait" if strings.TrimSpace(version) == "" { version = "2024-01-01" } // Action < Version lexicographically, so this raw query is already the // sorted canonical form required by the V4 signature. rawQuery := "Action=" + url.QueryEscape(action) + "&Version=" + url.QueryEscape(version) u.RawQuery = rawQuery return u.String(), rawQuery, nil } // signDoubaoVideoAssetRequest applies the Volcengine V4 HMAC-SHA256 signature // to the request: canonical request over (method, path, sorted query, // content-type/host/x-content-sha256/x-date headers, body hash) -> string to // sign with the {date}/{region}/ark/request scope -> chained HMAC keys. func signDoubaoVideoAssetRequest(req *http.Request, accessKey string, secretKey string, body []byte, rawQuery string) { now := time.Now().UTC() xDate := now.Format("20060102T150405Z") shortDate := now.Format("20060102") payloadHash := sha256.Sum256(body) payloadHashHex := hex.EncodeToString(payloadHash[:]) canonicalHeaders := fmt.Sprintf( "content-type:application/json\nhost:%s\nx-content-sha256:%s\nx-date:%s\n", req.URL.Host, payloadHashHex, xDate, ) signedHeaders := "content-type;host;x-content-sha256;x-date" canonicalRequest := strings.Join([]string{ req.Method, req.URL.EscapedPath(), rawQuery, canonicalHeaders, signedHeaders, payloadHashHex, }, "\n") scope := shortDate + "/cn-beijing/ark/request" canonicalHash := sha256.Sum256([]byte(canonicalRequest)) stringToSign := strings.Join([]string{ "HMAC-SHA256", xDate, scope, hex.EncodeToString(canonicalHash[:]), }, "\n") signingKey := volcengineSigningKey(secretKey, shortDate) signature := hmac.New(sha256.New, signingKey) signature.Write([]byte(stringToSign)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("X-Date", xDate) req.Header.Set("X-Content-Sha256", payloadHashHex) req.Header.Set("Authorization", fmt.Sprintf( "HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", accessKey, scope, signedHeaders, hex.EncodeToString(signature.Sum(nil)), )) } func volcengineSigningKey(secretKey string, shortDate string) []byte { key := hmacSHA256([]byte(secretKey), shortDate) key = hmacSHA256(key, "cn-beijing") key = hmacSHA256(key, "ark") return hmacSHA256(key, "request") } func hmacSHA256(key []byte, message string) []byte { mac := hmac.New(sha256.New, key) mac.Write([]byte(message)) return mac.Sum(nil) }