You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

215 lines
8.1 KiB

  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/hex"
  8. "fmt"
  9. "io"
  10. "net/http"
  11. "net/url"
  12. "strings"
  13. "time"
  14. "github.com/QuantumNous/new-api/common"
  15. "github.com/QuantumNous/new-api/model"
  16. "github.com/QuantumNous/new-api/setting/system_setting"
  17. )
  18. // DoubaoVideoAssetAdapter serves the Ark-compatible asset API on official
  19. // Volcengine (DoubaoVideo) channels. Unlike the aiping adapter (Bearer key),
  20. // the official endpoint authenticates every request with a Volcengine V4
  21. // HMAC-SHA256 signature built from an AccessKey/SecretKey pair stored in
  22. // channel_asset_credentials (PoolID holds the optional project code).
  23. type DoubaoVideoAssetAdapter struct {
  24. operation map[AssetOperation]struct{}
  25. }
  26. func NewDoubaoVideoAssetAdapter() AssetAdapter {
  27. operations := []AssetOperation{
  28. AssetOperationAssetCreate,
  29. AssetOperationAssetList,
  30. AssetOperationAssetGet,
  31. AssetOperationAssetUpdate,
  32. AssetOperationAssetDelete,
  33. AssetOperationAssetGroupCreate,
  34. AssetOperationAssetGroupList,
  35. AssetOperationAssetGroupGet,
  36. AssetOperationAssetGroupUpdate,
  37. AssetOperationAssetGroupDelete,
  38. }
  39. supported := make(map[AssetOperation]struct{}, len(operations))
  40. for _, op := range operations {
  41. supported[op] = struct{}{}
  42. }
  43. return &DoubaoVideoAssetAdapter{operation: supported}
  44. }
  45. func (a *DoubaoVideoAssetAdapter) Name() string {
  46. return "doubao_video_asset"
  47. }
  48. func (a *DoubaoVideoAssetAdapter) Supports(operation AssetOperation) bool {
  49. _, ok := a.operation[operation]
  50. return ok
  51. }
  52. func (a *DoubaoVideoAssetAdapter) DoAssetRequest(ctx context.Context, channel *model.Channel, req AssetRequest) (*AssetUpstreamResponse, *AssetError) {
  53. if !a.Supports(req.Action.Operation) {
  54. return nil, newAssetError(AssetErrorOperationNotSupported, fmt.Sprintf("asset operation %s is not supported", req.Action.Operation), http.StatusBadRequest)
  55. }
  56. credential, err := model.GetChannelAssetCredential(channel.Id)
  57. if err != nil {
  58. return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
  59. }
  60. if credential == nil || strings.TrimSpace(credential.AccessKey) == "" || strings.TrimSpace(credential.SecretKey) == "" {
  61. return nil, newAssetError(AssetErrorServer, "Volcengine asset AccessKey and SecretKey are required (configure the channel asset credential)", http.StatusBadRequest)
  62. }
  63. upstreamURL, rawQuery, err := buildDoubaoVideoAssetURL(channel, req.Action.Action, req.Version)
  64. if err != nil {
  65. return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
  66. }
  67. // The body is always serialized from req.Body so platform-side rewrites
  68. // (managed group scoping, ownership checks) take effect; RawBody is
  69. // intentionally not used here. The V4 signature covers the sent payload.
  70. body, err := common.Marshal(req.Body)
  71. if err != nil {
  72. return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
  73. }
  74. if len(body) == 0 {
  75. body = []byte("{}")
  76. }
  77. fetchSetting := system_setting.GetFetchSetting()
  78. if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
  79. return nil, newAssetError(AssetErrorServer, fmt.Sprintf("request blocked: %v", err), http.StatusForbidden)
  80. }
  81. httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
  82. if err != nil {
  83. return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
  84. }
  85. signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, body, rawQuery)
  86. if projectCode := strings.TrimSpace(credential.PoolID); projectCode != "" {
  87. httpReq.Header.Set("X-Project-Code", projectCode)
  88. }
  89. client, err := GetHttpClientWithProxy(channel.GetSetting().Proxy)
  90. if err != nil {
  91. return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
  92. }
  93. if client == nil {
  94. client = http.DefaultClient
  95. }
  96. resp, err := client.Do(httpReq)
  97. if err != nil {
  98. return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway)
  99. }
  100. defer resp.Body.Close()
  101. data, err := io.ReadAll(resp.Body)
  102. if err != nil {
  103. return nil, newAssetError(AssetErrorUpstream, err.Error(), http.StatusBadGateway)
  104. }
  105. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  106. return nil, newAssetError(AssetErrorUpstream, string(data), http.StatusBadGateway)
  107. }
  108. return &AssetUpstreamResponse{StatusCode: resp.StatusCode, Header: resp.Header, Body: data}, nil
  109. }
  110. // doubaoVideoAssetEndpointOverride hard-codes the asset endpoint for testing
  111. // or special deployments. When non-empty it wins over the per-channel
  112. // credential address (channel_asset_credentials.base_url); leave empty to
  113. // use the stored value, or the official default when nothing is configured.
  114. var doubaoVideoAssetEndpointOverride = ""
  115. // buildDoubaoVideoAssetURL returns the full asset endpoint URL and its raw
  116. // query (sorted Action < Version, as required by the V4 signature). Address
  117. // resolution order: hard-coded override, credential base_url (used verbatim,
  118. // it already includes the /openApi/portrait path), official default.
  119. func buildDoubaoVideoAssetURL(channel *model.Channel, action string, version string) (string, string, error) {
  120. baseURL := strings.TrimSpace(doubaoVideoAssetEndpointOverride)
  121. if baseURL == "" {
  122. if credential, err := model.GetChannelAssetCredential(channel.Id); err == nil && credential != nil {
  123. baseURL = strings.TrimSpace(credential.BaseURL)
  124. }
  125. }
  126. if baseURL == "" {
  127. baseURL = "https://ark.cn-beijing.volces.com/openApi/portrait"
  128. }
  129. u, err := url.Parse(baseURL)
  130. if err != nil {
  131. return "", "", err
  132. }
  133. if strings.TrimSpace(version) == "" {
  134. version = "2024-01-01"
  135. }
  136. // Action < Version lexicographically, so this raw query is already the
  137. // sorted canonical form required by the V4 signature.
  138. rawQuery := "Action=" + url.QueryEscape(action) + "&Version=" + url.QueryEscape(version)
  139. u.RawQuery = rawQuery
  140. return u.String(), rawQuery, nil
  141. }
  142. // signDoubaoVideoAssetRequest applies the Volcengine V4 HMAC-SHA256 signature
  143. // to the request: canonical request over (method, path, sorted query,
  144. // content-type/host/x-content-sha256/x-date headers, body hash) -> string to
  145. // sign with the {date}/{region}/ark/request scope -> chained HMAC keys.
  146. func signDoubaoVideoAssetRequest(req *http.Request, accessKey string, secretKey string, body []byte, rawQuery string) {
  147. now := time.Now().UTC()
  148. xDate := now.Format("20060102T150405Z")
  149. shortDate := now.Format("20060102")
  150. payloadHash := sha256.Sum256(body)
  151. payloadHashHex := hex.EncodeToString(payloadHash[:])
  152. canonicalHeaders := fmt.Sprintf(
  153. "content-type:application/json\nhost:%s\nx-content-sha256:%s\nx-date:%s\n",
  154. req.URL.Host, payloadHashHex, xDate,
  155. )
  156. signedHeaders := "content-type;host;x-content-sha256;x-date"
  157. canonicalRequest := strings.Join([]string{
  158. req.Method,
  159. req.URL.EscapedPath(),
  160. rawQuery,
  161. canonicalHeaders,
  162. signedHeaders,
  163. payloadHashHex,
  164. }, "\n")
  165. scope := shortDate + "/cn-beijing/ark/request"
  166. canonicalHash := sha256.Sum256([]byte(canonicalRequest))
  167. stringToSign := strings.Join([]string{
  168. "HMAC-SHA256",
  169. xDate,
  170. scope,
  171. hex.EncodeToString(canonicalHash[:]),
  172. }, "\n")
  173. signingKey := volcengineSigningKey(secretKey, shortDate)
  174. signature := hmac.New(sha256.New, signingKey)
  175. signature.Write([]byte(stringToSign))
  176. req.Header.Set("Content-Type", "application/json")
  177. req.Header.Set("Accept", "application/json")
  178. req.Header.Set("X-Date", xDate)
  179. req.Header.Set("X-Content-Sha256", payloadHashHex)
  180. req.Header.Set("Authorization", fmt.Sprintf(
  181. "HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
  182. accessKey, scope, signedHeaders, hex.EncodeToString(signature.Sum(nil)),
  183. ))
  184. }
  185. func volcengineSigningKey(secretKey string, shortDate string) []byte {
  186. key := hmacSHA256([]byte(secretKey), shortDate)
  187. key = hmacSHA256(key, "cn-beijing")
  188. key = hmacSHA256(key, "ark")
  189. return hmacSHA256(key, "request")
  190. }
  191. func hmacSHA256(key []byte, message string) []byte {
  192. mac := hmac.New(sha256.New, key)
  193. mac.Write([]byte(message))
  194. return mac.Sum(nil)
  195. }