All checks were successful
check / check (push) Successful in 2m2s
The background idle cleanup (DeleteStaleUsers) was removing stale clients/sessions directly via SQL without sending QUIT notifications to channel members. This caused timed-out users to silently disappear from channels. Now runCleanup identifies sessions that will be orphaned by the stale client deletion and calls cleanupUser for each one first, ensuring QUIT messages are sent to all channel members — matching the explicit logout behavior. Also refactored cleanupUser to accept context.Context instead of *http.Request so it can be called from both HTTP handlers and the background cleanup goroutine.
204 lines
4.0 KiB
Go
204 lines
4.0 KiB
Go
// Package handlers provides HTTP request handlers for the chat server.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/chat/internal/broker"
|
|
"git.eeqj.de/sneak/chat/internal/config"
|
|
"git.eeqj.de/sneak/chat/internal/db"
|
|
"git.eeqj.de/sneak/chat/internal/globals"
|
|
"git.eeqj.de/sneak/chat/internal/healthcheck"
|
|
"git.eeqj.de/sneak/chat/internal/logger"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
var errUnauthorized = errors.New("unauthorized")
|
|
|
|
// Params defines the dependencies for creating Handlers.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
Database *db.Database
|
|
Healthcheck *healthcheck.Healthcheck
|
|
}
|
|
|
|
const defaultIdleTimeout = 24 * time.Hour
|
|
|
|
// Handlers manages HTTP request handling.
|
|
type Handlers struct {
|
|
params *Params
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
broker *broker.Broker
|
|
cancelCleanup context.CancelFunc
|
|
}
|
|
|
|
// New creates a new Handlers instance.
|
|
func New(
|
|
lifecycle fx.Lifecycle,
|
|
params Params,
|
|
) (*Handlers, error) {
|
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
|
params: ¶ms,
|
|
log: params.Logger.Get(),
|
|
hc: params.Healthcheck,
|
|
broker: broker.New(),
|
|
}
|
|
|
|
lifecycle.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
hdlr.startCleanup(ctx)
|
|
|
|
return nil
|
|
},
|
|
OnStop: func(_ context.Context) error {
|
|
hdlr.stopCleanup()
|
|
|
|
return nil
|
|
},
|
|
})
|
|
|
|
return hdlr, nil
|
|
}
|
|
|
|
func (hdlr *Handlers) respondJSON(
|
|
writer http.ResponseWriter,
|
|
_ *http.Request,
|
|
data any,
|
|
status int,
|
|
) {
|
|
writer.Header().Set(
|
|
"Content-Type",
|
|
"application/json; charset=utf-8",
|
|
)
|
|
writer.WriteHeader(status)
|
|
|
|
if data != nil {
|
|
err := json.NewEncoder(writer).Encode(data)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"json encode error", "error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (hdlr *Handlers) respondError(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
msg string,
|
|
status int,
|
|
) {
|
|
hdlr.respondJSON(
|
|
writer, request,
|
|
map[string]string{"error": msg},
|
|
status,
|
|
)
|
|
}
|
|
|
|
func (hdlr *Handlers) idleTimeout() time.Duration {
|
|
raw := hdlr.params.Config.SessionIdleTimeout
|
|
if raw == "" {
|
|
return defaultIdleTimeout
|
|
}
|
|
|
|
dur, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"invalid SESSION_IDLE_TIMEOUT, using default",
|
|
"value", raw, "error", err,
|
|
)
|
|
|
|
return defaultIdleTimeout
|
|
}
|
|
|
|
return dur
|
|
}
|
|
|
|
// startCleanup launches the idle-user cleanup goroutine.
|
|
// We use context.Background rather than the OnStart ctx
|
|
// because the OnStart context is startup-scoped and would
|
|
// cancel the goroutine once all start hooks complete.
|
|
//
|
|
//nolint:contextcheck // intentional Background ctx
|
|
func (hdlr *Handlers) startCleanup(_ context.Context) {
|
|
cleanupCtx, cancel := context.WithCancel(
|
|
context.Background(),
|
|
)
|
|
hdlr.cancelCleanup = cancel
|
|
|
|
go hdlr.cleanupLoop(cleanupCtx)
|
|
}
|
|
|
|
func (hdlr *Handlers) stopCleanup() {
|
|
if hdlr.cancelCleanup != nil {
|
|
hdlr.cancelCleanup()
|
|
}
|
|
}
|
|
|
|
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
|
timeout := hdlr.idleTimeout()
|
|
|
|
interval := max(timeout/2, time.Minute) //nolint:mnd // half the timeout
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
hdlr.runCleanup(ctx, timeout)
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (hdlr *Handlers) runCleanup(
|
|
ctx context.Context,
|
|
timeout time.Duration,
|
|
) {
|
|
cutoff := time.Now().Add(-timeout)
|
|
|
|
// Find sessions that will be orphaned so we can send
|
|
// QUIT notifications before deleting anything.
|
|
stale, err := hdlr.params.Database.
|
|
GetStaleOrphanSessions(ctx, cutoff)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"stale session lookup failed", "error", err,
|
|
)
|
|
}
|
|
|
|
for _, ss := range stale {
|
|
hdlr.cleanupUser(ctx, ss.ID, ss.Nick)
|
|
}
|
|
|
|
deleted, err := hdlr.params.Database.DeleteStaleUsers(
|
|
ctx, cutoff,
|
|
)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"user cleanup failed", "error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if deleted > 0 {
|
|
hdlr.log.Info(
|
|
"cleaned up stale users",
|
|
"deleted", deleted,
|
|
)
|
|
}
|
|
}
|