From 6fb186a296c31042322e70ae6b9f549d4b7c2119 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 12 Feb 2026 10:21:03 +0100 Subject: [PATCH 1/9] fix(api): enhance content type handling in bzzUploadHandler for directory uploads --- pkg/api/bzz.go | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index d99594bc682..5695249f0b7 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -5,10 +5,12 @@ package api import ( + "bytes" "context" "encoding/hex" "errors" "fmt" + "io" "net/http" "path" "path/filepath" @@ -51,6 +53,9 @@ const ( largeFileBufferSize = 16 * 32 * 1024 largeBufferFilesizeThreshold = 10 * 1000000 // ten megs + + // contentTypeSniffLen is the max bytes used by http.DetectContentType. + contentTypeSniffLen = 512 ) func lookaheadBufferSize(size int64) int { @@ -65,7 +70,7 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { defer span.Finish() headers := struct { - ContentType string `map:"Content-Type,mimeMediaType" validate:"required"` + ContentType string `map:"Content-Type,mimeMediaType"` BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` SwarmTag uint64 `map:"Swarm-Tag"` Pin bool `map:"Swarm-Pin"` @@ -138,6 +143,12 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { } if headers.IsDir || headers.ContentType == multiPartFormData { + if headers.ContentType == "" { + logger.Debug("content-type required for directory upload") + logger.Error(nil, "content-type required for directory upload") + jsonhttp.BadRequest(w, errInvalidContentType) + return + } s.dirUploadHandler(ctx, logger, span, ow, r, putter, r.Header.Get(ContentTypeHeader), headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) return } @@ -174,8 +185,20 @@ func (s *Service) fileUploadHandler( p := requestPipelineFn(putter, encrypt, rLevel) + sniffBuf := make([]byte, contentTypeSniffLen) + n, err := io.ReadFull(r.Body, sniffBuf) + sniffBuf = sniffBuf[:n] + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + logger.Debug("body read failed", "file_name", queries.FileName, "error", err) + logger.Error(nil, "body read failed", "file_name", queries.FileName) + jsonhttp.BadRequest(w, "failed to read request body") + return + } + contentType := http.DetectContentType(sniffBuf) + bodyForStore := io.MultiReader(bytes.NewReader(sniffBuf), r.Body) + // first store the file and get its reference - fr, err := p(ctx, r.Body) + fr, err := p(ctx, bodyForStore) if err != nil { logger.Debug("file store failed", "file_name", queries.FileName, "error", err) logger.Error(nil, "file store failed", "file_name", queries.FileName) @@ -240,7 +263,7 @@ func (s *Service) fileUploadHandler( } fileMtdt := map[string]string{ - manifest.EntryMetadataContentTypeKey: r.Header.Get(ContentTypeHeader), // Content-Type has already been validated. + manifest.EntryMetadataContentTypeKey: contentType, manifest.EntryMetadataFilenameKey: queries.FileName, } From b405b73d7b713debd3a6e9871b3af3033609831e Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Tue, 10 Mar 2026 16:13:41 +0100 Subject: [PATCH 2/9] fix(api): improve error handling in fileUploadHandler for EOF scenarios --- pkg/api/bzz.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index 5695249f0b7..8a1c0579e0d 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -188,7 +188,7 @@ func (s *Service) fileUploadHandler( sniffBuf := make([]byte, contentTypeSniffLen) n, err := io.ReadFull(r.Body, sniffBuf) sniffBuf = sniffBuf[:n] - if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { logger.Debug("body read failed", "file_name", queries.FileName, "error", err) logger.Error(nil, "body read failed", "file_name", queries.FileName) jsonhttp.BadRequest(w, "failed to read request body") From 147ce4b4f4b9a8077f1efd8485e51130162fed6d Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 9 Apr 2026 13:18:06 +0200 Subject: [PATCH 3/9] refactor: remove unused mime parsing and streamline content type handling --- pkg/api/api.go | 5 ----- pkg/api/bzz.go | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 93595168cc3..799f62f0053 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -16,7 +16,6 @@ import ( "io" "math" "math/big" - "mime" "net/http" "reflect" "strconv" @@ -302,10 +301,6 @@ func New( s.chainBackend = chainBackend s.metricsRegistry = newDebugMetrics() s.preMapHooks = map[string]func(v string) (string, error){ - "mimeMediaType": func(v string) (string, error) { - typ, _, err := mime.ParseMediaType(v) - return typ, err - }, "decBase64url": func(v string) (string, error) { buf, err := base64.URLEncoding.DecodeString(v) return string(buf), err diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index 8a1c0579e0d..f85189e8942 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "path" "path/filepath" @@ -70,7 +71,7 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { defer span.Finish() headers := struct { - ContentType string `map:"Content-Type,mimeMediaType"` + ContentType string `map:"Content-Type"` BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` SwarmTag uint64 `map:"Swarm-Tag"` Pin bool `map:"Swarm-Pin"` @@ -142,14 +143,17 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { logger: logger, } - if headers.IsDir || headers.ContentType == multiPartFormData { - if headers.ContentType == "" { + contentTypeHdr := strings.TrimSpace(headers.ContentType) + mt, _, errParseCT := mime.ParseMediaType(contentTypeHdr) + isMultipart := errParseCT == nil && mt == multiPartFormData + if headers.IsDir || isMultipart { + if contentTypeHdr == "" { logger.Debug("content-type required for directory upload") logger.Error(nil, "content-type required for directory upload") jsonhttp.BadRequest(w, errInvalidContentType) return } - s.dirUploadHandler(ctx, logger, span, ow, r, putter, r.Header.Get(ContentTypeHeader), headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) + s.dirUploadHandler(ctx, logger, span, ow, r, putter, contentTypeHdr, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) return } s.fileUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) @@ -194,7 +198,10 @@ func (s *Service) fileUploadHandler( jsonhttp.BadRequest(w, "failed to read request body") return } - contentType := http.DetectContentType(sniffBuf) + contentType := strings.TrimSpace(r.Header.Get(ContentTypeHeader)) + if contentType == "" { + contentType = http.DetectContentType(sniffBuf) + } bodyForStore := io.MultiReader(bytes.NewReader(sniffBuf), r.Body) // first store the file and get its reference From fe1e6a36ac377dc2096f27b4668f451c9ba08f15 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 9 Apr 2026 13:18:58 +0200 Subject: [PATCH 4/9] test: add content type handling tests for file uploads and downloads --- pkg/api/bzz_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/pkg/api/bzz_test.go b/pkg/api/bzz_test.go index 3f444e6cb07..a3e72bb660a 100644 --- a/pkg/api/bzz_test.go +++ b/pkg/api/bzz_test.go @@ -465,6 +465,45 @@ func TestBzzFiles(t *testing.T) { ) }) + t.Run("omit-content-type-uses-sniff", func(t *testing.T) { + fileName := "plain.txt" + var resp api.BzzUploadResponse + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(simpleData)), + jsonhttptest.WithUnmarshalJSONResponse(&resp), + ) + rootHash := resp.Reference.String() + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(rootHash), http.StatusOK, + jsonhttptest.WithExpectedResponse(simpleData), + jsonhttptest.WithExpectedContentLength(len(simpleData)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/plain; charset=utf-8"), + jsonhttptest.WithExpectedResponseHeader(api.ContentDispositionHeader, fmt.Sprintf(`inline; filename="%s"`, fileName)), + ) + }) + + t.Run("image-content-type-preserved", func(t *testing.T) { + // Valid image/* media type is stored as sent (body still text; sniff would differ without this header). + ct := "image/png" + fileName := "test.txt" + var resp api.BzzUploadResponse + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, ct), + jsonhttptest.WithRequestBody(bytes.NewReader(simpleData)), + jsonhttptest.WithUnmarshalJSONResponse(&resp), + ) + rootHash := resp.Reference.String() + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(rootHash), http.StatusOK, + jsonhttptest.WithExpectedResponse(simpleData), + jsonhttptest.WithExpectedContentLength(len(simpleData)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, ct), + jsonhttptest.WithExpectedResponseHeader(api.ContentDispositionHeader, fmt.Sprintf(`inline; filename="%s"`, fileName)), + ) + }) + t.Run("upload-then-download-and-check-data", func(t *testing.T) { fileName := "sample.html" rootHash := "36e6c1bbdfee6ac21485d5f970479fd1df458d36df9ef4e8179708ed46da557f" From 10580df01990f1ac53ef70f4a3a4b6cb78c8b2c2 Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 9 Apr 2026 13:20:18 +0200 Subject: [PATCH 5/9] docs: update content-type docs --- openapi/Swarm.yaml | 2 +- openapi/SwarmCommon.yaml | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 6bf0e89fc3c..6e9088cc45d 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -342,7 +342,7 @@ paths: post: summary: "Upload a file or collection of files" description: - "Upload single files or collections of files. To upload a collection, send a multipart request with files in the form data with appropriate headers. Tar files can be uploaded with the `swarm-collection` header to extract and upload the directory structure. Without the `swarm-collection` header, requests are treated as single file uploads. Multipart requests are always treated as collections; use the `swarm-index-document` header to specify a single file to serve." + "Upload single files or collections of files. For a single file, `Content-Type` is optional: when present it is stored as metadata as-is; when absent the server infers a type from the start of the body. To upload a collection, send a multipart request with files in the form data with appropriate headers. Tar files can be uploaded with the `swarm-collection` header to extract and upload the directory structure. Without the `swarm-collection` header, requests are treated as single file uploads. Multipart requests are always treated as collections; use the `swarm-index-document` header to specify a single file to serve." tags: - BZZ parameters: diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index ab39fa48555..84cb557863d 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -1176,7 +1176,14 @@ components: name: Content-Type schema: type: string - description: The specified content-type is preserved for download of the asset + description: > + For a single file body: if set (after trimming leading and trailing whitespace), this + value is stored in metadata and returned on download unchanged. If omitted or empty, + the server infers a MIME type from the first bytes of the body. The server does not + check that the header matches the file contents. For tar uploads with + `swarm-collection` or for multipart collections, the request `Content-Type` must + still describe the full body (for example `application/x-tar` or + `multipart/form-data` with a boundary) so the upload can be parsed. SwarmIndexDocumentParameter: in: header From bfaf1e7a29dd75dc56c3ee65b6f19c77edddc45b Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 9 Apr 2026 13:31:39 +0200 Subject: [PATCH 6/9] docs: refine Content-Type description and remove comments --- openapi/SwarmCommon.yaml | 9 +-------- pkg/api/bzz.go | 1 - pkg/api/bzz_test.go | 1 - 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index 84cb557863d..5bf0a0fbc81 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -1176,14 +1176,7 @@ components: name: Content-Type schema: type: string - description: > - For a single file body: if set (after trimming leading and trailing whitespace), this - value is stored in metadata and returned on download unchanged. If omitted or empty, - the server infers a MIME type from the first bytes of the body. The server does not - check that the header matches the file contents. For tar uploads with - `swarm-collection` or for multipart collections, the request `Content-Type` must - still describe the full body (for example `application/x-tar` or - `multipart/form-data` with a boundary) so the upload can be parsed. + description: "Single file: trimmed Content-Type is stored as-is or, if omitted or empty, inferred from the first bytes without validating against the body; tar (`swarm-collection`) and multipart collection uploads still need a full-body Content-Type (e.g. `application/x-tar` or `multipart/form-data` with boundary) so the request can be parsed." SwarmIndexDocumentParameter: in: header diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index f85189e8942..0ef3535414a 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -55,7 +55,6 @@ const ( largeBufferFilesizeThreshold = 10 * 1000000 // ten megs - // contentTypeSniffLen is the max bytes used by http.DetectContentType. contentTypeSniffLen = 512 ) diff --git a/pkg/api/bzz_test.go b/pkg/api/bzz_test.go index a3e72bb660a..08052b97bbf 100644 --- a/pkg/api/bzz_test.go +++ b/pkg/api/bzz_test.go @@ -484,7 +484,6 @@ func TestBzzFiles(t *testing.T) { }) t.Run("image-content-type-preserved", func(t *testing.T) { - // Valid image/* media type is stored as sent (body still text; sniff would differ without this header). ct := "image/png" fileName := "test.txt" var resp api.BzzUploadResponse From 8dbae2067cfc20f56f8ba0329959e7e53eed804f Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 9 Apr 2026 14:21:41 +0200 Subject: [PATCH 7/9] refactor: improve Content-Type handling in upload handlers --- pkg/api/bzz.go | 11 ++++++----- pkg/api/dirs.go | 3 +-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index 0ef3535414a..a5abcbd6f4a 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -143,6 +143,7 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { } contentTypeHdr := strings.TrimSpace(headers.ContentType) + r.Header.Set(ContentTypeHeader, contentTypeHdr) mt, _, errParseCT := mime.ParseMediaType(contentTypeHdr) isMultipart := errParseCT == nil && mt == multiPartFormData if headers.IsDir || isMultipart { @@ -152,7 +153,7 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { jsonhttp.BadRequest(w, errInvalidContentType) return } - s.dirUploadHandler(ctx, logger, span, ow, r, putter, contentTypeHdr, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) + s.dirUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) return } s.fileUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) @@ -197,9 +198,9 @@ func (s *Service) fileUploadHandler( jsonhttp.BadRequest(w, "failed to read request body") return } - contentType := strings.TrimSpace(r.Header.Get(ContentTypeHeader)) - if contentType == "" { - contentType = http.DetectContentType(sniffBuf) + + if r.Header.Get(ContentTypeHeader) == "" { + r.Header.Set(ContentTypeHeader, http.DetectContentType(sniffBuf)) } bodyForStore := io.MultiReader(bytes.NewReader(sniffBuf), r.Body) @@ -269,7 +270,7 @@ func (s *Service) fileUploadHandler( } fileMtdt := map[string]string{ - manifest.EntryMetadataContentTypeKey: contentType, + manifest.EntryMetadataContentTypeKey: r.Header.Get(ContentTypeHeader), manifest.EntryMetadataFilenameKey: queries.FileName, } diff --git a/pkg/api/dirs.go b/pkg/api/dirs.go index 58160790857..bfa9f764a2a 100644 --- a/pkg/api/dirs.go +++ b/pkg/api/dirs.go @@ -44,7 +44,6 @@ func (s *Service) dirUploadHandler( w http.ResponseWriter, r *http.Request, putter storer.PutterSession, - contentTypeString string, encrypt bool, tag uint64, rLevel redundancy.Level, @@ -58,7 +57,7 @@ func (s *Service) dirUploadHandler( } // The error is ignored because the header was already validated by the caller. - mediaType, params, _ := mime.ParseMediaType(contentTypeString) + mediaType, params, _ := mime.ParseMediaType(r.Header.Get(ContentTypeHeader)) var dReader dirReader switch mediaType { From 7e687cb8b6e16fb9b233e9ca0e80837a2f21f8ea Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Thu, 9 Apr 2026 14:38:01 +0200 Subject: [PATCH 8/9] refactor: streamline body reading and Content-Type detection in file upload handler --- pkg/api/bzz.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index a5abcbd6f4a..39a93ce9597 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -189,23 +189,25 @@ func (s *Service) fileUploadHandler( p := requestPipelineFn(putter, encrypt, rLevel) - sniffBuf := make([]byte, contentTypeSniffLen) - n, err := io.ReadFull(r.Body, sniffBuf) - sniffBuf = sniffBuf[:n] - if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { - logger.Debug("body read failed", "file_name", queries.FileName, "error", err) - logger.Error(nil, "body read failed", "file_name", queries.FileName) - jsonhttp.BadRequest(w, "failed to read request body") - return - } - + var body io.Reader = r.Body if r.Header.Get(ContentTypeHeader) == "" { + sniffBuf := make([]byte, contentTypeSniffLen) + n, err := io.ReadFull(r.Body, sniffBuf) + sniffBuf = sniffBuf[:n] + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + logger.Debug("body read failed", "file_name", queries.FileName, "error", err) + logger.Error(nil, "body read failed", "file_name", queries.FileName) + jsonhttp.BadRequest(w, "failed to read request body") + return + } + r.Header.Set(ContentTypeHeader, http.DetectContentType(sniffBuf)) + body = io.MultiReader(bytes.NewReader(sniffBuf), r.Body) } - bodyForStore := io.MultiReader(bytes.NewReader(sniffBuf), r.Body) + // first store the file and get its reference - fr, err := p(ctx, bodyForStore) + fr, err := p(ctx, body) if err != nil { logger.Debug("file store failed", "file_name", queries.FileName, "error", err) logger.Error(nil, "file store failed", "file_name", queries.FileName) From 12061135256684a5f79be54bbaebe4098feef37a Mon Sep 17 00:00:00 2001 From: akrem-chabchoub Date: Tue, 14 Apr 2026 10:40:01 +0200 Subject: [PATCH 9/9] refactor: enhance upload handler logic to differentiate between file and directory uploads --- pkg/api/bzz.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index 39a93ce9597..dbd1ad8a423 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -146,17 +146,20 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { r.Header.Set(ContentTypeHeader, contentTypeHdr) mt, _, errParseCT := mime.ParseMediaType(contentTypeHdr) isMultipart := errParseCT == nil && mt == multiPartFormData - if headers.IsDir || isMultipart { - if contentTypeHdr == "" { - logger.Debug("content-type required for directory upload") - logger.Error(nil, "content-type required for directory upload") - jsonhttp.BadRequest(w, errInvalidContentType) - return - } - s.dirUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) + + isDirUpload := headers.IsDir || isMultipart + if !isDirUpload { + s.fileUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) + return + } + + if contentTypeHdr == "" { + logger.Error(nil, "content-type required for directory upload") + jsonhttp.BadRequest(w, errInvalidContentType) return } - s.fileUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) + + s.dirUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) } // bzzUploadResponse is returned when an HTTP request to upload a file is successful @@ -205,7 +208,6 @@ func (s *Service) fileUploadHandler( body = io.MultiReader(bytes.NewReader(sniffBuf), r.Body) } - // first store the file and get its reference fr, err := p(ctx, body) if err != nil {