diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go
index c08017e60..6f76fb804 100644
--- a/cmd/gotosocial/action/server/server.go
+++ b/cmd/gotosocial/action/server/server.go
@@ -69,6 +69,36 @@
"go.uber.org/automaxprocs/maxprocs"
)
+// Maintenance starts and creates a GoToSocial server
+// in maintenance mode (returns 503 for most requests).
+var Maintenance action.GTSAction = func(ctx context.Context) error {
+ route, err := router.New(ctx)
+ if err != nil {
+ return fmt.Errorf("error creating maintenance router: %w", err)
+ }
+
+ // Route maintenance handlers.
+ maintenance := web.NewMaintenance()
+ maintenance.Route(route)
+
+ // Start the maintenance router.
+ if err := route.Start(); err != nil {
+ return fmt.Errorf("error starting maintenance router: %w", err)
+ }
+
+ // Catch shutdown signals from the OS.
+ sigs := make(chan os.Signal, 1)
+ signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
+ sig := <-sigs // block until signal received
+ log.Infof(ctx, "received signal %s, shutting down", sig)
+
+ if err := route.Stop(); err != nil {
+ log.Errorf(ctx, "error stopping router: %v", err)
+ }
+
+ return nil
+}
+
// Start creates and starts a gotosocial server
var Start action.GTSAction = func(ctx context.Context) error {
// Set GOMAXPROCS / GOMEMLIMIT
@@ -148,6 +178,23 @@
log.Info(ctx, "done! exiting...")
}()
+ // Create maintenance router.
+ var err error
+ route, err = router.New(ctx)
+ if err != nil {
+ return fmt.Errorf("error creating maintenance router: %w", err)
+ }
+
+ // Route maintenance handlers.
+ maintenance := web.NewMaintenance()
+ maintenance.Route(route)
+
+ // Start the maintenance router to handle reqs
+ // while the instance is starting up / migrating.
+ if err := route.Start(); err != nil {
+ return fmt.Errorf("error starting maintenance router: %w", err)
+ }
+
// Initialize tracing (noop if not enabled).
if err := tracing.Initialize(); err != nil {
return fmt.Errorf("error initializing tracing: %w", err)
@@ -359,9 +406,15 @@ func(context.Context, time.Time) {
HTTP router initialization
*/
+ // Close down the maintenance router.
+ if err := route.Stop(); err != nil {
+ return fmt.Errorf("error stopping maintenance router: %w", err)
+ }
+
+ // Instantiate the main router.
route, err = router.New(ctx)
if err != nil {
- return fmt.Errorf("error creating router: %s", err)
+ return fmt.Errorf("error creating main router: %s", err)
}
// Start preparing middleware stack.
diff --git a/cmd/gotosocial/server.go b/cmd/gotosocial/server.go
index 80efc7486..da571ec3f 100644
--- a/cmd/gotosocial/server.go
+++ b/cmd/gotosocial/server.go
@@ -41,5 +41,19 @@ func serverCommands() *cobra.Command {
}
config.AddServerFlags(serverStartCmd)
serverCmd.AddCommand(serverStartCmd)
+
+ serverMaintenanceCmd := &cobra.Command{
+ Use: "maintenance",
+ Short: "start the gotosocial server in maintenance mode (returns 503 for almost all requests)",
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ return preRun(preRunArgs{cmd: cmd})
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return run(cmd.Context(), server.Maintenance)
+ },
+ }
+ config.AddServerFlags(serverMaintenanceCmd)
+ serverCmd.AddCommand(serverMaintenanceCmd)
+
return serverCmd
}
diff --git a/internal/web/assets.go b/internal/web/assets.go
index bc80bc398..ae185354d 100644
--- a/internal/web/assets.go
+++ b/internal/web/assets.go
@@ -21,10 +21,13 @@
"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)
+}
diff --git a/internal/web/etag.go b/internal/web/etag.go
index a87d9fa3b..88779871c 100644
--- a/internal/web/etag.go
+++ b/internal/web/etag.go
@@ -29,6 +29,10 @@
"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)
diff --git a/internal/web/maintenance.go b/internal/web/maintenance.go
new file mode 100644
index 000000000..f05fa83dd
--- /dev/null
+++ b/internal/web/maintenance.go
@@ -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 .
+
+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)
+ })
+}
diff --git a/internal/web/web.go b/internal/web/web.go
index ddf7d53ea..cfadc9283 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -21,14 +21,11 @@
"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) })
diff --git a/web/template/maintenance.tmpl b/web/template/maintenance.tmpl
new file mode 100644
index 000000000..153130c53
--- /dev/null
+++ b/web/template/maintenance.tmpl
@@ -0,0 +1,76 @@
+{{- /*
+// 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 .
+*/ -}}
+
+
+
+