Add ETag header (#15370)
* 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 <xiaolunwen@gmail.com> Co-authored-by: techknowlogick <techknowlogick@gitea.io>
This commit is contained in:
		
							parent
							
								
									6d2866f20c
								
							
						
					
					
						commit
						a35a5b225c
					
				
					 5 changed files with 200 additions and 20 deletions
				
			
		|  | @ -10,6 +10,7 @@ import ( | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"os" | 	"os" | ||||||
| 	"strconv" | 	"strconv" | ||||||
|  | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"code.gitea.io/gitea/modules/setting" | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  | @ -26,11 +27,13 @@ func GetCacheControl() string { | ||||||
| // generateETag generates an ETag based on size, filename and file modification time
 | // generateETag generates an ETag based on size, filename and file modification time
 | ||||||
| func generateETag(fi os.FileInfo) string { | func generateETag(fi os.FileInfo) string { | ||||||
| 	etag := fmt.Sprint(fi.Size()) + fi.Name() + fi.ModTime().UTC().Format(http.TimeFormat) | 	etag := fmt.Sprint(fi.Size()) + fi.Name() + fi.ModTime().UTC().Format(http.TimeFormat) | ||||||
| 	return base64.StdEncoding.EncodeToString([]byte(etag)) | 	return `"` + base64.StdEncoding.EncodeToString([]byte(etag)) + `"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // HandleTimeCache handles time-based caching for a HTTP request
 | // HandleTimeCache handles time-based caching for a HTTP request
 | ||||||
| func HandleTimeCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) { | func HandleTimeCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) { | ||||||
|  | 	w.Header().Set("Cache-Control", GetCacheControl()) | ||||||
|  | 
 | ||||||
| 	ifModifiedSince := req.Header.Get("If-Modified-Since") | 	ifModifiedSince := req.Header.Get("If-Modified-Since") | ||||||
| 	if ifModifiedSince != "" { | 	if ifModifiedSince != "" { | ||||||
| 		t, err := time.Parse(http.TimeFormat, ifModifiedSince) | 		t, err := time.Parse(http.TimeFormat, ifModifiedSince) | ||||||
|  | @ -40,20 +43,40 @@ func HandleTimeCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) ( | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	w.Header().Set("Cache-Control", GetCacheControl()) |  | ||||||
| 	w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat)) | 	w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat)) | ||||||
| 	return false | 	return false | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // HandleEtagCache handles ETag-based caching for a HTTP request
 | // HandleFileETagCache handles ETag-based caching for a HTTP request
 | ||||||
| func HandleEtagCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) { | func HandleFileETagCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) { | ||||||
| 	etag := generateETag(fi) | 	etag := generateETag(fi) | ||||||
| 	if req.Header.Get("If-None-Match") == etag { | 	return HandleGenericETagCache(req, w, etag) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 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) | 			w.WriteHeader(http.StatusNotModified) | ||||||
| 			return true | 			return true | ||||||
| 		} | 		} | ||||||
| 
 | 	} | ||||||
| 	w.Header().Set("Cache-Control", GetCacheControl()) | 	w.Header().Set("Cache-Control", GetCacheControl()) | ||||||
| 	w.Header().Set("ETag", etag) | 	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.TrimSpace(item) | ||||||
|  | 			if item == etag { | ||||||
|  | 				return true | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| 	return false | 	return false | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										144
									
								
								modules/httpcache/httpcache_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								modules/httpcache/httpcache_test.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,144 @@ | ||||||
|  | // Copyright 2021 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package httpcache | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/http/httptest" | ||||||
|  | 	"os" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type mockFileInfo struct { | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (m mockFileInfo) Name() string       { return "gitea.test" } | ||||||
|  | func (m mockFileInfo) Size() int64        { return int64(10) } | ||||||
|  | func (m mockFileInfo) Mode() os.FileMode  { return os.ModePerm } | ||||||
|  | func (m mockFileInfo) ModTime() time.Time { return time.Time{} } | ||||||
|  | func (m mockFileInfo) IsDir() bool        { return false } | ||||||
|  | func (m mockFileInfo) Sys() interface{}   { return nil } | ||||||
|  | 
 | ||||||
|  | func TestHandleFileETagCache(t *testing.T) { | ||||||
|  | 	fi := mockFileInfo{} | ||||||
|  | 	etag := `"MTBnaXRlYS50ZXN0TW9uLCAwMSBKYW4gMDAwMSAwMDowMDowMCBHTVQ="` | ||||||
|  | 
 | ||||||
|  | 	t.Run("No_If-None-Match", func(t *testing.T) { | ||||||
|  | 		req := &http.Request{Header: make(http.Header)} | ||||||
|  | 		w := httptest.NewRecorder() | ||||||
|  | 
 | ||||||
|  | 		handled := HandleFileETagCache(req, w, fi) | ||||||
|  | 
 | ||||||
|  | 		assert.False(t, handled) | ||||||
|  | 		assert.Len(t, w.Header(), 2) | ||||||
|  | 		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 := HandleFileETagCache(req, w, fi) | ||||||
|  | 
 | ||||||
|  | 		assert.False(t, handled) | ||||||
|  | 		assert.Len(t, w.Header(), 2) | ||||||
|  | 		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 := HandleFileETagCache(req, w, fi) | ||||||
|  | 
 | ||||||
|  | 		assert.True(t, handled) | ||||||
|  | 		assert.Len(t, w.Header(), 1) | ||||||
|  | 		assert.Contains(t, w.Header(), "Etag") | ||||||
|  | 		assert.Equal(t, etag, w.Header().Get("Etag")) | ||||||
|  | 		assert.Equal(t, http.StatusNotModified, w.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 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.Len(t, w.Header(), 2) | ||||||
|  | 		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.Len(t, w.Header(), 2) | ||||||
|  | 		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.Len(t, w.Header(), 1) | ||||||
|  | 		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.Len(t, w.Header(), 2) | ||||||
|  | 		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.Len(t, w.Header(), 1) | ||||||
|  | 		assert.Contains(t, w.Header(), "Etag") | ||||||
|  | 		assert.Equal(t, etag, w.Header().Get("Etag")) | ||||||
|  | 		assert.Equal(t, http.StatusNotModified, w.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | @ -165,7 +165,7 @@ func (opts *Options) handle(w http.ResponseWriter, req *http.Request, opt *Optio | ||||||
| 		log.Println("[Static] Serving " + file) | 		log.Println("[Static] Serving " + file) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if httpcache.HandleEtagCache(req, w, fi) { | 	if httpcache.HandleFileETagCache(req, w, fi) { | ||||||
| 		return true | 		return true | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -10,6 +10,7 @@ import ( | ||||||
| 
 | 
 | ||||||
| 	"code.gitea.io/gitea/models" | 	"code.gitea.io/gitea/models" | ||||||
| 	"code.gitea.io/gitea/modules/context" | 	"code.gitea.io/gitea/modules/context" | ||||||
|  | 	"code.gitea.io/gitea/modules/httpcache" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	"code.gitea.io/gitea/modules/setting" | 	"code.gitea.io/gitea/modules/setting" | ||||||
| 	"code.gitea.io/gitea/modules/storage" | 	"code.gitea.io/gitea/modules/storage" | ||||||
|  | @ -124,21 +125,25 @@ func GetAttachment(ctx *context.Context) { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if err := attach.IncreaseDownloadCount(); err != nil { | ||||||
|  | 		ctx.ServerError("IncreaseDownloadCount", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	if setting.Attachment.ServeDirect { | 	if setting.Attachment.ServeDirect { | ||||||
| 		//If we have a signed url (S3, object storage), redirect to this directly.
 | 		//If we have a signed url (S3, object storage), redirect to this directly.
 | ||||||
| 		u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name) | 		u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name) | ||||||
| 
 | 
 | ||||||
| 		if u != nil && err == nil { | 		if u != nil && err == nil { | ||||||
| 			if err := attach.IncreaseDownloadCount(); err != nil { |  | ||||||
| 				ctx.ServerError("Update", err) |  | ||||||
| 				return |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			ctx.Redirect(u.String()) | 			ctx.Redirect(u.String()) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`) { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	//If we have matched and access to release or issue
 | 	//If we have matched and access to release or issue
 | ||||||
| 	fr, err := storage.Attachments.Open(attach.RelativePath()) | 	fr, err := storage.Attachments.Open(attach.RelativePath()) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
|  | @ -147,11 +152,6 @@ func GetAttachment(ctx *context.Context) { | ||||||
| 	} | 	} | ||||||
| 	defer fr.Close() | 	defer fr.Close() | ||||||
| 
 | 
 | ||||||
| 	if err := attach.IncreaseDownloadCount(); err != nil { |  | ||||||
| 		ctx.ServerError("Update", err) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if err = ServeData(ctx, attach.Name, attach.Size, fr); err != nil { | 	if err = ServeData(ctx, attach.Name, attach.Size, fr); err != nil { | ||||||
| 		ctx.ServerError("ServeData", err) | 		ctx.ServerError("ServeData", err) | ||||||
| 		return | 		return | ||||||
|  |  | ||||||
|  | @ -15,6 +15,7 @@ import ( | ||||||
| 	"code.gitea.io/gitea/modules/charset" | 	"code.gitea.io/gitea/modules/charset" | ||||||
| 	"code.gitea.io/gitea/modules/context" | 	"code.gitea.io/gitea/modules/context" | ||||||
| 	"code.gitea.io/gitea/modules/git" | 	"code.gitea.io/gitea/modules/git" | ||||||
|  | 	"code.gitea.io/gitea/modules/httpcache" | ||||||
| 	"code.gitea.io/gitea/modules/lfs" | 	"code.gitea.io/gitea/modules/lfs" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| ) | ) | ||||||
|  | @ -31,6 +32,7 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	ctx.Resp.Header().Set("Cache-Control", "public,max-age=86400") | 	ctx.Resp.Header().Set("Cache-Control", "public,max-age=86400") | ||||||
|  | 
 | ||||||
| 	if size >= 0 { | 	if size >= 0 { | ||||||
| 		ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", size)) | 		ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", size)) | ||||||
| 	} else { | 	} else { | ||||||
|  | @ -71,6 +73,10 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader) | ||||||
| 
 | 
 | ||||||
| // ServeBlob download a git.Blob
 | // ServeBlob download a git.Blob
 | ||||||
| func ServeBlob(ctx *context.Context, blob *git.Blob) error { | func ServeBlob(ctx *context.Context, blob *git.Blob) error { | ||||||
|  | 	if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`) { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	dataRc, err := blob.DataAsync() | 	dataRc, err := blob.DataAsync() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
|  | @ -86,6 +92,10 @@ func ServeBlob(ctx *context.Context, blob *git.Blob) error { | ||||||
| 
 | 
 | ||||||
| // ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary
 | // ServeBlobOrLFS download a git.Blob redirecting to LFS if necessary
 | ||||||
| func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob) error { | func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob) error { | ||||||
|  | 	if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`) { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	dataRc, err := blob.DataAsync() | 	dataRc, err := blob.DataAsync() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
|  | @ -102,6 +112,9 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob) error { | ||||||
| 		if meta == nil { | 		if meta == nil { | ||||||
| 			return ServeBlob(ctx, blob) | 			return ServeBlob(ctx, blob) | ||||||
| 		} | 		} | ||||||
|  | 		if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) { | ||||||
|  | 			return nil | ||||||
|  | 		} | ||||||
| 		lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer) | 		lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue