Files
ttstd-infinite-canvas/backend/internal/app/resource_generated.go

305 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 生成结果(data URL / 远程地址)的持久化与远程下载。
//
// 远程下载经过出站地址校验(validateRemoteResourceURL),防止 SSRF。
package app
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"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(3 * time.Minute)
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
if err != nil {
return remoteResourcePayload{}, err
}
resp, err := client.Do(req)
if err != nil {
// 把下载层网络错误统一映射为可公开的 502/504 文案,避免在恢复接口上被 failService
// 折叠成通用的“系统处理失败”。同时保留真实原因在 Cause 里供诊断日志追踪。
if errors.Is(err, context.DeadlineExceeded) {
return remoteResourcePayload{}, WrapAppError(http.StatusGatewayTimeout, "远程资源下载超时,请检查地址是否可访问或稍后重试", err)
}
return remoteResourcePayload{}, WrapAppError(http.StatusBadGateway, "远程资源下载失败,请检查地址是否可访问", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return remoteResourcePayload{}, WrapAppError(http.StatusBadGateway, fmt.Sprintf("远程资源下载失败:%s", resp.Status), nil)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return remoteResourcePayload{}, WrapAppError(http.StatusGatewayTimeout, "远程资源下载超时,请检查地址是否可访问或稍后重试", err)
}
return remoteResourcePayload{}, WrapAppError(http.StatusBadGateway, "远程资源读取失败,请检查地址是否可访问", 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
}