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)