gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit c3b6a5b0f9976c861042d03b7fc124d566b9209f
parent ab03318b7a0d48e05893f5e740b0672fc08e8a8a
Author: tobi <31960611+tsmethurst@users.noreply.github.com>
Date:   Mon, 18 Jul 2022 12:55:06 +0200

[feature] Implement `cache-control` and etags for static assets (#711)

* start working on etag stuff

* add + use cache middleware

* generate etags on the fly

* remove unused field

* clean up filepath

* add license headers to cache files

* add attachgroup function to router interface

* move cache into web module

* rename a couple things

* remove attachStaticFS function from router

* rename + tidy up a few things

* mount assets filesystem

* create assetsFileInfoCache

* update comment

* simplify hash

* fix string fmt

* skip last mod chk, prefer strong etags w/long cache

* move base handler to its own file
this matches the modules in the api folder

* generate new etag if file was modified

* wrap strong etag in quotation marks as per spec

* clarify logic in avatar search

* make hashing a little niftier
Diffstat:
Minternal/cache/account.go | 18++++++++++++++++++
Minternal/cache/account_test.go | 18++++++++++++++++++
Minternal/cache/status.go | 18++++++++++++++++++
Minternal/cache/status_test.go | 18++++++++++++++++++
Minternal/router/attach.go | 7+++++++
Minternal/router/router.go | 11++++-------
Ainternal/web/assets.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/assetscache.go | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/base.go | 157-------------------------------------------------------------------------------
Dinternal/web/fileserver.go | 47-----------------------------------------------
Ainternal/web/panels.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/web.go | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 513 insertions(+), 211 deletions(-)

diff --git a/internal/cache/account.go b/internal/cache/account.go @@ -1,3 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + package cache import ( diff --git a/internal/cache/account_test.go b/internal/cache/account_test.go @@ -1,3 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + package cache_test import ( diff --git a/internal/cache/status.go b/internal/cache/status.go @@ -1,3 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + package cache import ( diff --git a/internal/cache/status_test.go b/internal/cache/status_test.go @@ -1,3 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + package cache_test import ( diff --git a/internal/router/attach.go b/internal/router/attach.go @@ -39,3 +39,10 @@ func (r *router) AttachMiddleware(middleware gin.HandlerFunc) { func (r *router) AttachNoRouteHandler(handler gin.HandlerFunc) { r.engine.NoRoute(handler) } + +// AttachGroup attaches the given handlers into a group with the given relativePath as +// base path for that group. It then returns the *gin.RouterGroup so that the caller +// can add any extra middlewares etc specific to that group, as desired. +func (r *router) AttachGroup(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup { + return r.engine.Group(relativePath, handlers...) +} diff --git a/internal/router/router.go b/internal/router/router.go @@ -47,8 +47,10 @@ type Router interface { AttachMiddleware(handler gin.HandlerFunc) // Attach 404 NoRoute handler AttachNoRouteHandler(handler gin.HandlerFunc) - // Add Gin StaticFS handler - AttachStaticFS(relativePath string, fs http.FileSystem) + // Attach a router group, and receive that group back. + // More middlewares and handlers can then be attached on + // the group by the caller. + AttachGroup(path string, handlers ...gin.HandlerFunc) *gin.RouterGroup // Start the router Start() // Stop the router @@ -62,11 +64,6 @@ type router struct { certManager *autocert.Manager } -// Add Gin StaticFS handler -func (r *router) AttachStaticFS(relativePath string, fs http.FileSystem) { - r.engine.StaticFS(relativePath, fs) -} - // Start starts the router nicely. It will serve two handlers if letsencrypt is enabled, and only the web/API handler if letsencrypt is not enabled. func (r *router) Start() { // listen is the server start function, by diff --git a/internal/web/assets.go b/internal/web/assets.go @@ -0,0 +1,60 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type fileSystem struct { + fs http.FileSystem +} + +// FileSystem server that only accepts directory listings when an index.html is available +// from https://gist.github.com/hauxe/f2ea1901216177ccf9550a1b8bd59178 +func (fs fileSystem) Open(path string) (http.File, error) { + f, err := fs.fs.Open(path) + if err != nil { + return nil, err + } + + s, _ := f.Stat() + if s.IsDir() { + index := strings.TrimSuffix(path, "/") + "/index.html" + if _, err := fs.fs.Open(index); err != nil { + return nil, err + } + } + + return f, nil +} + +func (m *Module) mountAssetsFilesystem(group *gin.RouterGroup) { + fs := fileSystem{http.Dir(m.webAssetsAbsFilePath)} + + // use the cache middleware on all handlers in this group + group.Use(m.cacheControlMiddleware(fs)) + + // serve static file system in the root of this group, + // will end up being something like "/assets/" + group.StaticFS("/", fs) +} diff --git a/internal/web/assetscache.go b/internal/web/assetscache.go @@ -0,0 +1,138 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + // nolint:gosec + "crypto/sha1" + "encoding/hex" + "fmt" + "io" + "net/http" + "path" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +type eTagCacheEntry struct { + eTag string + fileLastModified time.Time +} + +// generateEtag generates a strong (byte-for-byte) etag using +// the entirety of the provided reader. +func generateEtag(r io.Reader) (string, error) { + // nolint:gosec + hash := sha1.New() + + if _, err := io.Copy(hash, r); err != nil { + return "", err + } + + b := make([]byte, 0, sha1.Size) + b = hash.Sum(b) + + return `"` + hex.EncodeToString(b) + `"`, nil +} + +// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's +// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem +// to generate a new ETag to go in the cache, which it then returns. +func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) { + file, err := fs.Open(filePath) + if err != nil { + return "", fmt.Errorf("error opening %s: %s", filePath, err) + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return "", fmt.Errorf("error statting %s: %s", filePath, err) + } + + fileLastModified := fileInfo.ModTime() + + if cachedETag, ok := m.assetsETagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.fileLastModified) { + // only return our cached etag if the file wasn't + // modified since last time, otherwise generate a + // new one; eat fresh! + return cachedETag.eTag, nil + } + + eTag, err := generateEtag(file) + if err != nil { + return "", fmt.Errorf("error generating etag: %s", err) + } + + // put new entry in cache before we return + m.assetsETagCache.Set(filePath, eTagCacheEntry{ + eTag: eTag, + fileLastModified: fileLastModified, + }) + + return eTag, nil +} + +// cacheControlMiddleware implements Cache-Control header setting, and checks for +// files inside the given http.FileSystem. +// +// The middleware checks if the file has been modified using If-None-Match etag, +// if present. If the file hasn't been modified, the middleware returns 304. +// +// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match +// and: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control +func (m *Module) cacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc { + return func(c *gin.Context) { + // no-cache prevents clients using default caching or heuristic caching, + // and also ensures that clients will validate their cached version against + // the version stored on the server to keep up to date. + c.Header("Cache-Control", "no-cache") + + ifNoneMatch := c.Request.Header.Get("If-None-Match") + + // derive the path of the requested asset inside the provided filesystem + upath := c.Request.URL.Path + if !strings.HasPrefix(upath, "/") { + upath = "/" + upath + } + assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPath) + + // either fetch etag from ttlcache or generate it + eTag, err := m.getAssetETag(assetFilePath, fs) + if err != nil { + logrus.Errorf("error getting ETag for %s: %s", assetFilePath, err) + return + } + + // Regardless of what happens further down, set the etag header + // so that the client has the up-to-date version. + c.Header("Etag", eTag) + + // If client already has latest version of the asset, 304 + bail. + if ifNoneMatch == eTag { + c.AbortWithStatus(http.StatusNotModified) + return + } + + // else let the rest of the request be processed normally + } +} diff --git a/internal/web/base.go b/internal/web/base.go @@ -19,89 +19,14 @@ package web import ( - "errors" - "fmt" - "io/ioutil" "net/http" - "path/filepath" - "strings" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/processing" - "github.com/superseriousbusiness/gotosocial/internal/router" - "github.com/superseriousbusiness/gotosocial/internal/uris" ) -const ( - confirmEmailPath = "/" + uris.ConfirmEmailPath - tokenParam = "token" - usernameKey = "username" - statusIDKey = "status" - profilePath = "/@:" + usernameKey - statusPath = profilePath + "/statuses/:" + statusIDKey -) - -// Module implements the api.ClientModule interface for web pages. -type Module struct { - processor processing.Processor - assetsPath string - adminPath string - defaultAvatars []string -} - -// New returns a new api.ClientModule for web pages. -func New(processor processing.Processor) (api.ClientModule, error) { - assetsBaseDir := config.GetWebAssetBaseDir() - if assetsBaseDir == "" { - return nil, fmt.Errorf("%s cannot be empty and must be a relative or absolute path", config.WebAssetBaseDirFlag()) - } - - assetsPath, err := filepath.Abs(assetsBaseDir) - if err != nil { - return nil, fmt.Errorf("error getting absolute path of %s: %s", assetsBaseDir, err) - } - - defaultAvatarsPath := filepath.Join(assetsPath, "default_avatars") - defaultAvatarFiles, err := ioutil.ReadDir(defaultAvatarsPath) - if err != nil { - return nil, fmt.Errorf("error reading default avatars at %s: %s", defaultAvatarsPath, err) - } - - defaultAvatars := []string{} - for _, f := range defaultAvatarFiles { - // ignore directories - if f.IsDir() { - continue - } - - // ignore files bigger than 50kb - if f.Size() > 50000 { - continue - } - - extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(f.Name())), ".") - - // take only files with simple extensions - switch extension { - case "svg", "jpeg", "jpg", "gif", "png": - defaultAvatarPath := fmt.Sprintf("/assets/default_avatars/%s", f.Name()) - defaultAvatars = append(defaultAvatars, defaultAvatarPath) - default: - continue - } - } - - return &Module{ - processor: processor, - assetsPath: assetsPath, - adminPath: filepath.Join(assetsPath, "admin"), - defaultAvatars: defaultAvatars, - }, nil -} - func (m *Module) baseHandler(c *gin.Context) { host := config.GetHost() instance, err := m.processor.InstanceGet(c.Request.Context(), host) @@ -114,85 +39,3 @@ func (m *Module) baseHandler(c *gin.Context) { "instance": instance, }) } - -// TODO: abstract the {admin, user}panel handlers in some way -func (m *Module) AdminPanelHandler(c *gin.Context) { - host := config.GetHost() - instance, err := m.processor.InstanceGet(c.Request.Context(), host) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.HTML(http.StatusOK, "frontend.tmpl", gin.H{ - "instance": instance, - "stylesheets": []string{ - "/assets/Fork-Awesome/css/fork-awesome.min.css", - "/assets/dist/panels-admin-style.css", - }, - "javascript": []string{ - "/assets/dist/bundle.js", - "/assets/dist/admin-panel.js", - }, - }) -} - -func (m *Module) UserPanelHandler(c *gin.Context) { - host := config.GetHost() - instance, err := m.processor.InstanceGet(c.Request.Context(), host) - if err != nil { - api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) - return - } - - c.HTML(http.StatusOK, "frontend.tmpl", gin.H{ - "instance": instance, - "stylesheets": []string{ - "/assets/Fork-Awesome/css/fork-awesome.min.css", - "/assets/dist/_colors.css", - "/assets/dist/base.css", - "/assets/dist/panels-user-style.css", - }, - "javascript": []string{ - "/assets/dist/bundle.js", - "/assets/dist/user-panel.js", - }, - }) -} - -// Route satisfies the RESTAPIModule interface -func (m *Module) Route(s router.Router) error { - // serve static files from assets dir at /assets - s.AttachStaticFS("/assets", fileSystem{http.Dir(m.assetsPath)}) - - s.AttachHandler(http.MethodGet, "/admin", m.AdminPanelHandler) - // redirect /admin/ to /admin - s.AttachHandler(http.MethodGet, "/admin/", func(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, "/admin") - }) - - s.AttachHandler(http.MethodGet, "/user", m.UserPanelHandler) - // redirect /settings/ to /settings - s.AttachHandler(http.MethodGet, "/user/", func(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, "/user") - }) - - // serve front-page - s.AttachHandler(http.MethodGet, "/", m.baseHandler) - - // serve profile pages at /@username - s.AttachHandler(http.MethodGet, profilePath, m.profileGETHandler) - - // serve statuses - s.AttachHandler(http.MethodGet, statusPath, m.threadGETHandler) - - // serve email confirmation page at /confirm_email?token=whatever - s.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) - - // 404 handler - s.AttachNoRouteHandler(func(c *gin.Context) { - api.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGet) - }) - - return nil -} diff --git a/internal/web/fileserver.go b/internal/web/fileserver.go @@ -1,47 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <http://www.gnu.org/licenses/>. -*/ - -package web - -import ( - "net/http" - "strings" -) - -type fileSystem struct { - fs http.FileSystem -} - -// FileSystem server that only accepts directory listings when an index.html is available -// from https://gist.github.com/hauxe/f2ea1901216177ccf9550a1b8bd59178 -func (fs fileSystem) Open(path string) (http.File, error) { - f, err := fs.fs.Open(path) - if err != nil { - return nil, err - } - - s, _ := f.Stat() - if s.IsDir() { - index := strings.TrimSuffix(path, "/") + "/index.html" - if _, err := fs.fs.Open(index); err != nil { - return nil, err - } - } - - return f, nil -} diff --git a/internal/web/panels.go b/internal/web/panels.go @@ -0,0 +1,73 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +func (m *Module) UserPanelHandler(c *gin.Context) { + host := config.GetHost() + instance, err := m.processor.InstanceGet(c.Request.Context(), host) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.HTML(http.StatusOK, "frontend.tmpl", gin.H{ + "instance": instance, + "stylesheets": []string{ + assetsPath + "/Fork-Awesome/css/fork-awesome.min.css", + assetsPath + "/dist/_colors.css", + assetsPath + "/dist/base.css", + assetsPath + "/dist/panels-user-style.css", + }, + "javascript": []string{ + assetsPath + "/dist/bundle.js", + assetsPath + "/dist/user-panel.js", + }, + }) +} + +// TODO: abstract the {admin, user}panel handlers in some way +func (m *Module) AdminPanelHandler(c *gin.Context) { + host := config.GetHost() + instance, err := m.processor.InstanceGet(c.Request.Context(), host) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.HTML(http.StatusOK, "frontend.tmpl", gin.H{ + "instance": instance, + "stylesheets": []string{ + assetsPath + "/Fork-Awesome/css/fork-awesome.min.css", + assetsPath + "/dist/panels-admin-style.css", + }, + "javascript": []string{ + assetsPath + "/dist/bundle.js", + assetsPath + "/dist/admin-panel.js", + }, + }) +} diff --git a/internal/web/web.go b/internal/web/web.go @@ -0,0 +1,159 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "path/filepath" + "strings" + "time" + + "codeberg.org/gruf/go-cache/v2" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +const ( + confirmEmailPath = "/" + uris.ConfirmEmailPath + profilePath = "/@:" + usernameKey + statusPath = profilePath + "/statuses/:" + statusIDKey + adminPanelPath = "/admin" + userPanelpath = "/user" + assetsPath = "/assets" + + tokenParam = "token" + usernameKey = "username" + statusIDKey = "status" +) + +// Module implements the api.ClientModule interface for web pages. +type Module struct { + processor processing.Processor + webAssetsAbsFilePath string + assetsETagCache cache.Cache[string, eTagCacheEntry] + defaultAvatars []string +} + +// New returns a new api.ClientModule for web pages. +func New(processor processing.Processor) (api.ClientModule, error) { + webAssetsBaseDir := config.GetWebAssetBaseDir() + if webAssetsBaseDir == "" { + return nil, fmt.Errorf("%s cannot be empty and must be a relative or absolute path", config.WebAssetBaseDirFlag()) + } + + webAssetsAbsFilePath, err := filepath.Abs(webAssetsBaseDir) + if err != nil { + return nil, fmt.Errorf("error getting absolute path of %s: %s", webAssetsBaseDir, err) + } + + defaultAvatarsAbsFilePath := filepath.Join(webAssetsAbsFilePath, "default_avatars") + defaultAvatarFiles, err := ioutil.ReadDir(defaultAvatarsAbsFilePath) + if err != nil { + return nil, fmt.Errorf("error reading default avatars at %s: %s", defaultAvatarsAbsFilePath, err) + } + + defaultAvatars := []string{} + for _, f := range defaultAvatarFiles { + // ignore directories + if f.IsDir() { + continue + } + + // ignore files bigger than 50kb + if f.Size() > 50000 { + continue + } + + // get the name of the file, eg avatar.jpeg + fileName := f.Name() + + // get just the .jpeg, for example, from avatar.jpeg + extensionWithDot := filepath.Ext(fileName) + + // remove the leading . to just get, eg, jpeg + extension := strings.TrimPrefix(extensionWithDot, ".") + + // take only files with simple extensions + // that we know will work OK as avatars + switch strings.ToLower(extension) { + case "svg", "jpeg", "jpg", "gif", "png": + avatar := fmt.Sprintf("%s/default_avatars/%s", assetsPath, f.Name()) + defaultAvatars = append(defaultAvatars, avatar) + default: + continue + } + } + + assetsETagCache := cache.New[string, eTagCacheEntry]() + assetsETagCache.SetTTL(time.Hour, false) + assetsETagCache.Start(time.Minute) + + return &Module{ + processor: processor, + webAssetsAbsFilePath: webAssetsAbsFilePath, + assetsETagCache: assetsETagCache, + defaultAvatars: defaultAvatars, + }, nil +} + +// Route satisfies the RESTAPIModule interface +func (m *Module) Route(s router.Router) error { + // serve static files from assets dir at /assets + assetsGroup := s.AttachGroup(assetsPath) + m.mountAssetsFilesystem(assetsGroup) + + s.AttachHandler(http.MethodGet, adminPanelPath, m.AdminPanelHandler) + // redirect /admin/ to /admin + s.AttachHandler(http.MethodGet, adminPanelPath+"/", func(c *gin.Context) { + c.Redirect(http.StatusMovedPermanently, adminPanelPath) + }) + + s.AttachHandler(http.MethodGet, userPanelpath, m.UserPanelHandler) + // redirect /settings/ to /settings + s.AttachHandler(http.MethodGet, userPanelpath+"/", func(c *gin.Context) { + c.Redirect(http.StatusMovedPermanently, userPanelpath) + }) + + // serve front-page + s.AttachHandler(http.MethodGet, "/", m.baseHandler) + + // serve profile pages at /@username + s.AttachHandler(http.MethodGet, profilePath, m.profileGETHandler) + + // serve statuses + s.AttachHandler(http.MethodGet, statusPath, m.threadGETHandler) + + // serve email confirmation page at /confirm_email?token=whatever + s.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) + + // 404 handler + s.AttachNoRouteHandler(func(c *gin.Context) { + api.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGet) + }) + + return nil +}