All checks were successful
check / check (push) Successful in 4s
Enforce `QUEUE_MAX_AGE` and `MAX_HISTORY` config values that previously existed but were not applied. The existing cleanup loop now also: - **Prunes `client_queues`** entries older than `QUEUE_MAX_AGE` (default 48h / 172800s) - **Rotates `messages`** per target (channel or DM) beyond `MAX_HISTORY` (default 10000) - **Removes orphaned messages** no longer referenced by any client queue All pruning runs inside the existing periodic cleanup goroutine at the same interval as idle-user cleanup. ### Changes - `internal/config/config.go`: Added `QueueMaxAge` field, reads `QUEUE_MAX_AGE` env var (default 172800) - `internal/db/queries.go`: Added `PruneOldQueueEntries`, `PruneOrphanedMessages`, and `RotateChannelMessages` methods - `internal/handlers/handlers.go`: Added `pruneQueuesAndMessages` called from `runCleanup` - `README.md`: Updated data lifecycle, config table, and TODO checklist to reflect implementation closes #40 <!-- session: agent:sdlc-manager:subagent:f87d0eb0-968a-40d5-a1bc-a32ac14e1bda --> Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Co-authored-by: Jeffrey Paul <sneak@noreply.example.org> Reviewed-on: #67 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
277 lines
5.5 KiB
Go
277 lines
5.5 KiB
Go
// Package handlers provides HTTP request handlers for the neoirc server.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
"git.eeqj.de/sneak/neoirc/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 = 30 * 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,
|
|
)
|
|
}
|
|
|
|
hdlr.pruneQueuesAndMessages(ctx)
|
|
}
|
|
|
|
// parseDurationConfig parses a Go duration string,
|
|
// returning zero on empty input and logging on error.
|
|
func (hdlr *Handlers) parseDurationConfig(
|
|
name, raw string,
|
|
) time.Duration {
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
|
|
dur, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"invalid duration config, skipping",
|
|
"name", name, "value", raw, "error", err,
|
|
)
|
|
|
|
return 0
|
|
}
|
|
|
|
return dur
|
|
}
|
|
|
|
// pruneQueuesAndMessages removes old client output queue
|
|
// entries per QUEUE_MAX_AGE and old messages per
|
|
// MESSAGE_MAX_AGE.
|
|
func (hdlr *Handlers) pruneQueuesAndMessages(
|
|
ctx context.Context,
|
|
) {
|
|
queueMaxAge := hdlr.parseDurationConfig(
|
|
"QUEUE_MAX_AGE",
|
|
hdlr.params.Config.QueueMaxAge,
|
|
)
|
|
if queueMaxAge > 0 {
|
|
queueCutoff := time.Now().Add(-queueMaxAge)
|
|
|
|
pruned, err := hdlr.params.Database.
|
|
PruneOldQueueEntries(ctx, queueCutoff)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"client output queue pruning failed", "error", err,
|
|
)
|
|
} else if pruned > 0 {
|
|
hdlr.log.Info(
|
|
"pruned old client output queue entries",
|
|
"deleted", pruned,
|
|
)
|
|
}
|
|
}
|
|
|
|
messageMaxAge := hdlr.parseDurationConfig(
|
|
"MESSAGE_MAX_AGE",
|
|
hdlr.params.Config.MessageMaxAge,
|
|
)
|
|
if messageMaxAge > 0 {
|
|
msgCutoff := time.Now().Add(-messageMaxAge)
|
|
|
|
pruned, err := hdlr.params.Database.
|
|
PruneOldMessages(ctx, msgCutoff)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"message pruning failed", "error", err,
|
|
)
|
|
} else if pruned > 0 {
|
|
hdlr.log.Info(
|
|
"pruned old messages",
|
|
"deleted", pruned,
|
|
)
|
|
}
|
|
}
|
|
}
|