Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions openapi/Swarm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -255,32 +255,48 @@ paths:
$ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress"
required: true
description: Swarm address of chunk
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmCache"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyStrategyParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyFallbackModeParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyLevelParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmChunkRetrievalTimeoutParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress"
responses:
"200":
description: The chunk exists.
description: Headers for the content at the reference.
headers:
Content-Type:
description: The MIME type of the resource (e.g., application/octet-stream).
schema:
type: string
example: application/octet-stream
Content-Length:
description: The size of the chunk in bytes.
description: The size of the retrievable content in bytes.
schema:
type: integer
example: 1024
Accept-Ranges:
description: Indicates that ranged requests are supported for the reference.
schema:
type: string
example: bytes
ETag:
description: The reference, as an entity tag.
schema:
type: string
Access-Control-Expose-Headers:
description: Headers exposed for CORS.
schema:
type: string
example: Accept-Ranges, Content-Encoding
example: Content-Disposition, Accept-Ranges
"400":
$ref: "SwarmCommon.yaml#/components/responses/400"
"404":
$ref: "SwarmCommon.yaml#/components/responses/404"
"500":
$ref: "SwarmCommon.yaml#/components/responses/500"
default:
description: Default response

Expand Down Expand Up @@ -478,16 +494,48 @@ paths:
$ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress"
required: true
description: Swarm address of chunk
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmCache"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyStrategyParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyFallbackModeParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyLevelParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmChunkRetrievalTimeoutParameter"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher"
- $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress"
responses:
"200":
description: Chunk exists
description: Headers for the content at the reference.
headers:
Content-Type:
description: The MIME type of the resource.
schema:
type: string
example: application/octet-stream
Content-Length:
description: The size of the retrievable content in bytes.
schema:
type: integer
example: 1024
Accept-Ranges:
description: Indicates that ranged requests are supported for the reference.
schema:
type: string
example: bytes
ETag:
description: The reference, as an entity tag.
schema:
type: string
Access-Control-Expose-Headers:
description: Headers exposed for CORS.
schema:
type: string
example: Content-Disposition, Accept-Ranges
"400":
$ref: "SwarmCommon.yaml#/components/responses/400"
"404":
$ref: "SwarmCommon.yaml#/components/responses/404"
"500":
$ref: "SwarmCommon.yaml#/components/responses/500"
default:
description: Default response

Expand Down
1 change: 1 addition & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const (
ContentTypeHeader = "Content-Type"
ContentDispositionHeader = "Content-Disposition"
ContentLengthHeader = "Content-Length"
AcceptRangesHeader = "Accept-Ranges"
RangeHeader = "Range"
OriginHeader = "Origin"
AccessControlExposeHeaders = "Access-Control-Expose-Headers"
Expand Down
26 changes: 5 additions & 21 deletions pkg/api/bytes.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,12 @@
package api

import (
"encoding/binary"
"errors"
"fmt"
"net/http"
"strconv"
"time"

"github.com/ethersphere/bee/v2/pkg/accesscontrol"
"github.com/ethersphere/bee/v2/pkg/cac"
"github.com/ethersphere/bee/v2/pkg/file/redundancy"
"github.com/ethersphere/bee/v2/pkg/jsonhttp"
"github.com/ethersphere/bee/v2/pkg/postage"
Expand Down Expand Up @@ -209,24 +206,11 @@ func (s *Service) bytesHeadHandler(w http.ResponseWriter, r *http.Request) {
address = v
}

getter := s.storer.Download(true)
ch, err := getter.Get(r.Context(), address)
if err != nil {
logger.Debug("get root chunk failed", "chunk_address", address, "error", err)
logger.Error(nil, "get root chunk failed")
w.WriteHeader(http.StatusNotFound)
return
additionalHeaders := http.Header{
ContentTypeHeader: {"application/octet-stream"},
}

w.Header().Add(AccessControlExposeHeaders, "Accept-Ranges, Content-Encoding")
w.Header().Add(ContentTypeHeader, "application/octet-stream")
var span int64

if cac.Valid(ch) {
span = int64(binary.LittleEndian.Uint64(ch.Data()[:swarm.SpanSize]))
} else {
span = int64(len(ch.Data()))
}
w.Header().Set(ContentLengthHeader, strconv.FormatInt(span, 10))
w.WriteHeader(http.StatusOK) // HEAD requests do not write a body
// share the GET path: the joiner strips the redundancy level encoded in the
// root chunk span and splits an encrypted reference into address and key.
s.downloadHandler(logger, w, r, address, additionalHeaders, true, true, nil)
}
79 changes: 79 additions & 0 deletions pkg/api/bytes_test.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a test case to TestBytesHead for HEAD requests with a Range header (e.g. Range: bytes=0-10)?

Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,82 @@ func TestBytesRedundancyLevel(t *testing.T) {
})
}
}

// TestBytesHead tests that HEAD reports the same entity headers as GET, for every
// redundancy level and for encrypted references.
func TestBytesHead(t *testing.T) {
t.Parallel()

g := mockbytes.New(0, mockbytes.MockTypeStandard).WithModulus(255)
content, err := g.SequentialBytes(swarm.ChunkSize * 10)
if err != nil {
t.Fatal(err)
}

for level := redundancy.NONE; level <= redundancy.PARANOID; level++ {
for _, encrypt := range []bool{false, true} {
t.Run(fmt.Sprintf("level %d encrypt %v", level, encrypt), func(t *testing.T) {
t.Parallel()

client, _, _, _ := newTestServer(t, testServerOptions{
Storer: mockstorer.New(),
Post: mockpost.New(mockpost.WithAcceptAll()),
})

var resp struct {
Reference swarm.Address `json:"reference"`
}
jsonhttptest.Request(t, client, http.MethodPost, "/bytes", http.StatusCreated,
jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"),
jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr),
jsonhttptest.WithRequestHeader(api.SwarmRedundancyLevelHeader, strconv.Itoa(int(level))),
jsonhttptest.WithRequestHeader(api.SwarmEncryptHeader, strconv.FormatBool(encrypt)),
jsonhttptest.WithRequestBody(bytes.NewReader(content)),
jsonhttptest.WithUnmarshalJSONResponse(&resp),
)

resource := "/bytes/" + resp.Reference.String()

jsonhttptest.Request(t, client, http.MethodHead, resource, http.StatusOK,
jsonhttptest.WithExpectedContentLength(len(content)),
jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"),
jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"),
)
jsonhttptest.Request(t, client, http.MethodGet, resource, http.StatusOK,
jsonhttptest.WithExpectedContentLength(len(content)),
jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/octet-stream"),
jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"),
)
})
}
}
}

// TestBytesHeadErrorsMatchGet tests that HEAD reports the same status as GET for
// references that cannot be served.
func TestBytesHeadErrorsMatchGet(t *testing.T) {
t.Parallel()

client, _, _, _ := newTestServer(t, testServerOptions{
Storer: mockstorer.New(),
Post: mockpost.New(mockpost.WithAcceptAll()),
})

tests := []struct {
name string
ref string
want int
}{
{"short address", "abcd", http.StatusInternalServerError},
{"unknown address", "0000000000000000000000000000000000000000000000000000000000000001", http.StatusNotFound},
{"non-hex address", "zzzz", http.StatusBadRequest},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resource := "/bytes/" + tt.ref
jsonhttptest.Request(t, client, http.MethodHead, resource, tt.want)
jsonhttptest.Request(t, client, http.MethodGet, resource, tt.want)
})
}
}
4 changes: 4 additions & 0 deletions pkg/api/bzz.go
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,10 @@ func (s *Service) downloadHandler(logger log.Logger, w http.ResponseWriter, r *h
w.Header().Add(AccessControlExposeHeaders, ContentDispositionHeader)

if headersOnly {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early-returning 200 OK here bypasses Go's http.ServeContent, which causes HEAD requests containing a Range header (e.g. HEAD /bytes/ with Range: bytes=0-99) to return 200 OK with the total content length and no Content-Range header.

In contrast, a GET request with Range: bytes=0-99 returns 206 Partial Content with Content-Range: bytes 0-99/ and Content-Length: 100.

According to RFC 9110 Section 9.3.2, HEAD requests must return identical entity headers to GET. Conditional HEAD requests (If-None-Match, If-Modified-Since) also fail to return 304 Not Modified for the same reason.

// http.ServeContent would set this, but the GET path is not reached here.
// "bytes" is the range unit, not the endpoint.
w.Header().Set(AcceptRangesHeader, "bytes")
w.Header().Add(AccessControlExposeHeaders, AcceptRangesHeader)
w.WriteHeader(http.StatusOK)
return
}
Expand Down
1 change: 1 addition & 0 deletions pkg/api/bzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ func TestBzzFiles(t *testing.T) {
jsonhttptest.WithRequestBody(bytes.NewReader(simpleData)),
jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"),
jsonhttptest.WithExpectedContentLength(21),
jsonhttptest.WithExpectedResponseHeader(api.AcceptRangesHeader, "bytes"),
)
})
})
Expand Down
Loading