Skip to content

Commit fdd1965

Browse files
hacdiaslidel
andauthored
feat(gateway): JSON and CBOR response formats (IPIP-328) (#9335)
#9335 ipfs/specs#328 Co-authored-by: Marcin Rataj <[email protected]>
1 parent 4d4841f commit fdd1965

17 files changed

+938
-66
lines changed

assets/dag-index-html/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# dag-index-html
2+
3+
> HTML representation for non-UnixFS DAGs such as DAG-CBOR.

assets/dag-index-html/index.go

+81
Large diffs are not rendered by default.

assets/dir-index-html/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# dir-index-html
22

3-
> Directory listing HTML for `go-ipfs` gateways
3+
> Directory listing HTML for HTTP gateway
44
55
![](https://user-images.githubusercontent.com/157609/88379209-ce6f0600-cda2-11ea-9620-20b9237bb441.png)
66

assets/dir-index-html/dir-index.html

+6-6
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@
2626
<div id="page-header">
2727
<div id="page-header-logo" class="ipfs-logo">&nbsp;</div>
2828
<div id="page-header-menu">
29-
<div class="menu-item-wide"><a href="https://ipfs.io" target="_blank" rel="noopener noreferrer">About IPFS</a></div>
30-
<div class="menu-item-wide"><a href="https://ipfs.io#install" target="_blank" rel="noopener noreferrer">Install IPFS</a></div>
31-
<div class="menu-item-narrow"><a href="https://ipfs.io" target="_blank" rel="noopener noreferrer">About</a></div>
32-
<div class="menu-item-narrow"><a href="https://ipfs.io#install" target="_blank" rel="noopener noreferrer">Install</a></div>
29+
<div class="menu-item-wide"><a href="https://ipfs.tech" target="_blank" rel="noopener noreferrer">About IPFS</a></div>
30+
<div class="menu-item-wide"><a href="https://ipfs.tech#install" target="_blank" rel="noopener noreferrer">Install IPFS</a></div>
31+
<div class="menu-item-narrow"><a href="https://ipfs.tech" target="_blank" rel="noopener noreferrer">About</a></div>
32+
<div class="menu-item-narrow"><a href="https://ipfs.tech#install" target="_blank" rel="noopener noreferrer">Install</a></div>
3333
<div>
34-
<a href="https://github.com/ipfs/go-ipfs/issues/new/choose" target="_blank" rel="noopener noreferrer" title="Report a bug">
34+
<a href="https://github.com/ipfs/kubo/issues/new/choose" target="_blank" rel="noopener noreferrer" title="Report a bug">
3535
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.4 21"><circle cx="7.5" cy="4.8" r="1"/><circle cx="11.1" cy="4.8" r="1"/><path d="M12.7 8.4c-0.5-1.5-1.9-2.5-3.5-2.5 -1.6 0-3 1-3.5 2.5H12.7z"/><path d="M8.5 9.7H5c-0.5 0.8-0.7 1.7-0.7 2.7 0 2.6 1.8 4.8 4.2 5.2V9.7z"/><path d="M13.4 9.7H9.9v7.9c2.4-0.4 4.2-2.5 4.2-5.2C14.1 11.4 13.9 10.5 13.4 9.7z"/><circle cx="15.7" cy="12.9" r="1"/><circle cx="15.1" cy="15.4" r="1"/><circle cx="15.3" cy="10.4" r="1"/><circle cx="2.7" cy="12.9" r="1"/><circle cx="3.3" cy="15.4" r="1"/><circle cx="3.1" cy="10.4" r="1"/></svg>
3636
</a>
3737
</div>
@@ -84,7 +84,7 @@
8484
</td>
8585
<td class="no-linebreak">
8686
{{ if .Hash }}
87-
<a class="ipfs-hash" translate="no" href={{ if $root.DNSLink }}"https://cid.ipfs.io/#{{ .Hash | urlEscape}}" target="_blank" rel="noreferrer noopener"{{ else }}"{{ $root.GatewayURL }}/ipfs/{{ .Hash | urlEscape}}?filename={{ .Name | urlEscape }}"{{ end }}>
87+
<a class="ipfs-hash" translate="no" href={{ if $root.DNSLink }}"https://cid.ipfs.tech/#{{ .Hash | urlEscape}}" target="_blank" rel="noreferrer noopener"{{ else }}"{{ $root.GatewayURL }}/ipfs/{{ .Hash | urlEscape}}?filename={{ .Name | urlEscape }}"{{ end }}>
8888
{{ .ShortHash }}
8989
</a>
9090
{{ end }}

assets/dir-index-html/src/dir-index.html

+6-6
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@
2525
<div id="page-header">
2626
<div id="page-header-logo" class="ipfs-logo">&nbsp;</div>
2727
<div id="page-header-menu">
28-
<div class="menu-item-wide"><a href="https://ipfs.io" target="_blank" rel="noopener noreferrer">About IPFS</a></div>
29-
<div class="menu-item-wide"><a href="https://ipfs.io#install" target="_blank" rel="noopener noreferrer">Install IPFS</a></div>
30-
<div class="menu-item-narrow"><a href="https://ipfs.io" target="_blank" rel="noopener noreferrer">About</a></div>
31-
<div class="menu-item-narrow"><a href="https://ipfs.io#install" target="_blank" rel="noopener noreferrer">Install</a></div>
28+
<div class="menu-item-wide"><a href="https://ipfs.tech" target="_blank" rel="noopener noreferrer">About IPFS</a></div>
29+
<div class="menu-item-wide"><a href="https://ipfs.tech#install" target="_blank" rel="noopener noreferrer">Install IPFS</a></div>
30+
<div class="menu-item-narrow"><a href="https://ipfs.tech" target="_blank" rel="noopener noreferrer">About</a></div>
31+
<div class="menu-item-narrow"><a href="https://ipfs.tech#install" target="_blank" rel="noopener noreferrer">Install</a></div>
3232
<div>
33-
<a href="https://github.com/ipfs/go-ipfs/issues/new/choose" target="_blank" rel="noopener noreferrer" title="Report a bug">
33+
<a href="https://github.com/ipfs/kubo/issues/new/choose" target="_blank" rel="noopener noreferrer" title="Report a bug">
3434
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18.4 21"><circle cx="7.5" cy="4.8" r="1"/><circle cx="11.1" cy="4.8" r="1"/><path d="M12.7 8.4c-0.5-1.5-1.9-2.5-3.5-2.5 -1.6 0-3 1-3.5 2.5H12.7z"/><path d="M8.5 9.7H5c-0.5 0.8-0.7 1.7-0.7 2.7 0 2.6 1.8 4.8 4.2 5.2V9.7z"/><path d="M13.4 9.7H9.9v7.9c2.4-0.4 4.2-2.5 4.2-5.2C14.1 11.4 13.9 10.5 13.4 9.7z"/><circle cx="15.7" cy="12.9" r="1"/><circle cx="15.1" cy="15.4" r="1"/><circle cx="15.3" cy="10.4" r="1"/><circle cx="2.7" cy="12.9" r="1"/><circle cx="3.3" cy="15.4" r="1"/><circle cx="3.1" cy="10.4" r="1"/></svg>
3535
</a>
3636
</div>
@@ -83,7 +83,7 @@
8383
</td>
8484
<td class="no-linebreak">
8585
{{ if .Hash }}
86-
<a class="ipfs-hash" translate="no" href={{ if $root.DNSLink }}"https://cid.ipfs.io/#{{ .Hash | urlEscape}}" target="_blank" rel="noreferrer noopener"{{ else }}"{{ $root.GatewayURL }}/ipfs/{{ .Hash | urlEscape}}?filename={{ .Name | urlEscape }}"{{ end }}>
86+
<a class="ipfs-hash" translate="no" href={{ if $root.DNSLink }}"https://cid.ipfs.tech/#{{ .Hash | urlEscape}}" target="_blank" rel="noreferrer noopener"{{ else }}"{{ $root.GatewayURL }}/ipfs/{{ .Hash | urlEscape}}?filename={{ .Name | urlEscape }}"{{ end }}>
8787
{{ .ShortHash }}
8888
</a>
8989
{{ end }}

core/corehttp/gateway_handler.go

+26-4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
coreiface "github.com/ipfs/interface-go-ipfs-core"
2727
ipath "github.com/ipfs/interface-go-ipfs-core/path"
2828
routing "github.com/libp2p/go-libp2p/core/routing"
29+
mc "github.com/multiformats/go-multicodec"
2930
prometheus "github.com/prometheus/client_golang/prometheus"
3031
"go.opentelemetry.io/otel/attribute"
3132
"go.opentelemetry.io/otel/trace"
@@ -417,9 +418,15 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
417418

418419
// Support custom response formats passed via ?format or Accept HTTP header
419420
switch responseFormat {
420-
case "": // The implicit response format is UnixFS
421-
logger.Debugw("serving unixfs", "path", contentPath)
422-
i.serveUnixFS(r.Context(), w, r, resolvedPath, contentPath, begin, logger)
421+
case "":
422+
switch resolvedPath.Cid().Prefix().Codec {
423+
case uint64(mc.Json), uint64(mc.DagJson), uint64(mc.Cbor), uint64(mc.DagCbor):
424+
logger.Debugw("serving codec", "path", contentPath)
425+
i.serveCodec(r.Context(), w, r, resolvedPath, contentPath, begin, responseFormat)
426+
default:
427+
logger.Debugw("serving unixfs", "path", contentPath)
428+
i.serveUnixFS(r.Context(), w, r, resolvedPath, contentPath, begin, logger)
429+
}
423430
return
424431
case "application/vnd.ipld.raw":
425432
logger.Debugw("serving raw block", "path", contentPath)
@@ -434,6 +441,11 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
434441
logger.Debugw("serving tar file", "path", contentPath)
435442
i.serveTAR(r.Context(), w, r, resolvedPath, contentPath, begin, logger)
436443
return
444+
case "application/json", "application/vnd.ipld.dag-json",
445+
"application/cbor", "application/vnd.ipld.dag-cbor":
446+
logger.Debugw("serving codec", "path", contentPath)
447+
i.serveCodec(r.Context(), w, r, resolvedPath, contentPath, begin, responseFormat)
448+
return
437449
default: // catch-all for unsuported application/vnd.*
438450
err := fmt.Errorf("unsupported format %q", responseFormat)
439451
webError(w, "failed respond with requested content type", err, http.StatusBadRequest)
@@ -866,6 +878,14 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
866878
return "application/vnd.ipld.car", nil, nil
867879
case "tar":
868880
return "application/x-tar", nil, nil
881+
case "dag-json":
882+
return "application/vnd.ipld.dag-json", nil, nil
883+
case "json":
884+
return "application/json", nil, nil
885+
case "dag-cbor":
886+
return "application/vnd.ipld.dag-cbor", nil, nil
887+
case "cbor":
888+
return "application/cbor", nil, nil
869889
}
870890
}
871891
// Browsers and other user agents will send Accept header with generic types like:
@@ -874,7 +894,9 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
874894
for _, accept := range r.Header.Values("Accept") {
875895
// respond to the very first ipld content type
876896
if strings.HasPrefix(accept, "application/vnd.ipld") ||
877-
strings.HasPrefix(accept, "application/x-tar") {
897+
strings.HasPrefix(accept, "application/x-tar") ||
898+
strings.HasPrefix(accept, "application/json") ||
899+
strings.HasPrefix(accept, "application/cbor") {
878900
mediatype, params, err := mime.ParseMediaType(accept)
879901
if err != nil {
880902
return "", nil, err
+258
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package corehttp
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"html"
8+
"io"
9+
"net/http"
10+
"strings"
11+
"time"
12+
13+
cid "github.com/ipfs/go-cid"
14+
ipldlegacy "github.com/ipfs/go-ipld-legacy"
15+
ipath "github.com/ipfs/interface-go-ipfs-core/path"
16+
"github.com/ipfs/kubo/assets"
17+
dih "github.com/ipfs/kubo/assets/dag-index-html"
18+
"github.com/ipfs/kubo/tracing"
19+
"github.com/ipld/go-ipld-prime"
20+
"github.com/ipld/go-ipld-prime/multicodec"
21+
mc "github.com/multiformats/go-multicodec"
22+
"go.opentelemetry.io/otel/attribute"
23+
"go.opentelemetry.io/otel/trace"
24+
)
25+
26+
// codecToContentType maps the supported IPLD codecs to the HTTP Content
27+
// Type they should have.
28+
var codecToContentType = map[uint64]string{
29+
uint64(mc.Json): "application/json",
30+
uint64(mc.Cbor): "application/cbor",
31+
uint64(mc.DagJson): "application/vnd.ipld.dag-json",
32+
uint64(mc.DagCbor): "application/vnd.ipld.dag-cbor",
33+
}
34+
35+
// contentTypeToCodecs maps the HTTP Content Type to the respective
36+
// possible codecs. If the original data is in one of those codecs,
37+
// we stream the raw bytes. Otherwise, we encode in the last codec
38+
// of the list.
39+
var contentTypeToCodecs = map[string][]uint64{
40+
"application/json": {uint64(mc.Json), uint64(mc.DagJson)},
41+
"application/vnd.ipld.dag-json": {uint64(mc.DagJson)},
42+
"application/cbor": {uint64(mc.Cbor), uint64(mc.DagCbor)},
43+
"application/vnd.ipld.dag-cbor": {uint64(mc.DagCbor)},
44+
}
45+
46+
// contentTypeToExtension maps the HTTP Content Type to the respective file
47+
// extension, used in Content-Disposition header when downloading the file.
48+
var contentTypeToExtension = map[string]string{
49+
"application/json": ".json",
50+
"application/vnd.ipld.dag-json": ".json",
51+
"application/cbor": ".cbor",
52+
"application/vnd.ipld.dag-cbor": ".cbor",
53+
}
54+
55+
func (i *gatewayHandler) serveCodec(ctx context.Context, w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentPath ipath.Path, begin time.Time, requestedContentType string) {
56+
ctx, span := tracing.Span(ctx, "Gateway", "ServeCodec", trace.WithAttributes(attribute.String("path", resolvedPath.String()), attribute.String("requestedContentType", requestedContentType)))
57+
defer span.End()
58+
59+
cidCodec := resolvedPath.Cid().Prefix().Codec
60+
responseContentType := requestedContentType
61+
62+
// If the resolved path still has some remainder, return error for now.
63+
// TODO: handle this when we have IPLD Patch (https://ipld.io/specs/patch/) via HTTP PUT
64+
// TODO: (depends on https://github.com/ipfs/kubo/issues/4801 and https://github.com/ipfs/kubo/issues/4782)
65+
if resolvedPath.Remainder() != "" {
66+
path := strings.TrimSuffix(resolvedPath.String(), resolvedPath.Remainder())
67+
err := fmt.Errorf("%q of %q could not be returned: reading IPLD Kinds other than Links (CBOR Tag 42) is not implemented: try reading %q instead", resolvedPath.Remainder(), resolvedPath.String(), path)
68+
webError(w, "unsupported pathing", err, http.StatusNotImplemented)
69+
return
70+
}
71+
72+
// If no explicit content type was requested, the response will have one based on the codec from the CID
73+
if requestedContentType == "" {
74+
cidContentType, ok := codecToContentType[cidCodec]
75+
if !ok {
76+
// Should not happen unless function is called with wrong parameters.
77+
err := fmt.Errorf("content type not found for codec: %v", cidCodec)
78+
webError(w, "internal error", err, http.StatusInternalServerError)
79+
return
80+
}
81+
responseContentType = cidContentType
82+
}
83+
84+
// Set HTTP headers (for caching etc)
85+
modtime := addCacheControlHeaders(w, r, contentPath, resolvedPath.Cid())
86+
name := setCodecContentDisposition(w, r, resolvedPath, responseContentType)
87+
w.Header().Set("Content-Type", responseContentType)
88+
w.Header().Set("X-Content-Type-Options", "nosniff")
89+
90+
// No content type is specified by the user (via Accept, or format=). However,
91+
// we support this format. Let's handle it.
92+
if requestedContentType == "" {
93+
isDAG := cidCodec == uint64(mc.DagJson) || cidCodec == uint64(mc.DagCbor)
94+
acceptsHTML := strings.Contains(r.Header.Get("Accept"), "text/html")
95+
download := r.URL.Query().Get("download") == "true"
96+
97+
if isDAG && acceptsHTML && !download {
98+
i.serveCodecHTML(ctx, w, r, resolvedPath, contentPath)
99+
} else {
100+
i.serveCodecRaw(ctx, w, r, resolvedPath, contentPath, name, modtime)
101+
}
102+
103+
return
104+
}
105+
106+
// Otherwise, the user has requested a specific content type. Let's first get
107+
// the codecs that can be used with this content type.
108+
codecs, ok := contentTypeToCodecs[requestedContentType]
109+
if !ok {
110+
// This is never supposed to happen unless function is called with wrong parameters.
111+
err := fmt.Errorf("unsupported content type: %s", requestedContentType)
112+
webError(w, err.Error(), err, http.StatusInternalServerError)
113+
return
114+
}
115+
116+
// If we need to convert, use the last codec (strict dag- variant)
117+
toCodec := codecs[len(codecs)-1]
118+
119+
// If the requested content type has "dag-", ALWAYS go through the encoding
120+
// process in order to validate the content.
121+
if strings.Contains(requestedContentType, "dag-") {
122+
i.serveCodecConverted(ctx, w, r, resolvedPath, contentPath, toCodec, modtime)
123+
return
124+
}
125+
126+
// Otherwise, check if the data is encoded with the requested content type.
127+
// If so, we can directly stream the raw data. serveRawBlock cannot be directly
128+
// used here as it sets different headers.
129+
for _, codec := range codecs {
130+
if resolvedPath.Cid().Prefix().Codec == codec {
131+
i.serveCodecRaw(ctx, w, r, resolvedPath, contentPath, name, modtime)
132+
return
133+
}
134+
}
135+
136+
// Finally, if nothing of the above is true, we have to actually convert the codec.
137+
i.serveCodecConverted(ctx, w, r, resolvedPath, contentPath, toCodec, modtime)
138+
}
139+
140+
func (i *gatewayHandler) serveCodecHTML(ctx context.Context, w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentPath ipath.Path) {
141+
// A HTML directory index will be presented, be sure to set the correct
142+
// type instead of relying on autodetection (which may fail).
143+
w.Header().Set("Content-Type", "text/html")
144+
145+
// Clear Content-Disposition -- we want HTML to be rendered inline
146+
w.Header().Del("Content-Disposition")
147+
148+
// Generated index requires custom Etag (output may change between Kubo versions)
149+
dagEtag := getDagIndexEtag(resolvedPath.Cid())
150+
w.Header().Set("Etag", dagEtag)
151+
152+
// Remove Cache-Control for now to match UnixFS dir-index-html responses
153+
// (we don't want browser to cache HTML forever)
154+
// TODO: if we ever change behavior for UnixFS dir listings, same changes should be applied here
155+
w.Header().Del("Cache-Control")
156+
157+
cidCodec := mc.Code(resolvedPath.Cid().Prefix().Codec)
158+
if err := dih.DagIndexTemplate.Execute(w, dih.DagIndexTemplateData{
159+
Path: contentPath.String(),
160+
CID: resolvedPath.Cid().String(),
161+
CodecName: cidCodec.String(),
162+
CodecHex: fmt.Sprintf("0x%x", uint64(cidCodec)),
163+
}); err != nil {
164+
webError(w, "failed to generate HTML listing for this DAG: try fetching raw block with ?format=raw", err, http.StatusInternalServerError)
165+
}
166+
}
167+
168+
func (i *gatewayHandler) serveCodecRaw(ctx context.Context, w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentPath ipath.Path, name string, modtime time.Time) {
169+
blockCid := resolvedPath.Cid()
170+
blockReader, err := i.api.Block().Get(ctx, resolvedPath)
171+
if err != nil {
172+
webError(w, "ipfs block get "+blockCid.String(), err, http.StatusInternalServerError)
173+
return
174+
}
175+
block, err := io.ReadAll(blockReader)
176+
if err != nil {
177+
webError(w, "ipfs block get "+blockCid.String(), err, http.StatusInternalServerError)
178+
return
179+
}
180+
content := bytes.NewReader(block)
181+
182+
// ServeContent will take care of
183+
// If-None-Match+Etag, Content-Length and range requests
184+
_, _, _ = ServeContent(w, r, name, modtime, content)
185+
}
186+
187+
func (i *gatewayHandler) serveCodecConverted(ctx context.Context, w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentPath ipath.Path, toCodec uint64, modtime time.Time) {
188+
obj, err := i.api.Dag().Get(ctx, resolvedPath.Cid())
189+
if err != nil {
190+
webError(w, "ipfs dag get "+html.EscapeString(resolvedPath.String()), err, http.StatusInternalServerError)
191+
return
192+
}
193+
194+
universal, ok := obj.(ipldlegacy.UniversalNode)
195+
if !ok {
196+
err = fmt.Errorf("%T is not a valid IPLD node", obj)
197+
webError(w, err.Error(), err, http.StatusInternalServerError)
198+
return
199+
}
200+
finalNode := universal.(ipld.Node)
201+
202+
encoder, err := multicodec.LookupEncoder(toCodec)
203+
if err != nil {
204+
webError(w, err.Error(), err, http.StatusInternalServerError)
205+
return
206+
}
207+
208+
// Ensure IPLD node conforms to the codec specification.
209+
var buf bytes.Buffer
210+
err = encoder(finalNode, &buf)
211+
if err != nil {
212+
webError(w, err.Error(), err, http.StatusInternalServerError)
213+
return
214+
}
215+
216+
// Sets correct Last-Modified header. This code is borrowed from the standard
217+
// library (net/http/server.go) as we cannot use serveFile.
218+
if !(modtime.IsZero() || modtime.Equal(unixEpochTime)) {
219+
w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
220+
}
221+
222+
_, _ = w.Write(buf.Bytes())
223+
}
224+
225+
func setCodecContentDisposition(w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentType string) string {
226+
var dispType, name string
227+
228+
ext, ok := contentTypeToExtension[contentType]
229+
if !ok {
230+
// Should never happen.
231+
ext = ".bin"
232+
}
233+
234+
if urlFilename := r.URL.Query().Get("filename"); urlFilename != "" {
235+
name = urlFilename
236+
} else {
237+
name = resolvedPath.Cid().String() + ext
238+
}
239+
240+
// JSON should be inlined, but ?download=true should still override
241+
if r.URL.Query().Get("download") == "true" {
242+
dispType = "attachment"
243+
} else {
244+
switch ext {
245+
case ".json": // codecs that serialize to JSON can be rendered by browsers
246+
dispType = "inline"
247+
default: // everything else is assumed binary / opaque bytes
248+
dispType = "attachment"
249+
}
250+
}
251+
252+
setContentDispositionHeader(w, name, dispType)
253+
return name
254+
}
255+
256+
func getDagIndexEtag(dagCid cid.Cid) string {
257+
return `"DagIndex-` + assets.AssetHash + `_CID-` + dagCid.String() + `"`
258+
}

0 commit comments

Comments
 (0)