diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index 767d421f9..63cd81c51 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -163,48 +163,33 @@ func TestHeaders(t *testing.T) { ts, backend, root := newTestServerAndNode(t, nil, "ipns-hostname-redirects.car") backend.namesys["/ipns/example.net"] = newMockNamesysItem(path.NewIPFSPath(root), time.Second*30) + backend.namesys["/ipns/example.com"] = newMockNamesysItem(path.NewIPFSPath(root), time.Second*55) + backend.namesys["/ipns/unknown.com"] = newMockNamesysItem(path.NewIPFSPath(root), 0) - t.Run("UnixFS generated directory listing without index.html has no Cache-Control", func(t *testing.T) { - req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/example.net/", nil) - res := mustDoWithoutRedirect(t, req) - require.Empty(t, res.Header["Cache-Control"]) - }) - - t.Run("UnixFS directory with index.html has Cache-Control", func(t *testing.T) { - req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/example.net/foo/", nil) - res := mustDoWithoutRedirect(t, req) - require.Equal(t, "public, max-age=30", res.Header.Get("Cache-Control")) - }) - - t.Run("UnixFS file has Cache-Control", func(t *testing.T) { - req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/example.net/foo/index.html", nil) - res := mustDoWithoutRedirect(t, req) - require.Equal(t, "public, max-age=30", res.Header.Get("Cache-Control")) - }) - - t.Run("Raw block has Cache-Control", func(t *testing.T) { - req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/example.net?format=raw", nil) - res := mustDoWithoutRedirect(t, req) - require.Equal(t, "public, max-age=30", res.Header.Get("Cache-Control")) - }) - - t.Run("DAG-JSON block has Cache-Control", func(t *testing.T) { - req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/example.net?format=dag-json", nil) - res := mustDoWithoutRedirect(t, req) - require.Equal(t, "public, max-age=30", res.Header.Get("Cache-Control")) - }) - - t.Run("DAG-CBOR block has Cache-Control", func(t *testing.T) { - req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/example.net?format=dag-cbor", nil) - res := mustDoWithoutRedirect(t, req) - require.Equal(t, "public, max-age=30", res.Header.Get("Cache-Control")) - }) + testCases := []struct { + path string + cacheControl string + }{ + {"/ipns/example.net/", "public, max-age=30"}, // As generated directory listing + {"/ipns/example.com/", "public, max-age=55"}, // As generated directory listing (different) + {"/ipns/unknown.com/", ""}, // As generated directory listing (unknown) + {"/ipns/example.net/foo/", "public, max-age=30"}, // As index.html directory listing + {"/ipns/example.net/foo/index.html", "public, max-age=30"}, // As deserialized UnixFS file + {"/ipns/example.net/?format=raw", "public, max-age=30"}, // As Raw block + {"/ipns/example.net/?format=dag-json", "public, max-age=30"}, // As DAG-JSON block + {"/ipns/example.net/?format=dag-cbor", "public, max-age=30"}, // As DAG-CBOR block + {"/ipns/example.net/?format=car", "public, max-age=30"}, // As CAR block + } - t.Run("CAR block has Cache-Control", func(t *testing.T) { - req := mustNewRequest(t, http.MethodGet, ts.URL+"/ipns/example.net?format=car", nil) + for _, testCase := range testCases { + req := mustNewRequest(t, http.MethodGet, ts.URL+testCase.path, nil) res := mustDoWithoutRedirect(t, req) - require.Equal(t, "public, max-age=30", res.Header.Get("Cache-Control")) - }) + if testCase.cacheControl == "" { + assert.Empty(t, res.Header["Cache-Control"]) + } else { + assert.Equal(t, testCase.cacheControl, res.Header.Get("Cache-Control")) + } + } }) t.Run("Cache-Control is not immutable on generated /ipfs/ HTML dir listings", func(t *testing.T) { diff --git a/gateway/handler_unixfs_dir.go b/gateway/handler_unixfs_dir.go index 86c59850c..eda182f09 100644 --- a/gateway/handler_unixfs_dir.go +++ b/gateway/handler_unixfs_dir.go @@ -126,6 +126,11 @@ func (i *handler) serveDirectory(ctx context.Context, w http.ResponseWriter, r * dirEtag := getDirListingEtag(resolvedPath.Cid()) w.Header().Set("Etag", dirEtag) + // Add TTL if known. + if ttl > 0 { + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(ttl.Seconds()))) + } + if r.Method == http.MethodHead { logger.Debug("return as request's HTTP method is HEAD") return true diff --git a/namesys/ipns_resolver.go b/namesys/ipns_resolver.go index e694cfe49..2b4597db2 100644 --- a/namesys/ipns_resolver.go +++ b/namesys/ipns_resolver.go @@ -13,7 +13,12 @@ import ( "go.opentelemetry.io/otel/trace" ) -// IPNSResolver implements [Resolver] for IPNS Records. +// IPNSResolver implements [Resolver] for IPNS Records. This resolver always returns +// a TTL if the record is still valid. It happens as follows: +// +// 1. Provisory TTL is chosen: record TTL if it exists, otherwise [DefaultIPNSRecordTTL]. +// 2. If provisory TTL expires before EOL, then returned TTL is duration between EOL and now. +// 3. If record is expired, 0 is returned as TTL. type IPNSResolver struct { routing routing.ValueStore } @@ -102,24 +107,8 @@ func (r *IPNSResolver) resolveOnceAsync(ctx context.Context, nameStr string, opt return } - ttl := DefaultResolverCacheTTL - if recordTTL, err := rec.TTL(); err == nil { - ttl = recordTTL - } - - switch eol, err := rec.Validity(); err { - case ipns.ErrUnrecognizedValidity: - // No EOL. - case nil: - ttEol := time.Until(eol) - if ttEol < 0 { - // It *was* valid when we first resolved it. - ttl = 0 - } else if ttEol < ttl { - ttl = ttEol - } - default: - log.Errorf("encountered error when parsing EOL: %s", err) + ttl, err := calculateBestTTL(rec) + if err != nil { emitOnceResult(ctx, out, ResolveResult{Err: err}) return } @@ -166,3 +155,27 @@ func ResolveIPNS(ctx context.Context, ns NameSystem, p path.Path) (path.Path, ti return p, ttl, nil } + +func calculateBestTTL(rec *ipns.Record) (time.Duration, error) { + ttl := DefaultResolverCacheTTL + if recordTTL, err := rec.TTL(); err == nil { + ttl = recordTTL + } + + switch eol, err := rec.Validity(); err { + case ipns.ErrUnrecognizedValidity: + // No EOL. + case nil: + ttEol := time.Until(eol) + if ttEol < 0 { + // It *was* valid when we first resolved it. + ttl = 0 + } else if ttEol < ttl { + ttl = ttEol + } + default: + return 0, err + } + + return ttl, nil +} diff --git a/namesys/namesys.go b/namesys/namesys.go index 1c0363d24..e51cc7e8d 100644 --- a/namesys/namesys.go +++ b/namesys/namesys.go @@ -283,6 +283,7 @@ func (ns *namesys) Publish(ctx context.Context, name ci.PrivKey, value path.Path span.RecordError(err) return err } + ttl := DefaultResolverCacheTTL if publishOpts.TTL >= 0 { ttl = publishOpts.TTL