Add SHA-256-based hashcash proof-of-work requirement to POST /session to prevent abuse via rapid session creation. The server advertises the required difficulty via GET /server (hashcash_bits field), and clients must include a valid stamp in the X-Hashcash request header. Server-side: - New internal/hashcash package with stamp validation (format, bits, date, resource, replay prevention via in-memory spent set) - Config: NEOIRC_HASHCASH_BITS env var (default 20, set 0 to disable) - GET /server includes hashcash_bits when > 0 - POST /session validates X-Hashcash header when enabled - Returns HTTP 402 for missing/invalid stamps Client-side: - SPA: fetches hashcash_bits from /server, computes stamp using Web Crypto API with batched SHA-256, shows 'Computing proof-of-work...' feedback during computation - CLI: api package gains MintHashcash() function, CreateSession() auto-fetches server info and computes stamp when required Stamp format: 1:bits:YYMMDD:resource::counter (standard hashcash) closes #11
285 lines
5.8 KiB
Go
285 lines
5.8 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/hashcash"
|
|
"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
|
|
hashcashVal *hashcash.Validator
|
|
cancelCleanup context.CancelFunc
|
|
}
|
|
|
|
// New creates a new Handlers instance.
|
|
func New(
|
|
lifecycle fx.Lifecycle,
|
|
params Params,
|
|
) (*Handlers, error) {
|
|
resource := params.Config.ServerName
|
|
if resource == "" {
|
|
resource = "neoirc"
|
|
}
|
|
|
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
|
params: ¶ms,
|
|
log: params.Logger.Get(),
|
|
hc: params.Healthcheck,
|
|
broker: broker.New(),
|
|
hashcashVal: hashcash.NewValidator(resource),
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
}
|