From 17655cdf1b409521262d5d54eb19884d307c47ce Mon Sep 17 00:00:00 2001
From: Morgan Bazalgette <git@howl.moe>
Date: Sat, 3 Feb 2018 23:37:05 +0100
Subject: [PATCH]  Enable caching on assets and avatars (#3376)

* Enable caching on assets and avatars

Fixes #3323

* Only set avatar in user BeforeUpdate when there is no avatar set

* add error checking after stat

* gofmt

* Change cache time for avatars to an hour
---
 models/user.go            |   2 +-
 modules/public/dynamic.go |   7 +--
 modules/public/public.go  | 138 ++++++++++++++++++++++++++++++++++++++++++++--
 modules/public/static.go  |  23 ++++----
 routers/routes/routes.go  |  19 ++++---
 5 files changed, 155 insertions(+), 34 deletions(-)

diff --git a/models/user.go b/models/user.go
index bf28683285..ecfe3bca0f 100644
--- a/models/user.go
+++ b/models/user.go
@@ -145,7 +145,7 @@ func (u *User) BeforeUpdate() {
 		if len(u.AvatarEmail) == 0 {
 			u.AvatarEmail = u.Email
 		}
-		if len(u.AvatarEmail) > 0 {
+		if len(u.AvatarEmail) > 0 && u.Avatar == "" {
 			u.Avatar = base.HashEmail(u.AvatarEmail)
 		}
 	}
diff --git a/modules/public/dynamic.go b/modules/public/dynamic.go
index c196d67baa..282db44970 100644
--- a/modules/public/dynamic.go
+++ b/modules/public/dynamic.go
@@ -12,10 +12,5 @@ import (
 
 // Static implements the macaron static handler for serving assets.
 func Static(opts *Options) macaron.Handler {
-	return macaron.Static(
-		opts.Directory,
-		macaron.StaticOptions{
-			SkipLogging: opts.SkipLogging,
-		},
-	)
+	return opts.staticHandler(opts.Directory)
 }
diff --git a/modules/public/public.go b/modules/public/public.go
index 6f28ebc032..f03f8fcc15 100644
--- a/modules/public/public.go
+++ b/modules/public/public.go
@@ -5,7 +5,13 @@
 package public
 
 import (
+	"encoding/base64"
+	"log"
+	"net/http"
 	"path"
+	"path/filepath"
+	"strings"
+	"time"
 
 	"code.gitea.io/gitea/modules/setting"
 	"gopkg.in/macaron.v1"
@@ -19,15 +25,135 @@ import (
 // Options represents the available options to configure the macaron handler.
 type Options struct {
 	Directory   string
+	IndexFile   string
 	SkipLogging bool
+	// if set to true, will enable caching. Expires header will also be set to
+	// expire after the defined time.
+	ExpiresAfter time.Duration
+	FileSystem   http.FileSystem
+	Prefix       string
 }
 
 // Custom implements the macaron static handler for serving custom assets.
 func Custom(opts *Options) macaron.Handler {
-	return macaron.Static(
-		path.Join(setting.CustomPath, "public"),
-		macaron.StaticOptions{
-			SkipLogging: opts.SkipLogging,
-		},
-	)
+	return opts.staticHandler(path.Join(setting.CustomPath, "public"))
+}
+
+// staticFileSystem implements http.FileSystem interface.
+type staticFileSystem struct {
+	dir *http.Dir
+}
+
+func newStaticFileSystem(directory string) staticFileSystem {
+	if !filepath.IsAbs(directory) {
+		directory = filepath.Join(macaron.Root, directory)
+	}
+	dir := http.Dir(directory)
+	return staticFileSystem{&dir}
+}
+
+func (fs staticFileSystem) Open(name string) (http.File, error) {
+	return fs.dir.Open(name)
+}
+
+// StaticHandler sets up a new middleware for serving static files in the
+func StaticHandler(dir string, opts *Options) macaron.Handler {
+	return opts.staticHandler(dir)
+}
+
+func (opts *Options) staticHandler(dir string) macaron.Handler {
+	// Defaults
+	if len(opts.IndexFile) == 0 {
+		opts.IndexFile = "index.html"
+	}
+	// Normalize the prefix if provided
+	if opts.Prefix != "" {
+		// Ensure we have a leading '/'
+		if opts.Prefix[0] != '/' {
+			opts.Prefix = "/" + opts.Prefix
+		}
+		// Remove any trailing '/'
+		opts.Prefix = strings.TrimRight(opts.Prefix, "/")
+	}
+	if opts.FileSystem == nil {
+		opts.FileSystem = newStaticFileSystem(dir)
+	}
+
+	return func(ctx *macaron.Context, log *log.Logger) {
+		opts.handle(ctx, log, opts)
+	}
+}
+
+func (opts *Options) handle(ctx *macaron.Context, log *log.Logger, opt *Options) bool {
+	if ctx.Req.Method != "GET" && ctx.Req.Method != "HEAD" {
+		return false
+	}
+
+	file := ctx.Req.URL.Path
+	// if we have a prefix, filter requests by stripping the prefix
+	if opt.Prefix != "" {
+		if !strings.HasPrefix(file, opt.Prefix) {
+			return false
+		}
+		file = file[len(opt.Prefix):]
+		if file != "" && file[0] != '/' {
+			return false
+		}
+	}
+
+	f, err := opt.FileSystem.Open(file)
+	if err != nil {
+		return false
+	}
+	defer f.Close()
+
+	fi, err := f.Stat()
+	if err != nil {
+		log.Printf("[Static] %q exists, but fails to open: %v", file, err)
+		return true
+	}
+
+	// Try to serve index file
+	if fi.IsDir() {
+		// Redirect if missing trailing slash.
+		if !strings.HasSuffix(ctx.Req.URL.Path, "/") {
+			http.Redirect(ctx.Resp, ctx.Req.Request, ctx.Req.URL.Path+"/", http.StatusFound)
+			return true
+		}
+
+		f, err = opt.FileSystem.Open(file)
+		if err != nil {
+			return false // Discard error.
+		}
+		defer f.Close()
+
+		fi, err = f.Stat()
+		if err != nil || fi.IsDir() {
+			return true
+		}
+	}
+
+	if !opt.SkipLogging {
+		log.Println("[Static] Serving " + file)
+	}
+
+	// Add an Expires header to the static content
+	if opt.ExpiresAfter > 0 {
+		ctx.Resp.Header().Set("Expires", time.Now().Add(opt.ExpiresAfter).UTC().Format(http.TimeFormat))
+		tag := GenerateETag(string(fi.Size()), fi.Name(), fi.ModTime().UTC().Format(http.TimeFormat))
+		ctx.Resp.Header().Set("ETag", tag)
+		if ctx.Req.Header.Get("If-None-Match") == tag {
+			ctx.Resp.WriteHeader(304)
+			return false
+		}
+	}
+
+	http.ServeContent(ctx.Resp, ctx.Req.Request, file, fi.ModTime(), f)
+	return true
+}
+
+// GenerateETag generates an ETag based on size, filename and file modification time
+func GenerateETag(fileSize, fileName, modTime string) string {
+	etag := fileSize + fileName + modTime
+	return base64.StdEncoding.EncodeToString([]byte(etag))
 }
diff --git a/modules/public/static.go b/modules/public/static.go
index f68400d329..10e32dbd10 100644
--- a/modules/public/static.go
+++ b/modules/public/static.go
@@ -13,17 +13,14 @@ import (
 
 // Static implements the macaron static handler for serving assets.
 func Static(opts *Options) macaron.Handler {
-	return macaron.Static(
-		opts.Directory,
-		macaron.StaticOptions{
-			SkipLogging: opts.SkipLogging,
-			FileSystem: bindata.Static(bindata.Options{
-				Asset:      Asset,
-				AssetDir:   AssetDir,
-				AssetInfo:  AssetInfo,
-				AssetNames: AssetNames,
-				Prefix:     "",
-			}),
-		},
-	)
+	opts.FileSystem = bindata.Static(bindata.Options{
+		Asset:      Asset,
+		AssetDir:   AssetDir,
+		AssetInfo:  AssetInfo,
+		AssetNames: AssetNames,
+		Prefix:     "",
+	})
+	// we don't need to pass the directory, because the directory var is only
+	// used when in the options there is no FileSystem.
+	return opts.staticHandler("")
 }
diff --git a/routers/routes/routes.go b/routers/routes/routes.go
index e51bfb946a..1d95bb4c76 100644
--- a/routers/routes/routes.go
+++ b/routers/routes/routes.go
@@ -7,6 +7,7 @@ package routes
 import (
 	"os"
 	"path"
+	"time"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/auth"
@@ -53,21 +54,23 @@ func NewMacaron() *macaron.Macaron {
 	}
 	m.Use(public.Custom(
 		&public.Options{
-			SkipLogging: setting.DisableRouterLog,
+			SkipLogging:  setting.DisableRouterLog,
+			ExpiresAfter: time.Hour * 6,
 		},
 	))
 	m.Use(public.Static(
 		&public.Options{
-			Directory:   path.Join(setting.StaticRootPath, "public"),
-			SkipLogging: setting.DisableRouterLog,
+			Directory:    path.Join(setting.StaticRootPath, "public"),
+			SkipLogging:  setting.DisableRouterLog,
+			ExpiresAfter: time.Hour * 6,
 		},
 	))
-	m.Use(macaron.Static(
+	m.Use(public.StaticHandler(
 		setting.AvatarUploadPath,
-		macaron.StaticOptions{
-			Prefix:      "avatars",
-			SkipLogging: setting.DisableRouterLog,
-			ETag:        true,
+		&public.Options{
+			Prefix:       "avatars",
+			SkipLogging:  setting.DisableRouterLog,
+			ExpiresAfter: time.Hour * 6,
 		},
 	))