From e05b34a866f88dc12dc919e39e6f2873a24994ef Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sat, 2 May 2026 00:05:30 -0600 Subject: [PATCH 1/5] changes required to convert to Echo v5 --- community.go | 2 +- communityadmin.go | 2 +- conference.go | 6 +- conference_ops.go | 2 +- errors.go | 15 +++-- go.mod | 15 +++-- go.sum | 27 ++++----- logging.go | 149 +++++++++++++++++++--------------------------- main.go | 32 +++++----- top.go | 6 +- ui/amcontext.go | 12 ++-- ui/amsession.go | 4 +- ui/images.go | 6 +- ui/middleware.go | 18 +++--- ui/render_wrap.go | 18 +++--- ui/static.go | 2 +- ui/templates.go | 4 +- userdata.go | 4 +- 18 files changed, 148 insertions(+), 176 deletions(-) diff --git a/community.go b/community.go index 9de2d5f..9357b13 100644 --- a/community.go +++ b/community.go @@ -22,7 +22,7 @@ import ( "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" "github.com/biter777/countries" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" ) // ENOJOIN is an error for not being permitted to join a community. diff --git a/communityadmin.go b/communityadmin.go index ffce661..a3fc317 100644 --- a/communityadmin.go +++ b/communityadmin.go @@ -28,7 +28,7 @@ import ( "git.erbosoft.com/amy/amsterdam/exports" "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) diff --git a/conference.go b/conference.go index fdf8aea..b6a3c3e 100644 --- a/conference.go +++ b/conference.go @@ -26,7 +26,7 @@ import ( "git.erbosoft.com/amy/amsterdam/htmlcheck" "git.erbosoft.com/amy/amsterdam/ui" "github.com/CloudyKit/jet/v6" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) @@ -513,11 +513,11 @@ func ReadPosts(ctxt ui.AmContext) (string, any) { resetLastRead := false if ctxt.HasParameter("r") { if err := breakRange(topic, postRange, ctxt.Parameter("r"), ","); err != nil { - return "error", echo.NewHTTPError(http.StatusNotFound).SetInternal(err) + return "error", echo.NewHTTPError(http.StatusNotFound, err.Error()).Wrap(err) } } else if ctxt.HasParameter("rgo") { if err := breakRange(topic, postRange, ctxt.Parameter("rgo"), "-"); err != nil { - return "error", echo.NewHTTPError(http.StatusNotFound).SetInternal(err) + return "error", echo.NewHTTPError(http.StatusNotFound, err.Error()).Wrap(err) } } else { postRange[0] = lastRead + 1 diff --git a/conference_ops.go b/conference_ops.go index d864121..a7d1a2d 100644 --- a/conference_ops.go +++ b/conference_ops.go @@ -26,7 +26,7 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/ui" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) diff --git a/errors.go b/errors.go index f03bb96..c13396b 100644 --- a/errors.go +++ b/errors.go @@ -19,10 +19,9 @@ import ( "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/ui" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" log "github.com/sirupsen/logrus" - "golang.org/x/time/rate" ) // EBUTTON is the standard error for an unknown button. @@ -74,9 +73,9 @@ func AmNotFoundHandler(ctxt ui.AmContext) (string, any) { * err - The error to be handled. * c - The Echo context error is being handled on. */ -func AmErrorHandler(err error, c echo.Context) { +func AmErrorHandler(c *echo.Context, err error) { log.Infof("-> AmErrorHandler on path %s", c.Request().URL.Path) - if c.Response().Committed { + if c.Response().(*echo.Response).Committed { return } cerr := ui.AmWithTempContext(c, func(ctxt ui.AmContext) (string, any) { @@ -88,14 +87,14 @@ func AmErrorHandler(err error, c echo.Context) { } // rateLimitErrorHandler is called if there's an error getting the identifier for a connection (unlikely). -func rateLimitErrorHandler(c echo.Context, err error) error { +func rateLimitErrorHandler(c *echo.Context, err error) error { return ui.AmWithTempContext(c, func(ctxt ui.AmContext) (string, any) { return "error", err }) } // rateLimitDenyHandler is called if the rate limit is exceeded by a connection. -func rateLimitDenyHandler(c echo.Context, identifier string, err error) error { +func rateLimitDenyHandler(c *echo.Context, identifier string, err error) error { return ui.AmWithTempContext(c, func(ctxt ui.AmContext) (string, any) { ctxt.VarMap().Set("identifier", identifier) return "ratelimit", err @@ -105,7 +104,7 @@ func rateLimitDenyHandler(c echo.Context, identifier string, err error) error { // AmSetupRateLimiter sets up the rate-limiting middleware. func AmSetupRateLimiter() echo.MiddlewareFunc { rcfg := middleware.RateLimiterMemoryStoreConfig{ - Rate: rate.Limit(config.GlobalConfig.Site.RateLimit.Rate), + Rate: config.GlobalConfig.Site.RateLimit.Rate, Burst: config.GlobalConfig.Site.RateLimit.Burst, ExpiresIn: time.Duration(config.GlobalConfig.Site.RateLimit.ExpireMinutes) * time.Minute, } diff --git a/go.mod b/go.mod index e334fbe..7bd2e06 100644 --- a/go.mod +++ b/go.mod @@ -14,13 +14,12 @@ require ( github.com/hashicorp/golang-lru v1.0.2 github.com/jmoiron/sqlx v1.4.0 github.com/klauspost/lctime v0.1.0 - github.com/labstack/echo/v4 v4.15.1 - github.com/labstack/gommon v0.4.2 + github.com/labstack/echo/v5 v5.1.1 + github.com/labstack/gommon v0.5.0 github.com/sirupsen/logrus v1.9.4 github.com/tkuchiki/go-timezone v0.2.3 - golang.org/x/net v0.52.0 - golang.org/x/text v0.35.0 - golang.org/x/time v0.15.0 + golang.org/x/net v0.53.0 + golang.org/x/text v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,10 +28,10 @@ require ( github.com/CloudyKit/fastprinter v0.0.0-20251202014920-1725d2651bd4 // indirect github.com/alexflint/go-scalar v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.49.0 // indirect golang.org/x/image v0.37.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index 776d8d9..1559793 100644 --- a/go.sum +++ b/go.sum @@ -31,16 +31,16 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/klauspost/lctime v0.1.0 h1:nINsuFc860M9cyYhT6vfg6U1USh7kiVBj/s/2b04U70= github.com/klauspost/lctime v0.1.0/go.mod h1:OwdMhr8tbQvusAsnilqkkgDQqivWlqyg0w5cfXkLiDk= -github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs= -github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= +github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -56,19 +56,16 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/logging.go b/logging.go index 7cdaa78..c1f165a 100644 --- a/logging.go +++ b/logging.go @@ -13,104 +13,90 @@ package main import ( - "bufio" - "bytes" "compress/gzip" "context" "errors" "fmt" "io" + "log/slog" + "maps" "os" "path/filepath" - "strings" "sync" "time" "git.erbosoft.com/amy/amsterdam/config" "github.com/dustin/go-humanize" - "github.com/labstack/echo/v4" - glog "github.com/labstack/gommon/log" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) /*---------------------------------------------------------------------------- - * Gommon-log to logrus adapter + * slog handler that outputs to Logrus *---------------------------------------------------------------------------- */ -/* toglog converts a Logrus logging level to a glog one. - * Parameters: - * l - The Logrus log level to be converted. - * Returns: - * The equivalent glog log level. - */ -func toglog(l log.Level) glog.Lvl { - switch l { - case log.DebugLevel: - return glog.DEBUG - case log.InfoLevel: - return glog.INFO - case log.WarnLevel: - return glog.WARN - case log.ErrorLevel: - return glog.ERROR - default: - return glog.OFF - } +// slog2logrus converts slog levels to Logrus levels. +var slog2logrus = map[slog.Level]log.Level{ + slog.LevelDebug: log.DebugLevel, + slog.LevelInfo: log.InfoLevel, + slog.LevelWarn: log.WarnLevel, + slog.LevelError: log.ErrorLevel, } -/* fromglog converts a glog logging level to a Logrus one. - * Parameters: - * l - The glog log level to be converted. - * Returns: - * The equivalent Logrus log level. - */ -func fromglog(l glog.Lvl) log.Level { - switch l { - case glog.DEBUG: - return log.DebugLevel - case glog.INFO: - return log.InfoLevel - case glog.WARN: - return log.WarnLevel - case glog.ERROR: - return log.ErrorLevel - default: - return log.PanicLevel - } +// SlogLogrusHandler implements slog.Handler and routes to Logrus. +type SlogLogrusHandler struct { + fields log.Fields // fields defined in this handler + groupPrefix string // group prefix } -// EchoLogrusAdapter implements echo.Logger using logrus. -type EchoLogrusAdapter struct{} +// NewSlogLogrusHandler creates a SlogLogrusHandler with base information. +func NewSlogLogrusHandler() *SlogLogrusHandler { + rc := new(SlogLogrusHandler{ + fields: make(log.Fields), + groupPrefix: "", + }) + return rc +} -func (l *EchoLogrusAdapter) Output() io.Writer { return log.StandardLogger().Out } -func (l *EchoLogrusAdapter) SetOutput(w io.Writer) { log.SetOutput(w) } -func (l *EchoLogrusAdapter) Prefix() string { return "" } -func (l *EchoLogrusAdapter) SetPrefix(p string) {} -func (l *EchoLogrusAdapter) Level() glog.Lvl { return toglog(log.GetLevel()) } -func (l *EchoLogrusAdapter) SetLevel(lvl glog.Lvl) { log.SetLevel(fromglog(lvl)) } -func (l *EchoLogrusAdapter) Print(i ...any) { log.Print(i...) } -func (l *EchoLogrusAdapter) Printf(format string, args ...any) { log.Printf(format, args...) } -func (l *EchoLogrusAdapter) Printj(j glog.JSON) { log.WithFields(log.Fields(j)).Print() } -func (l *EchoLogrusAdapter) Debug(i ...any) { log.Debug(i...) } -func (l *EchoLogrusAdapter) Debugf(format string, args ...any) { log.Debugf(format, args...) } -func (l *EchoLogrusAdapter) Debugj(j glog.JSON) { log.WithFields(log.Fields(j)).Debug() } -func (l *EchoLogrusAdapter) Info(i ...any) { log.Info(i...) } -func (l *EchoLogrusAdapter) Infof(format string, args ...any) { log.Infof(format, args...) } -func (l *EchoLogrusAdapter) Infoj(j glog.JSON) { log.WithFields(log.Fields(j)).Info() } -func (l *EchoLogrusAdapter) Warn(i ...any) { log.Warn(i...) } -func (l *EchoLogrusAdapter) Warnf(format string, args ...any) { log.Warnf(format, args...) } -func (l *EchoLogrusAdapter) Warnj(j glog.JSON) { log.WithFields(log.Fields(j)).Warn() } -func (l *EchoLogrusAdapter) Error(i ...any) { log.Error(i...) } -func (l *EchoLogrusAdapter) Errorf(format string, args ...any) { log.Errorf(format, args...) } -func (l *EchoLogrusAdapter) Errorj(j glog.JSON) { log.WithFields(log.Fields(j)).Error() } -func (l *EchoLogrusAdapter) Fatal(i ...any) { log.Fatal(i...) } -func (l *EchoLogrusAdapter) Fatalf(format string, args ...any) { log.Fatalf(format, args...) } -func (l *EchoLogrusAdapter) Fatalj(j glog.JSON) { log.WithFields(log.Fields(j)).Fatal() } -func (l *EchoLogrusAdapter) Panic(i ...any) { log.Panic(i...) } -func (l *EchoLogrusAdapter) Panicf(format string, args ...any) { log.Panicf(format, args...) } -func (l *EchoLogrusAdapter) Panicj(j glog.JSON) { log.WithFields(log.Fields(j)).Panic() } -func (l *EchoLogrusAdapter) SetHeader(h string) {} +// Enabled returns true if the specified log level is handled. +func (h *SlogLogrusHandler) Enabled(ctx context.Context, lvl slog.Level) bool { + return log.IsLevelEnabled(slog2logrus[lvl]) +} + +// Handle sends a slog.Record to the log output. +func (h *SlogLogrusHandler) Handle(ctx context.Context, r slog.Record) error { + flds := make(log.Fields) + for k, v := range h.fields { + flds[h.groupPrefix+k] = v + } + r.Attrs(func(a slog.Attr) bool { + flds[h.groupPrefix+a.Key] = a.Value.Any() + return true + }) + ntry := log.NewEntry(log.StandardLogger()).WithTime(r.Time).WithFields(flds) + ntry.Log(slog2logrus[r.Level], r.Message) + return nil +} + +// WithAttrs creates a new Handler from this one, with extra attributes. +func (h *SlogLogrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newh := new(SlogLogrusHandler{fields: make(log.Fields)}) + maps.Copy(newh.fields, h.fields) + for _, a := range attrs { + newh.fields[a.Key] = a.Value.Any() + } + newh.groupPrefix = h.groupPrefix + return newh +} + +// WithGroup creates a new Handler from this one, with an extra group prefix. +func (h *SlogLogrusHandler) WithGroup(name string) slog.Handler { + newh := new(SlogLogrusHandler{fields: make(log.Fields)}) + maps.Copy(newh.fields, h.fields) + newh.groupPrefix = h.groupPrefix + name + "." + return newh +} /*---------------------------------------------------------------------------- * Echo middleware adapters @@ -119,13 +105,13 @@ func (l *EchoLogrusAdapter) SetHeader(h string) {} // LogrusMiddleware installs Logrus logging into the Echo middleware chain. func LogrusMiddleware(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { start := time.Now() err := next(c) stop := time.Now() req := c.Request() - res := c.Response() + res := c.Response().(*echo.Response) log.WithFields(log.Fields{ "remote_ip": c.RealIP(), @@ -139,17 +125,6 @@ func LogrusMiddleware(next echo.HandlerFunc) echo.HandlerFunc { } } -// LogrusPanicLogging is a log function hooked into the recovery middleware. -func LogrusPanicLogging(c echo.Context, err error, stack []byte) error { - log.Errorf("[PANIC RECOVERY] %v", err) - scanner := bufio.NewScanner(bytes.NewReader(stack)) - for scanner.Scan() { - line := strings.ReplaceAll(scanner.Text(), "\t", " ") - log.Error(line) - } - return scanner.Err() -} - /*---------------------------------------------------------------------------- * Log output file implementation *---------------------------------------------------------------------------- diff --git a/main.go b/main.go index 4e4a296..d91499d 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,9 @@ package main import ( "context" + "errors" "fmt" + "log/slog" "net/http" "os" "os/signal" @@ -30,8 +32,8 @@ import ( "git.erbosoft.com/amy/amsterdam/htmlcheck" "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" log "github.com/sirupsen/logrus" ) @@ -41,14 +43,11 @@ var GetAndPost = []string{http.MethodGet, http.MethodPost} // setupEcho creates, configures, and returns a new Echo instance. func setupEcho() *echo.Echo { e := echo.New() - e.HideBanner = true - e.Logger = &EchoLogrusAdapter{} + e.Logger = slog.New(NewSlogLogrusHandler()) e.Renderer = &ui.TemplateRenderer{} e.HTTPErrorHandler = AmErrorHandler if !config.CommandLine.DebugPanic { - e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ - LogErrorFunc: LogrusPanicLogging, - })) + e.Use(middleware.RecoverWithConfig(middleware.DefaultRecoverConfig)) } else { log.Warn("WARNING: --debug-panic in effect - DO NOT use this in production!") } @@ -264,21 +263,24 @@ func main() { database.AmStoreAudit(database.AmNewAudit(database.AuditShutdown, 0, myIP.String())) }() + sconf := echo.StartConfig{ + Address: config.GlobalComputedConfig.Listen, + HideBanner: true, + HidePort: true, + GracefulTimeout: 10 * time.Second, + } + stime := time.Since(SystemStartTime) log.Infof("Amsterdam %s startup sequence completed in %v", config.AMSTERDAM_VERSION, stime) // Start server go func() { - if err := e.Start(config.GlobalComputedConfig.Listen); err != nil && err != http.ErrServerClosed { - e.Logger.Fatalf("shutting down the server: %v", err) + if err := sconf.Start(ctx, e); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("shutting down the server: %v", err) } }() - // Wait for the interrupt signal and then gracefully shut the server down. + // Wait for the context to be done, when the server is shut down. <-ctx.Done() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := e.Shutdown(ctx); err != nil { - e.Logger.Fatal(err) - } + log.Infof("Amsterdam shut down") } diff --git a/top.go b/top.go index 890e28b..52cf72a 100644 --- a/top.go +++ b/top.go @@ -24,7 +24,7 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" "github.com/CloudyKit/jet/v6" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) @@ -285,14 +285,14 @@ func PolicyPage(ctxt ui.AmContext) (string, any) { func JumpToShortcut(ctxt ui.AmContext) (string, any) { link, err := database.AmDecodePostLink(ctxt.URLParam("postlink")) if err != nil { - return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink"))).SetInternal(err) + return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink"))).Wrap(err) } scope, target := link.Classify() if scope != "global" { return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink"))) } if err = link.VerifyNames(ctxt.Ctx()); err != nil { - return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink"))).SetInternal(err) + return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink"))).Wrap(err) } targetURL := "" switch target { diff --git a/ui/amcontext.go b/ui/amcontext.go index ddc3dc0..2dff118 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -27,7 +27,7 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/util" "github.com/CloudyKit/jet/v6" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) @@ -98,7 +98,7 @@ type AmContext interface { // amContext is the internal structure that implements AmContext. type amContext struct { - echoContext echo.Context + echoContext *echo.Context rendervars jet.VarMap frameTitle string frameMeta map[int]map[string]string @@ -243,7 +243,7 @@ func (c *amContext) FormFieldIsSet(name string) bool { // FormFieldValues returns all values for a specified parameter name. func (c *amContext) FormFieldValues(name string) ([]string, error) { - vals, err := c.echoContext.FormParams() + vals, err := c.echoContext.FormValues() if err != nil { return make([]string, 0), err } @@ -525,7 +525,7 @@ var amContextRecycleBin chan *amContext * Internal Amsterdam context structure pointer, or nil. * Standard Go error status. */ -func newContext(ctxt echo.Context) (*amContext, error) { +func newContext(ctxt *echo.Context) (*amContext, error) { var rc *amContext tmp := freeContext.Get() if tmp == nil { @@ -593,7 +593,7 @@ func newContext(ctxt echo.Context) (*amContext, error) { * Returns: * The associated AmContext. */ -func AmContextFromEchoContext(ctxt echo.Context) AmContext { +func AmContextFromEchoContext(ctxt *echo.Context) AmContext { myctxt := ctxt.Get("__amsterdam_context") if myctxt != nil { rc, ok := myctxt.(*amContext) @@ -641,7 +641,7 @@ func setupContext() func() { // ContextCreator is middleware that creates and recycles the AmContext. func ContextCreator(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { myctxt, err := newContext(c) if err == nil { err = next(c) diff --git a/ui/amsession.go b/ui/amsession.go index 4a6f9f9..86c8a8b 100644 --- a/ui/amsession.go +++ b/ui/amsession.go @@ -23,7 +23,7 @@ import ( "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) @@ -410,7 +410,7 @@ func setupSessionManager() func() { // SessionStoreInjector is middleware that injects the session store into the context variables. func SessionStoreInjector(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { c.Set("AmSessionStore", sessionStore) return next(c) } diff --git a/ui/images.go b/ui/images.go index 0034a26..83dc050 100644 --- a/ui/images.go +++ b/ui/images.go @@ -29,7 +29,7 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "github.com/disintegration/imaging" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" ) //go:embed static_images/* @@ -64,7 +64,7 @@ func mimeTypeFromFilename(filename string) string { * Returns: * Standard Go error return. */ -func AmServeImage(c echo.Context) error { +func AmServeImage(c *echo.Context) error { components := strings.SplitAfter(c.Request().URL.Path, "/") var err error = nil if len(components) == 4 { @@ -105,7 +105,7 @@ func AmServeImage(c echo.Context) error { * Returns: * Standard Go error return. */ -func AmServeVeniceCompatibleImage(c echo.Context) error { +func AmServeVeniceCompatibleImage(c *echo.Context) error { id, err := strconv.Atoi(c.Param("id")) if err == nil { var img *database.ImageStore diff --git a/ui/middleware.go b/ui/middleware.go index 6dea212..bb2e459 100644 --- a/ui/middleware.go +++ b/ui/middleware.go @@ -21,17 +21,17 @@ import ( "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) // IPBanTest is middleware that handles the IP banning. func IPBanTest(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { // Check IP banning. banmsg, banerr := database.AmTestIPBan(c.Request().Context(), c.RealIP()) if banerr != nil { - c.Logger().Warnf("address %s could not be tested: %v", c.RealIP(), banerr) + log.Warnf("address %s could not be tested: %v", c.RealIP(), banerr) // but let the request pass anyway } else if banmsg != "" { amctxt := AmContextFromEchoContext(c) @@ -43,7 +43,7 @@ func IPBanTest(next echo.HandlerFunc) echo.HandlerFunc { // CookieLoginTest is middleware that handles cookie logins. func CookieLoginTest(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { amctxt := AmContextFromEchoContext(c) // Check for cookie login. if amctxt.CurrentUser().IsAnon { @@ -77,11 +77,11 @@ func CookieLoginTest(next echo.HandlerFunc) echo.HandlerFunc { // SetCommunity is middleware that sets the community context based on the URL. func SetCommunity(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { ctxt := AmContextFromEchoContext(c) err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) if err != nil { - return AmSendPageData(c, ctxt, "error", echo.NewHTTPError(http.StatusNotFound).SetInternal(err)) + return AmSendPageData(c, ctxt, "error", echo.NewHTTPError(http.StatusNotFound, err.Error()).Wrap(err)) } var b strings.Builder b.WriteString("/comm/") @@ -94,7 +94,7 @@ func SetCommunity(next echo.HandlerFunc) echo.HandlerFunc { // ValidateConference is middleware that validates the user has access to the community's conference facility. func ValidateConference(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { ctxt := AmContextFromEchoContext(c) comm := ctxt.CurrentCommunity() // set by middleware b, err := database.AmTestService(c.Request().Context(), comm, "Conference") @@ -116,7 +116,7 @@ func ValidateConference(next echo.HandlerFunc) echo.HandlerFunc { // SetConference is middleware that sets the conference context based on the URL. func SetConference(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { ctxt := AmContextFromEchoContext(c) conf, err := database.AmGetConferenceByAlias(ctxt.Ctx(), ctxt.CurrentCommunity().Id, ctxt.URLParam("confid")) if err != nil { @@ -144,7 +144,7 @@ func SetConference(next echo.HandlerFunc) echo.HandlerFunc { // SetTopic is middleware that sets the topic context based on the URL. func SetTopic(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { ctxt := AmContextFromEchoContext(c) conf := ctxt.GetScratch("currentConference").(*database.Conference) diff --git a/ui/render_wrap.go b/ui/render_wrap.go index 8cfa282..e517028 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -21,7 +21,7 @@ import ( "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" "github.com/klauspost/lctime" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) @@ -44,7 +44,7 @@ import ( * Returns: * Standard Go error status. */ -func AmSendPageData(ctxt echo.Context, amctxt AmContext, command string, data any) error { +func AmSendPageData(ctxt *echo.Context, amctxt AmContext, command string, data any) error { // Preprocess certain commands into different ones. httprc := http.StatusOK switch command { @@ -56,7 +56,7 @@ func AmSendPageData(ctxt echo.Context, amctxt AmContext, command string, data an httprc = he.Code m1 := he.Message e1 := he.Unwrap() - if m1 == nil || m1 == "" { + if m1 == "" { if e1 == nil { message = fmt.Sprintf("Unspecified error in %s", ctxt.Request().URL.String()) } else { @@ -114,7 +114,7 @@ func AmSendPageData(ctxt echo.Context, amctxt AmContext, command string, data an err = ctxt.Render(httprc, data.(string), amctxt) case "framed": if amctxt.FrameTitle() == "" { - ctxt.Logger().Errorf("*** NO FRAME TITLE set for path %s", amctxt.URLPath()) + log.Errorf("*** NO FRAME TITLE set for path %s", amctxt.URLPath()) amctxt.SetFrameTitle("<<< NO FRAME TITLE >>>") } amctxt.VarMap().Set("__innerPage", data) @@ -177,7 +177,7 @@ type AmPageFunc func(AmContext) (string, any) * The wrapped function. */ func AmWrap(myfunc AmPageFunc) echo.HandlerFunc { - return func(c echo.Context) error { + return func(c *echo.Context) error { ctxt := AmContextFromEchoContext(c) // Add the dynamic headers. @@ -191,11 +191,11 @@ func AmWrap(myfunc AmPageFunc) echo.HandlerFunc { ctxt.SetSession("lastKnownGood", ctxt.Locator()) } if err := ctxt.SaveSession(); err != nil { - c.Logger().Errorf("Session save error: %v", err) + log.Errorf("Session save error: %v", err) return err } if err := AmSendPageData(c, ctxt, command, arg); err != nil { - c.Logger().Errorf("Rendering error: %v", err) + log.Errorf("Rendering error: %v", err) return err } return nil @@ -203,7 +203,7 @@ func AmWrap(myfunc AmPageFunc) echo.HandlerFunc { } // AmWithTempContext runs a page function with a temporary context. Used in error handling. -func AmWithTempContext(c echo.Context, fn AmPageFunc) error { +func AmWithTempContext(c *echo.Context, fn AmPageFunc) error { var ctxt AmContext = nil myctxt := c.Get("__amsterdam_context") if myctxt != nil { @@ -233,7 +233,7 @@ func AmWithTempContext(c echo.Context, fn AmPageFunc) error { c.Response().Header().Set("Expires", expireTime) if err := AmSendPageData(c, ctxt, command, arg); err != nil { - c.Logger().Errorf("Rendering error: %v", err) + log.Errorf("Rendering error: %v", err) return err } return nil diff --git a/ui/static.go b/ui/static.go index e3424ce..9f33a46 100644 --- a/ui/static.go +++ b/ui/static.go @@ -23,7 +23,7 @@ import ( "strings" "git.erbosoft.com/amy/amsterdam/config" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" "golang.org/x/net/html" ) diff --git a/ui/templates.go b/ui/templates.go index 4111f1f..11ec874 100644 --- a/ui/templates.go +++ b/ui/templates.go @@ -31,7 +31,7 @@ import ( "github.com/CloudyKit/jet/v6" "github.com/CloudyKit/jet/v6/loaders/embedfs" "github.com/CloudyKit/jet/v6/loaders/multi" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) @@ -363,7 +363,7 @@ type TemplateRenderer struct{} * Returns: * Standard Go error status. */ -func (r *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error { +func (r *TemplateRenderer) Render(c *echo.Context, w io.Writer, name string, data any) error { defer util.MeasureTime(fmt.Sprintf("ui.Render(%s)", name))() view, err := views.GetTemplate(name) diff --git a/userdata.go b/userdata.go index 6707498..d801899 100644 --- a/userdata.go +++ b/userdata.go @@ -26,7 +26,7 @@ import ( "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" "github.com/biter777/countries" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" log "github.com/sirupsen/logrus" ) @@ -382,7 +382,7 @@ func ShowProfile(ctxt ui.AmContext) (string, any) { // Gather the info on the current user. user, err := database.AmGetUserByName(ctxt.Ctx(), ctxt.URLParam("uname"), nil) if err != nil { - return "error", echo.NewHTTPError(http.StatusNotFound).SetInternal(err) + return "error", echo.NewHTTPError(http.StatusNotFound, err.Error()).Wrap(err) } ci, err := user.ContactInfo(ctxt.Ctx()) if err != nil { From d309b9095373484960a22e1d41c0d09898b95b28 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sat, 2 May 2026 23:30:15 -0600 Subject: [PATCH 2/5] inserted the timeout hooks --- main.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/main.go b/main.go index d91499d..031b0aa 100644 --- a/main.go +++ b/main.go @@ -263,11 +263,22 @@ func main() { database.AmStoreAudit(database.AmNewAudit(database.AuditShutdown, 0, myIP.String())) }() + // Set up the start configuration. sconf := echo.StartConfig{ Address: config.GlobalComputedConfig.Listen, HideBanner: true, HidePort: true, GracefulTimeout: 10 * time.Second, + OnShutdownError: func(err error) { + log.Fatalf("error in shutting down the server: %v", err) + }, + BeforeServeFunc: func(s *http.Server) error { + s.ReadTimeout = 30 * time.Second + s.WriteTimeout = 30 * time.Second + s.IdleTimeout = 120 * time.Second + s.ReadHeaderTimeout = 2 * time.Second + return nil + }, } stime := time.Since(SystemStartTime) From 53ee2281bcc7e465aa59864075f6cf60f0495dd8 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sun, 3 May 2026 11:19:20 -0600 Subject: [PATCH 3/5] timeout values now in config --- config/config.go | 11 ++++++++--- config/default.yaml | 4 ++++ main.go | 13 ++++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/config/config.go b/config/config.go index 7bff179..ef34243 100644 --- a/config/config.go +++ b/config/config.go @@ -167,7 +167,12 @@ type AmConfig struct { } `yaml:"posting"` Tuning struct { WorkerTasks int `yaml:"workerTasks"` - Queues struct { + Timeouts struct { + HttpRead int `yaml:"httpRead"` + HttpWrite int `yaml:"httpWrite"` + HttpIdle int `yaml:"httpIdle"` + } `yaml:"timeouts"` + Queues struct { AuditWrites int `yaml:"auditWrites"` ContextRecycle int `yaml:"contextRecycle"` EmailRecycle int `yaml:"emailRecycle"` @@ -200,7 +205,7 @@ func (c *AmConfig) ExPath(path string) string { return filepath.Join(c.baseDir, path) } -// AmConfigComputed is the configuration values which are "computed" based only on values in AmConfig. +// AmConfigComputed is the configuration values which are "computed" based only on values in AmConfig and CommandLine. type AmConfigComputed struct { DebugMode bool // are we in debug mode? LogLevel string // the logging level @@ -321,7 +326,7 @@ func overlayStructValue(dest, loaded, defaults reflect.Value) { } } else { // if we see this message, this function needs more work - log.Errorf("*** unable to deal with field %s of type %s", structField.Name, typ.Name()) + log.Fatalf("*** unable to deal with field %s of type %s", structField.Name, typ.Name()) } } } diff --git a/config/default.yaml b/config/default.yaml index a12e5e6..416b535 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -91,6 +91,10 @@ posting: - "image/png" tuning: workerTasks: 4 + timeouts: + httpRead: 30 + httpWrite: 30 + httpIdle: 120 queues: auditWrites: 16 contextRecycle: 16 diff --git a/main.go b/main.go index 031b0aa..464735c 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,9 @@ import ( log "github.com/sirupsen/logrus" ) +// READ_HEADER_TIMEOUT is the timeout value for reading headers in seconds. (Deliberately NOT configurable because this is a security issue) +const READ_HEADER_TIMEOUT = 2 + // GetAndPost is used to have functions that respond to both GET and POST on a URI. var GetAndPost = []string{http.MethodGet, http.MethodPost} @@ -214,7 +217,7 @@ func setupEcho() *echo.Echo { // ampool is the worker pool for one-shot background tasks. var ampool *util.WorkerPool -// SystemStartTime records the time since the system was started. +// SystemStartTime records the time the system was started. var SystemStartTime time.Time // main is Ye Olde Main Function. @@ -273,10 +276,10 @@ func main() { log.Fatalf("error in shutting down the server: %v", err) }, BeforeServeFunc: func(s *http.Server) error { - s.ReadTimeout = 30 * time.Second - s.WriteTimeout = 30 * time.Second - s.IdleTimeout = 120 * time.Second - s.ReadHeaderTimeout = 2 * time.Second + s.ReadTimeout = time.Duration(config.GlobalConfig.Tuning.Timeouts.HttpRead) * time.Second + s.WriteTimeout = time.Duration(config.GlobalConfig.Tuning.Timeouts.HttpWrite) * time.Second + s.IdleTimeout = time.Duration(config.GlobalConfig.Tuning.Timeouts.HttpIdle) * time.Second + s.ReadHeaderTimeout = READ_HEADER_TIMEOUT * time.Second return nil }, } From e962c4d0c5267ebb4056673e9100f7d9c0447588 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sun, 3 May 2026 14:01:10 -0600 Subject: [PATCH 4/5] worked in the timeout and panic protection for AmWrap and friends --- ui/render_wrap.go | 110 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 23 deletions(-) diff --git a/ui/render_wrap.go b/ui/render_wrap.go index e517028..3790324 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -13,9 +13,11 @@ package ui import ( + "context" "fmt" "io" "net/http" + "runtime" "time" "git.erbosoft.com/amy/amsterdam/config" @@ -25,6 +27,23 @@ import ( log "github.com/sirupsen/logrus" ) +// panicRecoveryErr is the error created for panic recovery. +type panicRecoveryErr struct { + Phase string // phase of operation + Err error // error value + Stack []byte // stack trace +} + +// Error returns the actual error string. +func (e *panicRecoveryErr) Error() string { + return fmt.Sprintf("[Panic Recovery in %s Phase] %s %s", e.Phase, e.Err.Error(), e.Stack) +} + +// Unwrap returns the error "nested" inside this error. +func (e *panicRecoveryErr) Unwrap() error { + return e.Err +} + /* AmSendPageData sends page data to the output based on the command string. * Parameters: * ctxt - The Echo context from the request. @@ -45,39 +64,53 @@ import ( * Standard Go error status. */ func AmSendPageData(ctxt *echo.Context, amctxt AmContext, command string, data any) error { + // Enable panic recovery. + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + panic(r) + } + tmperr, ok := r.(error) + if !ok { + tmperr = fmt.Errorf("%v", r) + } + stack := make([]byte, 4<<10) + length := runtime.Stack(stack, false) + log.Errorf("[Panic Recovery in SendData Phase] %s %s", tmperr.Error(), stack[:length]) + } + }() + // Preprocess certain commands into different ones. httprc := http.StatusOK switch command { case "error": - message := "" - if data == nil { - message = fmt.Sprintf("Unspecified error in %s", ctxt.Request().URL.String()) - } else if he, ok := data.(*echo.HTTPError); ok { - httprc = he.Code - m1 := he.Message - e1 := he.Unwrap() - if m1 == "" { - if e1 == nil { - message = fmt.Sprintf("Unspecified error in %s", ctxt.Request().URL.String()) + message := fmt.Sprintf("Unspecified error in %s", ctxt.Request().URL.String()) + if data != nil { + if he, ok := data.(*echo.HTTPError); ok { + httprc = he.Code + m1 := he.Message + e1 := he.Unwrap() + if m1 == "" { + if e1 != nil { + message = e1.Error() + } } else { - message = e1.Error() + if e1 == nil { + message = fmt.Sprintf("%v", m1) + } else { + message = fmt.Sprintf("%v (%v)", m1, e1) + } } + } else if er, ok := data.(error); ok { + message = er.Error() } else { - if e1 == nil { - message = fmt.Sprintf("%v", m1) - } else { - message = fmt.Sprintf("%v (%v)", m1, e1) - } + message = fmt.Sprintf("%v", data) } - } else if er, ok := data.(error); ok { - message = er.Error() - } else { - message = fmt.Sprintf("%v", data) } if httprc < 400 { httprc = http.StatusInternalServerError } - amctxt.SetFrameTitle("Internal Server Error") + amctxt.SetFrameTitle(http.StatusText(httprc)) amctxt.VarMap().Set("error", message) if tmp := amctxt.GetSession("lastKnownGood"); tmp != nil { amctxt.VarMap().Set("recovery", tmp) @@ -98,6 +131,11 @@ func AmSendPageData(ctxt *echo.Context, amctxt AmContext, command string, data a } // Process commands. + oldreq := ctxt.Request() + ctx, cancel := context.WithTimeout(oldreq.Context(), 15*time.Second) + defer cancel() + ctxt.SetRequest(oldreq.WithContext(ctx)) + defer ctxt.SetRequest(oldreq) var err error switch command { case "bytes": @@ -169,6 +207,32 @@ var expireTime string = lctime.Strftime("%c", time.Unix(1, 0)) // AmPageFunc is the definition for an Amsterdam "page function" that handles most of the work and defers to the wrapper for rendering. type AmPageFunc func(AmContext) (string, any) +// callWrappedPageFunc calls the specified page functon inside a wrapper that handles timeouts and panic recovery. +func callWrappedPageFunc(f AmPageFunc, ctxt *echo.Context, amctxt AmContext) (command string, arg any) { + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + panic(r) + } + tmperr, ok := r.(error) + if !ok { + tmperr = fmt.Errorf("%v", r) + } + stack := make([]byte, 4<<10) + length := runtime.Stack(stack, false) + arg = &panicRecoveryErr{Phase: "PageFunc", Err: tmperr, Stack: stack[:length]} + command = "error" + } + }() + oldreq := ctxt.Request() + ctx, cancel := context.WithTimeout(oldreq.Context(), 15*time.Second) + defer cancel() + ctxt.SetRequest(oldreq.WithContext(ctx)) + defer ctxt.SetRequest(oldreq) + command, arg = f(amctxt) + return +} + /* AmWrap wraps the Amsterdam handler function in a wrapper that implements the spec for * Echo handler functions. * Parameters: @@ -186,7 +250,7 @@ func AmWrap(myfunc AmPageFunc) echo.HandlerFunc { c.Response().Header().Set("Expires", expireTime) // Exec the wrapped function. - command, arg := myfunc(ctxt) + command, arg := callWrappedPageFunc(myfunc, c, ctxt) if command != "error" && command != "ipban" { ctxt.SetSession("lastKnownGood", ctxt.Locator()) } @@ -225,7 +289,7 @@ func AmWithTempContext(c *echo.Context, fn AmPageFunc) error { } // Call the function - command, arg := fn(ctxt) + command, arg := callWrappedPageFunc(fn, c, ctxt) // Add the dynamic headers. c.Response().Header().Set("Pragma", "No-cache") From d3e89b886e74110a4991cc84bcf3722deba0e92d Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sun, 3 May 2026 14:44:14 -0600 Subject: [PATCH 5/5] incorporated more configuration values into the timeout and panic recovery code --- config/config.go | 54 ++++++++++------ config/default.yaml | 7 ++ main.go | 4 +- ui/render_wrap.go | 151 +++++++++++++++++++++++--------------------- 4 files changed, 124 insertions(+), 92 deletions(-) diff --git a/config/config.go b/config/config.go index ef34243..a9205f3 100644 --- a/config/config.go +++ b/config/config.go @@ -100,7 +100,10 @@ type AmConfig struct { SessionExpire string `yaml:"sessionExpire"` UserAgreementResource string `yaml:"userAgreementResource"` PolicyResource string `yaml:"policyResource"` + FrameTemplate string `yaml:"frameTemplate"` FooterTemplate string `yaml:"footerTemplate"` + TopMenuId string `yaml:"topMenuId"` + FixedMenuId string `yaml:"fixedMenuId"` DefaultCommunityLogo string `yaml:"defaultCommunityLogo"` DefaultUserPhoto string `yaml:"defaultUserPhoto"` WelcomeTitle string `yaml:"welcomeTitle"` @@ -148,6 +151,9 @@ type AmConfig struct { Prioritize string `yaml:"prioritize"` } `yaml:"countryList"` VeniceCompatibleImageURLs bool `yaml:"veniceCompatibleImageURLs"` + PanicRecovery struct { + StackDataSize string `yaml:"stackDataSize"` + } `yaml:"panicRecovery"` } `yaml:"rendering"` Resources struct { ViewTemplateDir string `yaml:"viewTemplateDir"` @@ -168,9 +174,11 @@ type AmConfig struct { Tuning struct { WorkerTasks int `yaml:"workerTasks"` Timeouts struct { - HttpRead int `yaml:"httpRead"` - HttpWrite int `yaml:"httpWrite"` - HttpIdle int `yaml:"httpIdle"` + HttpRead int `yaml:"httpRead"` + HttpWrite int `yaml:"httpWrite"` + HttpIdle int `yaml:"httpIdle"` + PageExecute int `yaml:"pageExecute"` + PageRender int `yaml:"pageRender"` } `yaml:"timeouts"` Queues struct { AuditWrites int `yaml:"auditWrites"` @@ -207,22 +215,23 @@ func (c *AmConfig) ExPath(path string) string { // AmConfigComputed is the configuration values which are "computed" based only on values in AmConfig and CommandLine. type AmConfigComputed struct { - DebugMode bool // are we in debug mode? - LogLevel string // the logging level - Listen string // listen address - DatabaseDriver string // name of database driver - DatabaseHost string // hostname for database - DatabaseUser string // user name for database - DatabasePassword string // password for database - DatabaseName string // database name - MailHost string // SMTP host - MailPort int // SMTP port - MailTLS string // SMTP TLS setting - MailAuthType string // SMTP auth type - MailUser string // SMTP user name - MailPassword string // SMTP password - UploadMaxSize int32 // maximum upload size in bytes - UploadNoCompress map[string]bool // which upload types are not compressed? + DebugMode bool // are we in debug mode? + LogLevel string // the logging level + Listen string // listen address + DatabaseDriver string // name of database driver + DatabaseHost string // hostname for database + DatabaseUser string // user name for database + DatabasePassword string // password for database + DatabaseName string // database name + MailHost string // SMTP host + MailPort int // SMTP port + MailTLS string // SMTP TLS setting + MailAuthType string // SMTP auth type + MailUser string // SMTP user name + MailPassword string // SMTP password + PanicRecoveryStack int32 // stack size for panic recovery + UploadMaxSize int32 // maximum upload size in bytes + UploadNoCompress map[string]bool // which upload types are not compressed? } //go:embed default.yaml @@ -403,7 +412,12 @@ func SetupConfig() { GlobalComputedConfig.MailAuthType = util.IIF(CommandLine.MailAuthType != "", CommandLine.MailAuthType, GlobalConfig.Email.AuthType) GlobalComputedConfig.MailUser = util.IIF(CommandLine.MailUser != "", CommandLine.MailUser, GlobalConfig.Email.User) GlobalComputedConfig.MailPassword = util.IIF(CommandLine.MailPassword != "", CommandLine.MailPassword, GlobalConfig.Email.Password) - tmp, err := humanize.ParseBytes(GlobalConfig.Posting.Uploads.MaxSize) + tmp, err := humanize.ParseBytes(GlobalConfig.Rendering.PanicRecovery.StackDataSize) + if err != nil { + panic(err.Error()) + } + GlobalComputedConfig.PanicRecoveryStack = int32(tmp) + tmp, err = humanize.ParseBytes(GlobalConfig.Posting.Uploads.MaxSize) if err != nil { panic(err.Error()) } diff --git a/config/default.yaml b/config/default.yaml index 416b535..2c4260e 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -25,7 +25,10 @@ site: sessionExpire: "3h" userAgreementResource: "useragreement.html" policyResource: "policy.html" + frameTemplate: "frame.jet" footerTemplate: "footer.jet" + topMenuId: "top" + fixedMenuId: "fixed" defaultCommunityLogo: "/img/builtin/default-community.jpg" defaultUserPhoto: "/img/builtin/no-user.png" welcomeTitle: "Welcome to Amsterdam" @@ -72,6 +75,8 @@ rendering: countryList: prioritize: US veniceCompatibleImageURLs: false + panicRecovery: + stackDataSize: "4 KiB" resources: viewTemplateDir: "" dialogTemplateDir: "" @@ -95,6 +100,8 @@ tuning: httpRead: 30 httpWrite: 30 httpIdle: 120 + pageExecute: 15 + pageRender: 15 queues: auditWrites: 16 contextRecycle: 16 diff --git a/main.go b/main.go index 464735c..650cc9a 100644 --- a/main.go +++ b/main.go @@ -50,7 +50,9 @@ func setupEcho() *echo.Echo { e.Renderer = &ui.TemplateRenderer{} e.HTTPErrorHandler = AmErrorHandler if !config.CommandLine.DebugPanic { - e.Use(middleware.RecoverWithConfig(middleware.DefaultRecoverConfig)) + e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ + StackSize: int(config.GlobalComputedConfig.PanicRecoveryStack), + })) } else { log.Warn("WARNING: --debug-panic in effect - DO NOT use this in production!") } diff --git a/ui/render_wrap.go b/ui/render_wrap.go index 3790324..5497bf9 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -1,6 +1,6 @@ /* * Amsterdam Web Communities System - * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -44,6 +44,51 @@ func (e *panicRecoveryErr) Unwrap() error { return e.Err } +// doFrameRender renders the outer frame template with an inner template. +func doFrameRender(ctxt *echo.Context, amctxt AmContext, statusCode int, innerPage string) error { + if amctxt.FrameTitle() == "" { + log.Errorf("*** NO FRAME TITLE set for path %s", amctxt.URLPath()) + amctxt.SetFrameTitle("<<< NO FRAME TITLE >>>") + } + amctxt.VarMap().Set("__innerPage", innerPage) + menus := make([]*MenuDefinition, 2) + switch amctxt.LeftMenu() { + case "top": + menus[0] = AmMenu(config.GlobalConfig.Site.TopMenuId) + case "community": + comm := amctxt.CurrentCommunity() + if comm != nil { + md, err := AmBuildCommunityMenu(ctxt.Request().Context(), comm) + if err != nil { + return err + } + menus[0] = md + } else { + menus[0] = AmMenu(config.GlobalConfig.Site.TopMenuId) + } + default: + return fmt.Errorf("AmSendPageData(): unknown left menu context: %s", amctxt.LeftMenu()) + } + menus[1] = AmMenu(config.GlobalConfig.Site.FixedMenuId) + amctxt.VarMap().Set("__leftMenus", menus) + ad, err := database.AmGetRandomAd(ctxt.Request().Context()) + if err != nil { + ad = &database.Advert{ + AdId: -1, + ImagePath: "", + PathStyle: -1, + Caption: nil, + LinkURL: nil, + } + } + amctxt.VarMap().Set("__bannerad", ad) + amctxt.VarMap().Set("__debugMode", config.GlobalComputedConfig.DebugMode) + if tmp := amctxt.GetScratch("frame_suppressLogin"); tmp != nil { + amctxt.VarMap().Set("__suppressLogin", true) + } + return ctxt.Render(statusCode, config.GlobalConfig.Site.FrameTemplate, amctxt) +} + /* AmSendPageData sends page data to the output based on the command string. * Parameters: * ctxt - The Echo context from the request. @@ -65,20 +110,22 @@ func (e *panicRecoveryErr) Unwrap() error { */ func AmSendPageData(ctxt *echo.Context, amctxt AmContext, command string, data any) error { // Enable panic recovery. - defer func() { - if r := recover(); r != nil { - if r == http.ErrAbortHandler { - panic(r) + if !config.CommandLine.DebugPanic { + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + panic(r) + } + tmperr, ok := r.(error) + if !ok { + tmperr = fmt.Errorf("%v", r) + } + stack := make([]byte, config.GlobalComputedConfig.PanicRecoveryStack) + length := runtime.Stack(stack, false) + log.Errorf("[Panic Recovery in SendData Phase] %s %s", tmperr.Error(), stack[:length]) } - tmperr, ok := r.(error) - if !ok { - tmperr = fmt.Errorf("%v", r) - } - stack := make([]byte, 4<<10) - length := runtime.Stack(stack, false) - log.Errorf("[Panic Recovery in SendData Phase] %s %s", tmperr.Error(), stack[:length]) - } - }() + }() + } // Preprocess certain commands into different ones. httprc := http.StatusOK @@ -132,7 +179,7 @@ func AmSendPageData(ctxt *echo.Context, amctxt AmContext, command string, data a // Process commands. oldreq := ctxt.Request() - ctx, cancel := context.WithTimeout(oldreq.Context(), 15*time.Second) + ctx, cancel := context.WithTimeout(oldreq.Context(), time.Duration(config.GlobalConfig.Tuning.Timeouts.PageRender)*time.Second) defer cancel() ctxt.SetRequest(oldreq.WithContext(ctx)) defer ctxt.SetRequest(oldreq) @@ -151,47 +198,7 @@ func AmSendPageData(ctxt *echo.Context, amctxt AmContext, command string, data a case "template": err = ctxt.Render(httprc, data.(string), amctxt) case "framed": - if amctxt.FrameTitle() == "" { - log.Errorf("*** NO FRAME TITLE set for path %s", amctxt.URLPath()) - amctxt.SetFrameTitle("<<< NO FRAME TITLE >>>") - } - amctxt.VarMap().Set("__innerPage", data) - menus := make([]*MenuDefinition, 2) - switch amctxt.LeftMenu() { - case "top": - menus[0] = AmMenu("top") - case "community": - comm := amctxt.CurrentCommunity() - if comm != nil { - md, err := AmBuildCommunityMenu(ctxt.Request().Context(), comm) - if err != nil { - return err - } - menus[0] = md - } else { - menus[0] = AmMenu("top") - } - default: - return fmt.Errorf("AmSendPageData(): unknown left menu context: %s", amctxt.LeftMenu()) - } - menus[1] = AmMenu("fixed") - amctxt.VarMap().Set("__leftMenus", menus) - ad, err := database.AmGetRandomAd(ctxt.Request().Context()) - if err != nil { - ad = &database.Advert{ - AdId: -1, - ImagePath: "", - PathStyle: -1, - Caption: nil, - LinkURL: nil, - } - } - amctxt.VarMap().Set("__bannerad", ad) - amctxt.VarMap().Set("__debugMode", config.GlobalComputedConfig.DebugMode) - if tmp := amctxt.GetScratch("frame_suppressLogin"); tmp != nil { - amctxt.VarMap().Set("__suppressLogin", true) - } - err = ctxt.Render(httprc, "frame.jet", amctxt) + err = doFrameRender(ctxt, amctxt, httprc, data.(string)) default: err = fmt.Errorf("AmSendPageData(): unknown rendering type: %s", command) } @@ -209,23 +216,25 @@ type AmPageFunc func(AmContext) (string, any) // callWrappedPageFunc calls the specified page functon inside a wrapper that handles timeouts and panic recovery. func callWrappedPageFunc(f AmPageFunc, ctxt *echo.Context, amctxt AmContext) (command string, arg any) { - defer func() { - if r := recover(); r != nil { - if r == http.ErrAbortHandler { - panic(r) + if !config.CommandLine.DebugPanic { + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + panic(r) + } + tmperr, ok := r.(error) + if !ok { + tmperr = fmt.Errorf("%v", r) + } + stack := make([]byte, config.GlobalComputedConfig.PanicRecoveryStack) + length := runtime.Stack(stack, false) + arg = &panicRecoveryErr{Phase: "PageFunc", Err: tmperr, Stack: stack[:length]} + command = "error" } - tmperr, ok := r.(error) - if !ok { - tmperr = fmt.Errorf("%v", r) - } - stack := make([]byte, 4<<10) - length := runtime.Stack(stack, false) - arg = &panicRecoveryErr{Phase: "PageFunc", Err: tmperr, Stack: stack[:length]} - command = "error" - } - }() + }() + } oldreq := ctxt.Request() - ctx, cancel := context.WithTimeout(oldreq.Context(), 15*time.Second) + ctx, cancel := context.WithTimeout(oldreq.Context(), time.Duration(config.GlobalConfig.Tuning.Timeouts.PageExecute)*time.Second) defer cancel() ctxt.SetRequest(oldreq.WithContext(ctx)) defer ctxt.SetRequest(oldreq)