All checks were successful
check / check (push) Successful in 6s
## Summary Adds username and hostname support to sessions, enabling standard IRC hostmask format (`nick!user@host`) for WHOIS, WHO, and future `+b` ban matching. closes #81 ## Changes ### Schema (`001_initial.sql`) - Added `username TEXT NOT NULL DEFAULT ''` and `hostname TEXT NOT NULL DEFAULT ''` columns to the `sessions` table ### Database layer (`internal/db/`) - `CreateSession` now accepts `username` and `hostname` parameters; username defaults to nick if empty - `RegisterUser` now accepts `username` and `hostname` parameters - New `SessionHostInfo` type and `GetSessionHostInfo` query to retrieve username/hostname for a session - `MemberInfo` now includes `Username` and `Hostname` fields - `ChannelMembers` query updated to return username/hostname - New `FormatHostmask(nick, username, hostname)` helper that produces `nick!user@host` format - New `Hostmask()` method on `MemberInfo` ### Handler layer (`internal/handlers/`) - Session creation (`POST /api/v1/session`) accepts optional `username` field; resolves hostname via reverse DNS of connecting client IP (respects `X-Forwarded-For` and `X-Real-IP` headers) - Registration (`POST /api/v1/register`) accepts optional `username` field with the same hostname resolution - Username validation regex: `^[a-zA-Z0-9_\-\[\]\\^{}|` + "\`" + `]{1,32}$` - WHOIS (`311 RPL_WHOISUSER`) now returns the real username and hostname instead of nick/servername - WHO (`352 RPL_WHOREPLY`) now returns the real username and hostname instead of nick/servername - Extracted `validateHashcash` and `resolveUsername` helpers to keep functions under the linter's `funlen` limit - Extracted `executeRegister` helper for the same reason - Reverse DNS uses `(*net.Resolver).LookupAddr` with a 3-second timeout context ### Tests - `TestCreateSessionWithUserHost` — verifies username/hostname are stored and retrievable - `TestCreateSessionDefaultUsername` — verifies empty username defaults to nick - `TestGetSessionHostInfoNotFound` — verifies error on nonexistent session - `TestFormatHostmask` — verifies `nick!user@host` formatting - `TestFormatHostmaskDefaults` — verifies fallback when username/hostname empty - `TestMemberInfoHostmask` — verifies `Hostmask()` method on `MemberInfo` - `TestChannelMembersIncludeUserHost` — verifies `ChannelMembers` returns username/hostname - `TestRegisterUserWithUserHost` — verifies registration stores username/hostname - `TestRegisterUserDefaultUsername` — verifies registration defaults username to nick - `TestWhoisShowsHostInfo` — integration test verifying WHOIS returns the correct username - `TestWhoShowsHostInfo` — integration test verifying WHO returns the correct username - `TestSessionUsernameDefault` — integration test verifying default username in WHOIS - All existing tests updated for new `CreateSession`/`RegisterUser` signatures ### README - New "Hostmask" section documenting the `nick!user@host` format - Updated session creation and registration API docs with the new `username` field - Updated WHOIS/WHO numeric examples to show real username/hostname - Updated sessions schema table with new columns ## Docker build `docker build .` passes cleanly (lint, format, tests, build). Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Co-authored-by: clawbot <clawbot@eeqj.de> Reviewed-on: #82 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
242 lines
4.4 KiB
Go
242 lines
4.4 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"`
|
|
Username string `json:"username,omitempty"`
|
|
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
|
|
}
|
|
|
|
username := resolveUsername(
|
|
payload.Username, payload.Nick,
|
|
)
|
|
|
|
if !validUsernameRe.MatchString(username) {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid username format",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(payload.Password) < minPasswordLength {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"password must be at least 8 characters",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.executeRegister(
|
|
writer, request,
|
|
payload.Nick, payload.Password, username,
|
|
)
|
|
}
|
|
|
|
func (hdlr *Handlers) executeRegister(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
nick, password, username string,
|
|
) {
|
|
remoteIP := clientIP(request)
|
|
|
|
hostname := resolveHostname(
|
|
request.Context(), remoteIP,
|
|
)
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.RegisterUser(
|
|
request.Context(),
|
|
nick, password, username, hostname, remoteIP,
|
|
)
|
|
if err != nil {
|
|
hdlr.handleRegisterError(
|
|
writer, request, err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.stats.IncrSessions()
|
|
hdlr.stats.IncrConnections()
|
|
|
|
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
|
|
|
hdlr.respondJSON(writer, request, map[string]any{
|
|
"id": sessionID,
|
|
"nick": 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
|
|
}
|
|
|
|
remoteIP := clientIP(request)
|
|
|
|
hostname := resolveHostname(
|
|
request.Context(), remoteIP,
|
|
)
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.LoginUser(
|
|
request.Context(),
|
|
payload.Nick,
|
|
payload.Password,
|
|
remoteIP, hostname,
|
|
)
|
|
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)
|
|
}
|