Some checks failed
check / check (push) Has been cancelled
## Summary Expands the `/.well-known/healthcheck.json` endpoint with runtime statistics, giving operators visibility into server load and usage patterns. closes #74 ## New healthcheck fields | Field | Source | Description | |-------|--------|-------------| | `sessions` | DB | Current active session count | | `clients` | DB | Current connected client count | | `queuedLines` | DB | Total entries in client output queues | | `channels` | DB | Current channel count | | `connectionsSinceBoot` | Memory | Total client connections since server start | | `sessionsSinceBoot` | Memory | Total sessions created since server start | | `messagesSinceBoot` | Memory | Total PRIVMSG/NOTICE messages since server start | ## Implementation - **New `internal/stats` package** — atomic counters for boot-scoped metrics (`connectionsSinceBoot`, `sessionsSinceBoot`, `messagesSinceBoot`). Thread-safe via `sync/atomic`. - **New DB queries** — `GetClientCount()` and `GetQueueEntryCount()` for current snapshot counts. - **Healthcheck changes** — `Healthcheck()` now accepts `context.Context` to query the database. Response struct extended with all 7 new fields. DB-derived stats populated with graceful error handling (logged, not fatal). - **Counter instrumentation** — Increments added at: - `handleCreateSession` → `IncrSessions` + `IncrConnections` - `handleRegister` → `IncrSessions` + `IncrConnections` - `handleLogin` → `IncrConnections` (new client for existing session) - `handlePrivmsg` → `IncrMessages` (covers both PRIVMSG and NOTICE) - **Wired via fx** — `stats.Tracker` provided through Uber fx DI in both production and test setups. ## Tests - `internal/stats/stats_test.go` — 5 tests covering all counter operations (100% coverage) - `TestHealthcheckRuntimeStatsFields` — verifies all 7 new fields are present in the response - `TestHealthcheckRuntimeStatsValues` — end-to-end: creates a session, joins a channel, sends a message, then verifies counts are nonzero ## README Updated healthcheck documentation with full response shape, field descriptions, and project structure listing for `internal/stats/`. Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: #80 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
204 lines
3.7 KiB
Go
204 lines
3.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
)
|
|
|
|
const minPasswordLength = 8
|
|
|
|
// HandleRegister creates a new user with a password.
|
|
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
|
return func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
request.Body = http.MaxBytesReader(
|
|
writer, request.Body, hdlr.maxBodySize(),
|
|
)
|
|
|
|
hdlr.handleRegister(writer, request)
|
|
}
|
|
}
|
|
|
|
func (hdlr *Handlers) handleRegister(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
type registerRequest struct {
|
|
Nick string `json:"nick"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
var payload registerRequest
|
|
|
|
err := json.NewDecoder(request.Body).Decode(&payload)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid request body",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
|
|
|
if !validNickRe.MatchString(payload.Nick) {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid nick format",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(payload.Password) < minPasswordLength {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"password must be at least 8 characters",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.RegisterUser(
|
|
request.Context(),
|
|
payload.Nick,
|
|
payload.Password,
|
|
)
|
|
if err != nil {
|
|
hdlr.handleRegisterError(
|
|
writer, request, err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.stats.IncrSessions()
|
|
hdlr.stats.IncrConnections()
|
|
|
|
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
|
|
|
hdlr.respondJSON(writer, request, map[string]any{
|
|
"id": sessionID,
|
|
"nick": payload.Nick,
|
|
"token": token,
|
|
}, http.StatusCreated)
|
|
}
|
|
|
|
func (hdlr *Handlers) handleRegisterError(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
err error,
|
|
) {
|
|
if db.IsUniqueConstraintError(err) {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"nick already taken",
|
|
http.StatusConflict,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.log.Error(
|
|
"register user failed", "error", err,
|
|
)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
}
|
|
|
|
// HandleLogin authenticates a user with nick and password.
|
|
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
|
return func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
request.Body = http.MaxBytesReader(
|
|
writer, request.Body, hdlr.maxBodySize(),
|
|
)
|
|
|
|
hdlr.handleLogin(writer, request)
|
|
}
|
|
}
|
|
|
|
func (hdlr *Handlers) handleLogin(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
type loginRequest struct {
|
|
Nick string `json:"nick"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
var payload loginRequest
|
|
|
|
err := json.NewDecoder(request.Body).Decode(&payload)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid request body",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
|
|
|
if payload.Nick == "" || payload.Password == "" {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"nick and password required",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.LoginUser(
|
|
request.Context(),
|
|
payload.Nick,
|
|
payload.Password,
|
|
)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid credentials",
|
|
http.StatusUnauthorized,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.stats.IncrConnections()
|
|
|
|
hdlr.deliverMOTD(
|
|
request, clientID, sessionID, payload.Nick,
|
|
)
|
|
|
|
// Initialize channel state so the new client knows
|
|
// which channels the session already belongs to.
|
|
hdlr.initChannelState(
|
|
request, clientID, sessionID, payload.Nick,
|
|
)
|
|
|
|
hdlr.respondJSON(writer, request, map[string]any{
|
|
"id": sessionID,
|
|
"nick": payload.Nick,
|
|
"token": token,
|
|
}, http.StatusOK)
|
|
}
|