fix(media): 统一图片结果 URL 化并限制同步 Base64
将上游 URL 直接持久化,内联媒体经对象存储后仅保留 URL 与内部定位元数据;异步轮询、任务详情和幂等重放统一使用零对象读取的 URL 投影,并增加 64KiB 响应门禁。 OpenAI 图片接口接受 url 与 b64_json,同步 Base64 限制为 20MiB 和每 Pod 2 并发;新增历史结果迁移清零门禁、结果指标和 API GOMEMLIMIT。 验证:API go test ./...、go vet、聚焦 race、pnpm openapi、pnpm lint/test/build、迁移安全检查与 docker compose config 均通过。
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -21,6 +22,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSynchronousInlineResponseBytes bounds the raw media payload restored for
|
||||
// an explicit synchronous OpenAI-compatible b64_json response.
|
||||
MaxSynchronousInlineResponseBytes = int64(20 << 20)
|
||||
localBinaryResultDirName = "results"
|
||||
localBinaryPlaceholderPrefix = "[GatewayBinary:v1;"
|
||||
localBinaryGenericBase64MinLength = 4096
|
||||
@@ -96,6 +100,225 @@ func (s *Service) MaterializeTaskResultForStorage(ctx context.Context, taskID st
|
||||
return next, hadInline && !TaskResultHasInlineBinary(next), nil
|
||||
}
|
||||
|
||||
// MigrateTaskResultToURLs materializes historical inline payloads and rewrites
|
||||
// legacy assetRef wrappers into the canonical URL-plus-upload storage shape.
|
||||
func (s *Service) MigrateTaskResultToURLs(ctx context.Context, taskID string, result map[string]any) (map[string]any, bool, error) {
|
||||
next := result
|
||||
changed := false
|
||||
if TaskResultHasLocalPlaceholder(next) {
|
||||
restored, restoredChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, next, 0)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
mapped, ok := restored.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, &clients.ClientError{Code: "result_binary_not_materialized", Message: "generated result is not a JSON object", StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
next = mapped
|
||||
changed = restoredChanged
|
||||
}
|
||||
if TaskResultHasInlineBinary(next) {
|
||||
materialized, materializedChanged, err := s.MaterializeTaskResultForStorage(ctx, taskID, next)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
next = materialized
|
||||
changed = materializedChanged
|
||||
}
|
||||
rewritten, rewrittenChanged := migrateStoredResultURLValue(next, 0)
|
||||
mapped, ok := rewritten.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false, &clients.ClientError{Code: "result_binary_not_materialized", Message: "generated result is not a JSON object", StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
return mapped, changed || rewrittenChanged, nil
|
||||
}
|
||||
|
||||
// TaskResultNeedsURLMigration reports whether a stored result contains inline
|
||||
// binary, a local placeholder, or the legacy assetRef representation.
|
||||
func TaskResultNeedsURLMigration(result map[string]any) bool {
|
||||
if TaskResultHasInlineBinary(result) {
|
||||
return true
|
||||
}
|
||||
return storedResultURLValueNeedsMigration(result, 0)
|
||||
}
|
||||
|
||||
// TaskResultHasLocalPlaceholder reports whether a historical result still
|
||||
// references a node-local GatewayBinary file.
|
||||
func TaskResultHasLocalPlaceholder(result map[string]any) bool {
|
||||
return localBinaryResultHasPlaceholders(result)
|
||||
}
|
||||
|
||||
func (s *Service) restoreLocalPlaceholderValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if depth >= localBinaryMaxDepth {
|
||||
return nil, false, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result exceeds the maximum JSON depth", StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
next := make(map[string]any, len(typed))
|
||||
changed := false
|
||||
for key, childValue := range typed {
|
||||
child, childChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, childValue, depth+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
next[key] = child
|
||||
changed = changed || childChanged
|
||||
}
|
||||
if !changed {
|
||||
return value, false, nil
|
||||
}
|
||||
return next, true, nil
|
||||
case []any:
|
||||
next := make([]any, len(typed))
|
||||
changed := false
|
||||
for index, childValue := range typed {
|
||||
child, childChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, childValue, depth+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
next[index] = child
|
||||
changed = changed || childChanged
|
||||
}
|
||||
if !changed {
|
||||
return value, false, nil
|
||||
}
|
||||
return next, true, nil
|
||||
case string:
|
||||
descriptor, ok := parseLocalBinaryPlaceholder(typed)
|
||||
if !ok {
|
||||
return value, false, nil
|
||||
}
|
||||
payload, err := s.readLocalBinaryResult(taskID, descriptor)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(payload)
|
||||
if descriptor.Encoding == "data-uri" {
|
||||
return "data:" + descriptor.ContentType + ";base64," + encoded, true, nil
|
||||
}
|
||||
return encoded, true, nil
|
||||
default:
|
||||
return value, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func storedResultURLValueNeedsMigration(value any, depth int) bool {
|
||||
if depth >= localBinaryMaxDepth {
|
||||
return true
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if _, ok := typed["assetRef"].(map[string]any); ok {
|
||||
return true
|
||||
}
|
||||
for _, child := range typed {
|
||||
if storedResultURLValueNeedsMigration(child, depth+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if storedResultURLValueNeedsMigration(child, depth+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
_, ok := parseLocalBinaryPlaceholder(typed)
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func migrateStoredResultURLValue(value any, depth int) (any, bool) {
|
||||
if depth >= localBinaryMaxDepth {
|
||||
return value, false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if asset, ok := generatedResultAssetReference(typed); ok {
|
||||
upload, _ := typed["upload"].(map[string]any)
|
||||
if upload == nil {
|
||||
upload = uploadMetadataFromLegacyAsset(asset)
|
||||
}
|
||||
accessURL := firstNonEmptyString(stringFromAny(upload["url"]), asset.URL)
|
||||
next := map[string]any{
|
||||
"url": accessURL,
|
||||
"upload": upload,
|
||||
"assetStorage": map[string]any{
|
||||
"scene": store.FileStorageSceneImageResult,
|
||||
"source": stringFromAny(typed["assetStorage"].(map[string]any)["source"]),
|
||||
"strategy": "migrate_asset_ref",
|
||||
"contentType": asset.ContentType,
|
||||
},
|
||||
}
|
||||
for _, key := range []string{"type", "mime_type", "width", "height", "duration", "format", "seed", "revised_prompt"} {
|
||||
if item, exists := typed[key]; exists {
|
||||
next[key] = item
|
||||
}
|
||||
}
|
||||
if stringFromAny(next["mime_type"]) == "" && asset.ContentType != "" {
|
||||
next["mime_type"] = asset.ContentType
|
||||
}
|
||||
return next, true
|
||||
}
|
||||
next := make(map[string]any, len(typed))
|
||||
changed := false
|
||||
for key, childValue := range typed {
|
||||
child, childChanged := migrateStoredResultURLValue(childValue, depth+1)
|
||||
if resultCanonicalInlineField(key) {
|
||||
if item, ok := child.(map[string]any); ok && stringFromAny(item["url"]) != "" {
|
||||
mergeProjectedResultURL(next, item)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
next[key] = child
|
||||
changed = changed || childChanged
|
||||
}
|
||||
if !changed {
|
||||
return value, false
|
||||
}
|
||||
return next, true
|
||||
case []any:
|
||||
next := make([]any, len(typed))
|
||||
changed := false
|
||||
for index, childValue := range typed {
|
||||
child, childChanged := migrateStoredResultURLValue(childValue, depth+1)
|
||||
next[index] = child
|
||||
changed = changed || childChanged
|
||||
}
|
||||
if !changed {
|
||||
return value, false
|
||||
}
|
||||
return next, true
|
||||
default:
|
||||
return value, false
|
||||
}
|
||||
}
|
||||
|
||||
func uploadMetadataFromLegacyAsset(asset store.RequestAsset) map[string]any {
|
||||
upload := map[string]any{
|
||||
"url": asset.URL,
|
||||
"objectKey": asset.ObjectKey,
|
||||
"contentType": asset.ContentType,
|
||||
"size": asset.ByteSize,
|
||||
"sha256": asset.SHA256,
|
||||
"accessScope": asset.AccessScope,
|
||||
"storageChannel": map[string]any{
|
||||
"id": asset.StorageChannelID,
|
||||
"channelKey": asset.StorageChannelKey,
|
||||
"provider": asset.StorageProvider,
|
||||
},
|
||||
}
|
||||
if asset.ExpiresAt != nil {
|
||||
upload["objectExpiresAt"] = asset.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
return upload
|
||||
}
|
||||
|
||||
// CompactExpiredTaskResultForStorage keeps the historical maintenance API but
|
||||
// now uses the same object-storage path as live results. New GatewayBinary
|
||||
// placeholders are never created; their parser remains read-only compatibility.
|
||||
@@ -325,8 +548,9 @@ func verifyLocalBinaryFile(path string, expectedHash string, expectedSize int64)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HydrateTaskResult restores placeholders from verified local files. It is only
|
||||
// used by result/detail/replay endpoints, never by task lists or callbacks.
|
||||
// HydrateTaskResult restores verified stored payloads only for bounded,
|
||||
// explicit synchronous inline responses. Asynchronous readers use
|
||||
// ProjectTaskResultURLs and never call this path.
|
||||
func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result map[string]any) (map[string]any, error) {
|
||||
next, changed, err := s.hydrateLocalBinaryValue(ctx, taskID, result, 0)
|
||||
if err != nil {
|
||||
@@ -342,6 +566,244 @@ func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result m
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
// SynchronousInlineResultBytes returns the stored raw byte count that the
|
||||
// synchronous hydration path would read. It deliberately inspects metadata
|
||||
// only, so callers can reject oversized responses before any object GET or
|
||||
// local file read occurs.
|
||||
func SynchronousInlineResultBytes(result map[string]any) int64 {
|
||||
return synchronousInlineResultValueBytes(result, 0)
|
||||
}
|
||||
|
||||
func synchronousInlineResultValueBytes(value any, depth int) int64 {
|
||||
if depth >= localBinaryMaxDepth {
|
||||
return 0
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if asset, _, ok := generatedResultUploadReference(typed); ok {
|
||||
storage, _ := typed["assetStorage"].(map[string]any)
|
||||
if resultCanonicalInlineField(stringFromAny(storage["source"])) {
|
||||
return asset.ByteSize
|
||||
}
|
||||
}
|
||||
if asset, ok := generatedResultAssetReference(typed); ok {
|
||||
return asset.ByteSize
|
||||
}
|
||||
var total int64
|
||||
for _, child := range typed {
|
||||
total += synchronousInlineResultValueBytes(child, depth+1)
|
||||
if total > MaxSynchronousInlineResponseBytes {
|
||||
return total
|
||||
}
|
||||
}
|
||||
return total
|
||||
case []any:
|
||||
var total int64
|
||||
for _, child := range typed {
|
||||
total += synchronousInlineResultValueBytes(child, depth+1)
|
||||
if total > MaxSynchronousInlineResponseBytes {
|
||||
return total
|
||||
}
|
||||
}
|
||||
return total
|
||||
case string:
|
||||
if descriptor, ok := parseLocalBinaryPlaceholder(typed); ok {
|
||||
return descriptor.Size
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ProjectTaskResultURLs returns the public, URL-only representation of a
|
||||
// stored task result. It may refresh a signed object-storage URL, but it never
|
||||
// reads object bytes or restores historical inline binary payloads.
|
||||
func (s *Service) ProjectTaskResultURLs(ctx context.Context, taskID string, result map[string]any) (map[string]any, error) {
|
||||
next, changed, err := s.projectTaskResultURLValue(ctx, taskID, result, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !changed {
|
||||
return result, nil
|
||||
}
|
||||
mapped, ok := next.(map[string]any)
|
||||
if !ok {
|
||||
return nil, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result is not a JSON object", StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
func (s *Service) projectTaskResultURLValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if depth >= localBinaryMaxDepth {
|
||||
return nil, false, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result exceeds the maximum JSON depth", StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if asset, _, ok := generatedResultUploadReference(typed); ok {
|
||||
accessURL, err := s.resultAssetAccessURL(ctx, asset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
next := publicResultURLItem(typed, accessURL, asset.ContentType)
|
||||
return next, true, nil
|
||||
}
|
||||
if asset, ok := generatedResultAssetReference(typed); ok {
|
||||
accessURL, err := s.resultAssetAccessURL(ctx, asset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
next := publicResultURLItem(typed, accessURL, asset.ContentType)
|
||||
return next, true, nil
|
||||
}
|
||||
if _, _, ok := localBufferObjectBytes(typed); ok {
|
||||
return nil, false, resultMaterializationRequired(taskID)
|
||||
}
|
||||
next := make(map[string]any, len(typed))
|
||||
changed := false
|
||||
for key, childValue := range typed {
|
||||
if resultInternalField(key) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
child, childChanged, err := s.projectTaskResultURLValue(ctx, taskID, childValue, depth+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if resultCanonicalInlineField(key) {
|
||||
if item, ok := child.(map[string]any); ok && stringFromAny(item["url"]) != "" {
|
||||
mergeProjectedResultURL(next, item)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if raw, ok := child.(string); ok {
|
||||
if requestAssetStringIsHTTPURL(raw) {
|
||||
next["url"] = strings.TrimSpace(raw)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
return nil, false, resultMaterializationRequired(taskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if localBinaryKey(key) {
|
||||
switch inline := child.(type) {
|
||||
case map[string]any:
|
||||
if stringFromAny(inline["url"]) != "" {
|
||||
mergeProjectedResultURL(next, inline)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(inline) != "" {
|
||||
return nil, false, resultMaterializationRequired(taskID)
|
||||
}
|
||||
case []any:
|
||||
if len(inline) > 0 {
|
||||
return nil, false, resultMaterializationRequired(taskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
next[key] = child
|
||||
changed = changed || childChanged
|
||||
}
|
||||
if !changed {
|
||||
return value, false, nil
|
||||
}
|
||||
return next, true, nil
|
||||
case []any:
|
||||
next := make([]any, len(typed))
|
||||
changed := false
|
||||
for index, childValue := range typed {
|
||||
child, childChanged, err := s.projectTaskResultURLValue(ctx, taskID, childValue, depth+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
next[index] = child
|
||||
changed = changed || childChanged
|
||||
}
|
||||
if !changed {
|
||||
return value, false, nil
|
||||
}
|
||||
return next, true, nil
|
||||
case []byte:
|
||||
if len(typed) > 0 {
|
||||
return nil, false, resultMaterializationRequired(taskID)
|
||||
}
|
||||
return value, false, nil
|
||||
case string:
|
||||
if _, ok := parseLocalBinaryPlaceholder(typed); ok {
|
||||
return nil, false, resultMaterializationRequired(taskID)
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(typed)), "data:") {
|
||||
return nil, false, resultMaterializationRequired(taskID)
|
||||
}
|
||||
return value, false, nil
|
||||
default:
|
||||
return value, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) resultAssetAccessURL(ctx context.Context, asset store.RequestAsset) (string, error) {
|
||||
accessURL, err := s.requestAssetAccessURL(ctx, asset)
|
||||
if err != nil || strings.TrimSpace(accessURL) == "" {
|
||||
message := "stored result URL is unavailable"
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
return "", &clients.ClientError{Code: "result_url_unavailable", Message: message, StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
return accessURL, nil
|
||||
}
|
||||
|
||||
func resultMaterializationRequired(taskID string) error {
|
||||
return &clients.ClientError{
|
||||
Code: "result_materialization_required",
|
||||
Message: "stored result must be migrated to object storage before it can be returned",
|
||||
Details: map[string]any{"task_id": strings.TrimSpace(taskID)},
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func publicResultURLItem(source map[string]any, accessURL string, contentType string) map[string]any {
|
||||
next := map[string]any{"url": accessURL}
|
||||
for _, key := range []string{"type", "mime_type", "mimeType", "content_type", "contentType", "width", "height", "duration", "format", "seed", "revised_prompt"} {
|
||||
if value, ok := source[key]; ok && value != nil {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
if stringFromAny(next["mime_type"]) == "" && strings.TrimSpace(contentType) != "" {
|
||||
next["mime_type"] = strings.TrimSpace(contentType)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func mergeProjectedResultURL(target map[string]any, item map[string]any) {
|
||||
for key, value := range item {
|
||||
if _, exists := target[key]; !exists || key == "url" {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resultInternalField(key string) bool {
|
||||
switch normalizeLocalBinaryKey(key) {
|
||||
case "assetref", "assetstorage", "upload", "raw", "rawdata", "rawresponse", "providerpayload", "providerresponse", "providerraw", "upstreamresponse", "thinkingbytes", "thoughtsignature", "signaturebuffer":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resultCanonicalInlineField(key string) bool {
|
||||
normalized := normalizeLocalBinaryKey(key)
|
||||
return normalized == "b64json" || normalized == "base64" || normalized == "b64" ||
|
||||
normalized == "datauri" || strings.Contains(normalized, "base64") || strings.HasSuffix(normalized, "b64")
|
||||
}
|
||||
|
||||
func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
@@ -374,6 +836,31 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
|
||||
}
|
||||
typed = next
|
||||
refreshedAccessURL = true
|
||||
storage, _ := typed["assetStorage"].(map[string]any)
|
||||
sourceKey := strings.TrimSpace(stringFromAny(storage["source"]))
|
||||
if resultCanonicalInlineField(sourceKey) && asset.SHA256 != "" && asset.ByteSize > 0 {
|
||||
payload, contentType, err := s.readGeneratedResultAsset(ctx, asset)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(payload)
|
||||
inline := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
if resultInternalField(key) || key == "url" || key == "image_url" || key == "video_url" || key == "audio_url" {
|
||||
continue
|
||||
}
|
||||
inline[key] = item
|
||||
}
|
||||
if generatedResultAssetUsesDataURI(typed) {
|
||||
inline[sourceKey] = "data:" + contentType + ";base64," + encoded
|
||||
} else {
|
||||
inline[sourceKey] = encoded
|
||||
}
|
||||
if stringFromAny(inline["mime_type"]) == "" {
|
||||
inline["mime_type"] = contentType
|
||||
}
|
||||
return inline, true, nil
|
||||
}
|
||||
}
|
||||
if ref, ok := generatedResultAssetReference(typed); ok {
|
||||
payload, contentType, err := s.readGeneratedResultAsset(ctx, ref)
|
||||
@@ -389,6 +876,10 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
|
||||
next := make(map[string]any, len(typed))
|
||||
changed := refreshedAccessURL
|
||||
for key, childValue := range typed {
|
||||
if resultInternalField(key) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
child, childChanged, err := s.hydrateLocalBinaryValue(ctx, taskID, childValue, depth+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -446,6 +937,9 @@ func generatedResultUploadReference(value map[string]any) (store.RequestAsset, m
|
||||
return store.RequestAsset{}, nil, false
|
||||
}
|
||||
return store.RequestAsset{
|
||||
SHA256: strings.ToLower(strings.TrimSpace(stringFromAny(upload["sha256"]))),
|
||||
ContentType: stringFromAny(upload["contentType"]),
|
||||
ByteSize: int64(floatFromAny(upload["size"])),
|
||||
URL: stringFromAny(upload["url"]),
|
||||
StorageProvider: stringFromAny(channel["provider"]),
|
||||
StorageChannelID: stringFromAny(channel["id"]),
|
||||
|
||||
Reference in New Issue
Block a user