Files
chat/internal/handlers/api.go
clawbot 27df999942
All checks were successful
check / check (push) Successful in 2m9s
Complete IRC numerics module and move to pkg/irc/ (refs #52) (#71)
This PR addresses [issue #52](#52):

- **Adds all missing numeric reply codes** from RFC 1459 and RFC 2812, making the module spec-complete
- **Moves the package** from `internal/irc/` to `pkg/irc/` to indicate external usefulness
- **Updates all imports** throughout the codebase

### Added numerics

- Trace replies (200-209)
- Stats replies (211-219, 242-243)
- Service replies (234-235)
- Admin replies (256-259)
- Trace log/end, try again (261-263)
- WHOWAS (314, 369)
- List start (321), unique ops (325)
- Invite/except lists (346-349)
- Version (351), links (364-365)
- Info (371, 374)
- Oper/rehash/service (381-383)
- Time/users (391-395)
- All missing error codes (406-415, 422-424, 436-437, 443-446, 463-467, 472, 476-478, 481, 483-485, 491, 501-502)

refs #52

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #71
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 18:41:26 +01:00

2403 lines
47 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"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
)
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 db.IsUniqueConstraintError(err) {
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 connection registration numerics
// (001-005) to a new client.
func (hdlr *Handlers) deliverWelcome(
request *http.Request,
clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
// 001 RPL_WELCOME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWelcome, nick, nil,
"Welcome to the network, "+nick,
)
// 002 RPL_YOURHOST
hdlr.enqueueNumeric(
ctx, clientID, irc.RplYourHost, nick, nil,
"Your host is "+srvName+
", running version "+version,
)
// 003 RPL_CREATED
hdlr.enqueueNumeric(
ctx, clientID, irc.RplCreated, nick, nil,
"This server was created "+
hdlr.params.Globals.StartTime.
Format("2006-01-02"),
)
// 004 RPL_MYINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, "", "imnst"},
"",
)
// 005 RPL_ISUPPORT
hdlr.enqueueNumeric(
ctx, clientID, irc.RplIsupport, nick,
[]string{
"CHANTYPES=#",
"NICKLEN=32",
"CHANMODES=,,," + "imnst",
"NETWORK=neoirc",
"CASEMAPPING=ascii",
},
"are supported by this server",
)
// LUSERS
hdlr.deliverLusers(ctx, clientID, nick)
}
// deliverLusers sends RPL_LUSERCLIENT (251),
// RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and
// RPL_LUSERME (255) to the client.
func (hdlr *Handlers) deliverLusers(
ctx context.Context,
clientID int64,
nick string,
) {
userCount, err := hdlr.params.Database.GetUserCount(ctx)
if err != nil {
hdlr.log.Error(
"lusers user count", "error", err,
)
userCount = 0
}
chanCount, err := hdlr.params.Database.GetChannelCount(
ctx,
)
if err != nil {
hdlr.log.Error(
"lusers channel count", "error", err,
)
chanCount = 0
}
// 251 RPL_LUSERCLIENT
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserClient, nick, nil,
fmt.Sprintf(
"There are %d users and 0 invisible on 1 servers",
userCount,
),
)
// 252 RPL_LUSEROP
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserOp, nick,
[]string{"0"},
"operator(s) online",
)
// 254 RPL_LUSERCHANNELS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserChannels, nick,
[]string{strconv.FormatInt(chanCount, 10)},
"channels formed",
)
// 255 RPL_LUSERME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplLuserMe, nick, nil,
fmt.Sprintf(
"I have %d clients and 1 servers",
userCount,
),
)
}
func (hdlr *Handlers) serverVersion() string {
ver := hdlr.params.Globals.Version
if ver == "" {
return "dev"
}
return ver
}
// 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, irc.RplMotdStart, nick, nil,
"- "+srvName+" Message of the Day -",
)
for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplMotd, nick, nil,
"- "+line,
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfMotd, 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,
code irc.IRCMessageType,
nick string,
params []string,
text string,
) {
command := code.Code()
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. When called with ?initChannelState=1, it also
// enqueues synthetic JOIN + TOPIC + NAMES messages for
// every channel the session belongs to so that a
// reconnecting client can rebuild its channel tabs from
// the message stream.
func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
sessionID, clientID, 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
}
if request.URL.Query().Get("initChannelState") == "1" {
hdlr.initChannelState(
request, clientID, sessionID, nick,
)
}
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": nick,
"channels": channels,
}, http.StatusOK)
}
}
// initChannelState enqueues synthetic JOIN messages and
// join-numerics (TOPIC, NAMES) for every channel the
// session belongs to. Messages are enqueued only to the
// specified client so other clients/sessions are not
// affected.
func (hdlr *Handlers) initChannelState(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.
GetSessionChannels(ctx, sessionID)
if err != nil || len(channels) == 0 {
return
}
for _, chanInfo := range channels {
// Enqueue a synthetic JOIN (only to this client).
dbID, _, insErr := hdlr.params.Database.
InsertMessage(
ctx, "JOIN", nick, chanInfo.Name,
nil, nil, nil,
)
if insErr != nil {
hdlr.log.Error(
"initChannelState: insert JOIN",
"error", insErr,
)
continue
}
_ = hdlr.params.Database.EnqueueToClient(
ctx, clientID, dbID,
)
// Enqueue TOPIC + NAMES numerics.
hdlr.deliverJoinNumerics(
request, clientID, sessionID,
nick, chanInfo.Name, chanInfo.ID,
)
}
}
// 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 irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg(
writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines,
)
case irc.CmdJoin:
hdlr.handleJoin(
writer, request,
sessionID, clientID, nick, target,
)
case irc.CmdPart:
hdlr.handlePart(
writer, request,
sessionID, clientID, nick, target, body,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
)
case irc.CmdQuit:
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case irc.CmdMotd, irc.CmdPing:
hdlr.dispatchInfoCommand(
writer, request,
sessionID, clientID, nick,
command, target, bodyLines,
)
default:
hdlr.dispatchQueryCommand(
writer, request,
sessionID, clientID, nick,
command, target, bodyLines,
)
}
}
func (hdlr *Handlers) dispatchQueryCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
bodyLines func() []string,
) {
switch command {
case irc.CmdMode:
hdlr.handleMode(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case irc.CmdNames:
hdlr.handleNames(
writer, request,
sessionID, clientID, nick, target,
)
case irc.CmdList:
hdlr.handleList(
writer, request,
sessionID, clientID, nick,
)
case irc.CmdWhois:
hdlr.handleWhois(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case irc.CmdWho:
hdlr.handleWho(
writer, request,
sessionID, clientID, nick, target,
)
case irc.CmdLusers:
hdlr.handleLusers(
writer, request,
sessionID, clientID, nick,
)
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.ErrUnknownCommand, 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,
irc.ErrNeedMoreParams, 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,
irc.ErrNeedMoreParams, 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,
code irc.IRCMessageType,
nick string,
params []string,
text string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID,
code, 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,
irc.ErrNoSuchChannel, 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,
irc.ErrNotOnChannel, 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,
irc.ErrNoSuchNick, 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,
irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin},
"Not enough parameters",
)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, 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, irc.CmdJoin, 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, irc.RplTopic, nick,
[]string{channel}, topic,
)
} else {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplNoTopic, 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, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfNames, 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,
irc.ErrNeedMoreParams, nick, []string{irc.CmdPart},
"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,
irc.ErrNoSuchChannel, 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, irc.CmdPart, 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,
irc.ErrNeedMoreParams, nick, []string{irc.CmdNick},
"Not enough parameters",
)
return
}
newNick := strings.TrimSpace(lines[0])
if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrErroneusNickname, 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 db.IsUniqueConstraintError(err) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNicknameInUse, 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(), irc.CmdNick, 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,
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
"Not enough parameters",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
"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,
irc.ErrNoSuchChannel, 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, irc.CmdTopic, nick, channel, body, memberIDs,
)
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplTopic, nick, []string{channel}, topic,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{
"status": "ok", "topic": topic,
},
http.StatusOK)
}
// dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, PING).
func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
bodyLines func() []string,
) {
_ = target
_ = bodyLines
okResp := map[string]string{"status": "ok"}
switch command {
case irc.CmdMotd:
hdlr.deliverMOTD(
request, clientID, sessionID, nick,
)
case irc.CmdPing:
hdlr.respondJSON(writer, request,
map[string]string{
"command": irc.CmdPong,
"from": hdlr.serverName(),
},
http.StatusOK)
return
}
hdlr.respondJSON(
writer, request, okResp, 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(), irc.CmdQuit, 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)
}
// handleMode handles the MODE command for channels and
// users. Currently supports query-only (no mode changes).
func (hdlr *Handlers) handleMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
"Not enough parameters",
)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
// User mode query — return empty modes.
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplUmodeIs, nick, nil, "+",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
_ = bodyLines
hdlr.handleChannelMode(
writer, request,
sessionID, clientID, nick, channel,
)
}
func (hdlr *Handlers) handleChannelMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
) {
ctx := request.Context()
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
return
}
// 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, "+n"}, "",
)
// 329 RPL_CREATIONTIME
createdAt, timeErr := hdlr.params.Database.
GetChannelCreatedAt(ctx, chID)
if timeErr == nil {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplCreationTime, nick,
[]string{
channel,
strconv.FormatInt(
createdAt.Unix(), 10,
),
},
"",
)
}
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleNames sends NAMES reply for a channel.
func (hdlr *Handlers) handleNames(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdNames},
"Not enough parameters",
)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
ctx := request.Context()
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
return
}
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, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfNames, nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleList sends the LIST response with 322/323
// numerics.
func (hdlr *Handlers) handleList(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.
ListAllChannelsWithCounts(ctx)
if err != nil {
hdlr.log.Error(
"list channels failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
for _, chanInfo := range channels {
// 322 RPL_LIST
hdlr.enqueueNumeric(
ctx, clientID, irc.RplList, nick,
[]string{
chanInfo.Name,
strconv.FormatInt(
chanInfo.MemberCount, 10,
),
},
chanInfo.Topic,
)
}
// 323 — end of channel list.
hdlr.enqueueNumeric(
ctx, clientID, irc.RplListEnd, nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleWhois handles the WHOIS command.
func (hdlr *Handlers) handleWhois(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
queryNick := target
// If target is empty, check body for the nick.
if queryNick == "" {
lines := bodyLines()
if len(lines) > 0 {
queryNick = strings.TrimSpace(lines[0])
}
}
if queryNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois},
"Not enough parameters",
)
return
}
hdlr.executeWhois(
writer, request,
sessionID, clientID, nick, queryNick,
)
}
func (hdlr *Handlers) executeWhois(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, queryNick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
targetSID, err := hdlr.params.Database.GetSessionByNick(
ctx, queryNick,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrNoSuchNick, nick,
[]string{queryNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisUser, nick,
[]string{queryNick, queryNick, srvName, "*"},
queryNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisServer, nick,
[]string{queryNick, srvName},
"neoirc server",
)
// 319 RPL_WHOISCHANNELS
hdlr.deliverWhoisChannels(
ctx, clientID, nick, queryNick, targetSID,
)
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
func (hdlr *Handlers) deliverWhoisChannels(
ctx context.Context,
clientID int64,
nick, queryNick string,
targetSID int64,
) {
channels, chanErr := hdlr.params.Database.
GetSessionChannels(ctx, targetSID)
if chanErr != nil || len(channels) == 0 {
return
}
chanNames := make([]string, 0, len(channels))
for _, chanInfo := range channels {
chanNames = append(chanNames, chanInfo.Name)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoisChannels, nick,
[]string{queryNick},
strings.Join(chanNames, " "),
)
}
// handleWho handles the WHO command.
func (hdlr *Handlers) handleWho(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdWho},
"Not enough parameters",
)
return
}
ctx := request.Context()
srvName := hdlr.serverName()
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
// 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick,
[]string{target},
"End of /WHO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil {
for _, mem := range members {
// 352 RPL_WHOREPLY
hdlr.enqueueNumeric(
ctx, clientID, irc.RplWhoReply, nick,
[]string{
channel, mem.Nick, srvName,
srvName, mem.Nick, "H",
},
"0 "+mem.Nick,
)
}
}
// 315 RPL_ENDOFWHO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick,
[]string{channel},
"End of /WHO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleLusers handles the LUSERS command.
func (hdlr *Handlers) handleLusers(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
hdlr.deliverLusers(
request.Context(), clientID, nick,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
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, irc.CmdQuit, 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,
"version": hdlr.params.Globals.Version,
"motd": hdlr.params.Config.MOTD,
"users": users,
}, http.StatusOK)
}
}