// 生成结果(data URL / 远程地址)的持久化与远程下载。 // // 远程下载经过出站地址校验(validateRemoteResourceURL),防止 SSRF。 package app import ( "bytes" "encoding/base64" "encoding/json" "fmt" "image" "io" "mime" "net/http" "net/url" "path" "path/filepath" "strings" "time" ) func localObjectKey(userID string, kind string, fileName string, mimeType string, now time.Time) string { ext := resourceFileExtension(fileName, mimeType, kind) return path.Join("users", safeObjectSegment(userID), kind, now.Format("2006/01/02"), newID()+ext) } func (s *Service) persistGeneratedMediaResult(userID string, result map[string]interface{}) (map[string]interface{}, error) { return s.persistGeneratedMediaResultMode(userID, result, false, true) } func (s *Service) persistLegacyGeneratedMediaResult(userID string, result map[string]interface{}) (map[string]interface{}, error) { return s.persistGeneratedMediaResultMode(userID, result, true, false) } func (s *Service) persistGeneratedMediaResultMode(userID string, result map[string]interface{}, skipInvalidDataURL bool, enforceQuota bool) (map[string]interface{}, error) { if result == nil { return map[string]interface{}{}, nil } encoded, err := json.Marshal(result) if err != nil { return nil, err } var normalized map[string]interface{} if err := json.Unmarshal(encoded, &normalized); err != nil { return nil, err } value, err := s.persistGeneratedMediaValueMode(userID, normalized, skipInvalidDataURL, enforceQuota) if err != nil { return nil, err } return value.(map[string]interface{}), nil } func (s *Service) persistGeneratedMediaValue(userID string, value interface{}) (interface{}, error) { return s.persistGeneratedMediaValueMode(userID, value, false, true) } func (s *Service) persistGeneratedMediaValueMode(userID string, value interface{}, skipInvalidDataURL bool, enforceQuota bool) (interface{}, error) { switch item := value.(type) { case []interface{}: for index, child := range item { stored, err := s.persistGeneratedMediaValueMode(userID, child, skipInvalidDataURL, enforceQuota) if err != nil { return nil, err } item[index] = stored } return item, nil case map[string]interface{}: if raw := inlineMediaValue(item); raw != "" { mimeType, data, err := s.decodeDataURL(raw) if err != nil && !skipInvalidDataURL { return nil, err } if err == nil { kind := normalizeResourceKind("", mimeType) width, height := intValue(item["width"]), intValue(item["height"]) if kind == "image" && (width <= 0 || height <= 0) { width, height = imageDimensions(data) } quotaDay := "" if enforceQuota { quotaDay, err = s.reserveGeneratedResourceQuota(userID, int64(len(data))) if err != nil { return nil, err } } resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil, false) if err != nil { if enforceQuota { s.releaseUserUploadQuota(userID, quotaDay, int64(len(data))) } return nil, fmt.Errorf("生成内容写入资源存储失败:%w", err) } if enforceQuota { s.commitUserUploadQuota(userID, int64(len(data))) } resourceURL := resourceFileURL(resource.ID) for _, key := range []string{"dataUrl", "content", "url", "coverUrl"} { if text, ok := item[key].(string); ok && (text == raw || strings.HasPrefix(text, "blob:")) { item[key] = resourceURL } } if _, ok := item["dataUrl"]; ok { item["dataUrl"] = resourceURL } item["url"] = resourceURL item["storageKey"] = "resource:" + resource.ID item["resourceId"] = resource.ID item["bytes"] = resource.Size item["mimeType"] = resource.MimeType item["width"] = resource.Width item["height"] = resource.Height } } for key, child := range item { stored, err := s.persistGeneratedMediaValueMode(userID, child, skipInvalidDataURL, enforceQuota) if err != nil { return nil, err } item[key] = stored } return item, nil default: return value, nil } } func inlineMediaValue(item map[string]interface{}) string { for _, key := range []string{"dataUrl", "content", "url", "coverUrl"} { if text, ok := item[key].(string); ok && (strings.HasPrefix(text, "data:image/") || strings.HasPrefix(text, "data:video/") || strings.HasPrefix(text, "data:audio/")) { return text } } return "" } func (s *Service) decodeDataURL(value string) (string, []byte, error) { header, encoded, ok := strings.Cut(value, ",") if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(strings.ToLower(header), ";base64") { return "", nil, fmt.Errorf("%w:格式无效", errInvalidGeneratedDataURL) } mimeType := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64") data, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return "", nil, fmt.Errorf("%w:base64 解码失败:%v", errInvalidGeneratedDataURL, err) } policy, err := s.RuntimePolicy() if err != nil { return "", nil, err } if int64(len(data)) > megabytes(policy.Resource.GeneratedFileMB) { return "", nil, fmt.Errorf("单个生成资源超过 %dMB", policy.Resource.GeneratedFileMB) } return mimeType, data, nil } func intValue(value interface{}) int { switch number := value.(type) { case float64: return int(number) case int: return number case int64: return int(number) default: return 0 } } type remoteResourcePayload struct { url string endpoint string fileName string mimeType string data []byte } func downloadRemoteResource(rawURL string, maxBytes int64) (remoteResourcePayload, error) { parsed, err := validateRemoteResourceURL(rawURL) if err != nil { return remoteResourcePayload{}, err } client := OutboundHTTPClient(90 * time.Second) req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) if err != nil { return remoteResourcePayload{}, err } resp, err := client.Do(req) if err != nil { return remoteResourcePayload{}, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return remoteResourcePayload{}, fmt.Errorf("远程资源下载失败:%s", resp.Status) } data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes)) if err != nil { return remoteResourcePayload{}, err } if int64(len(data)) >= maxBytes { return remoteResourcePayload{}, BadAuthRequest(fmt.Sprintf("远程资源必须小于 %s", formatStorageLimit(maxBytes))) } mimeType := strings.TrimSpace(resp.Header.Get("Content-Type")) if idx := strings.Index(mimeType, ";"); idx >= 0 { mimeType = strings.TrimSpace(mimeType[:idx]) } if mimeType == "" || mimeType == "application/octet-stream" { mimeType = http.DetectContentType(data) } fileName := path.Base(parsed.Path) if fileName == "" || fileName == "." || !strings.Contains(fileName, ".") { fileName = "resource." + extensionFromMimeType(mimeType) } return remoteResourcePayload{url: parsed.String(), endpoint: parsed.Host, fileName: fileName, mimeType: mimeType, data: data}, nil } func openRemoteResource(rawURL string) (io.ReadCloser, error) { parsed, err := validateRemoteResourceURL(rawURL) if err != nil { return nil, err } client := OutboundHTTPClient(90 * time.Second) resp, err := client.Get(parsed.String()) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { defer resp.Body.Close() return nil, fmt.Errorf("远程资源读取失败:%s", resp.Status) } return resp.Body, nil } func validateRemoteResourceURL(rawURL string) (*url.URL, error) { return ValidateOutboundURL(rawURL) } func extensionFromMimeType(mimeType string) string { if strings.Contains(mimeType, "png") { return "png" } if strings.Contains(mimeType, "jpeg") { return "jpg" } if strings.Contains(mimeType, "webp") { return "webp" } if strings.Contains(mimeType, "gif") { return "gif" } if strings.Contains(mimeType, "mp4") { return "mp4" } if strings.Contains(mimeType, "webm") { return "webm" } if strings.Contains(mimeType, "mpeg") { return "mp3" } if strings.Contains(mimeType, "wav") { return "wav" } return "bin" } func resourceFileExtension(fileName string, mimeType string, kind string) string { if ext := strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))); ext != "" && ext != "." { return ext } cleanMimeType := strings.TrimSpace(strings.Split(mimeType, ";")[0]) if extensions, err := mime.ExtensionsByType(cleanMimeType); err == nil && len(extensions) > 0 { return strings.ToLower(extensions[0]) } switch kind { case "image": return ".png" case "video": return ".mp4" case "audio": return ".mp3" default: return ".bin" } } func imageDimensions(data []byte) (int, int) { config, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return 0, 0 } return config.Width, config.Height }