@cryptotaxi247 / kubo / commits / bcaacdd6c

feat(gateway): _redirects file support (#8890)

https://github.com/ipfs/kubo/pull/8890 https://github.com/ipfs/specs/pull/290

Justin Johnson committed Sep 23, 2022 at 11:44 UTC bcaacdd6c3168392c2e2673e5a35e8a8387edbbc
10 files changed +593 -93
core/coreapi/name.go
+3 -1
@@ -37,6 +37,8 @@ func (e *ipnsEntry) Value() path.Path {
37 return e.value
38 }
39
40 +type requestContextKey string
41 +
42 // Publish announces new IPNS name and returns the new IPNS entry.
43 func (api *NameAPI) Publish(ctx context.Context, p path.Path, opts ...caopts.NamePublishOption) (coreiface.IpnsEntry, error) {
44 ctx, span := tracing.Span(ctx, "CoreAPI.NameAPI", "Publish", trace.WithAttributes(attribute.String("path", p.String())))
@@ -76,7 +78,7 @@ func (api *NameAPI) Publish(ctx context.Context, p path.Path, opts ...caopts.Nam
78
79 if options.TTL != nil {
80 // nolint: staticcheck // non-backward compatible change
79 - ctx = context.WithValue(ctx, "ipns-publish-ttl", *options.TTL)
81 + ctx = context.WithValue(ctx, requestContextKey("ipns-publish-ttl"), *options.TTL)
82 }
83
84 eol := time.Now().Add(options.ValidTime)
core/corehttp/gateway_handler.go
+47 -89
@@ -13,7 +13,6 @@ import (
13 gopath "path"
14 "regexp"
15 "runtime/debug"
16 - "strconv"
16 "strings"
17 "time"
18
@@ -378,23 +377,6 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
377 return
378 }
379
381 - // Resolve path to the final DAG node for the ETag
382 - resolvedPath, err := i.api.ResolvePath(r.Context(), contentPath)
383 - switch err {
384 - case nil:
385 - case coreiface.ErrOffline:
386 - webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusServiceUnavailable)
387 - return
388 - default:
389 - // if Accept is text/html, see if ipfs-404.html is present
390 - if i.servePretty404IfPresent(w, r, contentPath) {
391 - logger.Debugw("serve pretty 404 if present")
392 - return
393 - }
394 - webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusBadRequest)
395 - return
396 - }
397 -
380 // Detect when explicit Accept header or ?format parameter are present
381 responseFormat, formatParams, err := customResponseFormat(r)
382 if err != nil {
@@ -402,6 +384,11 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
384 return
385 }
386 trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("ResponseFormat", responseFormat))
387 +
388 + resolvedPath, contentPath, ok := i.handlePathResolution(w, r, responseFormat, contentPath, logger)
389 + if !ok {
390 + return
391 + }
392 trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("ResolvedPath", resolvedPath.String()))
393
394 // Detect when If-None-Match HTTP header allows returning HTTP 304 Not Modified
@@ -450,36 +437,6 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
437 }
438 }
439
453 -func (i *gatewayHandler) servePretty404IfPresent(w http.ResponseWriter, r *http.Request, contentPath ipath.Path) bool {
454 - resolved404Path, ctype, err := i.searchUpTreeFor404(r, contentPath)
455 - if err != nil {
456 - return false
457 - }
458 -
459 - dr, err := i.api.Unixfs().Get(r.Context(), resolved404Path)
460 - if err != nil {
461 - return false
462 - }
463 - defer dr.Close()
464 -
465 - f, ok := dr.(files.File)
466 - if !ok {
467 - return false
468 - }
469 -
470 - size, err := f.Size()
471 - if err != nil {
472 - return false
473 - }
474 -
475 - log.Debugw("using pretty 404 file", "path", contentPath)
476 - w.Header().Set("Content-Type", ctype)
477 - w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
478 - w.WriteHeader(http.StatusNotFound)
479 - _, err = io.CopyN(w, f, size)
480 - return err == nil
481 -}
482 -
440 func (i *gatewayHandler) postHandler(w http.ResponseWriter, r *http.Request) {
441 p, err := i.api.Unixfs().Add(r.Context(), files.NewReaderFile(r.Body))
442 if err != nil {
@@ -920,55 +877,56 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
877 return "", nil, nil
878 }
879
923 -func (i *gatewayHandler) searchUpTreeFor404(r *http.Request, contentPath ipath.Path) (ipath.Resolved, string, error) {
924 - filename404, ctype, err := preferred404Filename(r.Header.Values("Accept"))
925 - if err != nil {
926 - return nil, "", err
880 +// returns unquoted path with all special characters revealed as \u codes
881 +func debugStr(path string) string {
882 + q := fmt.Sprintf("%+q", path)
883 + if len(q) >= 3 {
884 + q = q[1 : len(q)-1]
885 }
886 + return q
887 +}
888
929 - pathComponents := strings.Split(contentPath.String(), "/")
930 -
931 - for idx := len(pathComponents); idx >= 3; idx-- {
932 - pretty404 := gopath.Join(append(pathComponents[0:idx], filename404)...)
933 - parsed404Path := ipath.New("/" + pretty404)
934 - if parsed404Path.IsValid() != nil {
935 - break
936 - }
937 - resolvedPath, err := i.api.ResolvePath(r.Context(), parsed404Path)
938 - if err != nil {
939 - continue
940 - }
941 - return resolvedPath, ctype, nil
942 - }
889 +// Resolve the provided contentPath including any special handling related to
890 +// the requested responseFormat. Returned ok flag indicates if gateway handler
891 +// should continue processing the request.
892 +func (i *gatewayHandler) handlePathResolution(w http.ResponseWriter, r *http.Request, responseFormat string, contentPath ipath.Path, logger *zap.SugaredLogger) (resolvedPath ipath.Resolved, newContentPath ipath.Path, ok bool) {
893 + // Attempt to resolve the provided path.
894 + resolvedPath, err := i.api.ResolvePath(r.Context(), contentPath)
895
944 - return nil, "", fmt.Errorf("no pretty 404 in any parent folder")
945 -}
896 + switch err {
897 + case nil:
898 + return resolvedPath, contentPath, true
899 + case coreiface.ErrOffline:
900 + webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusServiceUnavailable)
901 + return nil, nil, false
902 + default:
903 + // The path can't be resolved.
904 + if isUnixfsResponseFormat(responseFormat) {
905 + // If we have origin isolation (subdomain gw, DNSLink website),
906 + // and response type is UnixFS (default for website hosting)
907 + // check for presence of _redirects file and apply rules defined there.
908 + // See: https://github.com/ipfs/specs/pull/290
909 + if hasOriginIsolation(r) {
910 + resolvedPath, newContentPath, ok, hadMatchingRule := i.serveRedirectsIfPresent(w, r, resolvedPath, contentPath, logger)
911 + if hadMatchingRule {
912 + logger.Debugw("applied a rule from _redirects file")
913 + return resolvedPath, newContentPath, ok
914 + }
915 + }
916
947 -func preferred404Filename(acceptHeaders []string) (string, string, error) {
948 - // If we ever want to offer a 404 file for a different content type
949 - // then this function will need to parse q weightings, but for now
950 - // the presence of anything matching HTML is enough.
951 - for _, acceptHeader := range acceptHeaders {
952 - accepted := strings.Split(acceptHeader, ",")
953 - for _, spec := range accepted {
954 - contentType := strings.SplitN(spec, ";", 1)[0]
955 - switch contentType {
956 - case "*/*", "text/*", "text/html":
957 - return "ipfs-404.html", "text/html", nil
917 + // if Accept is text/html, see if ipfs-404.html is present
918 + // This logic isn't documented and will likely be removed at some point.
919 + // Any 404 logic in _redirects above will have already run by this time, so it's really an extra fall back
920 + if i.serveLegacy404IfPresent(w, r, contentPath) {
921 + logger.Debugw("served legacy 404")
922 + return nil, nil, false
923 }
924 }
960 - }
925
962 - return "", "", fmt.Errorf("there is no 404 file for the requested content types")
963 -}
964 -
965 -// returns unquoted path with all special characters revealed as \u codes
966 -func debugStr(path string) string {
967 - q := fmt.Sprintf("%+q", path)
968 - if len(q) >= 3 {
969 - q = q[1 : len(q)-1]
926 + // Note: webError will replace http.StatusBadRequest with StatusNotFound if necessary
927 + webError(w, "ipfs resolve -r "+debugStr(contentPath.String()), err, http.StatusBadRequest)
928 + return nil, nil, false
929 }
971 - return q
930 }
931
932 // Detect 'Cache-Control: only-if-cached' in request and return data if it is already in the local datastore.
core/corehttp/gateway_handler_unixfs__redirects.go new
+287
@@ -0,0 +1,287 @@
1 +package corehttp
2 +
3 +import (
4 + "fmt"
5 + "io"
6 + "net/http"
7 + gopath "path"
8 + "strconv"
9 + "strings"
10 +
11 + files "github.com/ipfs/go-ipfs-files"
12 + redirects "github.com/ipfs/go-ipfs-redirects-file"
13 + ipath "github.com/ipfs/interface-go-ipfs-core/path"
14 + "go.uber.org/zap"
15 +)
16 +
17 +// Resolving a UnixFS path involves determining if the provided `path.Path` exists and returning the `path.Resolved`
18 +// corresponding to that path. For UnixFS, path resolution is more involved.
19 +//
20 +// When a path under requested CID does not exist, Gateway will check if a `_redirects` file exists
21 +// underneath the root CID of the path, and apply rules defined there.
22 +// See sepcification introduced in: https://github.com/ipfs/specs/pull/290
23 +//
24 +// Scenario 1:
25 +// If a path exists, we always return the `path.Resolved` corresponding to that path, regardless of the existence of a `_redirects` file.
26 +//
27 +// Scenario 2:
28 +// If a path does not exist, usually we should return a `nil` resolution path and an error indicating that the path
29 +// doesn't exist. However, a `_redirects` file may exist and contain a redirect rule that redirects that path to a different path.
30 +// We need to evaluate the rule and perform the redirect if present.
31 +//
32 +// Scenario 3:
33 +// Another possibility is that the path corresponds to a rewrite rule (i.e. a rule with a status of 200).
34 +// In this case, we don't perform a redirect, but do need to return a `path.Resolved` and `path.Path` corresponding to
35 +// the rewrite destination path.
36 +//
37 +// Note that for security reasons, redirect rules are only processed when the request has origin isolation.
38 +// See https://github.com/ipfs/specs/pull/290 for more information.
39 +func (i *gatewayHandler) serveRedirectsIfPresent(w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentPath ipath.Path, logger *zap.SugaredLogger) (newResolvedPath ipath.Resolved, newContentPath ipath.Path, continueProcessing bool, hadMatchingRule bool) {
40 + redirectsFile := i.getRedirectsFile(r, contentPath, logger)
41 + if redirectsFile != nil {
42 + redirectRules, err := i.getRedirectRules(r, redirectsFile)
43 + if err != nil {
44 + internalWebError(w, err)
45 + return nil, nil, false, true
46 + }
47 +
48 + redirected, newPath, err := i.handleRedirectsFileRules(w, r, contentPath, redirectRules)
49 + if err != nil {
50 + err = fmt.Errorf("trouble processing _redirects file at %q: %w", redirectsFile.String(), err)
51 + internalWebError(w, err)
52 + return nil, nil, false, true
53 + }
54 +
55 + if redirected {
56 + return nil, nil, false, true
57 + }
58 +
59 + // 200 is treated as a rewrite, so update the path and continue
60 + if newPath != "" {
61 + // Reassign contentPath and resolvedPath since the URL was rewritten
62 + contentPath = ipath.New(newPath)
63 + resolvedPath, err = i.api.ResolvePath(r.Context(), contentPath)
64 + if err != nil {
65 + internalWebError(w, err)
66 + return nil, nil, false, true
67 + }
68 +
69 + return resolvedPath, contentPath, true, true
70 + }
71 + }
72 + // No matching rule, paths remain the same, continue regular processing
73 + return resolvedPath, contentPath, true, false
74 +}
75 +
76 +func (i *gatewayHandler) handleRedirectsFileRules(w http.ResponseWriter, r *http.Request, contentPath ipath.Path, redirectRules []redirects.Rule) (redirected bool, newContentPath string, err error) {
77 + // Attempt to match a rule to the URL path, and perform the corresponding redirect or rewrite
78 + pathParts := strings.Split(contentPath.String(), "/")
79 + if len(pathParts) > 3 {
80 + // All paths should start with /ipfs/cid/, so get the path after that
81 + urlPath := "/" + strings.Join(pathParts[3:], "/")
82 + rootPath := strings.Join(pathParts[:3], "/")
83 + // Trim off the trailing /
84 + urlPath = strings.TrimSuffix(urlPath, "/")
85 +
86 + for _, rule := range redirectRules {
87 + // Error right away if the rule is invalid
88 + if !rule.MatchAndExpandPlaceholders(urlPath) {
89 + continue
90 + }
91 +
92 + // We have a match!
93 +
94 + // Rewrite
95 + if rule.Status == 200 {
96 + // Prepend the rootPath
97 + toPath := rootPath + rule.To
98 + return false, toPath, nil
99 + }
100 +
101 + // Or 4xx
102 + if rule.Status == 404 || rule.Status == 410 || rule.Status == 451 {
103 + toPath := rootPath + rule.To
104 + content4xxPath := ipath.New(toPath)
105 + err := i.serve4xx(w, r, content4xxPath, rule.Status)
106 + return true, toPath, err
107 + }
108 +
109 + // Or redirect
110 + if rule.Status >= 301 && rule.Status <= 308 {
111 + http.Redirect(w, r, rule.To, rule.Status)
112 + return true, "", nil
113 + }
114 + }
115 + }
116 +
117 + // No redirects matched
118 + return false, "", nil
119 +}
120 +
121 +func (i *gatewayHandler) getRedirectRules(r *http.Request, redirectsFilePath ipath.Resolved) ([]redirects.Rule, error) {
122 + // Convert the path into a file node
123 + node, err := i.api.Unixfs().Get(r.Context(), redirectsFilePath)
124 + if err != nil {
125 + return nil, fmt.Errorf("could not get _redirects: %w", err)
126 + }
127 + defer node.Close()
128 +
129 + // Convert the node into a file
130 + f, ok := node.(files.File)
131 + if !ok {
132 + return nil, fmt.Errorf("could not parse _redirects: %w", err)
133 + }
134 +
135 + // Parse redirect rules from file
136 + redirectRules, err := redirects.Parse(f)
137 + if err != nil {
138 + return nil, fmt.Errorf("could not parse _redirects: %w", err)
139 + }
140 +
141 + return redirectRules, nil
142 +}
143 +
144 +// Returns a resolved path to the _redirects file located in the root CID path of the requested path
145 +func (i *gatewayHandler) getRedirectsFile(r *http.Request, contentPath ipath.Path, logger *zap.SugaredLogger) ipath.Resolved {
146 + // contentPath is the full ipfs path to the requested resource,
147 + // regardless of whether path or subdomain resolution is used.
148 + rootPath := getRootPath(contentPath)
149 +
150 + // Check for _redirects file.
151 + // Any path resolution failures are ignored and we just assume there's no _redirects file.
152 + // Note that ignoring these errors also ensures that the use of the empty CID (bafkqaaa) in tests doesn't fail.
153 + path := ipath.Join(rootPath, "_redirects")
154 + resolvedPath, err := i.api.ResolvePath(r.Context(), path)
155 + if err != nil {
156 + return nil
157 + }
158 + return resolvedPath
159 +}
160 +
161 +// Returns the root CID Path for the given path
162 +func getRootPath(path ipath.Path) ipath.Path {
163 + parts := strings.Split(path.String(), "/")
164 + return ipath.New(gopath.Join("/", path.Namespace(), parts[2]))
165 +}
166 +
167 +func (i *gatewayHandler) serve4xx(w http.ResponseWriter, r *http.Request, content4xxPath ipath.Path, status int) error {
168 + resolved4xxPath, err := i.api.ResolvePath(r.Context(), content4xxPath)
169 + if err != nil {
170 + return err
171 + }
172 +
173 + node, err := i.api.Unixfs().Get(r.Context(), resolved4xxPath)
174 + if err != nil {
175 + return err
176 + }
177 + defer node.Close()
178 +
179 + f, ok := node.(files.File)
180 + if !ok {
181 + return fmt.Errorf("could not convert node for %d page to file", status)
182 + }
183 +
184 + size, err := f.Size()
185 + if err != nil {
186 + return fmt.Errorf("could not get size of %d page", status)
187 + }
188 +
189 + log.Debugf("using _redirects: custom %d file at %q", status, content4xxPath)
190 + w.Header().Set("Content-Type", "text/html")
191 + w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
192 + addCacheControlHeaders(w, r, content4xxPath, resolved4xxPath.Cid())
193 + w.WriteHeader(status)
194 + _, err = io.CopyN(w, f, size)
195 + return err
196 +}
197 +
198 +func hasOriginIsolation(r *http.Request) bool {
199 + _, gw := r.Context().Value(requestContextKey("gw-hostname")).(string)
200 + _, dnslink := r.Context().Value("dnslink-hostname").(string)
201 +
202 + if gw || dnslink {
203 + return true
204 + }
205 +
206 + return false
207 +}
208 +
209 +func isUnixfsResponseFormat(responseFormat string) bool {
210 + // The implicit response format is UnixFS
211 + return responseFormat == ""
212 +}
213 +
214 +// Deprecated: legacy ipfs-404.html files are superseded by _redirects file
215 +// This is provided only for backward-compatibility, until websites migrate
216 +// to 404s managed via _redirects file (https://github.com/ipfs/specs/pull/290)
217 +func (i *gatewayHandler) serveLegacy404IfPresent(w http.ResponseWriter, r *http.Request, contentPath ipath.Path) bool {
218 + resolved404Path, ctype, err := i.searchUpTreeFor404(r, contentPath)
219 + if err != nil {
220 + return false
221 + }
222 +
223 + dr, err := i.api.Unixfs().Get(r.Context(), resolved404Path)
224 + if err != nil {
225 + return false
226 + }
227 + defer dr.Close()
228 +
229 + f, ok := dr.(files.File)
230 + if !ok {
231 + return false
232 + }
233 +
234 + size, err := f.Size()
235 + if err != nil {
236 + return false
237 + }
238 +
239 + log.Debugw("using pretty 404 file", "path", contentPath)
240 + w.Header().Set("Content-Type", ctype)
241 + w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
242 + w.WriteHeader(http.StatusNotFound)
243 + _, err = io.CopyN(w, f, size)
244 + return err == nil
245 +}
246 +
247 +func (i *gatewayHandler) searchUpTreeFor404(r *http.Request, contentPath ipath.Path) (ipath.Resolved, string, error) {
248 + filename404, ctype, err := preferred404Filename(r.Header.Values("Accept"))
249 + if err != nil {
250 + return nil, "", err
251 + }
252 +
253 + pathComponents := strings.Split(contentPath.String(), "/")
254 +
255 + for idx := len(pathComponents); idx >= 3; idx-- {
256 + pretty404 := gopath.Join(append(pathComponents[0:idx], filename404)...)
257 + parsed404Path := ipath.New("/" + pretty404)
258 + if parsed404Path.IsValid() != nil {
259 + break
260 + }
261 + resolvedPath, err := i.api.ResolvePath(r.Context(), parsed404Path)
262 + if err != nil {
263 + continue
264 + }
265 + return resolvedPath, ctype, nil
266 + }
267 +
268 + return nil, "", fmt.Errorf("no pretty 404 in any parent folder")
269 +}
270 +
271 +func preferred404Filename(acceptHeaders []string) (string, string, error) {
272 + // If we ever want to offer a 404 file for a different content type
273 + // then this function will need to parse q weightings, but for now
274 + // the presence of anything matching HTML is enough.
275 + for _, acceptHeader := range acceptHeaders {
276 + accepted := strings.Split(acceptHeader, ",")
277 + for _, spec := range accepted {
278 + contentType := strings.SplitN(spec, ";", 1)[0]
279 + switch contentType {
280 + case "*/*", "text/*", "text/html":
281 + return "ipfs-404.html", "text/html", nil
282 + }
283 + }
284 + }
285 +
286 + return "", "", fmt.Errorf("there is no 404 file for the requested content types")
287 +}
core/corehttp/gateway_handler_unixfs_dir.go
+1 -1
@@ -185,7 +185,7 @@ func (i *gatewayHandler) serveDirectory(ctx context.Context, w http.ResponseWrit
185 var gwURL string
186
187 // Get gateway hostname and build gateway URL.
188 - if h, ok := r.Context().Value("gw-hostname").(string); ok {
188 + if h, ok := r.Context().Value(requestContextKey("gw-hostname")).(string); ok {
189 gwURL = "//" + h
190 } else {
191 gwURL = ""
core/corehttp/hostname.go
+5 -2
@@ -221,7 +221,8 @@ func HostnameOption() ServeOption {
221 if !cfg.Gateway.NoDNSLink && isDNSLinkName(r.Context(), coreAPI, host) {
222 // rewrite path and handle as DNSLink
223 r.URL.Path = "/ipns/" + stripPort(host) + r.URL.Path
224 - childMux.ServeHTTP(w, withHostnameContext(r, host))
224 + ctx := context.WithValue(r.Context(), requestContextKey("dnslink-hostname"), host)
225 + childMux.ServeHTTP(w, withHostnameContext(r.WithContext(ctx), host))
226 return
227 }
228
@@ -242,6 +243,8 @@ type wildcardHost struct {
243 spec *config.GatewaySpec
244 }
245
246 +type requestContextKey string
247 +
248 // Extends request context to include hostname of a canonical gateway root
249 // (subdomain root or dnslink fqdn)
250 func withHostnameContext(r *http.Request, hostname string) *http.Request {
@@ -250,7 +253,7 @@ func withHostnameContext(r *http.Request, hostname string) *http.Request {
253 // Host header, subdomain gateways have more comples rules (knownSubdomainDetails)
254 // More: https://github.com/ipfs/dir-index-html/issues/42
255 // nolint: staticcheck // non-backward compatible change
253 - ctx := context.WithValue(r.Context(), "gw-hostname", hostname)
256 + ctx := context.WithValue(r.Context(), requestContextKey("gw-hostname"), hostname)
257 return r.WithContext(ctx)
258 }
259
docs/examples/kubo-as-a-library/go.sum
+4
@@ -571,6 +571,7 @@ github.com/ipfs/go-ipfs-pq v0.0.2 h1:e1vOOW6MuOwG2lqxcLA+wEn93i/9laCY8sXAw76jFOY
571 github.com/ipfs/go-ipfs-pq v0.0.2/go.mod h1:LWIqQpqfRG3fNc5XsnIhz/wQ2XXGyugQwls7BgUmUfY=
572 github.com/ipfs/go-ipfs-provider v0.7.1 h1:eKToBUAb6ZY8iiA6AYVxzW4G1ep67XUaaEBUIYpxhfw=
573 github.com/ipfs/go-ipfs-provider v0.7.1/go.mod h1:QwdDYRYnC5sYGLlOwVDY/0ZB6T3zcMtu+5+GdGeUuw8=
574 +github.com/ipfs/go-ipfs-redirects-file v0.1.1/go.mod h1:tAwRjCV0RjLTjH8DR/AU7VYvfQECg+lpUy2Mdzv7gyk=
575 github.com/ipfs/go-ipfs-routing v0.0.1/go.mod h1:k76lf20iKFxQTjcJokbPM9iBXVXVZhcOwc360N4nuKs=
576 github.com/ipfs/go-ipfs-routing v0.1.0/go.mod h1:hYoUkJLyAUKhF58tysKpids8RNDPO42BVMgK5dNsoqY=
577 github.com/ipfs/go-ipfs-routing v0.2.1 h1:E+whHWhJkdN9YeoHZNj5itzc+OR292AJ2uE9FFiW0BY=
@@ -1535,9 +1536,11 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
1536 github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
1537 github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
1538 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
1539 +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
1540 github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
1541 github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ=
1542 github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
1543 +github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb/go.mod h1:ikPs9bRWicNw3S7XpJ8sK/smGwU9WcSVU3dy9qahYBM=
1544 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
1545 github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
1546 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
@@ -2179,6 +2182,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
2182 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
2183 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
2184 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
2185 +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
2186 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
2187 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2188 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
go.mod
+2
@@ -117,6 +117,7 @@ require (
117 require (
118 github.com/benbjohnson/clock v1.3.0
119 github.com/ipfs/go-delegated-routing v0.6.0
120 + github.com/ipfs/go-ipfs-redirects-file v0.1.1
121 github.com/ipfs/go-log/v2 v2.5.1
122 )
123
@@ -225,6 +226,7 @@ require (
226 github.com/tidwall/gjson v1.14.0 // indirect
227 github.com/tidwall/match v1.1.1 // indirect
228 github.com/tidwall/pretty v1.2.0 // indirect
229 + github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb // indirect
230 github.com/whyrusleeping/base32 v0.0.0-20170828182744-c30ac30633cc // indirect
231 github.com/whyrusleeping/cbor-gen v0.0.0-20210219115102-f37d292932f2 // indirect
232 github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f // indirect
go.sum
+5
@@ -565,6 +565,8 @@ github.com/ipfs/go-ipfs-pq v0.0.2 h1:e1vOOW6MuOwG2lqxcLA+wEn93i/9laCY8sXAw76jFOY
565 github.com/ipfs/go-ipfs-pq v0.0.2/go.mod h1:LWIqQpqfRG3fNc5XsnIhz/wQ2XXGyugQwls7BgUmUfY=
566 github.com/ipfs/go-ipfs-provider v0.7.1 h1:eKToBUAb6ZY8iiA6AYVxzW4G1ep67XUaaEBUIYpxhfw=
567 github.com/ipfs/go-ipfs-provider v0.7.1/go.mod h1:QwdDYRYnC5sYGLlOwVDY/0ZB6T3zcMtu+5+GdGeUuw8=
568 +github.com/ipfs/go-ipfs-redirects-file v0.1.1 h1:Io++k0Vf/wK+tfnhEh63Yte1oQK5VGT2hIEYpD0Rzx8=
569 +github.com/ipfs/go-ipfs-redirects-file v0.1.1/go.mod h1:tAwRjCV0RjLTjH8DR/AU7VYvfQECg+lpUy2Mdzv7gyk=
570 github.com/ipfs/go-ipfs-routing v0.0.1/go.mod h1:k76lf20iKFxQTjcJokbPM9iBXVXVZhcOwc360N4nuKs=
571 github.com/ipfs/go-ipfs-routing v0.1.0/go.mod h1:hYoUkJLyAUKhF58tysKpids8RNDPO42BVMgK5dNsoqY=
572 github.com/ipfs/go-ipfs-routing v0.2.1 h1:E+whHWhJkdN9YeoHZNj5itzc+OR292AJ2uE9FFiW0BY=
@@ -1511,9 +1513,12 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
1513 github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
1514 github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
1515 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
1516 +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
1517 github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
1518 github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ=
1519 github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
1520 +github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb h1:Ywfo8sUltxogBpFuMOFRrrSifO788kAFxmvVw31PtQQ=
1521 +github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb/go.mod h1:ikPs9bRWicNw3S7XpJ8sK/smGwU9WcSVU3dy9qahYBM=
1522 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
1523 github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
1524 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
test/sharness/t0109-gateway-web-_redirects-data/redirects.car
Binary files /dev/null and b/test/sharness/t0109-gateway-web-_redirects-data/redirects.car differ
test/sharness/t0109-gateway-web-_redirects.sh new
+239
@@ -0,0 +1,239 @@
1 +#!/usr/bin/env bash
2 +
3 +test_description="Test HTTP Gateway _redirects support"
4 +
5 +. lib/test-lib.sh
6 +
7 +test_init_ipfs
8 +test_launch_ipfs_daemon
9 +
10 +## ============================================================================
11 +## Test _redirects file support
12 +## ============================================================================
13 +
14 +# Import test case
15 +# Run `ipfs cat /ipfs/$REDIRECTS_DIR_CID/_redirects` to see sample _redirects file
16 +test_expect_success "Add the _redirects file test directory" '
17 + ipfs dag import ../t0109-gateway-web-_redirects-data/redirects.car
18 +'
19 +CAR_ROOT_CID=QmQyqMY5vUBSbSxyitJqthgwZunCQjDVtNd8ggVCxzuPQ4
20 +
21 +REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/examples | cut -d "/" -f3)
22 +REDIRECTS_DIR_HOSTNAME="${REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT"
23 +
24 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/redirect-one redirects with default of 301, per _redirects file" '
25 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/redirect-one" > response &&
26 + test_should_contain "301 Moved Permanently" response &&
27 + test_should_contain "Location: /one.html" response
28 +'
29 +
30 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/301-redirect-one redirects with 301, per _redirects file" '
31 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/301-redirect-one" > response &&
32 + test_should_contain "301 Moved Permanently" response &&
33 + test_should_contain "Location: /one.html" response
34 +'
35 +
36 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/302-redirect-two redirects with 302, per _redirects file" '
37 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/302-redirect-two" > response &&
38 + test_should_contain "302 Found" response &&
39 + test_should_contain "Location: /two.html" response
40 +'
41 +
42 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/200-index returns 200, per _redirects file" '
43 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/200-index" > response &&
44 + test_should_contain "my index" response &&
45 + test_should_contain "200 OK" response
46 +'
47 +
48 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/posts/:year/:month/:day/:title redirects with 301 and placeholders, per _redirects file" '
49 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/posts/2022/01/01/hello-world" > response &&
50 + test_should_contain "301 Moved Permanently" response &&
51 + test_should_contain "Location: /articles/2022/01/01/hello-world" response
52 +'
53 +
54 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/splat/one.html redirects with 301 and splat placeholder, per _redirects file" '
55 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/splat/one.html" > response &&
56 + test_should_contain "301 Moved Permanently" response &&
57 + test_should_contain "Location: /redirected-splat/one.html" response
58 +'
59 +
60 +# ensure custom 4xx works and has the same cache headers as regular /ipfs/ path
61 +CUSTOM_4XX_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/examples/404.html | cut -d "/" -f3)
62 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/not-found/has-no-redirects-entry returns custom 404, per _redirects file" '
63 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/not-found/has-no-redirects-entry" > response &&
64 + test_should_contain "404 Not Found" response &&
65 + test_should_contain "Cache-Control: public, max-age=29030400, immutable" response &&
66 + test_should_contain "Etag: \"$CUSTOM_4XX_CID\"" response &&
67 + test_should_contain "my 404" response
68 +'
69 +
70 +CUSTOM_4XX_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/examples/410.html | cut -d "/" -f3)
71 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/gone/has-no-redirects-entry returns custom 410, per _redirects file" '
72 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/gone/has-no-redirects-entry" > response &&
73 + test_should_contain "410 Gone" response &&
74 + test_should_contain "Cache-Control: public, max-age=29030400, immutable" response &&
75 + test_should_contain "Etag: \"$CUSTOM_4XX_CID\"" response &&
76 + test_should_contain "my 410" response
77 +'
78 +
79 +CUSTOM_4XX_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/examples/451.html | cut -d "/" -f3)
80 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/unavail/has-no-redirects-entry returns custom 451, per _redirects file" '
81 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/unavail/has-no-redirects-entry" > response &&
82 + test_should_contain "451 Unavailable For Legal Reasons" response &&
83 + test_should_contain "Cache-Control: public, max-age=29030400, immutable" response &&
84 + test_should_contain "Etag: \"$CUSTOM_4XX_CID\"" response &&
85 + test_should_contain "my 451" response
86 +'
87 +
88 +test_expect_success "request for $REDIRECTS_DIR_HOSTNAME/catch-all returns 200, per _redirects file" '
89 + curl -sD - --resolve $REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$REDIRECTS_DIR_HOSTNAME/catch-all" > response &&
90 + test_should_contain "200 OK" response &&
91 + test_should_contain "my index" response
92 +'
93 +
94 +# This test ensures _redirects is supported only on Web Gateways that use Host header (DNSLink, Subdomain)
95 +test_expect_success "request for http://127.0.0.1:$GWAY_PORT/ipfs/$REDIRECTS_DIR_CID/301-redirect-one returns generic 404 (no custom 404 from _redirects since no origin isolation)" '
96 + curl -sD - "http://127.0.0.1:$GWAY_PORT/ipfs/$REDIRECTS_DIR_CID/301-redirect-one" > response &&
97 + test_should_contain "404 Not Found" response &&
98 + test_should_not_contain "my 404" response
99 +'
100 +
101 +# With CRLF line terminator
102 +NEWLINE_REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/newlines | cut -d "/" -f3)
103 +NEWLINE_REDIRECTS_DIR_HOSTNAME="${NEWLINE_REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT"
104 +
105 +test_expect_success "newline: _redirects has CRLF line terminators" '
106 + ipfs cat /ipfs/$NEWLINE_REDIRECTS_DIR_CID/_redirects | file - > response &&
107 + test_should_contain "with CRLF line terminators" response
108 +'
109 +
110 +test_expect_success "newline: request for $NEWLINE_REDIRECTS_DIR_HOSTNAME/redirect-one redirects with default of 301, per _redirects file" '
111 + curl -sD - --resolve $NEWLINE_REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$NEWLINE_REDIRECTS_DIR_HOSTNAME/redirect-one" > response &&
112 + test_should_contain "301 Moved Permanently" response &&
113 + test_should_contain "Location: /one.html" response
114 +'
115 +
116 +# Good codes
117 +GOOD_REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/good-codes | cut -d "/" -f3)
118 +GOOD_REDIRECTS_DIR_HOSTNAME="${GOOD_REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT"
119 +
120 +test_expect_success "good codes: request for $GOOD_REDIRECTS_DIR_HOSTNAME/redirect-one redirects with default of 301, per _redirects file" '
121 + curl -sD - --resolve $GOOD_REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$GOOD_REDIRECTS_DIR_HOSTNAME/a301" > response &&
122 + test_should_contain "301 Moved Permanently" response &&
123 + test_should_contain "Location: /b301" response
124 +'
125 +
126 +# Bad codes
127 +BAD_REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/bad-codes | cut -d "/" -f3)
128 +BAD_REDIRECTS_DIR_HOSTNAME="${BAD_REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT"
129 +
130 +# if accessing a path that doesn't exist, read _redirects and fail parsing, and return error
131 +test_expect_success "bad codes: request for $BAD_REDIRECTS_DIR_HOSTNAME/not-found returns error about bad code" '
132 + curl -sD - --resolve $BAD_REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$BAD_REDIRECTS_DIR_HOSTNAME/not-found" > response &&
133 + test_should_contain "500" response &&
134 + test_should_contain "status code 999 is not supported" response
135 +'
136 +
137 +# if accessing a path that does exist, don't read _redirects and therefore don't fail parsing
138 +test_expect_success "bad codes: request for $BAD_REDIRECTS_DIR_HOSTNAME/found.html doesn't return error about bad code" '
139 + curl -sD - --resolve $BAD_REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$BAD_REDIRECTS_DIR_HOSTNAME/found.html" > response &&
140 + test_should_contain "200" response &&
141 + test_should_contain "my found" response &&
142 + test_should_not_contain "unsupported redirect status" response
143 +'
144 +
145 +# Invalid file, containing "hello"
146 +INVALID_REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/invalid | cut -d "/" -f3)
147 +INVALID_REDIRECTS_DIR_HOSTNAME="${INVALID_REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT"
148 +
149 +# if accessing a path that doesn't exist, read _redirects and fail parsing, and return error
150 +test_expect_success "invalid file: request for $INVALID_REDIRECTS_DIR_HOSTNAME/not-found returns error about invalid redirects file" '
151 + curl -sD - --resolve $INVALID_REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$INVALID_REDIRECTS_DIR_HOSTNAME/not-found" > response &&
152 + test_should_contain "500" response &&
153 + test_should_contain "could not parse _redirects:" response
154 +'
155 +
156 +# Invalid file, containing forced redirect
157 +INVALID_REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/forced | cut -d "/" -f3)
158 +INVALID_REDIRECTS_DIR_HOSTNAME="${INVALID_REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT"
159 +
160 +# if accessing a path that doesn't exist, read _redirects and fail parsing, and return error
161 +test_expect_success "invalid file: request for $INVALID_REDIRECTS_DIR_HOSTNAME/not-found returns error about invalid redirects file" '
162 + curl -sD - --resolve $INVALID_REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$INVALID_REDIRECTS_DIR_HOSTNAME/not-found" > response &&
163 + test_should_contain "500" response &&
164 + test_should_contain "could not parse _redirects:" response &&
165 + test_should_contain "forced redirects (or \"shadowing\") are not supported" response
166 +'
167 +
168 +# if accessing a path that doesn't exist and _redirects file is too large, return error
169 +TOO_LARGE_REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/too-large | cut -d "/" -f3)
170 +TOO_LARGE_REDIRECTS_DIR_HOSTNAME="${TOO_LARGE_REDIRECTS_DIR_CID}.ipfs.localhost:$GWAY_PORT"
171 +test_expect_success "invalid file: request for $TOO_LARGE_REDIRECTS_DIR_HOSTNAME/not-found returns error about too large redirects file" '
172 + curl -sD - --resolve $TOO_LARGE_REDIRECTS_DIR_HOSTNAME:127.0.0.1 "http://$TOO_LARGE_REDIRECTS_DIR_HOSTNAME/not-found" > response &&
173 + test_should_contain "500" response &&
174 + test_should_contain "could not parse _redirects:" response &&
175 + test_should_contain "redirects file size cannot exceed" response
176 +'
177 +
178 +test_kill_ipfs_daemon
179 +
180 +# disable wildcard DNSLink gateway
181 +# and enable it on specific DNSLink hostname
182 +ipfs config --json Gateway.NoDNSLink true && \
183 +ipfs config --json Gateway.PublicGateways '{
184 + "dnslink-enabled-on-fqdn.example.org": {
185 + "NoDNSLink": false,
186 + "UseSubdomains": false,
187 + "Paths": ["/ipfs"]
188 + },
189 + "dnslink-disabled-on-fqdn.example.com": {
190 + "NoDNSLink": true,
191 + "UseSubdomains": false,
192 + "Paths": []
193 + }
194 +}' || exit 1
195 +
196 +# DNSLink test requires a daemon in online mode with precached /ipns/ mapping
197 +# REDIRECTS_DIR_CID=$(ipfs resolve -r /ipfs/$CAR_ROOT_CID/examples | cut -d "/" -f3)
198 +DNSLINK_FQDN="dnslink-enabled-on-fqdn.example.org"
199 +NO_DNSLINK_FQDN="dnslink-disabled-on-fqdn.example.com"
200 +export IPFS_NS_MAP="$DNSLINK_FQDN:/ipfs/$REDIRECTS_DIR_CID"
201 +
202 +# restart daemon to apply config changes
203 +test_launch_ipfs_daemon
204 +
205 +# make sure test setup is valid (fail if CoreAPI is unable to resolve)
206 +test_expect_success "spoofed DNSLink record resolves in cli" "
207 + ipfs resolve /ipns/$DNSLINK_FQDN > result &&
208 + test_should_contain \"$REDIRECTS_DIR_CID\" result &&
209 + ipfs cat /ipns/$DNSLINK_FQDN/_redirects > result &&
210 + test_should_contain \"index.html\" result
211 +"
212 +
213 +test_expect_success "request for $DNSLINK_FQDN/redirect-one redirects with default of 301, per _redirects file" '
214 + curl -sD - --resolve $DNSLINK_FQDN:$GWAY_PORT:127.0.0.1 "http://$DNSLINK_FQDN:$GWAY_PORT/redirect-one" > response &&
215 + test_should_contain "301 Moved Permanently" response &&
216 + test_should_contain "Location: /one.html" response
217 +'
218 +
219 +# ensure custom 404 works and has the same cache headers as regular /ipns/ paths
220 +test_expect_success "request for $DNSLINK_FQDN/en/has-no-redirects-entry returns custom 404, per _redirects file" '
221 + curl -sD - --resolve $DNSLINK_FQDN:$GWAY_PORT:127.0.0.1 "http://$DNSLINK_FQDN:$GWAY_PORT/not-found/has-no-redirects-entry" > response &&
222 + test_should_contain "404 Not Found" response &&
223 + test_should_contain "Etag: \"Qmd9GD7Bauh6N2ZLfNnYS3b7QVAijbud83b8GE8LPMNBBP\"" response &&
224 + test_should_not_contain "Cache-Control: public, max-age=29030400, immutable" response &&
225 + test_should_not_contain "immutable" response &&
226 + test_should_contain "Date: " response &&
227 + test_should_contain "my 404" response
228 +'
229 +
230 +test_expect_success "request for $NO_DNSLINK_FQDN/redirect-one does not redirect, since DNSLink is disabled" '
231 + curl -sD - --resolve $NO_DNSLINK_FQDN:$GWAY_PORT:127.0.0.1 "http://$NO_DNSLINK_FQDN:$GWAY_PORT/redirect-one" > response &&
232 + test_should_not_contain "one.html" response &&
233 + test_should_not_contain "301 Moved Permanently" response &&
234 + test_should_not_contain "Location:" response
235 +'
236 +
237 +test_kill_ipfs_daemon
238 +
239 +test_done