From 17655cdf1b409521262d5d54eb19884d307c47ce Mon Sep 17 00:00:00 2001 From: Morgan Bazalgette 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, }, ))