From 629beda1eba1fc93e583fdd66e9900573eb4aa42 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 27 May 2026 12:13:01 -0700 Subject: [PATCH] fix(remote_storage/s3): forward entry mime as ContentType (#9708) fix(remote_storage/s3): forward entry.Attributes.Mime as ContentType filer.remote.sync was uploading every object without a Content-Type, so S3-compatible backends (e.g. Backblaze B2) stored binary/octet-stream and browsers refused to render HTML, CSS, etc. Pass entry.Attributes.Mime through to UploadInput.ContentType, leaving the header unset when no Mime is recorded so the remote keeps its own default behavior. --- weed/remote_storage/s3/s3_storage_client.go | 3 + .../s3/s3_storage_client_test.go | 89 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/weed/remote_storage/s3/s3_storage_client.go b/weed/remote_storage/s3/s3_storage_client.go index 023d6cb7f..ec0ab0f06 100644 --- a/weed/remote_storage/s3/s3_storage_client.go +++ b/weed/remote_storage/s3/s3_storage_client.go @@ -299,6 +299,9 @@ func (s *s3RemoteStorageClient) WriteFile(loc *remote_pb.RemoteStorageLocation, Body: reader, Tagging: awsTags, } + if entry.Attributes != nil && entry.Attributes.Mime != "" { + uploadInput.ContentType = aws.String(entry.Attributes.Mime) + } if s.conf.S3StorageClass != "" { uploadInput.StorageClass = aws.String(s.conf.S3StorageClass) } diff --git a/weed/remote_storage/s3/s3_storage_client_test.go b/weed/remote_storage/s3/s3_storage_client_test.go index 4976cad77..79bb794b6 100644 --- a/weed/remote_storage/s3/s3_storage_client_test.go +++ b/weed/remote_storage/s3/s3_storage_client_test.go @@ -1,10 +1,15 @@ package s3 import ( + "bytes" + "io" + "net/http" + "strings" "testing" "github.com/aws/aws-sdk-go/aws/credentials" awss3 "github.com/aws/aws-sdk-go/service/s3" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/remote_pb" "github.com/seaweedfs/seaweedfs/weed/remote_storage" "github.com/stretchr/testify/require" @@ -65,3 +70,87 @@ func TestS3ErrRemoteObjectNotFoundIsAccessible(t *testing.T) { require.Error(t, remote_storage.ErrRemoteObjectNotFound) require.Equal(t, "remote object not found", remote_storage.ErrRemoteObjectNotFound.Error()) } + +// captureRoundTripper records the PUT request that the s3manager uploader +// sends, and short-circuits all calls with a 200 so the SDK is satisfied. +type captureRoundTripper struct { + uploadReq *http.Request +} + +func (c *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodPut { + c.uploadReq = req.Clone(req.Context()) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + _ = req.Body.Close() + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + Header: http.Header{ + "ETag": []string{"\"etag\""}, + }, + Request: req, + }, nil +} + +func (c *captureRoundTripper) uploadContentType() string { + if c.uploadReq == nil { + return "" + } + return c.uploadReq.Header.Get("Content-Type") +} + +func newCapturingS3Client(t *testing.T) (*s3RemoteStorageClient, *captureRoundTripper) { + t.Helper() + rt := &captureRoundTripper{} + conf := &remote_pb.RemoteConf{ + Name: "test", + S3Region: "us-east-1", + S3Endpoint: "https://example.invalid", + S3ForcePathStyle: true, + S3AccessKey: "test-key", + S3SecretKey: "test-secret", + } + httpClient := &http.Client{Transport: rt} + rs, err := MakeWithHTTPClient(conf, httpClient) + require.NoError(t, err) + return rs.(*s3RemoteStorageClient), rt +} + +func TestS3WriteFilePassesMimeAsContentType(t *testing.T) { + client, rt := newCapturingS3Client(t) + loc := &remote_pb.RemoteStorageLocation{ + Name: "test", + Bucket: "bucket", + Path: "/dir/test.html", + } + entry := &filer_pb.Entry{ + Attributes: &filer_pb.FuseAttributes{Mime: "text/html"}, + } + + _, _ = client.WriteFile(loc, entry, bytes.NewReader([]byte(""))) + + require.NotNil(t, rt.uploadReq, "uploader should have issued a PUT") + require.Equal(t, "text/html", rt.uploadContentType(), "Content-Type should match entry.Attributes.Mime") +} + +func TestS3WriteFileOmitsContentTypeWhenMimeMissing(t *testing.T) { + client, rt := newCapturingS3Client(t) + loc := &remote_pb.RemoteStorageLocation{ + Name: "test", + Bucket: "bucket", + Path: "/dir/test.bin", + } + entry := &filer_pb.Entry{ + Attributes: &filer_pb.FuseAttributes{}, + } + + _, _ = client.WriteFile(loc, entry, bytes.NewReader([]byte("data"))) + + require.NotNil(t, rt.uploadReq, "uploader should have issued a PUT") + // When entry.Attributes.Mime is empty we don't force a Content-Type so the + // remote can apply its own default rather than getting a misleading one. + require.Equal(t, "", rt.uploadContentType()) +}