Files
chat/internal/handlers/api.go
clawbot f8f0b6afbb
All checks were successful
check / check (push) Successful in 58s
refactor: replace HTTP error codes with IRC numeric replies (#56)
## Summary

Refactors all IRC command handlers to respond with proper IRC numeric replies via the message queue instead of HTTP status codes.

HTTP error codes are now reserved exclusively for transport-level concerns:
- **401** — missing/invalid auth token
- **400** — malformed JSON, empty command
- **500** — server errors

## IRC Numerics Implemented

### Success replies (delivered via message queue on success):
- **001 RPL_WELCOME** — sent on session creation and login
- **331 RPL_NOTOPIC** — channel has no topic (on JOIN)
- **332 RPL_TOPIC** — channel topic (on JOIN, TOPIC set)
- **353 RPL_NAMREPLY** — channel member list (on JOIN)
- **366 RPL_ENDOFNAMES** — end of NAMES list (on JOIN)
- **375/372/376** — MOTD (already existed)

### Error replies (delivered via message queue instead of HTTP 4xx):
- **401 ERR_NOSUCHNICK** — DM target not found (was HTTP 404)
- **403 ERR_NOSUCHCHANNEL** — channel not found / invalid name (was HTTP 404)
- **421 ERR_UNKNOWNCOMMAND** — unrecognized command (was HTTP 400)
- **432 ERR_ERRONEUSNICKNAME** — invalid nick format (was HTTP 400)
- **433 ERR_NICKNAMEINUSE** — nick taken (was HTTP 409)
- **442 ERR_NOTONCHANNEL** — not a member of channel (was HTTP 403)
- **461 ERR_NEEDMOREPARAMS** — missing required fields (was HTTP 400)

## Database Changes
- Added `params` column to messages table for IRC-style parameters
- Added `Params` field to `IRCMessage` struct
- Updated `InsertMessage` to accept params

## Test Updates
- All existing tests updated to expect HTTP 200 + IRC numerics
- New tests: `TestWelcomeNumeric`, `TestJoinNumerics`

## Client Impact
- CLI and SPA already handle unknown numerics via default event handlers
- PRIVMSG/NOTICE success changed from HTTP 201 to HTTP 200

closes #54

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #56
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-09 22:21:30 +01:00

1729 lines
33 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi"
)
var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
)
var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
const (
maxLongPollTimeout = 30
pollMessageLimit = 100
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
cmdPrivmsg = "PRIVMSG"
)
func (hdlr *Handlers) maxBodySize() int64 {
if hdlr.params.Config.MaxMessageSize > 0 {
return int64(hdlr.params.Config.MaxMessageSize)
}
return defaultMaxBodySize
}
// authSession extracts the session from the client token.
func (hdlr *Handlers) authSession(
request *http.Request,
) (int64, int64, string, error) {
auth := request.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
return 0, 0, "", errUnauthorized
}
sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken(
request.Context(), token,
)
if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err)
}
return sessionID, clientID, nick, nil
}
func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter,
request *http.Request,
) (int64, int64, string, bool) {
sessionID, clientID, nick, err :=
hdlr.authSession(request)
if err != nil {
hdlr.respondError(
writer, request,
"unauthorized",
http.StatusUnauthorized,
)
return 0, 0, "", false
}
return sessionID, clientID, nick, true
}
// fanOut stores a message and enqueues it to all specified
// session IDs, then notifies them.
func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
body json.RawMessage,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, nil, body, nil,
)
if err != nil {
return "", fmt.Errorf("insert message: %w", err)
}
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
"error", enqErr, "session_id", sid)
}
hdlr.broker.Notify(sid)
}
return msgUUID, nil
}
// fanOutSilent calls fanOut and discards the UUID.
func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
body json.RawMessage,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target, body, sessionIDs,
)
return err
}
// HandleCreateSession creates a new user session.
func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleCreateSession(writer, request)
}
}
func (hdlr *Handlers) handleCreateSession(
writer http.ResponseWriter,
request *http.Request,
) {
type createRequest struct {
Nick string `json:"nick"`
}
var payload createRequest
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
}
sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession(
request.Context(), payload.Nick,
)
if err != nil {
hdlr.handleCreateSessionError(
writer, request, err,
)
return
}
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) handleCreateSessionError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"create session failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// deliverWelcome sends the RPL_WELCOME (001) numeric to a
// new client.
func (hdlr *Handlers) deliverWelcome(
request *http.Request,
clientID int64,
nick string,
) {
ctx := request.Context()
hdlr.enqueueNumeric(
ctx, clientID, "001", nick, nil,
"Welcome to the network, "+nick,
)
}
// deliverMOTD sends the MOTD as IRC numeric messages to a
// new client.
func (hdlr *Handlers) deliverMOTD(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
motd := hdlr.params.Config.MOTD
srvName := hdlr.serverName()
ctx := request.Context()
hdlr.deliverWelcome(request, clientID, nick)
if motd == "" {
hdlr.broker.Notify(sessionID)
return
}
hdlr.enqueueNumeric(
ctx, clientID, "375", nick, nil,
"- "+srvName+" Message of the Day -",
)
for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric(
ctx, clientID, "372", nick, nil,
"- "+line,
)
}
hdlr.enqueueNumeric(
ctx, clientID, "376", nick, nil,
"End of /MOTD command.",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) serverName() string {
name := hdlr.params.Config.ServerName
if name == "" {
return "neoirc"
}
return name
}
func (hdlr *Handlers) enqueueNumeric(
ctx context.Context,
clientID int64,
command, nick string,
params []string,
text string,
) {
body, err := json.Marshal([]string{text})
if err != nil {
hdlr.log.Error(
"marshal numeric body", "error", err,
)
return
}
var paramsJSON json.RawMessage
if len(params) > 0 {
paramsJSON, err = json.Marshal(params)
if err != nil {
hdlr.log.Error(
"marshal numeric params", "error", err,
)
return
}
}
dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, hdlr.serverName(), nick,
paramsJSON, json.RawMessage(body), nil,
)
if insertErr != nil {
hdlr.log.Error(
"insert numeric message", "error", insertErr,
)
return
}
_ = hdlr.params.Database.EnqueueToClient(
ctx, clientID, dbID,
)
}
// HandleState returns the current session's info and
// channels.
func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
sessionID, _, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
channels, err := hdlr.params.Database.ListChannels(
request.Context(), sessionID,
)
if err != nil {
hdlr.log.Error(
"list channels failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": nick,
"channels": channels,
}, http.StatusOK)
}
}
// HandleListAllChannels returns all channels on the server.
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
_, _, _, ok := hdlr.requireAuth(writer, request)
if !ok {
return
}
channels, err := hdlr.params.Database.ListAllChannels(
request.Context(),
)
if err != nil {
hdlr.log.Error(
"list all channels failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(
writer, request, channels, http.StatusOK,
)
}
}
// HandleChannelMembers returns members of a channel.
func (hdlr *Handlers) HandleChannelMembers() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
_, _, _, ok := hdlr.requireAuth(writer, request)
if !ok {
return
}
name := "#" + chi.URLParam(request, "channel")
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), name,
)
if err != nil {
hdlr.respondError(
writer, request,
"channel not found",
http.StatusNotFound,
)
return
}
members, err := hdlr.params.Database.ChannelMembers(
request.Context(), chID,
)
if err != nil {
hdlr.log.Error(
"channel members failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(
writer, request, members, http.StatusOK,
)
}
}
// HandleGetMessages returns messages via long-polling.
func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
sessionID, clientID, _, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
afterID, _ := strconv.ParseInt(
request.URL.Query().Get("after"), 10, 64,
)
timeout, _ := strconv.Atoi(
request.URL.Query().Get("timeout"),
)
if timeout < 0 {
timeout = 0
}
if timeout > maxLongPollTimeout {
timeout = maxLongPollTimeout
}
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID,
afterID, pollMessageLimit,
)
if err != nil {
hdlr.log.Error(
"poll messages failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if len(msgs) > 0 || timeout == 0 {
hdlr.respondJSON(writer, request, map[string]any{
"messages": msgs,
"last_id": lastQID,
}, http.StatusOK)
return
}
hdlr.longPoll(
writer, request,
sessionID, clientID, afterID, timeout,
)
}
}
func (hdlr *Handlers) longPoll(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID, afterID int64,
timeout int,
) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer(
time.Duration(timeout) * time.Second,
)
defer timer.Stop()
select {
case <-waitCh:
case <-timer.C:
case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return
}
hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID,
afterID, pollMessageLimit,
)
if err != nil {
hdlr.log.Error(
"poll messages failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request, map[string]any{
"messages": msgs,
"last_id": lastQID,
}, http.StatusOK)
}
// HandleSendCommand handles all C2S commands.
func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
type commandRequest struct {
Command string `json:"command"`
To string `json:"to"`
Body json.RawMessage `json:"body,omitempty"`
Meta json.RawMessage `json:"meta,omitempty"`
}
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
var payload commandRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Command = strings.ToUpper(
strings.TrimSpace(payload.Command),
)
payload.To = strings.TrimSpace(payload.To)
if payload.Command == "" {
hdlr.respondError(
writer, request,
"command required",
http.StatusBadRequest,
)
return
}
bodyLines := func() []string {
if payload.Body == nil {
return nil
}
var lines []string
decErr := json.Unmarshal(payload.Body, &lines)
if decErr != nil {
return nil
}
return lines
}
hdlr.dispatchCommand(
writer, request,
sessionID, clientID, nick,
payload.Command, payload.To,
payload.Body, bodyLines,
)
}
}
func (hdlr *Handlers) dispatchCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
bodyLines func() []string,
) {
switch command {
case cmdPrivmsg, "NOTICE":
hdlr.handlePrivmsg(
writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines,
)
case "JOIN":
hdlr.handleJoin(
writer, request,
sessionID, clientID, nick, target,
)
case "PART":
hdlr.handlePart(
writer, request,
sessionID, clientID, nick, target, body,
)
case "NICK":
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case "TOPIC":
hdlr.handleTopic(
writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
)
case "QUIT":
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case "PING":
hdlr.respondJSON(writer, request,
map[string]string{
"command": "PONG",
"from": hdlr.serverName(),
},
http.StatusOK)
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
"421", nick, []string{command},
"Unknown command",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
}
}
func (hdlr *Handlers) handlePrivmsg(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
bodyLines func() []string,
) {
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
"461", nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.enqueueNumeric(
request.Context(), clientID,
"461", nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return
}
if strings.HasPrefix(target, "#") {
hdlr.handleChannelMsg(
writer, request,
sessionID, clientID, nick,
command, target, body,
)
return
}
hdlr.handleDirectMsg(
writer, request,
sessionID, clientID, nick,
command, target, body,
)
}
// respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
clientID, sessionID int64,
numeric, nick string,
params []string,
text string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID,
numeric, nick, params, text,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
}
func (hdlr *Handlers) handleChannelMsg(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
) {
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target,
)
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{target},
"No such channel",
)
return
}
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"check membership failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"442", nick, []string{target},
"You're not on that channel",
)
return
}
hdlr.sendChannelMsg(
writer, request, command, nick, target, body, chID,
)
}
func (hdlr *Handlers) sendChannelMsg(
writer http.ResponseWriter,
request *http.Request,
command, nick, target string,
body json.RawMessage,
chID int64,
) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID,
)
if err != nil {
hdlr.log.Error(
"get channel members failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, memberIDs,
)
if err != nil {
hdlr.log.Error("send message failed", "error", err)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"},
http.StatusOK)
}
func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
) {
targetSID, err := hdlr.params.Database.GetSessionByNick(
request.Context(), target,
)
if err != nil {
hdlr.enqueueNumeric(
request.Context(), clientID,
"401", nick, []string{target},
"No such nick/channel",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return
}
recipients := []int64{targetSID}
if targetSID != sessionID {
recipients = append(recipients, sessionID)
}
msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, recipients,
)
if err != nil {
hdlr.log.Error("send dm failed", "error", err)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"},
http.StatusOK)
}
func (hdlr *Handlers) handleJoin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"JOIN"},
"Not enough parameters",
)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
"No such channel",
)
return
}
hdlr.executeJoin(
writer, request,
sessionID, clientID, nick, channel,
)
}
func (hdlr *Handlers) executeJoin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
) {
chID, err := hdlr.params.Database.GetOrCreateChannel(
request.Context(), channel,
)
if err != nil {
hdlr.log.Error(
"get/create channel failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
err = hdlr.params.Database.JoinChannel(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"join channel failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID,
)
_ = hdlr.fanOutSilent(
request, "JOIN", nick, channel, nil, memberIDs,
)
hdlr.deliverJoinNumerics(
request, clientID, sessionID, nick, channel, chID,
)
hdlr.respondJSON(writer, request,
map[string]string{
"status": "joined",
"channel": channel,
},
http.StatusOK)
}
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
func (hdlr *Handlers) deliverJoinNumerics(
request *http.Request,
clientID, sessionID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
chInfo, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err == nil {
_ = chInfo // chInfo is the ID; topic comes from DB.
}
// Get topic from channel info.
channels, listErr := hdlr.params.Database.ListChannels(
ctx, sessionID,
)
topic := ""
if listErr == nil {
for _, ch := range channels {
if ch.Name == channel {
topic = ch.Topic
break
}
}
}
if topic != "" {
hdlr.enqueueNumeric(
ctx, clientID, "332", nick,
[]string{channel}, topic,
)
} else {
hdlr.enqueueNumeric(
ctx, clientID, "331", nick,
[]string{channel}, "No topic is set",
)
}
// Get member list for NAMES reply.
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members))
for _, mem := range members {
nicks = append(nicks, mem.Nick)
}
hdlr.enqueueNumeric(
ctx, clientID, "353", nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, "366", nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handlePart(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
body json.RawMessage,
) {
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
"461", nick, []string{"PART"},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), channel,
)
if err != nil {
hdlr.enqueueNumeric(
request.Context(), clientID,
"403", nick, []string{channel},
"No such channel",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
return
}
memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID,
)
_ = hdlr.fanOutSilent(
request, "PART", nick, channel, body, memberIDs,
)
err = hdlr.params.Database.PartChannel(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"part channel failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.params.Database.DeleteChannelIfEmpty(
request.Context(), chID,
)
hdlr.respondJSON(writer, request,
map[string]string{
"status": "parted",
"channel": channel,
},
http.StatusOK)
}
func (hdlr *Handlers) handleNick(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"NICK"},
"Not enough parameters",
)
return
}
newNick := strings.TrimSpace(lines[0])
if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"432", nick, []string{newNick},
"Erroneous nickname",
)
return
}
if newNick == nick {
hdlr.respondJSON(writer, request,
map[string]string{
"status": "ok", "nick": newNick,
},
http.StatusOK)
return
}
hdlr.executeNickChange(
writer, request,
sessionID, clientID, nick, newNick,
)
}
func (hdlr *Handlers) executeNickChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, newNick string,
) {
err := hdlr.params.Database.ChangeNick(
request.Context(), sessionID, newNick,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"433", nick, []string{newNick},
"Nickname is already in use",
)
return
}
hdlr.log.Error(
"change nick failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.broadcastNick(request, sessionID, nick, newNick)
hdlr.respondJSON(writer, request,
map[string]string{
"status": "ok", "nick": newNick,
},
http.StatusOK)
}
func (hdlr *Handlers) broadcastNick(
request *http.Request,
sessionID int64,
oldNick, newNick string,
) {
channels, _ := hdlr.params.Database.
GetSessionChannels(
request.Context(), sessionID,
)
notified := map[int64]bool{sessionID: true}
nickBody, err := json.Marshal([]string{newNick})
if err != nil {
hdlr.log.Error(
"marshal nick body", "error", err,
)
return
}
dbID, _, _ := hdlr.params.Database.InsertMessage(
request.Context(), "NICK", oldNick, "",
nil, json.RawMessage(nickBody), nil,
)
_ = hdlr.params.Database.EnqueueToSession(
request.Context(), sessionID, dbID,
)
hdlr.broker.Notify(sessionID)
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
GetChannelMemberIDs(
request.Context(), chanInfo.ID,
)
for _, mid := range memberIDs {
if !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueToSession(
request.Context(), mid, dbID,
)
hdlr.broker.Notify(mid)
}
}
}
}
func (hdlr *Handlers) handleTopic(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
body json.RawMessage,
bodyLines func() []string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"TOPIC"},
"Not enough parameters",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"TOPIC"},
"Not enough parameters",
)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), channel,
)
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
"No such channel",
)
return
}
hdlr.executeTopic(
writer, request,
sessionID, clientID, nick,
channel, strings.Join(lines, " "),
body, chID,
)
}
func (hdlr *Handlers) executeTopic(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel, topic string,
body json.RawMessage,
chID int64,
) {
setErr := hdlr.params.Database.SetTopic(
request.Context(), channel, topic,
)
if setErr != nil {
hdlr.log.Error(
"set topic failed", "error", setErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID,
)
_ = hdlr.fanOutSilent(
request, "TOPIC", nick, channel, body, memberIDs,
)
hdlr.enqueueNumeric(
request.Context(), clientID,
"332", nick, []string{channel}, topic,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{
"status": "ok", "topic": topic,
},
http.StatusOK)
}
func (hdlr *Handlers) handleQuit(
writer http.ResponseWriter,
request *http.Request,
sessionID int64,
nick string,
body json.RawMessage,
) {
channels, _ := hdlr.params.Database.
GetSessionChannels(
request.Context(), sessionID,
)
notified := map[int64]bool{}
var dbID int64
if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage(
request.Context(), "QUIT", nick, "",
nil, body, nil,
)
}
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
GetChannelMemberIDs(
request.Context(), chanInfo.ID,
)
for _, mid := range memberIDs {
if mid != sessionID && !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueToSession(
request.Context(), mid, dbID,
)
hdlr.broker.Notify(mid)
}
}
_ = hdlr.params.Database.PartChannel(
request.Context(), chanInfo.ID, sessionID,
)
_ = hdlr.params.Database.DeleteChannelIfEmpty(
request.Context(), chanInfo.ID,
)
}
_ = hdlr.params.Database.DeleteSession(
request.Context(), sessionID,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"},
http.StatusOK)
}
// HandleGetHistory returns message history for a target.
func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
sessionID, _, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
target := request.URL.Query().Get("target")
if target == "" {
hdlr.respondError(
writer, request,
"target required",
http.StatusBadRequest,
)
return
}
if !hdlr.canAccessHistory(
writer, request, sessionID, nick, target,
) {
return
}
beforeID, _ := strconv.ParseInt(
request.URL.Query().Get("before"), 10, 64,
)
limit, _ := strconv.Atoi(
request.URL.Query().Get("limit"),
)
if limit <= 0 || limit > maxHistLimit {
limit = defaultHistLimit
}
msgs, err := hdlr.params.Database.GetHistory(
request.Context(), target, beforeID, limit,
)
if err != nil {
hdlr.log.Error(
"get history failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(
writer, request, msgs, http.StatusOK,
)
}
}
// canAccessHistory verifies the user can read history
// for the given target (channel or DM participant).
func (hdlr *Handlers) canAccessHistory(
writer http.ResponseWriter,
request *http.Request,
sessionID int64,
nick, target string,
) bool {
if strings.HasPrefix(target, "#") {
return hdlr.canAccessChannelHistory(
writer, request, sessionID, target,
)
}
// DM history: only allow if the target is the
// requester's own nick (messages sent to them).
if target != nick {
hdlr.respondError(
writer, request,
"forbidden",
http.StatusForbidden,
)
return false
}
return true
}
func (hdlr *Handlers) canAccessChannelHistory(
writer http.ResponseWriter,
request *http.Request,
sessionID int64,
target string,
) bool {
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target,
)
if err != nil {
hdlr.respondError(
writer, request,
"channel not found",
http.StatusNotFound,
)
return false
}
isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, sessionID,
)
if err != nil {
hdlr.log.Error(
"check membership failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false
}
if !isMember {
hdlr.respondError(
writer, request,
"not a member of this channel",
http.StatusForbidden,
)
return false
}
return true
}
// HandleLogout deletes the authenticated client's token
// and cleans up the user (session) if no clients remain.
func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
sessionID, clientID, nick, ok :=
hdlr.requireAuth(writer, request)
if !ok {
return
}
ctx := request.Context()
err := hdlr.params.Database.DeleteClient(
ctx, clientID,
)
if err != nil {
hdlr.log.Error(
"delete client failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
}
// cleanupUser parts the user from all channels (notifying
// members) and deletes the session.
func (hdlr *Handlers) cleanupUser(
ctx context.Context,
sessionID int64,
nick string,
) {
channels, _ := hdlr.params.Database.
GetSessionChannels(ctx, sessionID)
notified := map[int64]bool{}
var quitDBID int64
if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, "QUIT", nick, "",
nil, nil, nil,
)
}
for _, chanInfo := range channels {
memberIDs, _ := hdlr.params.Database.
GetChannelMemberIDs(ctx, chanInfo.ID)
for _, mid := range memberIDs {
if mid != sessionID && !notified[mid] {
notified[mid] = true
_ = hdlr.params.Database.EnqueueToSession(
ctx, mid, quitDBID,
)
hdlr.broker.Notify(mid)
}
}
_ = hdlr.params.Database.PartChannel(
ctx, chanInfo.ID, sessionID,
)
_ = hdlr.params.Database.DeleteChannelIfEmpty(
ctx, chanInfo.ID,
)
}
_ = hdlr.params.Database.DeleteSession(ctx, sessionID)
}
// HandleUsersMe returns the current user's session info.
func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc {
return hdlr.HandleState()
}
// HandleServerInfo returns server metadata.
func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
users, err := hdlr.params.Database.GetUserCount(
request.Context(),
)
if err != nil {
hdlr.log.Error(
"get user count failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request, map[string]any{
"name": hdlr.params.Config.ServerName,
"motd": hdlr.params.Config.MOTD,
"users": users,
}, http.StatusOK)
}
}