All checks were successful
check / check (push) Successful in 2m24s
Complete rename of the application from `chat` to `neoirc` with binary name `neoircd`. closes #46 ## Changes - **Go module path**: `git.eeqj.de/sneak/chat` → `git.eeqj.de/sneak/neoirc` - **Server binary**: `chatd` → `neoircd` - **CLI binary**: `chat-cli` → `neoirc-cli` - **Cmd directories**: `cmd/chatd` → `cmd/neoircd`, `cmd/chat-cli` → `cmd/neoirc-cli` - **Go package**: `chatapi` → `neoircapi` - **Makefile**: binary name, build targets, docker image tag, clean target - **Dockerfile**: binary paths, user/group names (`chat` → `neoirc`), ENTRYPOINT - **`.gitignore`/`.dockerignore`**: artifact names - **All Go imports and doc comments** - **Default server name**: `chat` → `neoirc` - **Web client**: localStorage keys (`chat_token`/`chat_channels` → `neoirc_token`/`neoirc_channels`), page title, default server display name - **Schema files**: all `$id` URLs and example hostnames - **README.md**: project name, all binary references, examples, directory tree - **AGENTS.md**: build command reference - **Test fixtures**: app name and channel names Docker build passes. All tests pass. <!-- session: agent:sdlc-manager:subagent:a4b8dbd3-a7c8-4fad-8239-bb5a64a9b3d6 --> Co-authored-by: clawbot <clawbot@noreply.eeqj.de> Reviewed-on: #47 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
204 lines
4.0 KiB
Go
204 lines
4.0 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 = 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,
|
|
)
|
|
}
|
|
}
|