fix(media): 资源交付 - 按内容识别上传类型并统一重试与附件交付 (#678)
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
161
backend/internal/app/resource_upload_content_test.go
Normal file
161
backend/internal/app/resource_upload_content_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
24
backend/internal/assets/content_delivery_test.go
Normal file
24
backend/internal/assets/content_delivery_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/internal/assets/content_type.go
Normal file
16
backend/internal/assets/content_type.go
Normal 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/")
|
||||
}
|
||||
39
backend/internal/handler/resource_content_delivery_test.go
Normal file
39
backend/internal/handler/resource_content_delivery_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -27,13 +27,17 @@ description: 信封、错误码与 OpenAPI
|
||||
|
||||
## 资源访问与 OSS/CDN 分发
|
||||
|
||||
普通上传、分片合并及 URL 导入按内容签名识别 MIME,不以客户端声明或文件扩展名决定媒体类型;无法识别的内容仍可作为附件上传。上传标识对应的 ready/failed 重试均先读取内容,并核对检测后的 MIME、资源类型与大小;不一致返回 409,不覆盖原对象。这是内容判型与元数据一致性校验,不是完整格式解码或内容摘要去重。
|
||||
|
||||
非媒体文件(包括可执行脚本的 SVG)通过平台本地读取或代理交付,强制 `Content-Disposition: attachment` 与 `Content-Security-Policy: sandbox; default-src 'none'`,不向浏览器或访问描述返回其 OSS/CDN 直链。普通图片、音频及视频保持原有媒体分发规则。历史资源不在本次变更中批量重新判型。
|
||||
|
||||
资源展示、播放、下载、复制地址和模型输入统一消费资源访问合同,不在客户端通过 `direct=1`、`proxy=1` 或存储厂商规则选择出口。
|
||||
|
||||
- `POST /api/resources/access` 要求登录,请求体为 1–100 项 `{ resourceId, purpose?, variant? }`。`purpose` 支持 `display`、`copy`、`download`、`browser-process`、`provider-input`;`variant` 支持 `original`、`playback`、`thumbnail`(缩略图未生成前回落原件,`fallbackReason` 为 `thumbnail_not_ready`)。每项独立返回 `access` 或结构化 `error`。
|
||||
- `access.delivery` 为 `cdn`、`origin`、`platform-local` 或 `platform-proxy`。配置了可用 CDN 时返回 CDN 地址;OSS 源站签名是源站直连,不等于 CDN。`requireCDN` 开启且 CDN 鉴权不完整时失败,不静默回源。浏览器的展示、复制、下载和本地处理不允许使用 `platform-proxy`,云资源正文始终由 OSS/CDN 直接发送给浏览器;`allowPrivateProxy` 只允许服务端在 `provider-input` 场景向第三方模型提交参考素材时读取私有源站。
|
||||
- `access.delivery` 为 `cdn`、`origin`、`platform-local` 或 `platform-proxy`。媒体配置了可用 CDN 时返回 CDN 地址;OSS 源站签名是源站直连,不等于 CDN。媒体的 `requireCDN` 开启且 CDN 鉴权不完整时失败,不静默回源。浏览器媒体的展示、复制、下载和本地处理不使用 `platform-proxy`,正文由 OSS/CDN 直接发送;`allowPrivateProxy` 允许服务端在 `provider-input` 场景读取私有媒体源站。非媒体附件始终通过平台控制响应头,不受上述媒体直连规则影响。
|
||||
- `expiresAt` / `refreshAt` 只描述当前授权有效期。有效期来自后台「资源与请求策略」(`provider-input` 与其余签名访问分别配置,默认均为 4 小时);公开 CDN 可没有 `expiresAt`。签名地址不写入画布或服务端持久化内容。
|
||||
- `display`、`copy`、`browser-process` 的源站签名把签名起始时间对齐到有效期 1/4 的时间窗(按 Unix 纪元对齐),过期时间为窗口起点加有效期:同一窗口内重复签发得到逐字节相同的 URL,刷新画布或多实例签发都能命中浏览器 HTTP 缓存;签名同时携带 `response-cache-control=private, max-age=<有效期>, immutable`,由对象存储回写缓存头。`download` 与 `provider-input` 仍按当前时间签发,不带缓存覆盖。
|
||||
- `GET /api/resources/:id/file` 仍是稳定平台入口,负责本地/平台代理字节流,云资源只返回 307 到 CDN 或对象存储;`?access=1` 只返回访问描述,不打开正文。`Range` 只影响字节流路径。
|
||||
- `GET /api/resources/:id/file` 仍是稳定平台入口,负责本地/平台代理字节流,可直连的云媒体返回 307 到 CDN 或对象存储,非媒体附件走平台流;`?access=1` 只返回访问描述,不打开正文。`Range` 只影响字节流路径。
|
||||
- 公共资源、画布分享、外观、公告、管理员资源和日志媒体复用同一个 HTTP 执行器。公共签名链接受自身到期时间和变体绑定限制,但已签发的外部 URL 不承诺立即撤销。
|
||||
|
||||
当前自动签名能力:公开 CDN、七牛私有下载签名和对象存储源站签名。阿里云/腾讯云私有 CDN 签名尚未自动支持,不能仅填写 CDN 域名就宣称已命中 CDN。真实 CORS、Range、CDN 命中率及账单收益必须在实际云环境通过浏览器网络记录和服务端流量观测验收。
|
||||
|
||||
@@ -18,6 +18,11 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
- [x] PostgreSQL 独立测试区验证帧选择进入任务输入和持久化草稿,协议按明确字段识别首尾帧,不依赖参考数组顺序;未调用外部模型,测试区已删除。
|
||||
- [ ] 开启新的 Agent 对话,确认 Agent 能设置下方首尾帧选项,刷新后仍保留;用户确认后再批准真实视频生成。
|
||||
|
||||
## 上传内容与附件交付
|
||||
|
||||
- [ ] 运行 `TestUploadContentImportSocket` 验证 URL 导入与重试;在可解析外部存储域名的环境复测 `TestResolveAccessPolicyMatrix`。
|
||||
- [ ] PostgreSQL 上传重试、真实 OSS 附件代理与浏览器附件下载验收。
|
||||
|
||||
## Agent 失败消息合并与展示
|
||||
|
||||
- 已修复:同一运行的 `run_failed` / `error` 与失败状态快照共用一条错误消息;以公开失败原因补全内容,重放、乱序和空原因不覆盖已有详细说明。不同运行即使错误文本相同也分别保留,工具失败与连接错误不合并。
|
||||
|
||||
Reference in New Issue
Block a user