/
ipfs.go
266 lines (240 loc) · 7.88 KB
/
ipfs.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
package ipfs
import (
"context"
"fmt"
"github.com/fapiper/onchain-access-control/core/env"
"github.com/fapiper/onchain-access-control/core/internal/util"
shell "github.com/ipfs/go-ipfs-api"
"io"
"net/http"
"net/url"
"strings"
"time"
)
func init() {
env.RegisterValidation("IPFS_URL", "required")
env.RegisterValidation("FALLBACK_IPFS_URL", "required")
}
type ErrInfuraQuotaExceeded struct {
Err error
}
func (r ErrInfuraQuotaExceeded) Unwrap() error { return r.Err }
func (r ErrInfuraQuotaExceeded) Error() string {
return fmt.Sprintf("quota exceeded: %s", r.Err.Error())
}
// HTTPReader is a reader that uses a HTTP gateway to read from
type HTTPReader struct {
Host string
Client *http.Client
}
func (r HTTPReader) Do(ctx context.Context, path string) (io.ReadCloser, error) {
path = pathURL(r.Host, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
resp, err := r.Client.Do(req)
if err != nil {
return nil, err
}
if isInfura(path) && resp.StatusCode == http.StatusTooManyRequests {
return nil, ErrInfuraQuotaExceeded{Err: err}
}
if resp.StatusCode != http.StatusOK {
return nil, &util.ErrHTTP{StatusCode: resp.StatusCode, URL: path}
}
return resp.Body, nil
}
func (r HTTPReader) Head(ctx context.Context, path string) (http.Header, error) {
path = pathURL(r.Host, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
resp, err := r.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, &util.ErrHTTP{StatusCode: resp.StatusCode, URL: path}
}
return resp.Header, nil
}
// IPFSReader is a reader that uses an IPFS shell to read from IPFS
type IPFSReader struct {
Client *shell.Shell
}
func (r IPFSReader) Do(ctx context.Context, path string) (io.ReadCloser, error) {
reader, err := r.Client.Cat(path)
if err != nil && isInfura(path) && strings.Contains(err.Error(), "transfer quota reached") {
return nil, ErrInfuraQuotaExceeded{Err: err}
}
return reader, err
}
// NewShell returns an IPFS shell with default configuration
func NewShell() *shell.Shell {
sh := shell.NewShellWithClient(env.GetString("IPFS_API_URL"), defaultHTTPClient())
sh.SetTimeout(600 * time.Second)
return sh
}
var (
nodeCustom = func(h *http.Client, s *shell.Shell) HTTPReader {
return HTTPReader{Host: env.GetString("FALLBACK_IPFS_URL"), Client: h}
}
nodeIPFS = func(h *http.Client, s *shell.Shell) IPFSReader {
return IPFSReader{Client: s}
}
nodeIpfsIO = func(h *http.Client, s *shell.Shell) HTTPReader {
return HTTPReader{Host: "https://ipfs.io", Client: h}
}
nodePinata = func(h *http.Client, s *shell.Shell) HTTPReader {
return HTTPReader{Host: "https://gateway.pinata.cloud", Client: h}
}
nodeNftStorage = func(h *http.Client, s *shell.Shell) HTTPReader {
return HTTPReader{Host: "https://nftstorage.link", Client: h}
}
nodeCloudFlare = func(h *http.Client, s *shell.Shell) HTTPReader {
return HTTPReader{Host: "https://cloudflare-ipfs.com", Client: h}
}
nodeFxHash = func(h *http.Client, s *shell.Shell) HTTPReader {
return HTTPReader{Host: "https://gateway.fxhash.xyz", Client: h}
}
)
func GetResponse(ctx context.Context, path string) (io.ReadCloser, error) {
httpClient := defaultHTTPClient()
ipfsClient := NewShell()
return util.FirstNonErrorWithValue(ctx, false, nil,
func(ctx context.Context) (io.ReadCloser, error) {
return nodeCustom(httpClient, ipfsClient).Do(ctx, path)
},
func(ctx context.Context) (io.ReadCloser, error) {
return nodeIPFS(httpClient, ipfsClient).Do(ctx, path)
},
func(ctx context.Context) (io.ReadCloser, error) {
return nodeIpfsIO(httpClient, ipfsClient).Do(ctx, path)
},
func(ctx context.Context) (io.ReadCloser, error) {
return nodePinata(httpClient, ipfsClient).Do(ctx, path)
},
func(ctx context.Context) (io.ReadCloser, error) {
return nodeNftStorage(httpClient, ipfsClient).Do(ctx, path)
},
func(ctx context.Context) (io.ReadCloser, error) {
return nodeCloudFlare(httpClient, ipfsClient).Do(ctx, path)
},
)
}
func GetHeader(ctx context.Context, path string) (http.Header, error) {
httpClient := defaultHTTPClient()
ipfsClient := NewShell()
return util.FirstNonErrorWithValue(ctx, true, nil,
func(ctx context.Context) (http.Header, error) {
return nodeCustom(httpClient, ipfsClient).Head(ctx, path)
},
func(ctx context.Context) (http.Header, error) {
return nodeIpfsIO(httpClient, ipfsClient).Head(ctx, path)
},
func(ctx context.Context) (http.Header, error) {
return nodePinata(httpClient, ipfsClient).Head(ctx, path)
},
func(ctx context.Context) (http.Header, error) {
return nodeNftStorage(httpClient, ipfsClient).Head(ctx, path)
},
func(ctx context.Context) (http.Header, error) {
return nodeCloudFlare(httpClient, ipfsClient).Head(ctx, path)
},
)
}
// defaultHTTPClient returns an http.Client configured with default settings intended for IPFS calls.
func defaultHTTPClient() *http.Client {
return &http.Client{
Timeout: 600 * time.Second,
Transport: authTransport{
ProjectID: env.GetString("IPFS_PROJECT_ID"),
ProjectSecret: env.GetString("IPFS_PROJECT_SECRET"),
},
}
}
// BestGatewayNodeFrom rewrites an IPFS URL to a gateway URL using the appropriate gateway
func BestGatewayNodeFrom(ipfsURL string, isFxHash bool) string {
if !IsIpfsURL(ipfsURL) {
return ipfsURL
}
// Use fxhash's specific node
if isFxHash {
return PathGatewayFrom(nodeFxHash(nil, nil).Host, ipfsURL)
}
// Re-write to a more reliable one while Infura node is down
if strings.HasPrefix(ipfsURL, "https://gallery.infura-ipfs.io") {
return DefaultGatewayFrom(ipfsURL)
}
// Keep the original gateway otherwise
if IsIpfsGatewayURL(ipfsURL) {
return ipfsURL
}
// Map ipfs:// to the default gateway
return DefaultGatewayFrom(ipfsURL)
}
// DefaultGatewayFrom rewrites an IPFS URL to a gateway URL using the default gateway
func DefaultGatewayFrom(ipfsURL string) string {
return PathGatewayFrom(nodeIpfsIO(nil, nil).Host, ipfsURL)
}
// PathGatewayFrom is a helper function that rewrites an IPFS URI to an IPFS gateway URL
func PathGatewayFrom(gatewayHost, ipfsURL string) string {
return pathURL(gatewayHost, uriFrom(ipfsURL))
}
func IsIpfsURL(ipfsURL string) bool {
return IsIpfsProtoURL(ipfsURL) || IsIpfsGatewayURL(ipfsURL)
}
func IsIpfsProtoURL(ipfsURL string) bool {
return strings.HasPrefix(ipfsURL, "ipfs://")
}
func IsIpfsGatewayURL(ipfsURL string) bool {
u, err := url.Parse(ipfsURL)
if err != nil {
return false
}
if u.Scheme != "https" {
return false
}
return strings.HasPrefix(u.Path, "/ipfs")
}
// authTransport decorates each request with a basic auth header.
type authTransport struct {
http.RoundTripper
ProjectID string
ProjectSecret string
}
func (t authTransport) RoundTrip(r *http.Request) (*http.Response, error) {
r.SetBasicAuth(t.ProjectID, t.ProjectSecret)
return t.RoundTripper.RoundTrip(r)
}
func uriFrom(u string) string {
u = strings.TrimSpace(u)
u = strings.TrimPrefix(u, "ipfs://")
u = strings.TrimPrefix(u, "https://")
pathIdx := strings.Index(u, "/ipfs/")
if pathIdx != -1 {
u = u[pathIdx+6:] // len("/ipfs/") == 6
}
return u
}
// pathURL returns the gateway URL in path resolution sytle
func pathURL(host, uri string) string {
uri = standardizeQueryParams(uri)
return fmt.Sprintf("%s/ipfs/%s", host, uri)
}
// standardizeQueryParams converts a URI with optional params from the format <cid>?key=val&key=val to the format <cid>/?key=val&key=val
// Most gateways will redirect the former to the latter, but some gateways don't. https://docs.ipfs.tech/concepts/ipfs-gateway/#path
func standardizeQueryParams(uri string) string {
paramIdx := strings.Index(uri, "?")
isClean := strings.Contains(uri, "/?")
if paramIdx != -1 && !isClean {
uri = uri[:paramIdx] + "/?" + uri[paramIdx+1:]
}
return uri
}
func isInfura(gateway string) bool {
return strings.Contains(gateway, "infura")
}