diff --git a/core/corehttp/gateway_handler.go b/core/corehttp/gateway_handler.go index 71fe73c3cb5..6c3be3e774b 100644 --- a/core/corehttp/gateway_handler.go +++ b/core/corehttp/gateway_handler.go @@ -12,7 +12,6 @@ import ( gopath "path" "regexp" "runtime/debug" - "strconv" "strings" "time" @@ -342,24 +341,6 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request return } - // Resolve path to the final DAG node for the ETag - resolvedPath, err := i.api.ResolvePath(r.Context(), contentPath) - switch err { - case nil: - case coreiface.ErrOffline: - webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusServiceUnavailable) - return - default: - // if Accept is text/html, see if ipfs-404.html is present - if i.servePretty404IfPresent(w, r, contentPath) { - logger.Debugw("serve pretty 404 if present") - return - } - - webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusNotFound) - return - } - // Detect when explicit Accept header or ?format parameter are present responseFormat, formatParams, err := customResponseFormat(r) if err != nil { @@ -367,6 +348,17 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request return } trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("ResponseFormat", responseFormat)) + + var ok bool + var resolvedPath ipath.Resolved + if isUnixfsResponseFormat(responseFormat) { + resolvedPath, contentPath, ok = i.handleUnixfsPathResolution(w, r, contentPath, logger) + } else { + resolvedPath, contentPath, ok = i.handleNonUnixfsPathResolution(w, r, contentPath, logger) + } + if !ok { + return + } trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("ResolvedPath", resolvedPath.String())) // Finish early if client already has matching Etag @@ -406,36 +398,6 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request } } -func (i *gatewayHandler) servePretty404IfPresent(w http.ResponseWriter, r *http.Request, contentPath ipath.Path) bool { - resolved404Path, ctype, err := i.searchUpTreeFor404(r, contentPath) - if err != nil { - return false - } - - dr, err := i.api.Unixfs().Get(r.Context(), resolved404Path) - if err != nil { - return false - } - defer dr.Close() - - f, ok := dr.(files.File) - if !ok { - return false - } - - size, err := f.Size() - if err != nil { - return false - } - - log.Debugw("using pretty 404 file", "path", contentPath) - w.Header().Set("Content-Type", ctype) - w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) - w.WriteHeader(http.StatusNotFound) - _, err = io.CopyN(w, f, size) - return err == nil -} - func (i *gatewayHandler) postHandler(w http.ResponseWriter, r *http.Request) { p, err := i.api.Unixfs().Add(r.Context(), files.NewReaderFile(r.Body)) if err != nil { @@ -805,48 +767,6 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string] return "", nil, nil } -func (i *gatewayHandler) searchUpTreeFor404(r *http.Request, contentPath ipath.Path) (ipath.Resolved, string, error) { - filename404, ctype, err := preferred404Filename(r.Header.Values("Accept")) - if err != nil { - return nil, "", err - } - - pathComponents := strings.Split(contentPath.String(), "/") - - for idx := len(pathComponents); idx >= 3; idx-- { - pretty404 := gopath.Join(append(pathComponents[0:idx], filename404)...) - parsed404Path := ipath.New("/" + pretty404) - if parsed404Path.IsValid() != nil { - break - } - resolvedPath, err := i.api.ResolvePath(r.Context(), parsed404Path) - if err != nil { - continue - } - return resolvedPath, ctype, nil - } - - return nil, "", fmt.Errorf("no pretty 404 in any parent folder") -} - -func preferred404Filename(acceptHeaders []string) (string, string, error) { - // If we ever want to offer a 404 file for a different content type - // then this function will need to parse q weightings, but for now - // the presence of anything matching HTML is enough. - for _, acceptHeader := range acceptHeaders { - accepted := strings.Split(acceptHeader, ",") - for _, spec := range accepted { - contentType := strings.SplitN(spec, ";", 1)[0] - switch contentType { - case "*/*", "text/*", "text/html": - return "ipfs-404.html", "text/html", nil - } - } - } - - return "", "", fmt.Errorf("there is no 404 file for the requested content types") -} - // returns unquoted path with all special characters revealed as \u codes func debugStr(path string) string { q := fmt.Sprintf("%+q", path) @@ -985,3 +905,19 @@ func (i *gatewayHandler) handledSetHeaders(w http.ResponseWriter, r *http.Reques return false } + +func (i *gatewayHandler) handleNonUnixfsPathResolution(w http.ResponseWriter, r *http.Request, contentPath ipath.Path, logger *zap.SugaredLogger) (ipath.Resolved, ipath.Path, bool) { + // Resolve the path for the provided contentPath + resolvedPath, err := i.api.ResolvePath(r.Context(), contentPath) + + switch err { + case nil: + return resolvedPath, contentPath, true + case coreiface.ErrOffline: + webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusServiceUnavailable) + return nil, nil, false + default: + webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusNotFound) + return nil, nil, false + } +} diff --git a/core/corehttp/gateway_handler_unixfs__redirects.go b/core/corehttp/gateway_handler_unixfs__redirects.go new file mode 100644 index 00000000000..c4659aeb8f6 --- /dev/null +++ b/core/corehttp/gateway_handler_unixfs__redirects.go @@ -0,0 +1,369 @@ +package corehttp + +import ( + "errors" + "fmt" + "io" + "net/http" + gopath "path" + "strconv" + "strings" + + "github.com/fission-suite/go-redirects" + files "github.com/ipfs/go-ipfs-files" + "github.com/ipfs/go-path/resolver" + coreiface "github.com/ipfs/interface-go-ipfs-core" + ipath "github.com/ipfs/interface-go-ipfs-core/path" + "github.com/ucarion/urlpath" + "go.uber.org/zap" +) + +// Resolve the provided path. +// If we can't resolve the path, then for Unixfs requests, look for a _redirects file in the root CID path. +// If _redirects file exists, attempt to match redirect rules for the path. +// If a rule matches, either redirect or rewrite as determined by the rule. +// For rewrites, we need to attempt to resolve the rewrite path as well, and if it doesn't resolve, this time we just return the error. +func (i *gatewayHandler) handleUnixfsPathResolution(w http.ResponseWriter, r *http.Request, contentPath ipath.Path, logger *zap.SugaredLogger) (ipath.Resolved, ipath.Path, bool) { + // Attempt to resolve the path for the provided contentPath + resolvedPath, err := i.api.ResolvePath(r.Context(), contentPath) + + // If path resolved and we have origin isolation, we need to attempt to read redirects file and find a force redirect for the corresponding path. If found, redirect, but only if force. + // If path resolved and we do not have origin isolation, no need to attempt to read redirects file. Just return resolvedPath, contentPath, true. + // If path didn't resolve, if ErrOffline, write error and return nil, nil, false. + // If path didn't resolve for any other error, if we have origin isolation, attempt to read redirects file and apply any redirect rules, regardless of force. + // Fallback to pretty 404 page, and then normal 404 + + switch err { + case nil: + // TODO: I believe for the force option, we might need to short circuit this, and thus we would need to read the redirects file first + return resolvedPath, contentPath, true + case coreiface.ErrOffline: + webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusServiceUnavailable) + return nil, nil, false + default: + // If we can't resolve the path + // Only look for _redirects file if we have Unixfs and Origin isolation + if hasOriginIsolation(r) { + // Check for _redirects file and redirect as needed + // /ipfs/CID/a/b/c/ + // /ipfs/CID/_redirects + // /ipns/domain/ipfs/CID + // /ipns/domain + logger.Debugf("r.URL.Path=%v", r.URL.Path) + redirectsFile, err := i.getRedirectsFile(r) + + if err != nil { + switch err.(type) { + case resolver.ErrNoLink: + // _redirects files doesn't exist, so don't error + // case coreiface.ErrResolveFailed.(type): + // How to get type? + // // Couldn't resolve ipns name when trying to compute root + // Tests indicate we should return 404, not 500 + // internalWebError(w, err) + // return nil, nil, false + default: + // Let users know about issues with _redirects file handling + internalWebError(w, err) + return nil, nil, false + } + } else { + // _redirects file exists, so parse it and redirect + redirected, newPath, err := i.handleRedirectsFile(w, r, redirectsFile, logger) + if err != nil { + err = fmt.Errorf("trouble processing _redirects file at %q: %w", redirectsFile.String(), err) + internalWebError(w, err) + return nil, nil, false + } + + if redirected { + return nil, nil, false + } + + // 200 is treated as a rewrite, so update the path and continue + if newPath != "" { + // Reassign contentPath and resolvedPath since the URL was rewritten + contentPath = ipath.New(newPath) + resolvedPath, err = i.api.ResolvePath(r.Context(), contentPath) + if err != nil { + internalWebError(w, err) + return nil, nil, false + } + logger.Debugf("_redirects: 200 rewrite. newPath=%v", newPath) + + return resolvedPath, contentPath, true + } + } + } + + // if Accept is text/html, see if ipfs-404.html is present + // This logic isn't documented and will likely be removed at some point. + // Any 404 logic in _redirects above will have already run by this time, so it's really an extra fall back + if i.servePretty404IfPresent(w, r, contentPath) { + logger.Debugw("serve pretty 404 if present") + return nil, nil, false + } + + // Fallback + webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusNotFound) + return nil, nil, false + } +} + +func (i *gatewayHandler) servePretty404IfPresent(w http.ResponseWriter, r *http.Request, contentPath ipath.Path) bool { + resolved404Path, ctype, err := i.searchUpTreeFor404(r, contentPath) + if err != nil { + return false + } + + dr, err := i.api.Unixfs().Get(r.Context(), resolved404Path) + if err != nil { + return false + } + defer dr.Close() + + f, ok := dr.(files.File) + if !ok { + return false + } + + size, err := f.Size() + if err != nil { + return false + } + + log.Debugw("using pretty 404 file", "path", contentPath) + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.WriteHeader(http.StatusNotFound) + _, err = io.CopyN(w, f, size) + return err == nil +} + +func (i *gatewayHandler) searchUpTreeFor404(r *http.Request, contentPath ipath.Path) (ipath.Resolved, string, error) { + filename404, ctype, err := preferred404Filename(r.Header.Values("Accept")) + if err != nil { + return nil, "", err + } + + pathComponents := strings.Split(contentPath.String(), "/") + + for idx := len(pathComponents); idx >= 3; idx-- { + pretty404 := gopath.Join(append(pathComponents[0:idx], filename404)...) + parsed404Path := ipath.New("/" + pretty404) + if parsed404Path.IsValid() != nil { + break + } + resolvedPath, err := i.api.ResolvePath(r.Context(), parsed404Path) + if err != nil { + continue + } + return resolvedPath, ctype, nil + } + + return nil, "", fmt.Errorf("no pretty 404 in any parent folder") +} + +func preferred404Filename(acceptHeaders []string) (string, string, error) { + // If we ever want to offer a 404 file for a different content type + // then this function will need to parse q weightings, but for now + // the presence of anything matching HTML is enough. + for _, acceptHeader := range acceptHeaders { + accepted := strings.Split(acceptHeader, ",") + for _, spec := range accepted { + contentType := strings.SplitN(spec, ";", 1)[0] + switch contentType { + case "*/*", "text/*", "text/html": + return "ipfs-404.html", "text/html", nil + } + } + } + + return "", "", fmt.Errorf("there is no 404 file for the requested content types") +} + +func (i *gatewayHandler) handleRedirectsFile(w http.ResponseWriter, r *http.Request, redirectsFilePath ipath.Resolved, logger *zap.SugaredLogger) (bool, string, error) { + // Convert the path into a file node + node, err := i.api.Unixfs().Get(r.Context(), redirectsFilePath) + if err != nil { + return false, "", fmt.Errorf("could not get _redirects node: %v", err) + } + defer node.Close() + + // Convert the node into a file + f, ok := node.(files.File) + if !ok { + return false, "", fmt.Errorf("could not convert _redirects node to file") + } + + // Parse redirect rules from file + redirectRules, err := redirects.Parse(f) + if err != nil { + return false, "", fmt.Errorf("could not parse redirect rules: %v", err) + } + logger.Debugf("redirectRules=%v", redirectRules) + + // Attempt to match a rule to the URL path, and perform the corresponding redirect or rewrite + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) > 3 { + // All paths should start with /ipfs/cid/, so get the path after that + urlPath := "/" + strings.Join(pathParts[3:], "/") + rootPath := strings.Join(pathParts[:3], "/") + // Trim off the trailing / + urlPath = strings.TrimSuffix(urlPath, "/") + + logger.Debugf("_redirects: urlPath=", urlPath) + for _, rule := range redirectRules { + // get rule.From, trim trailing slash, ... + fromPath := urlpath.New(strings.TrimSuffix(rule.From, "/")) + logger.Debugf("_redirects: fromPath=%v", strings.TrimSuffix(rule.From, "/")) + match, ok := fromPath.Match(urlPath) + if !ok { + continue + } + + // We have a match! Perform substitutions. + toPath := rule.To + toPath = replacePlaceholders(toPath, match) + toPath = replaceSplat(toPath, match) + + logger.Debugf("_redirects: toPath=%v", toPath) + + // Rewrite + if rule.Status == 200 { + // Prepend the rootPath + toPath = rootPath + rule.To + return false, toPath, nil + } + + // Or 404 + if rule.Status == 404 { + toPath = rootPath + rule.To + content404Path := ipath.New(toPath) + err = i.serve404(w, r, content404Path) + return true, toPath, err + } + + // Or redirect + http.Redirect(w, r, toPath, rule.Status) + return true, toPath, nil + } + } + + // No redirects matched + return false, "", nil +} + +func replacePlaceholders(to string, match urlpath.Match) string { + if len(match.Params) > 0 { + for key, value := range match.Params { + to = strings.ReplaceAll(to, ":"+key, value) + } + } + + return to +} + +func replaceSplat(to string, match urlpath.Match) string { + return strings.ReplaceAll(to, ":splat", match.Trailing) +} + +// Returns a resolved path to the _redirects file located in the root CID path of the requested path +func (i *gatewayHandler) getRedirectsFile(r *http.Request) (ipath.Resolved, error) { + // r.URL.Path is the full ipfs path to the requested resource, + // regardless of whether path or subdomain resolution is used. + rootPath, err := getRootPath(r.URL.Path) + if err != nil { + return nil, err + } + + path := ipath.Join(rootPath, "_redirects") + resolvedPath, err := i.api.ResolvePath(r.Context(), path) + if err != nil { + return nil, err + } + return resolvedPath, nil +} + +// Returns the root CID Path for the given path +// /ipfs/CID/* +// CID is the root CID +// /ipns/domain/* +// Need to resolve domain ipns path to get CID +// /ipns/domain/ipfs/CID +// Is this legit? If so, we should use CID? +func getRootPath(path string) (ipath.Path, error) { + if isIpfsPath(path) { + parts := strings.Split(path, "/") + return ipath.New(gopath.Join(ipfsPathPrefix, parts[2])), nil + } + + if isIpnsPath(path) { + parts := strings.Split(path, "/") + return ipath.New(gopath.Join(ipnsPathPrefix, parts[2])), nil + } + + return ipath.New(""), errors.New("failed to get root CID path") +} + +func (i *gatewayHandler) serve404(w http.ResponseWriter, r *http.Request, content404Path ipath.Path) error { + resolved404Path, err := i.api.ResolvePath(r.Context(), content404Path) + if err != nil { + return err + } + + node, err := i.api.Unixfs().Get(r.Context(), resolved404Path) + if err != nil { + return err + } + defer node.Close() + + f, ok := node.(files.File) + if !ok { + return fmt.Errorf("could not convert node for 404 page to file") + } + + size, err := f.Size() + if err != nil { + return fmt.Errorf("could not get size of 404 page") + } + + log.Debugw("using _redirects 404 file", "path", content404Path) + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.WriteHeader(http.StatusNotFound) + _, err = io.CopyN(w, f, size) + return err +} + +// TODO(JJ): Confirm approach +func hasOriginIsolation(r *http.Request) bool { + _, gw := r.Context().Value("gw-hostname").(string) + _, dnslink := r.Context().Value("dnslink-hostname").(string) + + if gw || dnslink { + return true + } + + return false +} + +func isIpfsPath(path string) bool { + if strings.HasPrefix(path, ipfsPathPrefix) && strings.Count(gopath.Clean(path), "/") >= 2 { + return true + } + + return false +} + +func isIpnsPath(path string) bool { + if strings.HasPrefix(path, ipnsPathPrefix) && strings.Count(gopath.Clean(path), "/") >= 2 { + return true + } + + return false +} + +func isUnixfsResponseFormat(responseFormat string) bool { + return responseFormat == "" +} diff --git a/core/corehttp/hostname.go b/core/corehttp/hostname.go index 6c0ad5bca13..0a6ec55f2e0 100644 --- a/core/corehttp/hostname.go +++ b/core/corehttp/hostname.go @@ -221,7 +221,8 @@ func HostnameOption() ServeOption { if !cfg.Gateway.NoDNSLink && isDNSLinkName(r.Context(), coreAPI, host) { // rewrite path and handle as DNSLink r.URL.Path = "/ipns/" + stripPort(host) + r.URL.Path - childMux.ServeHTTP(w, withHostnameContext(r, host)) + ctx := context.WithValue(r.Context(), "dnslink-hostname", host) + childMux.ServeHTTP(w, withHostnameContext(r.WithContext(ctx), host)) return } diff --git a/go.mod b/go.mod index 1756df7f291..9a70fd48c4e 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/dustin/go-humanize v1.0.0 github.com/elgris/jsondiff v0.0.0-20160530203242-765b5c24c302 github.com/facebookgo/atomicfile v0.0.0-20151019160806-2de1f203e7d5 + github.com/fission-suite/go-redirects v0.0.0-20220412202828-a86b2398567d github.com/fsnotify/fsnotify v1.5.1 github.com/gabriel-vasile/mimetype v1.4.0 github.com/hashicorp/go-multierror v1.1.1 @@ -103,6 +104,8 @@ require ( github.com/prometheus/client_golang v1.11.0 github.com/stretchr/testify v1.7.0 github.com/syndtr/goleveldb v1.0.0 + github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37 // indirect + github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb github.com/wI2L/jsondiff v0.2.0 github.com/whyrusleeping/go-sysinfo v0.0.0-20190219211824-4a357d4b90b1 github.com/whyrusleeping/multiaddr-filter v0.0.0-20160516205228-e903e4adabd7 @@ -123,4 +126,6 @@ require ( golang.org/x/sys v0.0.0-20211025112917-711f33c9992c ) +replace github.com/fission-suite/go-redirects => ../go-redirects + go 1.16 diff --git a/go.sum b/go.sum index c66a3626052..322ca8dcafd 100644 --- a/go.sum +++ b/go.sum @@ -1429,9 +1429,15 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37 h1:K11tjwz8zTTSZkz4TUjfLN+y8uJWP38BbyPqZ2yB/Yk= +github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37/go.mod h1:E0E2H2gQA+uoi27VCSU+a/BULPtadQA78q3cpTjZbZw= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= +github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb h1:Ywfo8sUltxogBpFuMOFRrrSifO788kAFxmvVw31PtQQ= +github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb/go.mod h1:ikPs9bRWicNw3S7XpJ8sK/smGwU9WcSVU3dy9qahYBM= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= @@ -2018,6 +2024,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= diff --git a/test/sharness/t0109-gateway-web-_redirects.sh b/test/sharness/t0109-gateway-web-_redirects.sh new file mode 100755 index 00000000000..bb6dcb6f90a --- /dev/null +++ b/test/sharness/t0109-gateway-web-_redirects.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +test_description="Test HTTP Gateway _redirects support" + +. lib/test-lib.sh + +test_init_ipfs +test_launch_ipfs_daemon + +## ============================================================================ +## Test _redirects file support +## ============================================================================ + +# Directory tree crafted to test _redirects file support +test_expect_success "Add the _redirects file test directory" ' + mkdir -p testredirect/ && + echo "my index" > testredirect/index.html && + echo "my one" > testredirect/one.html && + echo "my two" > testredirect/two.html && + echo "my 404" > testredirect/404.html && + mkdir testredirect/redirected-splat && + echo "redirected splat one" > testredirect/redirected-splat/one.html && + echo "/redirect-one /one.html" > testredirect/_redirects && + echo "/301-redirect-one /one.html 301" >> testredirect/_redirects && + echo "/302-redirect-two /two.html 302" >> testredirect/_redirects && + echo "/200-index /index.html 200" >> testredirect/_redirects && + echo "/posts/:year/:month/:day/:title /articles/:year/:month/:day/:title 301" >> testredirect/_redirects && + echo "/splat/:splat /redirected-splat/:splat 301" >> testredirect/_redirects && + echo "/en/* /404.html 404" >> testredirect/_redirects && + echo "/* /index.html 200" >> testredirect/_redirects && + REDIRECTS_DIR_CID=$(ipfs add -Qr --cid-version 1 testredirect) +' + +REDIRECTS_DIR_HOSTNAME="${REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT" + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/redirect-one redirects with default of 301, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/redirect-one" > response && + test_should_contain "one.html" response && + test_should_contain "301 Moved Permanently" response +' + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/301-redirect-one redirects with 301, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/301-redirect-one" > response && + test_should_contain "one.html" response && + test_should_contain "301 Moved Permanently" response +' + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/302-redirect-two redirects with 302, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/302-redirect-two" > response && + test_should_contain "two.html" response && + test_should_contain "302 Found" response +' + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/200-index returns 200, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/200-index" > response && + test_should_contain "my index" response && + test_should_contain "200 OK" response +' + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/posts/:year/:month/:day/:title redirects with 301 and placeholders, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/posts/2022/01/01/hello-world" > response && + test_should_contain "/articles/2022/01/01/hello-world" response && + test_should_contain "301 Moved Permanently" response +' + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/splat/one.html redirects with 301 and splat placeholder, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/splat/one.html" > response && + test_should_contain "/redirected-splat/one.html" response && + test_should_contain "301 Moved Permanently" response +' + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/en/has-no-redirects-entry returns custom 404, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/en/has-no-redirects-entry" > response && + test_should_contain "404 Not Found" response +' + +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/catch-all returns 200, per _redirects file" ' + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/catch-all" > response && + test_should_contain "200 OK" response +' + +test_expect_success "request for http://127.0.0.1:$GWAY_PORT/ipfs/$REDIRECTS_DIR_CID/301-redirect-one returns 404, no _redirects since no origin isolation" ' + curl -sD - "http://127.0.0.1:$GWAY_PORT/ipfs/$REDIRECTS_DIR_CID/301-redirect-one" > response && + test_should_contain "404 Not Found" response +' + +test_kill_ipfs_daemon + +# disable wildcard DNSLink gateway +# and enable it on specific NSLink hostname +ipfs config --json Gateway.NoDNSLink true && \ +ipfs config --json Gateway.PublicGateways '{ + "dnslink-enabled-on-fqdn.example.org": { + "NoDNSLink": false, + "UseSubdomains": false, + "Paths": ["/ipfs"] + }, + "only-dnslink-enabled-on-fqdn.example.org": { + "NoDNSLink": false, + "UseSubdomains": false, + "Paths": [] + }, + "dnslink-disabled-on-fqdn.example.com": { + "NoDNSLink": true, + "UseSubdomains": false, + "Paths": [] + } +}' || exit 1 + +# DNSLink test requires a daemon in online mode with precached /ipns/ mapping +DNSLINK_FQDN="dnslink-enabled-on-fqdn.example.org" +ONLY_DNSLINK_FQDN="only-dnslink-enabled-on-fqdn.example.org" +NO_DNSLINK_FQDN="dnslink-disabled-on-fqdn.example.com" +export IPFS_NS_MAP="$DNSLINK_FQDN:/ipfs/$REDIRECTS_DIR_CID,$ONLY_DNSLINK_FQDN:/ipfs/$REDIRECTS_DIR_CID" + +# restart daemon to apply config changes +test_launch_ipfs_daemon + +# make sure test setup is valid (fail if CoreAPI is unable to resolve) +test_expect_success "spoofed DNSLink record resolves in cli" " + ipfs resolve /ipns/$DNSLINK_FQDN > result && + test_should_contain \"$REDIRECTS_DIR_CID\" result && + ipfs cat /ipns/$DNSLINK_FQDN/_redirects > result && + test_should_contain \"index.html\" result +" + +test_done