@cryptotaxi247 / kubo / commits / dfceafdbd

Gateway renders pretty 404 pages if available

In the same way that an `index.html` file is rendered, if one is present, when the requested path is a directory, now an `ipfs-404.html` file is rendered if the requested file is not present within the specified IPFS object. `ipfs-404.html` files are looked for in the directory of the requested path and each parent until one is found, falling back on the well-known 404 error message. License: MIT Signed-off-by: JP Hastings-Spital <jphastings@gmail.com>

JP Hastings-Spital committed May 9, 2020 at 00:16 UTC dfceafdbd3debca60959bdb9ecb28dd4c431c163
2 files changed +145
core/corehttp/gateway_handler.go
+81
@@ -11,6 +11,7 @@ import (
11 gopath "path"
12 "regexp"
13 "runtime/debug"
14 + "strconv"
15 "strings"
16 "time"
17
@@ -203,6 +204,10 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
204 webError(w, "ipfs resolve -r "+escapedURLPath, err, http.StatusServiceUnavailable)
205 return
206 default:
207 + if i.servePretty404IfPresent(w, r, parsedPath) {
208 + return
209 + }
210 +
211 webError(w, "ipfs resolve -r "+escapedURLPath, err, http.StatusNotFound)
212 return
213 }
@@ -290,6 +295,10 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
295 return
296 }
297
298 + if i.servePretty404IfPresent(w, r, parsedPath) {
299 + return
300 + }
301 +
302 // storage for directory listing
303 var dirListing []directoryItem
304 dirit := dir.Entries()
@@ -406,6 +415,36 @@ func (i *gatewayHandler) serveFile(w http.ResponseWriter, req *http.Request, nam
415 http.ServeContent(w, req, name, modtime, content)
416 }
417
418 +func (i *gatewayHandler) servePretty404IfPresent(w http.ResponseWriter, r *http.Request, parsedPath ipath.Path) bool {
419 + resolved404Path, ctype, err := i.searchUpTreeFor404(r, parsedPath)
420 + if err != nil {
421 + return false
422 + }
423 +
424 + dr, err := i.api.Unixfs().Get(r.Context(), resolved404Path)
425 + if err != nil {
426 + return false
427 + }
428 + defer dr.Close()
429 +
430 + f, ok := dr.(files.File)
431 + if !ok {
432 + return false
433 + }
434 +
435 + size, err := f.Size()
436 + if err != nil {
437 + return false
438 + }
439 +
440 + log.Debugf("using pretty 404 file for %s", parsedPath.String())
441 + w.Header().Set("Content-Type", ctype)
442 + w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
443 + w.WriteHeader(http.StatusNotFound)
444 + _, err = io.CopyN(w, f, size)
445 + return err == nil
446 +}
447 +
448 func (i *gatewayHandler) postHandler(w http.ResponseWriter, r *http.Request) {
449 p, err := i.api.Unixfs().Add(r.Context(), files.NewReaderFile(r.Body))
450 if err != nil {
@@ -619,3 +658,45 @@ func getFilename(s string) string {
658 }
659 return gopath.Base(s)
660 }
661 +
662 +func (i *gatewayHandler) searchUpTreeFor404(r *http.Request, parsedPath ipath.Path) (ipath.Resolved, string, error) {
663 + filename404, ctype, err := preferred404Filename(r.Header.Values("Accept"))
664 + if err != nil {
665 + return nil, "", err
666 + }
667 +
668 + pathComponents := strings.Split(parsedPath.String(), "/")
669 +
670 + for idx := len(pathComponents); idx >= 3; idx-- {
671 + pretty404 := gopath.Join(append(pathComponents[0:idx], filename404)...)
672 + parsed404Path := ipath.New("/" + pretty404)
673 + if parsed404Path.IsValid() != nil {
674 + break
675 + }
676 + resolvedPath, err := i.api.ResolvePath(r.Context(), parsed404Path)
677 + if err != nil {
678 + continue
679 + }
680 + return resolvedPath, ctype, nil
681 + }
682 +
683 + return nil, "", fmt.Errorf("no pretty 404 in any parent folder")
684 +}
685 +
686 +func preferred404Filename(acceptHeaders []string) (string, string, error) {
687 + // If we ever want to offer a 404 file for a different content type
688 + // then this function will need to parse q weightings, but for now
689 + // the presence of anything matching HTML is enough.
690 + for _, acceptHeader := range acceptHeaders {
691 + accepted := strings.Split(acceptHeader, ",")
692 + for _, spec := range accepted {
693 + contentType := strings.SplitN(spec, ";", 1)[0]
694 + switch contentType {
695 + case "*/*", "text/*", "text/html":
696 + return "ipfs-404.html", "text/html", nil
697 + }
698 + }
699 + }
700 +
701 + return "", "", fmt.Errorf("there is no 404 file for the requested content types")
702 +}
core/corehttp/gateway_test.go
+64
@@ -235,6 +235,70 @@ func TestGatewayGet(t *testing.T) {
235 }
236 }
237
238 +func TestPretty404(t *testing.T) {
239 + ns := mockNamesys{}
240 + ts, api, ctx := newTestServerAndNode(t, ns)
241 + defer ts.Close()
242 +
243 + f1 := files.NewMapDirectory(map[string]files.Node{
244 + "ipfs-404.html": files.NewBytesFile([]byte("Custom 404")),
245 + "deeper": files.NewMapDirectory(map[string]files.Node{
246 + "ipfs-404.html": files.NewBytesFile([]byte("Deep custom 404")),
247 + }),
248 + })
249 +
250 + k, err := api.Unixfs().Add(ctx, f1)
251 + if err != nil {
252 + t.Fatal(err)
253 + }
254 +
255 + host := "example.net"
256 + ns["/ipns/"+host] = path.FromString(k.String())
257 +
258 + for _, test := range []struct {
259 + path string
260 + accept string
261 + status int
262 + text string
263 + }{
264 + {"/ipfs-404.html", "text/html", http.StatusOK, "Custom 404"},
265 + {"/nope", "text/html", http.StatusNotFound, "Custom 404"},
266 + {"/nope", "text/*", http.StatusNotFound, "Custom 404"},
267 + {"/nope", "*/*", http.StatusNotFound, "Custom 404"},
268 + {"/nope", "application/json", http.StatusNotFound, "ipfs resolve -r /ipns/example.net/nope: no link named \"nope\" under QmcmnF7XG5G34RdqYErYDwCKNFQ6jb8oKVR21WAJgubiaj\n"},
269 + {"/deeper/nope", "text/html", http.StatusNotFound, "Deep custom 404"},
270 + {"/deeper/", "text/html", http.StatusNotFound, "Deep custom 404"},
271 + {"/deeper", "text/html", http.StatusNotFound, "Deep custom 404"},
272 + {"/nope/nope", "text/html", http.StatusNotFound, "Custom 404"},
273 + } {
274 + var c http.Client
275 + req, err := http.NewRequest("GET", ts.URL+test.path, nil)
276 + if err != nil {
277 + t.Fatal(err)
278 + }
279 + req.Header.Add("Accept", test.accept)
280 + req.Host = host
281 + resp, err := c.Do(req)
282 +
283 + if err != nil {
284 + t.Fatalf("error requesting %s: %s", test.path, err)
285 + }
286 +
287 + defer resp.Body.Close()
288 + if resp.StatusCode != test.status {
289 + t.Fatalf("got %d, expected %d, from %s", resp.StatusCode, test.status, test.path)
290 + }
291 + body, err := ioutil.ReadAll(resp.Body)
292 + if err != nil {
293 + t.Fatalf("error reading response from %s: %s", test.path, err)
294 + }
295 +
296 + if string(body) != test.text {
297 + t.Fatalf("unexpected response body from %s: got %q, expected %q", test.path, body, test.text)
298 + }
299 + }
300 +}
301 +
302 func TestIPNSHostnameRedirect(t *testing.T) {
303 ns := mockNamesys{}
304 ts, api, ctx := newTestServerAndNode(t, ns)