From 2c319a3e5f40086b815fbed48763e5751b529cfa 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 --- 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 | 71 +++++++++++ docs/examples/kubo-as-a-library/go.mod | 2 +- docs/examples/kubo-as-a-library/go.sum | 4 +- go.mod | 2 +- go.sum | 4 +- test/sharness/t0124-gateway-ipns-record.sh | 52 ++++++++ test/sharness/t0170-legacy-dht.sh | 2 +- test/sharness/t0170-routing-dht.sh | 2 +- 13 files changed, 216 insertions(+), 111 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 bf7d2a6995b..d6596ba3f47 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 fb549171a6c..85bd60c1240 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 00000000000..78f21c42997 --- /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 334000b5ab3..00c2f748386 100644 --- a/core/corehttp/gateway.go +++ b/core/corehttp/gateway.go @@ -33,6 +33,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 1c6797e685d..c3e8fa0d63d 100644 --- a/core/corehttp/gateway_handler.go +++ b/core/corehttp/gateway_handler.go @@ -444,6 +444,9 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request case "application/vnd.ipld.dag-json", "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) @@ -885,6 +888,8 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string] return "application/vnd.ipld.dag-json", nil, nil case "dag-cbor": return "application/vnd.ipld.dag-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: @@ -898,7 +903,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 00000000000..16d9663fada --- /dev/null +++ b/core/corehttp/gateway_handler_ipns_record.go @@ -0,0 +1,71 @@ +package corehttp + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gogo/protobuf/proto" + ipns_pb "github.com/ipfs/go-ipns/pb" + 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 + } + + rawRecord, err := i.api.Routing().Get(ctx, key) + if err != nil { + webError(w, err.Error(), err, http.StatusInternalServerError) + return + } + + var record ipns_pb.IpnsEntry + err = proto.Unmarshal(rawRecord, &record) + if err != nil { + webError(w, err.Error(), err, http.StatusInternalServerError) + return + } + + // Set cache control headers based on the TTL set in the IPNS record. If the + // TTL is not present, we use the Last-Modified tag. We are tracking IPNS + // caching on: https://github.com/ipfs/kubo/issues/1818. + // TODO: use addCacheControlHeaders once #1818 is fixed. + w.Header().Set("Etag", getEtag(r, resolvedPath.Cid())) + if record.Ttl != nil { + seconds := int(time.Duration(*record.Ttl).Seconds()) + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds)) + } else { + w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) + } + + // 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(rawRecord) +} diff --git a/docs/examples/kubo-as-a-library/go.mod b/docs/examples/kubo-as-a-library/go.mod index 29b99699e07..c90c4b2d8fe 100644 --- a/docs/examples/kubo-as-a-library/go.mod +++ b/docs/examples/kubo-as-a-library/go.mod @@ -8,7 +8,7 @@ replace github.com/ipfs/kubo => ./../../.. require ( github.com/ipfs/go-libipfs v0.3.0 - github.com/ipfs/interface-go-ipfs-core v0.9.0 + github.com/ipfs/interface-go-ipfs-core v0.10.0 github.com/ipfs/kubo v0.0.0-00010101000000-000000000000 github.com/libp2p/go-libp2p v0.24.2 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 75135d8bed5..456c3110569 100644 --- a/docs/examples/kubo-as-a-library/go.sum +++ b/docs/examples/kubo-as-a-library/go.sum @@ -594,8 +594,8 @@ github.com/ipfs/go-unixfsnode v1.5.1/go.mod h1:ed79DaG9IEuZITJVQn4U6MZDftv6I3ygU 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.9.0 h1:+RCouVtSU/SldgkqWufjIu1smmGaSyKgUIfbYwLukgI= -github.com/ipfs/interface-go-ipfs-core v0.9.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0= +github.com/ipfs/interface-go-ipfs-core v0.10.0 h1:b/psL1oqJcySdQAsIBfW5ZJJkOAsYlhWtC0/Qvr4WiM= +github.com/ipfs/interface-go-ipfs-core v0.10.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0= 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 fbc56cc8456..82d0b10536f 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,7 @@ require ( github.com/ipfs/go-unixfs v0.4.2 github.com/ipfs/go-unixfsnode v1.5.1 github.com/ipfs/go-verifcid v0.0.2 - github.com/ipfs/interface-go-ipfs-core v0.9.0 + github.com/ipfs/interface-go-ipfs-core v0.10.0 github.com/ipld/go-car v0.4.0 github.com/ipld/go-car/v2 v2.5.1 github.com/ipld/go-codec-dagpb v1.5.0 diff --git a/go.sum b/go.sum index 27674871fd1..7c6d714d202 100644 --- a/go.sum +++ b/go.sum @@ -620,8 +620,8 @@ github.com/ipfs/go-unixfsnode v1.5.1/go.mod h1:ed79DaG9IEuZITJVQn4U6MZDftv6I3ygU 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.9.0 h1:+RCouVtSU/SldgkqWufjIu1smmGaSyKgUIfbYwLukgI= -github.com/ipfs/interface-go-ipfs-core v0.9.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0= +github.com/ipfs/interface-go-ipfs-core v0.10.0 h1:b/psL1oqJcySdQAsIBfW5ZJJkOAsYlhWtC0/Qvr4WiM= +github.com/ipfs/interface-go-ipfs-core v0.10.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0= 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 00000000000..cff00aa84d6 --- /dev/null +++ b/test/sharness/t0124-gateway-ipns-record.sh @@ -0,0 +1,52 @@ +#!/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 --ttl=30m && + 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 inspect --verify $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 inspect --verify $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_should_contain "Cache-Control: public, max-age=1800" 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_should_contain "Cache-Control: public, max-age=1800" 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 a4dffb34c6e..fc11b9044dd 100755 --- a/test/sharness/t0170-legacy-dht.sh +++ b/test/sharness/t0170-legacy-dht.sh @@ -111,7 +111,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 2ef0a9cd1e2..85462dcc22c 100755 --- a/test/sharness/t0170-routing-dht.sh +++ b/test/sharness/t0170-routing-dht.sh @@ -112,7 +112,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 ' }