From eb161866a794d586418f07e7458ff65d84855af9 Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Mon, 15 Jan 2018 22:38:31 +0100 Subject: [PATCH 1/5] Enable caching on assets and avatars Fixes #3323 --- modules/public/dynamic.go | 7 +- modules/public/public.go | 137 ++++++++++++++++++++++++++++++++++++-- modules/public/static.go | 23 +++---- routers/routes/routes.go | 19 +++--- 4 files changed, 153 insertions(+), 33 deletions(-) diff --git a/modules/public/dynamic.go b/modules/public/dynamic.go index c196d67baa588..282db4497047f 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 6f28ebc032855..58ec84d7a3bee 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,134 @@ 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 { + return true // File exists but fail to open. + } + + // 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 f68400d329c1e..10e32dbd10f1d 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 e51bfb946ab72..100dd1376a5d2 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, }, )) From 4e66d2618fbfcd3de93df7a09161ead7c867473c Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Mon, 15 Jan 2018 23:05:11 +0100 Subject: [PATCH 2/5] Only set avatar in user BeforeUpdate when there is no avatar set --- models/user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/user.go b/models/user.go index bf28683285d47..ecfe3bca0fa64 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) } } From 1543ddce1dbb395e976ebe466a8cd914535e4b08 Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Tue, 16 Jan 2018 08:33:55 +0100 Subject: [PATCH 3/5] add error checking after stat --- modules/public/public.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/public/public.go b/modules/public/public.go index 58ec84d7a3bee..6aa3a0e307ea3 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -106,10 +106,11 @@ func (opts *Options) handle(ctx *macaron.Context, log *log.Logger, opt *Options) return false } defer f.Close() - + fi, err := f.Stat() if err != nil { - return true // File exists but fail to open. + log.Printf("[Static] %q exists, but fails to open: %v", file, err) + return true } // Try to serve index file From dbc536edfd72353d9974b1725a5e3d2ef62401bd Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Tue, 16 Jan 2018 12:58:11 +0100 Subject: [PATCH 4/5] gofmt --- modules/public/public.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/public/public.go b/modules/public/public.go index 6aa3a0e307ea3..f03f8fcc15d96 100644 --- a/modules/public/public.go +++ b/modules/public/public.go @@ -106,7 +106,7 @@ func (opts *Options) handle(ctx *macaron.Context, log *log.Logger, opt *Options) return false } defer f.Close() - + fi, err := f.Stat() if err != nil { log.Printf("[Static] %q exists, but fails to open: %v", file, err) From 54926d7913d4e1502b484915d8711e7afe310530 Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette Date: Sat, 20 Jan 2018 18:44:13 +0100 Subject: [PATCH 5/5] Change cache time for avatars to an hour --- routers/routes/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 100dd1376a5d2..1d95bb4c7613f 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -70,7 +70,7 @@ func NewMacaron() *macaron.Macaron { &public.Options{ Prefix: "avatars", SkipLogging: setting.DisableRouterLog, - ExpiresAfter: time.Hour, + ExpiresAfter: time.Hour * 6, }, ))