diff options
Diffstat (limited to 'modules/httpcache')
-rw-r--r-- | modules/httpcache/httpcache.go | 101 | ||||
-rw-r--r-- | modules/httpcache/httpcache_test.go | 100 |
2 files changed, 201 insertions, 0 deletions
diff --git a/modules/httpcache/httpcache.go b/modules/httpcache/httpcache.go new file mode 100644 index 0000000..30ce0a4 --- /dev/null +++ b/modules/httpcache/httpcache.go @@ -0,0 +1,101 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package httpcache + +import ( + "io" + "net/http" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/modules/setting" +) + +// SetCacheControlInHeader sets suitable cache-control headers in the response +func SetCacheControlInHeader(h http.Header, maxAge time.Duration, additionalDirectives ...string) { + directives := make([]string, 0, 2+len(additionalDirectives)) + + // "max-age=0 + must-revalidate" (aka "no-cache") is preferred instead of "no-store" + // because browsers may restore some input fields after navigate-back / reload a page. + if setting.IsProd { + if maxAge == 0 { + directives = append(directives, "max-age=0", "private", "must-revalidate") + } else { + directives = append(directives, "private", "max-age="+strconv.Itoa(int(maxAge.Seconds()))) + } + } else { + directives = append(directives, "max-age=0", "private", "must-revalidate") + + // to remind users they are using non-prod setting. + h.Set("X-Gitea-Debug", "RUN_MODE="+setting.RunMode) + h.Set("X-Forgejo-Debug", "RUN_MODE="+setting.RunMode) + } + + h.Set("Cache-Control", strings.Join(append(directives, additionalDirectives...), ", ")) +} + +func ServeContentWithCacheControl(w http.ResponseWriter, req *http.Request, name string, modTime time.Time, content io.ReadSeeker) { + SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) + http.ServeContent(w, req, name, modTime, content) +} + +// HandleGenericETagCache handles ETag-based caching for a HTTP request. +// It returns true if the request was handled. +func HandleGenericETagCache(req *http.Request, w http.ResponseWriter, etag string) (handled bool) { + if len(etag) > 0 { + w.Header().Set("Etag", etag) + if checkIfNoneMatchIsValid(req, etag) { + w.WriteHeader(http.StatusNotModified) + return true + } + } + SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) + return false +} + +// checkIfNoneMatchIsValid tests if the header If-None-Match matches the ETag +func checkIfNoneMatchIsValid(req *http.Request, etag string) bool { + ifNoneMatch := req.Header.Get("If-None-Match") + if len(ifNoneMatch) > 0 { + for _, item := range strings.Split(ifNoneMatch, ",") { + item = strings.TrimPrefix(strings.TrimSpace(item), "W/") // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#directives + if item == etag { + return true + } + } + } + return false +} + +// HandleGenericETagTimeCache handles ETag-based caching with Last-Modified caching for a HTTP request. +// It returns true if the request was handled. +func HandleGenericETagTimeCache(req *http.Request, w http.ResponseWriter, etag string, lastModified *time.Time) (handled bool) { + if len(etag) > 0 { + w.Header().Set("Etag", etag) + } + if lastModified != nil && !lastModified.IsZero() { + // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat + w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat)) + } + + if len(etag) > 0 { + if checkIfNoneMatchIsValid(req, etag) { + w.WriteHeader(http.StatusNotModified) + return true + } + } + if lastModified != nil && !lastModified.IsZero() { + ifModifiedSince := req.Header.Get("If-Modified-Since") + if ifModifiedSince != "" { + t, err := time.Parse(http.TimeFormat, ifModifiedSince) + if err == nil && lastModified.Unix() <= t.Unix() { + w.WriteHeader(http.StatusNotModified) + return true + } + } + } + SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) + return false +} diff --git a/modules/httpcache/httpcache_test.go b/modules/httpcache/httpcache_test.go new file mode 100644 index 0000000..65a8a9b --- /dev/null +++ b/modules/httpcache/httpcache_test.go @@ -0,0 +1,100 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package httpcache + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func countFormalHeaders(h http.Header) (c int) { + for k := range h { + // ignore our headers for internal usage + if strings.HasPrefix(k, "X-Gitea-") { + continue + } + if strings.HasPrefix(k, "X-Forgejo-") { + continue + } + c++ + } + return c +} + +func TestHandleGenericETagCache(t *testing.T) { + etag := `"test"` + + t.Run("No_If-None-Match", func(t *testing.T) { + req := &http.Request{Header: make(http.Header)} + w := httptest.NewRecorder() + + handled := HandleGenericETagCache(req, w, etag) + + assert.False(t, handled) + assert.Equal(t, 2, countFormalHeaders(w.Header())) + assert.Contains(t, w.Header(), "Cache-Control") + assert.Contains(t, w.Header(), "Etag") + assert.Equal(t, etag, w.Header().Get("Etag")) + }) + t.Run("Wrong_If-None-Match", func(t *testing.T) { + req := &http.Request{Header: make(http.Header)} + w := httptest.NewRecorder() + + req.Header.Set("If-None-Match", `"wrong etag"`) + + handled := HandleGenericETagCache(req, w, etag) + + assert.False(t, handled) + assert.Equal(t, 2, countFormalHeaders(w.Header())) + assert.Contains(t, w.Header(), "Cache-Control") + assert.Contains(t, w.Header(), "Etag") + assert.Equal(t, etag, w.Header().Get("Etag")) + }) + t.Run("Correct_If-None-Match", func(t *testing.T) { + req := &http.Request{Header: make(http.Header)} + w := httptest.NewRecorder() + + req.Header.Set("If-None-Match", etag) + + handled := HandleGenericETagCache(req, w, etag) + + assert.True(t, handled) + assert.Equal(t, 1, countFormalHeaders(w.Header())) + assert.Contains(t, w.Header(), "Etag") + assert.Equal(t, etag, w.Header().Get("Etag")) + assert.Equal(t, http.StatusNotModified, w.Code) + }) + t.Run("Multiple_Wrong_If-None-Match", func(t *testing.T) { + req := &http.Request{Header: make(http.Header)} + w := httptest.NewRecorder() + + req.Header.Set("If-None-Match", `"wrong etag", "wrong etag "`) + + handled := HandleGenericETagCache(req, w, etag) + + assert.False(t, handled) + assert.Equal(t, 2, countFormalHeaders(w.Header())) + assert.Contains(t, w.Header(), "Cache-Control") + assert.Contains(t, w.Header(), "Etag") + assert.Equal(t, etag, w.Header().Get("Etag")) + }) + t.Run("Multiple_Correct_If-None-Match", func(t *testing.T) { + req := &http.Request{Header: make(http.Header)} + w := httptest.NewRecorder() + + req.Header.Set("If-None-Match", `"wrong etag", `+etag) + + handled := HandleGenericETagCache(req, w, etag) + + assert.True(t, handled) + assert.Equal(t, 1, countFormalHeaders(w.Header())) + assert.Contains(t, w.Header(), "Etag") + assert.Equal(t, etag, w.Header().Get("Etag")) + assert.Equal(t, http.StatusNotModified, w.Code) + }) +} |