check / check (push) Waiting to run
Creating a session, logging in and posting a message stopped reading after the first JSON value, so the limit never saw the rest of the body and trailing bytes were ignored. All three now read the whole limited body and decode it with json.Unmarshal. A body over the limit, or anything but whitespace after the value, gets 400 invalid request body. Model: opus-5-5
364 lines
7.9 KiB
Go
364 lines
7.9 KiB
Go
// Package handlers provides HTTP request handlers for the neoirc server.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/neoirc/internal/broker"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/internal/ratelimit"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/internal/stats"
|
|
)
|
|
|
|
var errUnauthorized = errors.New("unauthorized")
|
|
|
|
// Field names and values used in many JSON responses.
|
|
const (
|
|
statusKey = "status"
|
|
statusError = "error"
|
|
errorKey = "error"
|
|
nickKey = "nick"
|
|
)
|
|
|
|
// 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
|
|
Stats *stats.Tracker
|
|
Broker *broker.Broker
|
|
Service *service.Service
|
|
}
|
|
|
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
|
|
|
// spentHashcashTTL is how long spent hashcash tokens are
|
|
// retained for replay prevention. Per issue requirements,
|
|
// this is 1 year.
|
|
const spentHashcashTTL = 365 * 24 * time.Hour
|
|
|
|
// Handlers manages HTTP request handling.
|
|
type Handlers struct {
|
|
params *Params
|
|
log *slog.Logger
|
|
hc *healthcheck.Healthcheck
|
|
broker *broker.Broker
|
|
svc *service.Service
|
|
hashcashVal *hashcash.Validator
|
|
loginLimiter *ratelimit.Limiter
|
|
stats *stats.Tracker
|
|
cancelCleanup context.CancelFunc
|
|
|
|
// pollMessages is Database.PollMessages, with which
|
|
// HandleGetMessages reads the client output queue. Tests
|
|
// replace it to act just after a read, or to make a read
|
|
// fail.
|
|
pollMessages func(
|
|
ctx context.Context,
|
|
clientID, afterQueueID int64,
|
|
limit int,
|
|
) ([]db.IRCMessage, int64, error)
|
|
}
|
|
|
|
// New creates a new Handlers instance.
|
|
func New(
|
|
lifecycle fx.Lifecycle,
|
|
params Params,
|
|
) (*Handlers, error) {
|
|
resource := params.Config.ServerName
|
|
if resource == "" {
|
|
resource = "neoirc"
|
|
}
|
|
|
|
loginRate := params.Config.LoginRateLimit
|
|
if loginRate <= 0 {
|
|
loginRate = ratelimit.DefaultRate
|
|
}
|
|
|
|
loginBurst := params.Config.LoginRateBurst
|
|
if loginBurst <= 0 {
|
|
loginBurst = ratelimit.DefaultBurst
|
|
}
|
|
|
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
|
params: ¶ms,
|
|
log: params.Logger.Get(),
|
|
hc: params.Healthcheck,
|
|
broker: params.Broker,
|
|
svc: params.Service,
|
|
hashcashVal: hashcash.NewValidator(resource),
|
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
|
stats: params.Stats,
|
|
pollMessages: params.Database.PollMessages,
|
|
}
|
|
|
|
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{errorKey: msg},
|
|
status,
|
|
)
|
|
}
|
|
|
|
// decodeJSON reads the whole request body and decodes it into dest.
|
|
// It fails if the body is larger than the http.MaxBytesReader limit,
|
|
// or holds anything other than one JSON value and whitespace.
|
|
func decodeJSON(request *http.Request, dest any) error {
|
|
body, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return json.Unmarshal(body, dest)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
if hdlr.loginLimiter != nil {
|
|
hdlr.loginLimiter.Stop()
|
|
}
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Prune spent hashcash tokens older than 1 year.
|
|
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
|
|
|
|
pruned, err := hdlr.params.Database.
|
|
PruneSpentHashcash(ctx, hashcashCutoff)
|
|
if err != nil {
|
|
hdlr.log.Error(
|
|
"spent hashcash pruning failed", "error", err,
|
|
)
|
|
} else if pruned > 0 {
|
|
hdlr.log.Info(
|
|
"pruned spent hashcash tokens",
|
|
"deleted", pruned,
|
|
)
|
|
}
|
|
}
|