Skip to content

Commit de679ed

Browse files
committed
gateway: set Content-Location for non-default response formats
1 parent 7f95068 commit de679ed

5 files changed

Lines changed: 141 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The following emojis are used to highlight certain changes:
1919
*`gateway` has new backend possibilities:
2020
* `NewRemoteBlocksBackend` allows you to create a gateway backend that uses one or multiple other gateways as backend. These gateways must support RAW block requests (`application/vnd.ipld.raw`), as well as IPNS Record requests (`application/vnd.ipfs.ipns-record`). With this, we also introduced `NewCacheBlockStore`, `NewRemoteBlockstore` and `NewRemoteValueStore`.
2121
* `NewRemoteCarBackend` allows you to create a gateway backend that uses one or multiple Trustless Gateways as backend. These gateways must support CAR requests (`application/vnd.ipld.car`), as well as the extensions describe in [IPIP-402](https://specs.ipfs.tech/ipips/ipip-0402/). With this, we also introduced `NewCarBackend`, `NewRemoteCarFetcher` and `NewRetryCarFetcher`.
22+
* `gateway` now sets the [`Content-Location`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Location) header for requests with non-default content format, as a result of content negotiation.
2223

2324
### Changed
2425

gateway/gateway.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,11 @@ const (
405405
// [Subdomain Gateway]: https://specs.ipfs.tech/http-gateways/subdomain-gateway/
406406
SubdomainHostnameKey RequestContextKey = "subdomain-hostname"
407407

408-
// ContentPathKey is the key for the original [http.Request] URL Path, as an [ipath.Path].
408+
// OriginalPathKey is the key for the original [http.Request] [url.URL.Path],
409+
// as a string. This is the original path of the request, before [NewHostnameHandler].
410+
OriginalPathKey RequestContextKey = "original-path-key"
411+
412+
// ContentPathKey is the key for the content [path.Path] of the current request.
413+
// This already accounts with changes made with [NewHostnameHandler].
409414
ContentPathKey RequestContextKey = "content-path"
410415
)

gateway/gateway_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,79 @@ func TestHeaders(t *testing.T) {
417417
testCORSPreflightRequest(t, "/", cid+".ipfs.subgw.example.com", "https://other.example.net", http.StatusOK)
418418
})
419419
})
420+
421+
t.Run("Content-Location is set when possible", func(t *testing.T) {
422+
backend, root := newMockBackend(t, "fixtures.car")
423+
backend.namesys["/ipns/dnslink-gateway.com"] = newMockNamesysItem(path.FromCid(root), 0)
424+
425+
ts := newTestServerWithConfig(t, backend, Config{
426+
NoDNSLink: false,
427+
PublicGateways: map[string]*PublicGateway{
428+
"dnslink-gateway.com": {
429+
Paths: []string{},
430+
NoDNSLink: false,
431+
DeserializedResponses: true,
432+
},
433+
"subdomain-gateway.com": {
434+
Paths: []string{"/ipfs", "/ipns"},
435+
UseSubdomains: true,
436+
NoDNSLink: true,
437+
DeserializedResponses: true,
438+
},
439+
},
440+
DeserializedResponses: true,
441+
})
442+
443+
runTest := func(name, path, accept, host, expectedContentPath string) {
444+
t.Run(name, func(t *testing.T) {
445+
t.Parallel()
446+
447+
req := mustNewRequest(t, http.MethodGet, ts.URL+path, nil)
448+
449+
if accept != "" {
450+
req.Header.Set("Accept", accept)
451+
}
452+
453+
if host != "" {
454+
req.Host = host
455+
}
456+
457+
resp := mustDoWithoutRedirect(t, req)
458+
defer resp.Body.Close()
459+
460+
body, err := io.ReadAll(resp.Body)
461+
require.NoError(t, err)
462+
463+
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
464+
require.Equal(t, expectedContentPath, resp.Header.Get("Content-Location"))
465+
})
466+
}
467+
468+
contentPath := path.FromCid(root).String() + "/empty-dir/"
469+
subdomainGatewayHost := root.String() + ".ipfs.subdomain-gateway.com"
470+
dnslinkGatewayHost := "dnslink-gateway.com"
471+
472+
runTest("Regular gateway with default format", contentPath, "", "", "")
473+
runTest("Regular gateway with Accept: application/vnd.ipld.car has no Content-Location", contentPath, "application/vnd.ipld.car;version=1;order=dfs;dups=n", "", "")
474+
runTest("Regular gateway with ?dag-scope=entity&format=car", contentPath+"?dag-scope=entity&format=car", "", "", contentPath+"?dag-scope=entity&format=car")
475+
runTest("Subdomain gateway with default format", "/empty-dir/", "", subdomainGatewayHost, "")
476+
runTest("DNSLink gateway with default format", "/empty-dir/", "", dnslinkGatewayHost, "")
477+
478+
for responseFormat, formatParam := range responseFormatToFormatParam {
479+
if responseFormat == ipnsRecordResponseFormat {
480+
continue
481+
}
482+
483+
runTest("Regular gateway with Accept: "+responseFormat, contentPath, responseFormat, "", contentPath+"?format="+formatParam)
484+
runTest("Regular gateway with ?format="+formatParam, contentPath+"?format="+formatParam, "", "", contentPath+"?format="+formatParam)
485+
486+
runTest("Subdomain gateway with Accept: "+responseFormat, "/empty-dir/", responseFormat, subdomainGatewayHost, "/empty-dir/?format="+formatParam)
487+
runTest("Subdomain gateway with ?format="+formatParam, "/empty-dir/?format="+formatParam, "", subdomainGatewayHost, "/empty-dir/?format="+formatParam)
488+
489+
runTest("DNSLink gateway with Accept: "+responseFormat, "/empty-dir/", responseFormat, dnslinkGatewayHost, "/empty-dir/?format="+formatParam)
490+
runTest("DNSLink gateway with ?format="+formatParam, "/empty-dir/?format="+formatParam, "", dnslinkGatewayHost, "/empty-dir/?format="+formatParam)
491+
}
492+
})
420493
}
421494

422495
func TestGoGetSupport(t *testing.T) {

gateway/handler.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) {
259259
responseParams: formatParams,
260260
}
261261

262+
addContentLocation(r, w, rq)
263+
262264
// IPNS Record response format can be handled now, since (1) it needs the
263265
// non-resolved mutable path, and (2) has custom If-None-Match header handling
264266
// due to custom ETag.
@@ -586,6 +588,27 @@ const (
586588
ipnsRecordResponseFormat = "application/vnd.ipfs.ipns-record"
587589
)
588590

591+
var (
592+
formatParamToResponseFormat = map[string]string{
593+
"raw": rawResponseFormat,
594+
"car": carResponseFormat,
595+
"tar": tarResponseFormat,
596+
"json": jsonResponseFormat,
597+
"cbor": cborResponseFormat,
598+
"dag-json": dagJsonResponseFormat,
599+
"dag-cbor": dagCborResponseFormat,
600+
"ipns-record": ipnsRecordResponseFormat,
601+
}
602+
603+
responseFormatToFormatParam = map[string]string{}
604+
)
605+
606+
func init() {
607+
for k, v := range formatParamToResponseFormat {
608+
responseFormatToFormatParam[v] = k
609+
}
610+
}
611+
589612
// return explicit response format if specified in request as query parameter or via Accept HTTP header
590613
func customResponseFormat(r *http.Request) (mediaType string, params map[string]string, err error) {
591614
// First, inspect Accept header, as it may not only include content type, but also optional parameters.
@@ -615,23 +638,8 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
615638

616639
// If no Accept header, translate query param to a content type, if present.
617640
if formatParam := r.URL.Query().Get("format"); formatParam != "" {
618-
switch formatParam {
619-
case "raw":
620-
return rawResponseFormat, nil, nil
621-
case "car":
622-
return carResponseFormat, nil, nil
623-
case "tar":
624-
return tarResponseFormat, nil, nil
625-
case "json":
626-
return jsonResponseFormat, nil, nil
627-
case "cbor":
628-
return cborResponseFormat, nil, nil
629-
case "dag-json":
630-
return dagJsonResponseFormat, nil, nil
631-
case "dag-cbor":
632-
return dagCborResponseFormat, nil, nil
633-
case "ipns-record":
634-
return ipnsRecordResponseFormat, nil, nil
641+
if responseFormat, ok := formatParamToResponseFormat[formatParam]; ok {
642+
return responseFormat, nil, nil
635643
}
636644
}
637645

@@ -640,6 +648,39 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
640648
return "", nil, nil
641649
}
642650

651+
// Add 'Content-Location' headers for non-default response formats. This allows
652+
// correct caching of such format requests when the format is passed via the
653+
// Accept header, for example.
654+
func addContentLocation(r *http.Request, w http.ResponseWriter, rq *requestData) {
655+
if rq.responseFormat == "" {
656+
return
657+
}
658+
659+
// Response format parameters, such as 'dups' and 'order' for CAR requests
660+
// cannot be translated into the URL. Therefore, we cannot add a 'Content-Location'
661+
// header.
662+
if len(rq.responseParams) != 0 {
663+
return
664+
}
665+
666+
param := responseFormatToFormatParam[rq.responseFormat]
667+
path := r.URL.Path
668+
if p, ok := r.Context().Value(OriginalPathKey).(string); ok {
669+
path = p
670+
}
671+
672+
// Copy known query parameters.
673+
query := url.Values{}
674+
for _, param := range []string{carRangeBytesKey, carTerminalElementTypeKey, "filename", "download"} {
675+
if v := r.URL.Query().Get(param); v != "" {
676+
query.Set(param, v)
677+
}
678+
}
679+
query.Set("format", param)
680+
681+
w.Header().Set("Content-Location", path+"?"+query.Encode())
682+
}
683+
643684
// returns unquoted path with all special characters revealed as \u codes
644685
func debugStr(path string) string {
645686
q := fmt.Sprintf("%+q", path)

gateway/hostname.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ func NewHostnameHandler(c Config, backend IPFSBackend, next http.Handler) http.H
2828
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2929
defer panicHandler(w)
3030

31+
ctx := context.WithValue(r.Context(), OriginalPathKey, r.URL.Path)
32+
r = r.WithContext(ctx)
33+
3134
// First check for protocol handler redirects.
3235
if handleProtocolHandlerRedirect(w, r, &c) {
3336
return

0 commit comments

Comments
 (0)