fix(media): 资源交付 - 按内容识别上传类型并统一重试与附件交付 (#678)

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
Catlaxy AI
2026-10-06 09:35:50 +08:00
committed by GitHub
parent 9597a9b4ed
commit 3698249254
10 changed files with 322 additions and 39 deletions

View File

@@ -4,6 +4,7 @@ go 1.25.0
require (
github.com/aws/aws-sdk-go v1.45.5
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.5.0
github.com/qiniu/go-sdk/v7 v7.27.0
@@ -32,7 +33,6 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect

View File

@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
@@ -19,7 +18,9 @@ import (
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"gorm.io/gorm"
"infinite-canvas/backend/internal/assets"
"infinite-canvas/backend/internal/model"
)
@@ -43,9 +44,6 @@ func (s *Service) uploadResource(userID string, header *multipart.FileHeader, ki
if err != nil {
return nil, err
}
if existing != nil && existing.Status == model.ResourceStatusReady {
return existing, nil
}
if existing != nil && existing.Status == model.ResourceStatusPending {
return nil, resourceUploadInProgress()
}
@@ -55,8 +53,13 @@ func (s *Service) uploadResource(userID string, header *multipart.FileHeader, ki
}
defer file.Close()
mimeType := strings.TrimSpace(header.Header.Get("Content-Type"))
mimeType = detectUploadedMimeType(file, header.Filename, mimeType)
mimeType, err := detectUploadedMimeType(file)
if err != nil {
return nil, err
}
if !(forceLocal && kind == "live2d") {
kind = uploadedResourceKind(mimeType)
}
if existing != nil {
return s.retryStoredResource(userID, existing, kind, mimeType, header.Size, file)
}
@@ -87,13 +90,14 @@ func (s *Service) UploadResourceFile(userID string, fileName string, size int64,
if err != nil {
return nil, err
}
if existing != nil && existing.Status == model.ResourceStatusReady {
return existing, nil
}
if existing != nil && existing.Status == model.ResourceStatusPending {
return nil, resourceUploadInProgress()
}
mimeType := detectUploadedMimeType(file, fileName, "")
mimeType, err := detectUploadedMimeType(file)
if err != nil {
return nil, err
}
kind = uploadedResourceKind(mimeType)
if existing != nil {
return s.retryStoredResource(userID, existing, kind, mimeType, size, file)
}
@@ -112,21 +116,23 @@ func (s *Service) UploadResourceFile(userID string, fileName string, size int64,
return resource, err
}
func detectUploadedMimeType(file io.ReadSeeker, fileName string, declared string) string {
declared = strings.TrimSpace(strings.Split(declared, ";")[0])
if declared != "" && declared != "application/octet-stream" {
return declared
func detectUploadedMimeType(file io.ReadSeeker) (string, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
buffer := make([]byte, 512)
read, _ := file.Read(buffer)
_, _ = file.Seek(0, io.SeekStart)
if detected := http.DetectContentType(buffer[:read]); detected != "" && detected != "application/octet-stream" {
return strings.TrimSpace(strings.Split(detected, ";")[0])
detected, err := mimetype.DetectReader(file)
_, seekErr := file.Seek(0, io.SeekStart)
if err != nil || seekErr != nil {
return "", errors.Join(err, seekErr)
}
if fromExtension := mime.TypeByExtension(filepath.Ext(fileName)); fromExtension != "" {
return strings.TrimSpace(strings.Split(fromExtension, ";")[0])
return strings.TrimSpace(strings.Split(detected.String(), ";")[0]), nil
}
func uploadedResourceKind(mimeType string) string {
if !assets.InlineMediaType(mimeType) {
return "file"
}
return "application/octet-stream"
return normalizeResourceKind("", mimeType)
}
func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) {
@@ -135,9 +141,6 @@ func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, w
if err != nil {
return nil, err
}
if existing != nil && existing.Status == model.ResourceStatusReady {
return existing, nil
}
if existing != nil && existing.Status == model.ResourceStatusPending {
return nil, resourceUploadInProgress()
}
@@ -149,7 +152,11 @@ func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, w
if err != nil {
return nil, err
}
kind = normalizeResourceKind(kind, payload.mimeType)
payload.mimeType, err = detectUploadedMimeType(bytes.NewReader(payload.data))
if err != nil {
return nil, err
}
kind = uploadedResourceKind(payload.mimeType)
if kind == "image" && (width <= 0 || height <= 0) {
if decodedWidth, decodedHeight := imageDimensions(payload.data); decodedWidth > 0 && decodedHeight > 0 {
width = decodedWidth
@@ -251,19 +258,22 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim
}
func (s *Service) storeResourceWithWriter(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool, writeObject func(*model.Resource, string, io.Reader) (string, error)) (*model.Resource, bool, error) {
// Live2D is reserved for the administrator's validated local import path.
if !(forceLocal && kind == "live2d") {
kind = normalizeResourceKind(kind, mimeType)
}
if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil {
return nil, false, err
} else if existing != nil {
if existing.Status == model.ResourceStatusReady {
if err := validateResourceUploadIdentity(existing, kind, mimeType, size); err != nil {
return nil, false, err
}
return existing, false, nil
}
return nil, false, resourceUploadInProgress()
}
now := time.Now()
// Live2D is reserved for the administrator's validated local import path.
if !(forceLocal && kind == "live2d") {
kind = normalizeResourceKind(kind, mimeType)
}
var setting ossSettingValue
var storageSettingID string
var useOSS bool
@@ -289,6 +299,9 @@ func (s *Service) storeResourceWithWriter(userID string, kind string, fileName s
if err := s.repo.CreateResource(&resource); err != nil {
if existing, lookupErr := s.resourceForUploadKey(userID, uploadKey); lookupErr == nil && existing != nil {
if existing.Status == model.ResourceStatusReady {
if err := validateResourceUploadIdentity(existing, kind, mimeType, size); err != nil {
return nil, false, err
}
return existing, false, nil
}
return nil, false, resourceUploadInProgress()
@@ -371,15 +384,15 @@ func (s *Service) retryStoredResource(userID string, resource *model.Resource, k
if resource == nil {
return nil, errors.New("资源不存在")
}
if resource.Status == model.ResourceStatusReady {
return resource, nil
}
if resource.Status != model.ResourceStatusFailed {
if resource.Status != model.ResourceStatusFailed && resource.Status != model.ResourceStatusReady {
return nil, resourceUploadInProgress()
}
kind = normalizeResourceKind(kind, mimeType)
if resource.Size != size || resource.Kind != kind || (resource.MimeType != "" && mimeType != "" && resource.MimeType != mimeType) {
return nil, NewAppError(http.StatusConflict, "上传幂等标识已用于其他文件")
if err := validateResourceUploadIdentity(resource, kind, mimeType, size); err != nil {
return nil, err
}
if resource.Status == model.ResourceStatusReady {
return resource, nil
}
claimed, err := s.repo.ClaimFailedResourceUpload(userID, resource.ID)
if err != nil {
@@ -388,6 +401,9 @@ func (s *Service) retryStoredResource(userID string, resource *model.Resource, k
if !claimed {
latest, latestErr := s.repo.ResourceForUser(userID, resource.ID)
if latestErr == nil && latest.Status == model.ResourceStatusReady {
if err := validateResourceUploadIdentity(latest, kind, mimeType, size); err != nil {
return nil, err
}
return latest, nil
}
return nil, resourceUploadInProgress()
@@ -431,3 +447,10 @@ func (s *Service) retryStoredResource(userID string, resource *model.Resource, k
s.recordActivity(userID, "resource", 1)
return resource, nil
}
func validateResourceUploadIdentity(resource *model.Resource, kind, mimeType string, size int64) error {
if resource.Size != size || resource.Kind != kind || resource.MimeType != mimeType {
return NewAppError(http.StatusConflict, "上传幂等标识已用于其他文件")
}
return nil
}

View File

@@ -0,0 +1,161 @@
package app
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"sync/atomic"
"testing"
"infinite-canvas/backend/internal/model"
)
func contentUploadHeader(t *testing.T, data []byte) *multipart.FileHeader {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="file"; filename="picture.png"`)
h.Set("Content-Type", "image/png")
p, err := w.CreatePart(h)
if err != nil {
t.Fatal(err)
}
p.Write(data)
w.Close()
form, err := multipart.NewReader(&buf, w.Boundary()).ReadForm(1 << 20)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { form.RemoveAll() })
return form.File["file"][0]
}
func TestUploadContentImportSocket(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
png := mediaTestPNG(t)
var payload atomic.Value
payload.Store(png)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload.Load().([]byte))
}))
defer server.Close()
for _, status := range []model.ResourceStatus{model.ResourceStatusReady, model.ResourceStatusFailed} {
t.Run(string(status), func(t *testing.T) {
s := newResourceTestService(t)
payload.Store(png)
r, err := s.ImportResourceURL("user", server.URL+"/picture.png", "image", 0, 0, 0, "identity")
if err != nil {
t.Fatal(err)
}
r.Status = status
if err := s.repo.SaveResource(r); err != nil {
t.Fatal(err)
}
payload.Store([]byte("<html>" + strings.Repeat(" ", len(png)-6)))
if _, err := s.ImportResourceURL("user", server.URL+"/picture.png", "image", 0, 0, 0, "identity"); err == nil {
t.Fatal("changed import content accepted")
}
fresh, err := s.ImportResourceURL("user", server.URL+"/picture.png", "image", 0, 0, 0, "fresh-identity")
if err != nil || fresh.MimeType != "text/html" || fresh.Kind != "file" {
t.Fatalf("spoofed import: %+v %v", fresh, err)
}
})
}
}
func TestUploadContentDetection(t *testing.T) {
data := []byte("<html><script>alert(1)</script></html>")
s := newResourceTestService(t)
r, err := s.UploadResource("user", contentUploadHeader(t, data), "image", 0, 0, 0)
if err != nil {
t.Fatal(err)
}
if r.MimeType != "text/html" || r.Kind != "file" {
t.Fatalf("spoofed content stored as MIME=%s kind=%s", r.MimeType, r.Kind)
}
}
func TestUploadContentRetryConsistency(t *testing.T) {
for _, status := range []model.ResourceStatus{model.ResourceStatusReady, model.ResourceStatusFailed} {
for _, chunked := range []bool{false, true} {
name := string(status)
if chunked {
name += "/chunked"
} else {
name += "/multipart"
}
t.Run(name, func(t *testing.T) {
s := newResourceTestService(t)
png := mediaTestPNG(t)
r, err := s.UploadResource("user", contentUploadHeader(t, png), "image", 0, 0, 0, "identity")
if err != nil {
t.Fatal(err)
}
r.Status = status
if err := s.repo.SaveResource(r); err != nil {
t.Fatal(err)
}
data := []byte("<html>" + strings.Repeat(" ", len(png)-6))
if chunked {
_, err = s.UploadResourceFile("user", "picture.png", int64(len(data)), "image", 0, 0, 0, bytes.NewReader(data), "identity")
} else {
_, err = s.UploadResource("user", contentUploadHeader(t, data), "image", 0, 0, 0, "identity")
}
if err == nil {
t.Fatal("changed upload content accepted under existing identity")
}
stored, err := s.repo.ResourceForUser("user", r.ID)
if err != nil || stored.Status != status || stored.MimeType != "image/png" {
t.Fatalf("rejected retry changed resource: %+v %v", stored, err)
}
var same *model.Resource
if chunked {
same, err = s.UploadResourceFile("user", "picture.png", int64(len(png)), "image", 0, 0, 0, bytes.NewReader(png), "identity")
} else {
same, err = s.UploadResource("user", contentUploadHeader(t, png), "image", 0, 0, 0, "identity")
}
if err != nil || same.ID != r.ID || same.Status != model.ResourceStatusReady {
t.Fatalf("valid retry failed: %+v %v", same, err)
}
})
}
}
}
func TestUploadContentAcceptsAttachments(t *testing.T) {
for _, tc := range []struct {
name string
data []byte
mimeType string
}{
{"pdf", []byte("%PDF-1.7\nexample"), "application/pdf"},
{"svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`), "image/svg+xml"},
{"unknown", []byte{0, 1, 0, 2, 0, 3}, "application/octet-stream"},
} {
t.Run(tc.name, func(t *testing.T) {
s := newResourceTestService(t)
r, err := s.UploadResourceFile("user", "picture.png", int64(len(tc.data)), "image", 0, 0, 0, bytes.NewReader(tc.data))
if err != nil {
t.Fatal(err)
}
if r.MimeType != tc.mimeType || r.Kind != "file" {
t.Fatalf("attachment detection: %+v", r)
}
_, body, err := s.OpenResource("user", r.ID)
if err != nil {
t.Fatal(err)
}
defer body.Close()
got, err := io.ReadAll(body)
if err != nil || !bytes.Equal(got, tc.data) {
t.Fatalf("stored bytes changed: %v", err)
}
})
}
}

View File

@@ -140,6 +140,10 @@ func ResolveAccess(resource *model.Resource, setting storage.Settings, options A
}
if resource.Provider == "local" {
access.Delivery = DeliveryLocal
} else if !InlineMediaType(resource.MimeType) {
// A redirect cannot enforce our attachment and sandbox response headers.
access.Delivery = DeliveryProxy
access.FallbackReason = "attachment_required"
} else if options.Purpose == PurposeDownload {
// Cross-origin `a[download]` is only advisory. A real browser download must
// therefore be enforced by the object store response itself. Downloads use

View File

@@ -0,0 +1,24 @@
package assets
import (
"testing"
"time"
"infinite-canvas/backend/internal/storage"
)
func TestNonMediaDeliveryUsesPlatform(t *testing.T) {
for _, mimeType := range []string{"text/html", "image/svg+xml", "application/pdf", "application/octet-stream"} {
for _, purpose := range []AccessPurpose{PurposeDisplay, PurposeCopy, PurposeProcess, PurposeDownload, PurposeProvider} {
t.Run(mimeType+"/"+string(purpose), func(t *testing.T) {
r := testReadyResource("aliyun")
r.MimeType = mimeType
var variants []ResourceVariant
got, err := ResolveAccess(r, storage.Settings{Provider: "aliyun", CDNBaseURL: "https://cdn.example", Delivery: storage.DeliverySettings{CDNAuthMode: "public"}}, AccessOptions{Purpose: purpose}, time.Now(), testPlatformURL(&variants))
if err != nil || got.Delivery != DeliveryProxy || len(variants) != 1 {
t.Fatalf("non-media escaped controlled delivery: access=%+v err=%v", got, err)
}
})
}
}
}

View File

@@ -0,0 +1,16 @@
package assets
import (
"mime"
"strings"
)
// InlineMediaType excludes active XML images such as SVG. Other documents and
// unknown formats remain uploadable, but must use controlled attachment delivery.
func InlineMediaType(value string) bool {
mediaType, _, err := mime.ParseMediaType(value)
if err != nil || strings.HasSuffix(mediaType, "+xml") || mediaType == "image/svg" {
return false
}
return strings.HasPrefix(mediaType, "image/") || strings.HasPrefix(mediaType, "audio/") || strings.HasPrefix(mediaType, "video/")
}

View File

@@ -0,0 +1,39 @@
package handler
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"infinite-canvas/backend/internal/assets"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/service"
)
func TestNonMediaDeliveryAttachment(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, mimeType := range []string{"text/html", "image/svg+xml", "application/pdf", "application/octet-stream", "image/png"} {
t.Run(mimeType, func(t *testing.T) {
router := gin.New()
router.GET("/file", func(c *gin.Context) {
r := &model.Resource{ID: "file", MimeType: mimeType}
serveResourceDelivery(c, &service.ResourceDelivery{Resource: r, Access: &assets.ResourceAccess{Delivery: assets.DeliveryLocal}, Stream: &service.ResourceStream{Resource: r, Body: io.NopCloser(strings.NewReader("content")), StatusCode: 200, ContentLength: 7}}, "", "inline")
})
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/file", nil))
if w.Code != 200 {
t.Fatalf("status=%d", w.Code)
}
if mimeType == "image/png" {
if w.Header().Get("Content-Disposition") != "inline" {
t.Fatal("media no longer inline")
}
} else if !strings.HasPrefix(w.Header().Get("Content-Disposition"), "attachment") || !strings.Contains(w.Header().Get("Content-Security-Policy"), "sandbox") {
t.Fatalf("unsafe non-media headers: %v", w.Header())
}
})
}
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"io"
"mime"
"net/http"
"strings"
@@ -69,6 +70,12 @@ func serveResourceDelivery(c *gin.Context, delivery *service.ResourceDelivery, c
c.Header("Content-Disposition", disposition)
}
resource := stream.Resource
if !assets.InlineMediaType(resource.MimeType) {
if dispositionType, _, _ := mime.ParseMediaType(disposition); dispositionType != "attachment" {
c.Header("Content-Disposition", "attachment")
}
c.Header("Content-Security-Policy", "sandbox; default-src 'none'")
}
mimeType := resource.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"