Skip to content

Commit

Permalink
feat(gateway): improved templates, user friendly errors
Browse files Browse the repository at this point in the history
  • Loading branch information
hacdias committed May 30, 2023
1 parent 8ec71db commit 188b07b
Show file tree
Hide file tree
Showing 35 changed files with 1,184 additions and 1,235 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ The following emojis are used to highlight certain changes:

## [Unreleased]

- ✨ The gateway templates were updated to provide better features for users and gateway implementers:
- New human-friendly error messages.
- Updated, higher-definition icons in directory listings.
- Customizable menu items next to "About IPFS" and "Install IPFS".

## [0.8.0] - 2023-04-05
### Added

Expand Down
7 changes: 7 additions & 0 deletions examples/gateway/common/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"net/http"

"github.com/ipfs/boxo/gateway"
"github.com/ipfs/boxo/gateway/assets"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
Expand All @@ -16,6 +17,12 @@ func NewHandler(gwAPI gateway.IPFSBackend) http.Handler {
gateway.AddAccessControlHeaders(headers)
conf := gateway.Config{
Headers: headers,
Menu: []assets.MenuItem{
{
URL: "https://github.com/ipfs/boxo",
Title: "Boxo",
},
},
}

// Initialize the public gateways that we will want to have available through
Expand Down
23 changes: 8 additions & 15 deletions gateway/assets/README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
# Required Assets for the Gateway

> DAG and Directory HTML for HTTP gateway
> HTTP Gateway Templates.
## Updating

When making updates to the templates, please note the following:

1. Make your changes to the (human-friendly) source documents in the `src` directory.
2. Before testing or releasing, go to `assets/` and run `go generate .`.
To update the templates, make changes to `.html` and `.css` files in the current directory.

## Testing

1. Make sure you have [Go](https://golang.org/dl/) installed
2. Start the test server, which lives in its own directory:

```bash
> cd test
> go run .
```
1. Make sure you have [Go](https://golang.org/dl/) installed.
2. From the `assets/` directory, start the test server: `go run test/main.go`.

This will listen on [`localhost:3000`](http://localhost:3000/) and reload the template every time you refresh the page. Here you have two pages:
This will listen on [`localhost:3000`](http://localhost:3000/) and reload the template every time you refresh the page. Here you have three pages:

- [`localhost:3000/dag`](http://localhost:3000/dag) for the DAG template preview; and
- [`localhost:3000/directory`](http://localhost:3000/directory) for the Directory template preview.
- [`localhost:3000/directory`](http://localhost:3000/directory) for the Directory template preview; and
- [`localhost:3000/error?code=500`](http://localhost:3000/error?status=500) for the Error template preview, you can replace `500` by a different status code.

If you get a "no such file or directory" error upon trying `go run .`, make sure you ran `go generate .` to generate the minified artifact that the test is looking for.
Every time you refresh, the template will be reloaded.
69 changes: 30 additions & 39 deletions gateway/assets/assets.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
//go:generate ./build.sh
package assets

import (
Expand All @@ -9,24 +8,23 @@ import (
"strconv"

"html/template"
"net/url"
"path"
"strings"

"github.com/cespare/xxhash/v2"

ipfspath "github.com/ipfs/boxo/path"
)

//go:embed dag-index.html directory-index.html knownIcons.txt
var asset embed.FS
//go:embed *.html *.css
var assets embed.FS

// AssetHash a non-cryptographic hash of all embedded assets
var AssetHash string

var (
DirectoryTemplate *template.Template
DagTemplate *template.Template
ErrorTemplate *template.Template
)

func init() {
Expand All @@ -36,7 +34,7 @@ func init() {

func initAssetsHash() {
sum := xxhash.New()
err := fs.WalkDir(asset, ".", func(path string, d fs.DirEntry, err error) error {
err := fs.WalkDir(assets, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
Expand All @@ -45,7 +43,7 @@ func initAssetsHash() {
return nil
}

file, err := asset.Open(path)
file, err := assets.Open(path)
if err != nil {
return err
}
Expand All @@ -61,60 +59,53 @@ func initAssetsHash() {
}

func initTemplates() {
knownIconsBytes, err := asset.ReadFile("knownIcons.txt")
var err error

// Directory listing template
DirectoryTemplate, err = BuildTemplate(assets, "directory.html")
if err != nil {
panic(err)
}
knownIcons := make(map[string]struct{})
for _, ext := range strings.Split(strings.TrimSuffix(string(knownIconsBytes), "\n"), "\n") {
knownIcons[ext] = struct{}{}
}

// helper to guess the type/icon for it by the extension name
iconFromExt := func(name string) string {
ext := path.Ext(name)
_, ok := knownIcons[ext]
if !ok {
// default blank icon
return "ipfs-_blank"
}
return "ipfs-" + ext[1:] // slice of the first dot
}

// custom template-escaping function to escape a full path, including '#' and '?'
urlEscape := func(rawUrl string) string {
pathURL := url.URL{Path: rawUrl}
return pathURL.String()
}

// Directory listing template
dirIndexBytes, err := asset.ReadFile("directory-index.html")
// DAG Index template
DagTemplate, err = BuildTemplate(assets, "dag.html")
if err != nil {
panic(err)
}

DirectoryTemplate = template.Must(template.New("dir").Funcs(template.FuncMap{
"iconFromExt": iconFromExt,
"urlEscape": urlEscape,
}).Parse(string(dirIndexBytes)))

// DAG Index template
dagIndexBytes, err := asset.ReadFile("dag-index.html")
// Error template
ErrorTemplate, err = BuildTemplate(assets, "error.html")
if err != nil {
panic(err)
}
}

DagTemplate = template.Must(template.New("dir").Parse(string(dagIndexBytes)))
type MenuItem struct {
URL string
Title string
}

type GlobalData struct {
Menu []MenuItem
}

type DagTemplateData struct {
GlobalData
Path string
CID string
CodecName string
CodecHex string
}

type ErrorTemplateData struct {
GlobalData
StatusCode int
StatusText string
Error string
}

type DirectoryTemplateData struct {
GlobalData
GatewayURL string
DNSLink bool
Listing []DirectoryItem
Expand Down
14 changes: 0 additions & 14 deletions gateway/assets/build.sh

This file was deleted.

67 changes: 0 additions & 67 deletions gateway/assets/dag-index.html

This file was deleted.

33 changes: 33 additions & 0 deletions gateway/assets/dag.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="Content-addressed {{.CodecName}} document hosted on IPFS.">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlo89/56ZQ/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjDu1lo89/6mhTP+zrVP/nplD/5+aRK8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHNiIS6Wjz3/ubFY/761W/+vp1D/urRZ/8vDZf/GvmH/nplD/1BNIm8AAAAAAAAAAAAAAAAAAAAAAAAAAJaPPf+knEj/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf+tpk7/nplD/wAAAAAAAAAAAAAAAJaPPf+2rVX/vrVb/761W/++tVv/vrVb/6+nUP+6tFn/y8Nl/8vDZf/Lw2X/y8Nl/8G6Xv+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/761W/+vp1D/urRZ/8vDZf/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf++tVv/vrVb/761W/++tVv/vbRa/5aPPf+emUP/y8Nl/8vDZf/Lw2X/y8Nl/8vDZf+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/5qTQP+inkb/op5G/6KdRv/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/sqlS/56ZQ//LxWb/0Mlp/9DJaf/Kw2X/oJtE/7+3XP/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf+9tFr/mJE+/7GsUv/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+xrFL/nplD/8vDZf+emUP/AAAAAAAAAACWjz3/op5G/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+inkb/nplD/wAAAAAAAAAAAAAAAKKeRv+3slb/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+1sFX/op5G/wAAAAAAAAAAAAAAAAAAAAAAAAAAop5GUKKeRv/Nxmf/0cpq/9HKav/Rymr/0cpq/83GZ/+inkb/op5GSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G16KeRv/LxWb/y8Vm/6KeRv+inkaPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G/6KeRtcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/n8AAPgfAADwDwAAwAMAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAwAMAAPAPAAD4HwAA/n8AAA==" />
<title>{{ .Path }}</title>
<link rel="stylesheet">
</head>
<body>
<header></header>
<main id="main">
<header>
<div>
<strong>CID: <code translate="no">{{.CID}}</code></strong>
</div>
<div>
<strong>Codec</strong>: <code translate="no">{{.CodecName}} ({{.CodecHex}})</code>
</div>
</header>
<section class="container">
<p>You can download this block as:</p>

<ul>
<li><a href="?format=raw" rel="nofollow">Raw Block</a> (no conversion)</li>
<li><a href="?format=dag-json" rel="nofollow">Valid DAG-JSON</a> (specs at <a href="https://ipld.io/specs/codecs/dag-json/spec/" target="_blank" rel="noopener noreferrer">IPLD</a> and <a href="https://www.iana.org/assignments/media-types/application/vnd.ipld.dag-json" target="_blank" rel="noopener noreferrer">IANA</a>)</li>
<li><a href="?format=dag-cbor" rel="nofollow">Valid DAG-CBOR</a> (specs at <a href="https://ipld.io/specs/codecs/dag-cbor/spec/" target="_blank" rel="noopener noreferrer">IPLD</a> and <a href="https://www.iana.org/assignments/media-types/application/vnd.ipld.dag-cbor" target="_blank" rel="noopener noreferrer">IANA</a>)</li>
</ul>
</section>
</main>
</body>
</html>
99 changes: 0 additions & 99 deletions gateway/assets/directory-index.html

This file was deleted.

70 changes: 70 additions & 0 deletions gateway/assets/directory.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!DOCTYPE html>
{{ $root := . }}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="A directory of content-addressed files hosted on IPFS.">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlo89/56ZQ/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjDu1lo89/6mhTP+zrVP/nplD/5+aRK8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHNiIS6Wjz3/ubFY/761W/+vp1D/urRZ/8vDZf/GvmH/nplD/1BNIm8AAAAAAAAAAAAAAAAAAAAAAAAAAJaPPf+knEj/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf+tpk7/nplD/wAAAAAAAAAAAAAAAJaPPf+2rVX/vrVb/761W/++tVv/vrVb/6+nUP+6tFn/y8Nl/8vDZf/Lw2X/y8Nl/8G6Xv+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/761W/+vp1D/urRZ/8vDZf/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf++tVv/vrVb/761W/++tVv/vbRa/5aPPf+emUP/y8Nl/8vDZf/Lw2X/y8Nl/8vDZf+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/5qTQP+inkb/op5G/6KdRv/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/sqlS/56ZQ//LxWb/0Mlp/9DJaf/Kw2X/oJtE/7+3XP/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf+9tFr/mJE+/7GsUv/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+xrFL/nplD/8vDZf+emUP/AAAAAAAAAACWjz3/op5G/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+inkb/nplD/wAAAAAAAAAAAAAAAKKeRv+3slb/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+1sFX/op5G/wAAAAAAAAAAAAAAAAAAAAAAAAAAop5GUKKeRv/Nxmf/0cpq/9HKav/Rymr/0cpq/83GZ/+inkb/op5GSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G16KeRv/LxWb/y8Vm/6KeRv+inkaPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G/6KeRtcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/n8AAPgfAADwDwAAwAMAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAwAMAAPAPAAD4HwAA/n8AAA==" />
<title>{{ .Path }}</title>
<link rel="stylesheet">
</head>
<body>
<header></header>
<main id="main">
<header class="flex flex-wrap">
<div>
<strong>
Index of
{{ range .Breadcrumbs -}}
/{{ if .Path }}<a href="{{ $root.GatewayURL }}{{ .Path | urlEscape }}">{{ .Name }}</a>{{ else }}{{ .Name }}{{ end }}
{{- else }}
{{ .Path }}
{{ end }}
</strong>
{{ if .Hash }}
<div class="ipfs-hash" translate="no">
{{- .Hash -}}
</div>
{{ end }}
</div>
{{ if .Size }}
<div class="nowrap flex-shrink ml-auto">
<strong title="Cumulative size of IPFS DAG (data + metadata)">&nbsp;{{ .Size }}</strong>
</div>
{{ end }}
</header>
<section>
<div class="grid dir">
{{ if .BackLink }}
<div class="type-icon">
<div class="ipfs-_blank">&nbsp;</div>
</div>
<div>
<a href="{{.BackLink | urlEscape}}">..</a>
</div>
<div></div>
<div></div>
</tr>
{{ end }}
{{ range .Listing }}
<div class="type-icon">
<div class="{{iconFromExt .Name}}">&nbsp;</div>
</div>
<div>
<a href="{{ .Path | urlEscape }}">{{ .Name }}</a>
</div>
<div class="nowrap">
{{ if .Hash }}
<a class="ipfs-hash" translate="no" href={{ if $root.DNSLink }}"https://cid.ipfs.tech/#{{ .Hash | urlEscape}}" target="_blank" rel="noreferrer noopener"{{ else }}"{{ $root.GatewayURL }}/ipfs/{{ .Hash | urlEscape}}?filename={{ .Name | urlEscape }}"{{ end }}>
{{- .ShortHash -}}
</a>
{{ end }}
</div>
<div class="nowrap" title="Cumulative size of IPFS DAG (data + metadata)">{{ .Size }}</div>
{{ end }}
</div>
</section>
</main>
</body>
</html>
56 changes: 56 additions & 0 deletions gateway/assets/error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="A {{ .StatusCode }} {{ .StatusText }} error has occurred when trying to fetch content from the IPFS network.">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlo89/56ZQ/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjDu1lo89/6mhTP+zrVP/nplD/5+aRK8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHNiIS6Wjz3/ubFY/761W/+vp1D/urRZ/8vDZf/GvmH/nplD/1BNIm8AAAAAAAAAAAAAAAAAAAAAAAAAAJaPPf+knEj/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf+tpk7/nplD/wAAAAAAAAAAAAAAAJaPPf+2rVX/vrVb/761W/++tVv/vrVb/6+nUP+6tFn/y8Nl/8vDZf/Lw2X/y8Nl/8G6Xv+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/761W/+vp1D/urRZ/8vDZf/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf++tVv/vrVb/761W/++tVv/vbRa/5aPPf+emUP/y8Nl/8vDZf/Lw2X/y8Nl/8vDZf+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/5qTQP+inkb/op5G/6KdRv/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/sqlS/56ZQ//LxWb/0Mlp/9DJaf/Kw2X/oJtE/7+3XP/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf+9tFr/mJE+/7GsUv/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+xrFL/nplD/8vDZf+emUP/AAAAAAAAAACWjz3/op5G/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+inkb/nplD/wAAAAAAAAAAAAAAAKKeRv+3slb/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+1sFX/op5G/wAAAAAAAAAAAAAAAAAAAAAAAAAAop5GUKKeRv/Nxmf/0cpq/9HKav/Rymr/0cpq/83GZ/+inkb/op5GSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G16KeRv/LxWb/y8Vm/6KeRv+inkaPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G/6KeRtcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/n8AAPgfAADwDwAAwAMAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAwAMAAPAPAAD4HwAA/n8AAA==" />
<title>{{ .StatusCode }} {{ .StatusText }}</title>
<link rel="stylesheet">
</head>
<body>
<header></header>
<main id="main">
<header>
<strong>{{ .StatusCode }} {{ .StatusText }}</strong>
</header>
<section class="container">
{{ if eq .StatusCode 400 }}
<p>Your request is invalid. Please check the error below for more information.</p>
{{ else if eq .StatusCode 404 }}
<p>The content path you requested cannot be found. There's likely an invalid or missing DAG node.</p>
{{ else if eq .StatusCode 406 }}
<p>This gateway is unable to return the data in the format requested by the client.</p>
{{ else if eq .StatusCode 410 }}
<p>This gateway refuses to return the requested data.</p>
{{ else if eq .StatusCode 412 }}
<p>This gateway is unable to return the requested data under the conditions sent by the client.</p>
{{ else if eq .StatusCode 429 }}
<p>There are too many pending requests. Please wait some time and try again.</p>
{{ else if eq .StatusCode 451 }}
<p>This gateway is not allowed to return the requested data due to legal reasons.</p>
{{ else if eq .StatusCode 500 }}
<p>This gateway was unable to return the requested data due to an internal error. Please check the error below for more information.</p>
{{ else if eq .StatusCode 502 }}
<p>The gateway backed was unable to fullfil your request due to an error.</p>
{{ else if eq .StatusCode 504 }}
<p>The gateway backend was unable to fullfil your request due to a timeout.</p>
{{ else if eq .StatusCode 506 }}
<p>The gateway backend was unable to fullfil your request at this time. Try again later.</p>
{{ end }}

<pre class="terminal wrap">{{ .Error }}</pre>

<p>How you can proceed:</p>
<ul>
<li>Check the <a href="https://discuss.ipfs.tech/c/help/13" rel="noopener noreferrer">Discussion Forums</a> for similar errors.</li>
<li>Try diagnosing your request with the <a href="https://docs.ipfs.tech/reference/diagnostic-tools/" rel="noopener noreferrer">diagnostic tools</a>.</li>
<li>Self-host and run an <a href="https://docs.ipfs.tech/concepts/ipfs-implementations/" rel="noopener noreferrer">IPFS client</a> that verifies your data.</li>
{{ if or (eq .StatusCode 400) (eq .StatusCode 404) }}
<li>Inspect the <a href="https://cid.ipfs.tech/" rel="noopener noreferrer">CID</a> or <a href="https://explore.ipld.io/" rel="noopener noreferrer">DAG</a>.</li>
{{ end }}
</ul>
</section>
</main>
</body>
</html>
10 changes: 10 additions & 0 deletions gateway/assets/header.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<header id="header">
<div class="ipfs-logo">&nbsp;</div>
<nav>
<a href="https://ipfs.tech" target="_blank" rel="noopener noreferrer">About<span class="dn-mobile"> IPFS</span></a>
<a href="https://ipfs.tech#install" target="_blank" rel="noopener noreferrer">Install<span class="dn-mobile"> IPFS</span></a>
{{ range .Menu }}
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer">{{ .Title }}</a>
{{ end }}
</nav>
</header>
Loading

0 comments on commit 188b07b

Please sign in to comment.