Skip to content

Commit 5da024a

Browse files
KN4CK3Rlunnytechknowlogick
authored
Add ETag header (#15370) (#15552)
* Add ETag header. * Comply with RFC 7232. * Moved logic into httpcache.go * Changed name. * Lint * Implemented If-None-Match list. * Fixed missing header on * * Removed weak etag support. * Removed * support. * Added unit test. * Lint Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: techknowlogick <[email protected]> Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: techknowlogick <[email protected]>
1 parent eff2499 commit 5da024a

File tree

5 files changed

+200
-20
lines changed

5 files changed

+200
-20
lines changed

modules/httpcache/httpcache.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http"
1111
"os"
1212
"strconv"
13+
"strings"
1314
"time"
1415

1516
"code.gitea.io/gitea/modules/setting"
@@ -26,11 +27,13 @@ func GetCacheControl() string {
2627
// generateETag generates an ETag based on size, filename and file modification time
2728
func generateETag(fi os.FileInfo) string {
2829
etag := fmt.Sprint(fi.Size()) + fi.Name() + fi.ModTime().UTC().Format(http.TimeFormat)
29-
return base64.StdEncoding.EncodeToString([]byte(etag))
30+
return `"` + base64.StdEncoding.EncodeToString([]byte(etag)) + `"`
3031
}
3132

3233
// HandleTimeCache handles time-based caching for a HTTP request
3334
func HandleTimeCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) {
35+
w.Header().Set("Cache-Control", GetCacheControl())
36+
3437
ifModifiedSince := req.Header.Get("If-Modified-Since")
3538
if ifModifiedSince != "" {
3639
t, err := time.Parse(http.TimeFormat, ifModifiedSince)
@@ -40,20 +43,40 @@ func HandleTimeCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (
4043
}
4144
}
4245

43-
w.Header().Set("Cache-Control", GetCacheControl())
4446
w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat))
4547
return false
4648
}
4749

48-
// HandleEtagCache handles ETag-based caching for a HTTP request
49-
func HandleEtagCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) {
50+
// HandleFileETagCache handles ETag-based caching for a HTTP request
51+
func HandleFileETagCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) {
5052
etag := generateETag(fi)
51-
if req.Header.Get("If-None-Match") == etag {
52-
w.WriteHeader(http.StatusNotModified)
53-
return true
54-
}
53+
return HandleGenericETagCache(req, w, etag)
54+
}
5555

56+
// HandleGenericETagCache handles ETag-based caching for a HTTP request.
57+
// It returns true if the request was handled.
58+
func HandleGenericETagCache(req *http.Request, w http.ResponseWriter, etag string) (handled bool) {
59+
if len(etag) > 0 {
60+
w.Header().Set("Etag", etag)
61+
if checkIfNoneMatchIsValid(req, etag) {
62+
w.WriteHeader(http.StatusNotModified)
63+
return true
64+
}
65+
}
5666
w.Header().Set("Cache-Control", GetCacheControl())
57-
w.Header().Set("ETag", etag)
67+
return false
68+
}
69+
70+
// checkIfNoneMatchIsValid tests if the header If-None-Match matches the ETag
71+
func checkIfNoneMatchIsValid(req *http.Request, etag string) bool {
72+
ifNoneMatch := req.Header.Get("If-None-Match")
73+
if len(ifNoneMatch) > 0 {
74+
for _, item := range strings.Split(ifNoneMatch, ",") {
75+
item = strings.TrimSpace(item)
76+
if item == etag {
77+
return true
78+
}
79+
}
80+
}
5881
return false
5982
}

modules/httpcache/httpcache_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package httpcache
6+
7+
import (
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"testing"
12+
"time"
13+
14+
"github.com/stretchr/testify/assert"
15+
)
16+
17+
type mockFileInfo struct {
18+
}
19+
20+
func (m mockFileInfo) Name() string { return "gitea.test" }
21+
func (m mockFileInfo) Size() int64 { return int64(10) }
22+
func (m mockFileInfo) Mode() os.FileMode { return os.ModePerm }
23+
func (m mockFileInfo) ModTime() time.Time { return time.Time{} }
24+
func (m mockFileInfo) IsDir() bool { return false }
25+
func (m mockFileInfo) Sys() interface{} { return nil }
26+
27+
func TestHandleFileETagCache(t *testing.T) {
28+
fi := mockFileInfo{}
29+
etag := `"MTBnaXRlYS50ZXN0TW9uLCAwMSBKYW4gMDAwMSAwMDowMDowMCBHTVQ="`
30+
31+
t.Run("No_If-None-Match", func(t *testing.T) {
32+
req := &http.Request{Header: make(http.Header)}
33+
w := httptest.NewRecorder()
34+
35+
handled := HandleFileETagCache(req, w, fi)
36+
37+
assert.False(t, handled)
38+
assert.Len(t, w.Header(), 2)
39+
assert.Contains(t, w.Header(), "Cache-Control")
40+
assert.Contains(t, w.Header(), "Etag")
41+
assert.Equal(t, etag, w.Header().Get("Etag"))
42+
})
43+
t.Run("Wrong_If-None-Match", func(t *testing.T) {
44+
req := &http.Request{Header: make(http.Header)}
45+
w := httptest.NewRecorder()
46+
47+
req.Header.Set("If-None-Match", `"wrong etag"`)
48+
49+
handled := HandleFileETagCache(req, w, fi)
50+
51+
assert.False(t, handled)
52+
assert.Len(t, w.Header(), 2)
53+
assert.Contains(t, w.Header(), "Cache-Control")
54+
assert.Contains(t, w.Header(), "Etag")
55+
assert.Equal(t, etag, w.Header().Get("Etag"))
56+
})
57+
t.Run("Correct_If-None-Match", func(t *testing.T) {
58+
req := &http.Request{Header: make(http.Header)}
59+
w := httptest.NewRecorder()
60+
61+
req.Header.Set("If-None-Match", etag)
62+
63+
handled := HandleFileETagCache(req, w, fi)
64+
65+
assert.True(t, handled)
66+
assert.Len(t, w.Header(), 1)
67+
assert.Contains(t, w.Header(), "Etag")
68+
assert.Equal(t, etag, w.Header().Get("Etag"))
69+
assert.Equal(t, http.StatusNotModified, w.Code)
70+
})
71+
}
72+
73+
func TestHandleGenericETagCache(t *testing.T) {
74+
etag := `"test"`
75+
76+
t.Run("No_If-None-Match", func(t *testing.T) {
77+
req := &http.Request{Header: make(http.Header)}
78+
w := httptest.NewRecorder()
79+
80+
handled := HandleGenericETagCache(req, w, etag)
81+
82+
assert.False(t, handled)
83+
assert.Len(t, w.Header(), 2)
84+
assert.Contains(t, w.Header(), "Cache-Control")
85+
assert.Contains(t, w.Header(), "Etag")
86+
assert.Equal(t, etag, w.Header().Get("Etag"))
87+
})
88+
t.Run("Wrong_If-None-Match", func(t *testing.T) {
89+
req := &http.Request{Header: make(http.Header)}
90+
w := httptest.NewRecorder()
91+
92+
req.Header.Set("If-None-Match", `"wrong etag"`)
93+
94+
handled := HandleGenericETagCache(req, w, etag)
95+
96+
assert.False(t, handled)
97+
assert.Len(t, w.Header(), 2)
98+
assert.Contains(t, w.Header(), "Cache-Control")
99+
assert.Contains(t, w.Header(), "Etag")
100+
assert.Equal(t, etag, w.Header().Get("Etag"))
101+
})
102+
t.Run("Correct_If-None-Match", func(t *testing.T) {
103+
req := &http.Request{Header: make(http.Header)}
104+
w := httptest.NewRecorder()
105+
106+
req.Header.Set("If-None-Match", etag)
107+
108+
handled := HandleGenericETagCache(req, w, etag)
109+
110+
assert.True(t, handled)
111+
assert.Len(t, w.Header(), 1)
112+
assert.Contains(t, w.Header(), "Etag")
113+
assert.Equal(t, etag, w.Header().Get("Etag"))
114+
assert.Equal(t, http.StatusNotModified, w.Code)
115+
})
116+
t.Run("Multiple_Wrong_If-None-Match", func(t *testing.T) {
117+
req := &http.Request{Header: make(http.Header)}
118+
w := httptest.NewRecorder()
119+
120+
req.Header.Set("If-None-Match", `"wrong etag", "wrong etag "`)
121+
122+
handled := HandleGenericETagCache(req, w, etag)
123+
124+
assert.False(t, handled)
125+
assert.Len(t, w.Header(), 2)
126+
assert.Contains(t, w.Header(), "Cache-Control")
127+
assert.Contains(t, w.Header(), "Etag")
128+
assert.Equal(t, etag, w.Header().Get("Etag"))
129+
})
130+
t.Run("Multiple_Correct_If-None-Match", func(t *testing.T) {
131+
req := &http.Request{Header: make(http.Header)}
132+
w := httptest.NewRecorder()
133+
134+
req.Header.Set("If-None-Match", `"wrong etag", `+etag)
135+
136+
handled := HandleGenericETagCache(req, w, etag)
137+
138+
assert.True(t, handled)
139+
assert.Len(t, w.Header(), 1)
140+
assert.Contains(t, w.Header(), "Etag")
141+
assert.Equal(t, etag, w.Header().Get("Etag"))
142+
assert.Equal(t, http.StatusNotModified, w.Code)
143+
})
144+
}

modules/public/public.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func (opts *Options) handle(w http.ResponseWriter, req *http.Request, opt *Optio
165165
log.Println("[Static] Serving " + file)
166166
}
167167

168-
if httpcache.HandleEtagCache(req, w, fi) {
168+
if httpcache.HandleFileETagCache(req, w, fi) {
169169
return true
170170
}
171171

routers/repo/attachment.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"code.gitea.io/gitea/models"
1212
"code.gitea.io/gitea/modules/context"
13+
"code.gitea.io/gitea/modules/httpcache"
1314
"code.gitea.io/gitea/modules/log"
1415
"code.gitea.io/gitea/modules/setting"
1516
"code.gitea.io/gitea/modules/storage"
@@ -124,21 +125,25 @@ func GetAttachment(ctx *context.Context) {
124125
}
125126
}
126127

128+
if err := attach.IncreaseDownloadCount(); err != nil {
129+
ctx.ServerError("IncreaseDownloadCount", err)
130+
return
131+
}
132+
127133
if setting.Attachment.ServeDirect {
128134
//If we have a signed url (S3, object storage), redirect to this directly.
129135
u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name)
130136

131137
if u != nil && err == nil {
132-
if err := attach.IncreaseDownloadCount(); err != nil {
133-
ctx.ServerError("Update", err)
134-
return
135-
}
136-
137138
ctx.Redirect(u.String())
138139
return
139140
}
140141
}
141142

143+
if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`) {
144+
return
145+
}
146+
142147
//If we have matched and access to release or issue
143148
fr, err := storage.Attachments.Open(attach.RelativePath())
144149
if err != nil {
@@ -147,11 +152,6 @@ func GetAttachment(ctx *context.Context) {
147152
}
148153
defer fr.Close()
149154

150-
if err := attach.IncreaseDownloadCount(); err != nil {
151-
ctx.ServerError("Update", err)
152-
return
153-
}
154-
155155
if err = ServeData(ctx, attach.Name, attach.Size, fr); err != nil {
156156
ctx.ServerError("ServeData", err)
157157
return

routers/repo/download.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"code.gitea.io/gitea/modules/charset"
1616
"code.gitea.io/gitea/modules/context"
1717
"code.gitea.io/gitea/modules/git"
18+
"code.gitea.io/gitea/modules/httpcache"
1819
"code.gitea.io/gitea/modules/lfs"
1920
"code.gitea.io/gitea/modules/log"
2021
)
@@ -31,6 +32,7 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader)
3132
}
3233

3334
ctx.Resp.Header().Set("Cache-Control", "public,max-age=86400")
35+
3436
if size >= 0 {
3537
ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", size))
3638
} else {
@@ -71,6 +73,10 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader)
7173

7274
// ServeBlob download a git.Blob
7375
func ServeBlob(ctx *context.Context, blob *git.Blob) error {
76+
if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`) {
77+
return nil
78+
}
79+
7480
dataRc, err := blob.DataAsync()
7581
if err != nil {
7682
return err
@@ -86,6 +92,10 @@ func ServeBlob(ctx *context.Context, blob *git.Blob) error {
8692

8793
// ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary
8894
func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob) error {
95+
if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`) {
96+
return nil
97+
}
98+
8999
dataRc, err := blob.DataAsync()
90100
if err != nil {
91101
return err
@@ -101,6 +111,9 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob) error {
101111
if meta == nil {
102112
return ServeBlob(ctx, blob)
103113
}
114+
if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+meta.Oid+`"`) {
115+
return nil
116+
}
104117
lfsDataRc, err := lfs.ReadMetaObject(meta)
105118
if err != nil {
106119
return err

0 commit comments

Comments
 (0)