[feature] Use maintenance router to serve 503 while server is starting/migrating (#3705)

* [feature] Use maintenance router to serve 503 while server is starting/migrating

* love you linter, kissies
This commit is contained in:
tobi 2025-01-29 16:57:04 +01:00 committed by GitHub
commit d16e4fa34d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 271 additions and 26 deletions

View file

@ -21,10 +21,13 @@ import (
"fmt"
"net/http"
"path"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
type fileSystem struct {
@ -53,7 +56,11 @@ func (fs fileSystem) Open(path string) (http.File, error) {
// 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) {
func getAssetETag(
wet withETagCache,
filePath string,
fs http.FileSystem,
) (string, error) {
file, err := fs.Open(filePath)
if err != nil {
return "", fmt.Errorf("error opening %s: %s", filePath, err)
@ -67,7 +74,8 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
fileLastModified := fileInfo.ModTime()
if cachedETag, ok := m.eTagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
cache := wet.ETagCache()
if cachedETag, ok := cache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
// only return our cached etag if the file wasn't
// modified since last time, otherwise generate a
// new one; eat fresh!
@ -80,7 +88,7 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
}
// put new entry in cache before we return
m.eTagCache.Set(filePath, eTagCacheEntry{
cache.Set(filePath, eTagCacheEntry{
eTag: eTag,
lastModified: fileLastModified,
})
@ -99,7 +107,10 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
//
// todo: move this middleware out of the 'web' package and into the 'middleware'
// package along with the other middlewares
func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc {
func assetsCacheControlMiddleware(
wet withETagCache,
fs http.FileSystem,
) gin.HandlerFunc {
return func(c *gin.Context) {
// Acquire context from gin request.
ctx := c.Request.Context()
@ -118,7 +129,7 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
// either fetch etag from ttlcache or generate it
eTag, err := m.getAssetETag(assetFilePath, fs)
eTag, err := getAssetETag(wet, assetFilePath, fs)
if err != nil {
log.Errorf(ctx, "error getting ETag for %s: %s", assetFilePath, err)
return
@ -137,3 +148,23 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
// else let the rest of the request be processed normally
}
}
// routeAssets attaches *just* the
// assets filesystem to the given router.
func routeAssets(
wet withETagCache,
r *router.Router,
mi ...gin.HandlerFunc,
) {
// Group all static files from assets dir at /assets,
// so that they can use the same cache control middleware.
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
if err != nil {
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
}
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
assetsGroup := r.AttachGroup(assetsPathPrefix)
assetsGroup.Use(assetsCacheControlMiddleware(wet, fs))
assetsGroup.Use(mi...)
assetsGroup.StaticFS("/", fs)
}

View file

@ -29,6 +29,10 @@ import (
"codeberg.org/gruf/go-cache/v3"
)
type withETagCache interface {
ETagCache() cache.Cache[string, eTagCacheEntry]
}
func newETagCache() cache.TTLCache[string, eTagCacheEntry] {
eTagCache := cache.NewTTL[string, eTagCacheEntry](0, 1000, 0)
eTagCache.SetTTL(time.Hour, false)

View file

@ -0,0 +1,70 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// 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"
"time"
"codeberg.org/gruf/go-cache/v3"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/api/health"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/router"
)
type MaintenanceModule struct {
eTagCache cache.Cache[string, eTagCacheEntry]
}
// NewMaintenance returns a module that routes only
// static assets, and returns a code 503 maintenance
// message template to all other requests.
func NewMaintenance() *MaintenanceModule {
return &MaintenanceModule{
eTagCache: newETagCache(),
}
}
// ETagCache implements withETagCache.
func (m *MaintenanceModule) ETagCache() cache.Cache[string, eTagCacheEntry] {
return m.eTagCache
}
func (m *MaintenanceModule) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Route static assets.
routeAssets(m, r, mi...)
// Serve OK in response to live
// requests, but not ready requests.
liveHandler := func(c *gin.Context) {
c.Status(http.StatusOK)
}
r.AttachHandler(http.MethodGet, health.LivePath, liveHandler)
r.AttachHandler(http.MethodHead, health.LivePath, liveHandler)
// For everything else, serve maintenance template.
obj := map[string]string{"host": config.GetHost()}
r.AttachNoRouteHandler(func(c *gin.Context) {
retryAfter := time.Now().Add(120 * time.Second).UTC()
c.Writer.Header().Add("Retry-After", "120")
c.Writer.Header().Add("Retry-After", retryAfter.Format(http.TimeFormat))
c.Header("Cache-Control", "no-store")
c.HTML(http.StatusServiceUnavailable, "maintenance.tmpl", obj)
})
}

View file

@ -21,14 +21,11 @@ import (
"context"
"net/http"
"net/url"
"path/filepath"
"codeberg.org/gruf/go-cache/v3"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/middleware"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/router"
@ -87,22 +84,22 @@ func New(db db.DB, processor *processing.Processor) *Module {
}
}
func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Group all static files from assets dir at /assets,
// so that they can use the same cache control middleware.
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
if err != nil {
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
}
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
assetsGroup := r.AttachGroup(assetsPathPrefix)
assetsGroup.Use(m.assetsCacheControlMiddleware(fs))
assetsGroup.Use(mi...)
assetsGroup.StaticFS("/", fs)
// ETagCache implements withETagCache.
func (m *Module) ETagCache() cache.Cache[string, eTagCacheEntry] {
return m.eTagCache
}
// handlers that serve profiles and statuses should use the SignatureCheck
// middleware, so that requests with content-type application/activity+json
// can still be served
// Route attaches the assets filesystem and profile,
// status, and other web handlers to the router.
func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
// Route static assets.
routeAssets(m, r, mi...)
// Route all other endpoints + handlers.
//
// Handlers that serve profiles and statuses should use
// the SignatureCheck middleware, so that requests with
// content-type application/activity+json can be served
profileGroup := r.AttachGroup(profileGroupPath)
profileGroup.Use(mi...)
profileGroup.Use(middleware.SignatureCheck(m.isURIBlocked), middleware.CacheControl(middleware.CacheControlConfig{
@ -111,7 +108,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
profileGroup.Handle(http.MethodGet, "", m.profileGETHandler) // use empty path here since it's the base of the group
profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler)
// Attach individual web handlers which require no specific middlewares
// Individual web handlers requiring no specific middlewares.
r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
@ -128,7 +125,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, signupPath, m.signupGETHandler)
r.AttachHandler(http.MethodPost, signupPath, m.signupPOSTHandler)
// Attach redirects from old endpoints to current ones for backwards compatibility
// Redirects from old endpoints to for back compat.
r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
r.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
r.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, adminPanelPath) })