From 6387e03de091adc1bc72e42b84877765e3909404 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Fri, 11 Nov 2022 13:49:37 +0100 Subject: [PATCH] feat(gateway): IPNS record response format (IPIP-351) --- core/commands/routing.go | 118 +++---------------- core/coreapi/coreapi.go | 5 + core/coreapi/routing.go | 53 +++++++++ core/corehttp/gateway.go | 4 + core/corehttp/gateway_handler.go | 8 +- core/corehttp/gateway_handler_ipns_record.go | 55 +++++++++ docs/examples/kubo-as-a-library/go.mod | 4 +- docs/examples/kubo-as-a-library/go.sum | 3 +- go.mod | 2 +- go.sum | 3 +- test/sharness/t0124-gateway-ipns-record.sh | 50 ++++++++ test/sharness/t0170-legacy-dht.sh | 2 +- test/sharness/t0170-routing-dht.sh | 2 +- 13 files changed, 199 insertions(+), 110 deletions(-) create mode 100644 core/coreapi/routing.go create mode 100644 core/corehttp/gateway_handler_ipns_record.go create mode 100755 test/sharness/t0124-gateway-ipns-record.sh diff --git a/core/commands/routing.go b/core/commands/routing.go index bf7d2a6995b8..d6596ba3f478 100644 --- a/core/commands/routing.go +++ b/core/commands/routing.go @@ -2,7 +2,6 @@ package commands import ( "context" - "encoding/base64" "errors" "fmt" "io" @@ -366,71 +365,25 @@ Different key types can specify other 'best' rules. cmds.BoolOption(dhtVerboseOptionName, "v", "Print extra information."), }, Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { - nd, err := cmdenv.GetNode(env) + api, err := cmdenv.GetApi(env, req) if err != nil { return err } - if !nd.IsOnline { - return ErrNotOnline - } - - dhtkey, err := escapeDhtKey(req.Arguments[0]) + r, err := api.Routing().Get(req.Context, req.Arguments[0]) if err != nil { return err } - ctx, cancel := context.WithCancel(req.Context) - ctx, events := routing.RegisterForQueryEvents(ctx) - - var getErr error - go func() { - defer cancel() - var val []byte - val, getErr = nd.Routing.GetValue(ctx, dhtkey) - if getErr != nil { - routing.PublishQueryEvent(ctx, &routing.QueryEvent{ - Type: routing.QueryError, - Extra: getErr.Error(), - }) - } else { - routing.PublishQueryEvent(ctx, &routing.QueryEvent{ - Type: routing.Value, - Extra: base64.StdEncoding.EncodeToString(val), - }) - } - }() - - for e := range events { - if err := res.Emit(e); err != nil { - return err - } - } - - return getErr + return res.Emit(r) }, Encoders: cmds.EncoderMap{ - cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *routing.QueryEvent) error { - pfm := pfuncMap{ - routing.Value: func(obj *routing.QueryEvent, out io.Writer, verbose bool) error { - if verbose { - _, err := fmt.Fprintf(out, "got value: '%s'\n", obj.Extra) - return err - } - res, err := base64.StdEncoding.DecodeString(obj.Extra) - if err != nil { - return err - } - _, err = out.Write(res) - return err - }, - } - - verbose, _ := req.Options[dhtVerboseOptionName].(bool) - return printEvent(out, w, verbose, pfm) + cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out []byte) error { + _, err := w.Write(out) + return err }), }, - Type: routing.QueryEvent{}, + Type: []byte{}, } var putValueRoutingCmd = &cmds.Command{ @@ -463,16 +416,7 @@ identified by QmFoo. cmds.BoolOption(dhtVerboseOptionName, "v", "Print extra information."), }, Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error { - nd, err := cmdenv.GetNode(env) - if err != nil { - return err - } - - if !nd.IsOnline { - return ErrNotOnline - } - - key, err := escapeDhtKey(req.Arguments[0]) + api, err := cmdenv.GetApi(env, req) if err != nil { return err } @@ -488,50 +432,20 @@ identified by QmFoo. return err } - ctx, cancel := context.WithCancel(req.Context) - ctx, events := routing.RegisterForQueryEvents(ctx) - - var putErr error - go func() { - defer cancel() - putErr = nd.Routing.PutValue(ctx, key, []byte(data)) - if putErr != nil { - routing.PublishQueryEvent(ctx, &routing.QueryEvent{ - Type: routing.QueryError, - Extra: putErr.Error(), - }) - } - }() - - for e := range events { - if err := res.Emit(e); err != nil { - return err - } + err = api.Routing().Put(req.Context, req.Arguments[0], data) + if err != nil { + return err } - return putErr + return res.Emit([]byte(fmt.Sprintf("%s added", req.Arguments[0]))) }, Encoders: cmds.EncoderMap{ - cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *routing.QueryEvent) error { - pfm := pfuncMap{ - routing.FinalPeer: func(obj *routing.QueryEvent, out io.Writer, verbose bool) error { - if verbose { - fmt.Fprintf(out, "* closest peer %s\n", obj.ID) - } - return nil - }, - routing.Value: func(obj *routing.QueryEvent, out io.Writer, verbose bool) error { - fmt.Fprintf(out, "%s\n", obj.ID.Pretty()) - return nil - }, - } - - verbose, _ := req.Options[dhtVerboseOptionName].(bool) - - return printEvent(out, w, verbose, pfm) + cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out []byte) error { + _, err := w.Write(out) + return err }), }, - Type: routing.QueryEvent{}, + Type: []byte{}, } type printFunc func(obj *routing.QueryEvent, out io.Writer, verbose bool) error diff --git a/core/coreapi/coreapi.go b/core/coreapi/coreapi.go index fb549171a6ce..85bd60c12403 100644 --- a/core/coreapi/coreapi.go +++ b/core/coreapi/coreapi.go @@ -144,6 +144,11 @@ func (api *CoreAPI) PubSub() coreiface.PubSubAPI { return (*PubSubAPI)(api) } +// Routing returns the RoutingAPI interface implementation backed by the kubo node +func (api *CoreAPI) Routing() coreiface.RoutingAPI { + return (*RoutingAPI)(api) +} + // WithOptions returns api with global options applied func (api *CoreAPI) WithOptions(opts ...options.ApiOption) (coreiface.CoreAPI, error) { settings := api.parentOpts // make sure to copy diff --git a/core/coreapi/routing.go b/core/coreapi/routing.go new file mode 100644 index 000000000000..78f21c42997f --- /dev/null +++ b/core/coreapi/routing.go @@ -0,0 +1,53 @@ +package coreapi + +import ( + "context" + "errors" + + "github.com/ipfs/go-path" + coreiface "github.com/ipfs/interface-go-ipfs-core" + peer "github.com/libp2p/go-libp2p/core/peer" +) + +type RoutingAPI CoreAPI + +func (r *RoutingAPI) Get(ctx context.Context, key string) ([]byte, error) { + if !r.nd.IsOnline { + return nil, coreiface.ErrOffline + } + + dhtKey, err := normalizeKey(key) + if err != nil { + return nil, err + } + + return r.routing.GetValue(ctx, dhtKey) +} + +func (r *RoutingAPI) Put(ctx context.Context, key string, value []byte) error { + if !r.nd.IsOnline { + return coreiface.ErrOffline + } + + dhtKey, err := normalizeKey(key) + if err != nil { + return err + } + + return r.routing.PutValue(ctx, dhtKey, value) +} + +func normalizeKey(s string) (string, error) { + parts := path.SplitList(s) + if len(parts) != 3 || + parts[0] != "" || + !(parts[1] == "ipns" || parts[1] == "pk") { + return "", errors.New("invalid key") + } + + k, err := peer.Decode(parts[2]) + if err != nil { + return "", err + } + return path.Join(append(parts[:2], string(k))), nil +} diff --git a/core/corehttp/gateway.go b/core/corehttp/gateway.go index 0d0a234d946f..e95946107fa5 100644 --- a/core/corehttp/gateway.go +++ b/core/corehttp/gateway.go @@ -34,6 +34,10 @@ type NodeAPI interface { // Dag returns an implementation of Dag API Dag() coreiface.APIDagService + // Routing returns an implementation of Routing API. + // Used for returning signed IPNS records, see IPIP-0328 + Routing() coreiface.RoutingAPI + // ResolvePath resolves the path using Unixfs resolver ResolvePath(context.Context, path.Path) (path.Resolved, error) } diff --git a/core/corehttp/gateway_handler.go b/core/corehttp/gateway_handler.go index 1222b17bcd7c..4dc4e75c6ee2 100644 --- a/core/corehttp/gateway_handler.go +++ b/core/corehttp/gateway_handler.go @@ -445,6 +445,9 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request "application/cbor", "application/vnd.ipld.dag-cbor": logger.Debugw("serving codec", "path", contentPath) i.serveCodec(r.Context(), w, r, resolvedPath, contentPath, begin, responseFormat) + case "application/vnd.ipfs.ipns-record": + logger.Debugw("serving ipns record", "path", contentPath) + i.serveIpnsRecord(r.Context(), w, r, resolvedPath, contentPath, begin, logger) return default: // catch-all for unsuported application/vnd.* err := fmt.Errorf("unsupported format %q", responseFormat) @@ -886,6 +889,8 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string] return "application/vnd.ipld.dag-cbor", nil, nil case "cbor": return "application/cbor", nil, nil + case "ipns-record": + return "application/vnd.ipfs.ipns-record", nil, nil } } // Browsers and other user agents will send Accept header with generic types like: @@ -896,7 +901,8 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string] if strings.HasPrefix(accept, "application/vnd.ipld") || strings.HasPrefix(accept, "application/x-tar") || strings.HasPrefix(accept, "application/json") || - strings.HasPrefix(accept, "application/cbor") { + strings.HasPrefix(accept, "application/cbor") || + strings.HasPrefix(accept, "application/vnd.ipfs") { mediatype, params, err := mime.ParseMediaType(accept) if err != nil { return "", nil, err diff --git a/core/corehttp/gateway_handler_ipns_record.go b/core/corehttp/gateway_handler_ipns_record.go new file mode 100644 index 000000000000..de5dd51772a6 --- /dev/null +++ b/core/corehttp/gateway_handler_ipns_record.go @@ -0,0 +1,55 @@ +package corehttp + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + path "github.com/ipfs/go-path" + ipath "github.com/ipfs/interface-go-ipfs-core/path" + "go.uber.org/zap" +) + +func (i *gatewayHandler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentPath ipath.Path, begin time.Time, logger *zap.SugaredLogger) { + if contentPath.Namespace() != "ipns" { + err := fmt.Errorf("%s is not an IPNS link", contentPath.String()) + webError(w, err.Error(), err, http.StatusBadRequest) + return + } + + key := contentPath.String() + key = strings.TrimSuffix(key, "/") + if strings.Count(key, "/") > 2 { + err := errors.New("cannot find ipns key for subpath") + webError(w, err.Error(), err, http.StatusBadRequest) + return + } + + record, err := i.api.Routing().Get(ctx, key) + if err != nil { + webError(w, err.Error(), err, http.StatusInternalServerError) + return + } + + // Set cache control headers. See the linked issue for improvements on + // IPNS caching based on keys' TTL. + // https://github.com/ipfs/kubo/issues/1818#issuecomment-1015849462 + _ = addCacheControlHeaders(w, r, contentPath, resolvedPath.Cid()) + + // Set Content-Disposition + var name string + if urlFilename := r.URL.Query().Get("filename"); urlFilename != "" { + name = urlFilename + } else { + name = path.SplitList(key)[2] + ".ipns-record" + } + setContentDispositionHeader(w, name, "attachment") + + w.Header().Set("Content-Type", "application/vnd.ipfs.ipns-record") + w.Header().Set("X-Content-Type-Options", "nosniff") + + _, _ = w.Write(record) +} diff --git a/docs/examples/kubo-as-a-library/go.mod b/docs/examples/kubo-as-a-library/go.mod index 3dc6f1e737e1..d9cb98fd85ae 100644 --- a/docs/examples/kubo-as-a-library/go.mod +++ b/docs/examples/kubo-as-a-library/go.mod @@ -8,8 +8,8 @@ replace github.com/ipfs/kubo => ./../../.. require ( github.com/ipfs/go-ipfs-files v0.2.0 - github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221207140113-4f1c5845bf21 - github.com/ipfs/kubo v0.0.0-00010101000000-000000000000 + github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221208104547-250fb9731ae8 + github.com/ipfs/kubo v0.14.0-rc1 github.com/libp2p/go-libp2p v0.23.4 github.com/multiformats/go-multiaddr v0.8.0 ) diff --git a/docs/examples/kubo-as-a-library/go.sum b/docs/examples/kubo-as-a-library/go.sum index 7e4696887def..dc8c1b4a4a86 100644 --- a/docs/examples/kubo-as-a-library/go.sum +++ b/docs/examples/kubo-as-a-library/go.sum @@ -615,8 +615,9 @@ github.com/ipfs/go-unixfsnode v1.4.0/go.mod h1:qc7YFFZ8tABc58p62HnIYbUMwj9chhUuF github.com/ipfs/go-verifcid v0.0.1/go.mod h1:5Hrva5KBeIog4A+UpqlaIU+DEstipcJYQQZc0g37pY0= github.com/ipfs/go-verifcid v0.0.2 h1:XPnUv0XmdH+ZIhLGKg6U2vaPaRDXb9urMyNVCE7uvTs= github.com/ipfs/go-verifcid v0.0.2/go.mod h1:40cD9x1y4OWnFXbLNJYRe7MpNvWlMn3LZAG5Wb4xnPU= -github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221207140113-4f1c5845bf21 h1:iEbS/b3JgNaN8ayvLSQPe4FqHW35MiRU4ZT6G+seELA= github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221207140113-4f1c5845bf21/go.mod h1:X/udt0qeqxXlgv69JQ8g38gWy4LrCyOuav6f7KDoJMo= +github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221208104547-250fb9731ae8 h1:JN89fRndO6CB+Rcqm3uFlWUXeGCuZ6/4atMmwT3A5dc= +github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221208104547-250fb9731ae8/go.mod h1:X/udt0qeqxXlgv69JQ8g38gWy4LrCyOuav6f7KDoJMo= github.com/ipld/edelweiss v0.2.0 h1:KfAZBP8eeJtrLxLhi7r3N0cBCo7JmwSRhOJp3WSpNjk= github.com/ipld/edelweiss v0.2.0/go.mod h1:FJAzJRCep4iI8FOFlRriN9n0b7OuX3T/S9++NpBDmA4= github.com/ipld/go-car v0.4.0 h1:U6W7F1aKF/OJMHovnOVdst2cpQE5GhmHibQkAixgNcQ= diff --git a/go.mod b/go.mod index 16b057a5b344..a03aed0131e6 100644 --- a/go.mod +++ b/go.mod @@ -63,7 +63,7 @@ require ( github.com/ipfs/go-unixfs v0.4.1 github.com/ipfs/go-unixfsnode v1.4.0 github.com/ipfs/go-verifcid v0.0.2 - github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221207140113-4f1c5845bf21 + github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221208104547-250fb9731ae8 github.com/ipld/go-car v0.4.0 github.com/ipld/go-car/v2 v2.4.0 github.com/ipld/go-codec-dagpb v1.4.1 diff --git a/go.sum b/go.sum index 6729b4dc287d..00f12e5c6ff4 100644 --- a/go.sum +++ b/go.sum @@ -641,8 +641,9 @@ github.com/ipfs/go-unixfsnode v1.4.0/go.mod h1:qc7YFFZ8tABc58p62HnIYbUMwj9chhUuF github.com/ipfs/go-verifcid v0.0.1/go.mod h1:5Hrva5KBeIog4A+UpqlaIU+DEstipcJYQQZc0g37pY0= github.com/ipfs/go-verifcid v0.0.2 h1:XPnUv0XmdH+ZIhLGKg6U2vaPaRDXb9urMyNVCE7uvTs= github.com/ipfs/go-verifcid v0.0.2/go.mod h1:40cD9x1y4OWnFXbLNJYRe7MpNvWlMn3LZAG5Wb4xnPU= -github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221207140113-4f1c5845bf21 h1:iEbS/b3JgNaN8ayvLSQPe4FqHW35MiRU4ZT6G+seELA= github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221207140113-4f1c5845bf21/go.mod h1:X/udt0qeqxXlgv69JQ8g38gWy4LrCyOuav6f7KDoJMo= +github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221208104547-250fb9731ae8 h1:JN89fRndO6CB+Rcqm3uFlWUXeGCuZ6/4atMmwT3A5dc= +github.com/ipfs/interface-go-ipfs-core v0.7.1-0.20221208104547-250fb9731ae8/go.mod h1:X/udt0qeqxXlgv69JQ8g38gWy4LrCyOuav6f7KDoJMo= github.com/ipld/edelweiss v0.2.0 h1:KfAZBP8eeJtrLxLhi7r3N0cBCo7JmwSRhOJp3WSpNjk= github.com/ipld/edelweiss v0.2.0/go.mod h1:FJAzJRCep4iI8FOFlRriN9n0b7OuX3T/S9++NpBDmA4= github.com/ipld/go-car v0.4.0 h1:U6W7F1aKF/OJMHovnOVdst2cpQE5GhmHibQkAixgNcQ= diff --git a/test/sharness/t0124-gateway-ipns-record.sh b/test/sharness/t0124-gateway-ipns-record.sh new file mode 100755 index 000000000000..50d69c5f0229 --- /dev/null +++ b/test/sharness/t0124-gateway-ipns-record.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +test_description="Test HTTP Gateway IPNS Record (application/vnd.ipfs.ipns-record) Support" + +. lib/test-lib.sh + +test_init_ipfs +test_launch_ipfs_daemon + +test_expect_success "Create and Publish IPNS Key" ' + FILE_CID=$(echo "Hello IPFS" | ipfs add --cid-version 1 -q) && + IPNS_KEY=$(ipfs key gen ipns-record) && + ipfs name publish /ipfs/$FILE_CID --key=ipns-record && + curl "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY" > curl_output_filename && + test_should_contain "Hello IPFS" curl_output_filename +' + +test_expect_success "GET KEY with format=ipns-record and validate key" ' + curl "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY?format=ipns-record" > curl_output_filename && + ipfs name verify-record $IPNS_KEY < curl_output_filename > verify_output && + test_should_contain "$FILE_CID" verify_output +' + +test_expect_success "GET KEY with 'Accept: application/vnd.ipfs.ipns-record' and validate key" ' + curl -H "Accept: application/vnd.ipfs.ipns-record" "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY" > curl_output_filename && + ipfs name verify-record $IPNS_KEY < curl_output_filename > verify_output && + test_should_contain "$FILE_CID" verify_output +' + +test_expect_success "GET KEY with format=ipns-record has expected HTTP headers" ' + curl -sD - "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY?format=ipns-record" > curl_output_filename 2>&1 && + test_should_contain "Content-Disposition: attachment;" curl_output_filename && + test_should_contain "Content-Type: application/vnd.ipfs.ipns-record" curl_output_filename +' + +test_expect_success "GET KEY with 'Accept: application/vnd.ipfs.ipns-record' has expected HTTP headers" ' + curl -H "Accept: application/vnd.ipfs.ipns-record" -sD - "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY" > curl_output_filename 2>&1 && + test_should_contain "Content-Disposition: attachment;" curl_output_filename && + test_should_contain "Content-Type: application/vnd.ipfs.ipns-record" curl_output_filename +' + +test_expect_success "GET KEY with expliciy ?filename= succeeds with modified Content-Disposition header" ' + curl -sD - "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY?format=ipns-record&filename=testтест.ipns-record" > curl_output_filename 2>&1 && + grep -F "Content-Disposition: attachment; filename=\"test____.ipns-record\"; filename*=UTF-8'\'\''test%D1%82%D0%B5%D1%81%D1%82.ipns-record" curl_output_filename && + test_should_contain "Content-Type: application/vnd.ipfs.ipns-record" curl_output_filename +' + +test_kill_ipfs_daemon + +test_done diff --git a/test/sharness/t0170-legacy-dht.sh b/test/sharness/t0170-legacy-dht.sh index c18735b5b180..edb73ca53127 100755 --- a/test/sharness/t0170-legacy-dht.sh +++ b/test/sharness/t0170-legacy-dht.sh @@ -107,7 +107,7 @@ test_dht() { test_must_fail ipfsi 0 dht put "/ipns/$PEERID_2" "get_result" 2>err_put && test_should_contain "this command must be run in online mode" err_findprovs && test_should_contain "this command must be run in online mode" err_findpeer && - test_should_contain "this command must be run in online mode" err_put + test_should_contain "this action must be run in online mode" err_put ' } diff --git a/test/sharness/t0170-routing-dht.sh b/test/sharness/t0170-routing-dht.sh index 2eef692a8cb1..d411ac01f5ca 100755 --- a/test/sharness/t0170-routing-dht.sh +++ b/test/sharness/t0170-routing-dht.sh @@ -108,7 +108,7 @@ test_dht() { test_must_fail ipfsi 0 routing put "/ipns/$PEERID_2" "get_result" 2>err_put && test_should_contain "this command must be run in online mode" err_findprovs && test_should_contain "this command must be run in online mode" err_findpeer && - test_should_contain "this command must be run in online mode" err_put + test_should_contain "this action must be run in online mode" err_put ' }