mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 16:40:24 +08:00 
			
		
		
		
	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:
		@@ -10,6 +10,7 @@ import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"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
 | 
			
		||||
func generateETag(fi os.FileInfo) string {
 | 
			
		||||
	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
 | 
			
		||||
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")
 | 
			
		||||
	if 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))
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// HandleEtagCache handles ETag-based caching for a HTTP request
 | 
			
		||||
func HandleEtagCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) {
 | 
			
		||||
// HandleFileETagCache handles ETag-based caching for a HTTP request
 | 
			
		||||
func HandleFileETagCache(req *http.Request, w http.ResponseWriter, fi os.FileInfo) (handled bool) {
 | 
			
		||||
	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)
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if httpcache.HandleEtagCache(req, w, fi) {
 | 
			
		||||
	if httpcache.HandleFileETagCache(req, w, fi) {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ import (
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models"
 | 
			
		||||
	"code.gitea.io/gitea/modules/context"
 | 
			
		||||
	"code.gitea.io/gitea/modules/httpcache"
 | 
			
		||||
	"code.gitea.io/gitea/modules/log"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"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 we have a signed url (S3, object storage), redirect to this directly.
 | 
			
		||||
		u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name)
 | 
			
		||||
 | 
			
		||||
		if u != nil && err == nil {
 | 
			
		||||
			if err := attach.IncreaseDownloadCount(); err != nil {
 | 
			
		||||
				ctx.ServerError("Update", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			ctx.Redirect(u.String())
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+attach.UUID+`"`) {
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	//If we have matched and access to release or issue
 | 
			
		||||
	fr, err := storage.Attachments.Open(attach.RelativePath())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@@ -147,11 +152,6 @@ func GetAttachment(ctx *context.Context) {
 | 
			
		||||
	}
 | 
			
		||||
	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 {
 | 
			
		||||
		ctx.ServerError("ServeData", err)
 | 
			
		||||
		return
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ import (
 | 
			
		||||
	"code.gitea.io/gitea/modules/charset"
 | 
			
		||||
	"code.gitea.io/gitea/modules/context"
 | 
			
		||||
	"code.gitea.io/gitea/modules/git"
 | 
			
		||||
	"code.gitea.io/gitea/modules/httpcache"
 | 
			
		||||
	"code.gitea.io/gitea/modules/lfs"
 | 
			
		||||
	"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")
 | 
			
		||||
 | 
			
		||||
	if size >= 0 {
 | 
			
		||||
		ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", size))
 | 
			
		||||
	} else {
 | 
			
		||||
@@ -71,6 +73,10 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader)
 | 
			
		||||
 | 
			
		||||
// ServeBlob download a git.Blob
 | 
			
		||||
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()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		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
 | 
			
		||||
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()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
@@ -102,6 +112,9 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob) error {
 | 
			
		||||
		if meta == nil {
 | 
			
		||||
			return ServeBlob(ctx, blob)
 | 
			
		||||
		}
 | 
			
		||||
		if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
		lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user