summaryrefslogtreecommitdiffstats
path: root/modules/httpcache
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /modules/httpcache
parentInitial commit. (diff)
downloadforgejo-debian.tar.xz
forgejo-debian.zip
Adding upstream version 9.0.0.HEADupstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--modules/httpcache/httpcache.go101
-rw-r--r--modules/httpcache/httpcache_test.go100
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)
+ })
+}