// 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, ) } }