diff --git a/README.md b/README.md index 1483a32..1899843 100644 --- a/README.md +++ b/README.md @@ -2228,7 +2228,7 @@ directory is also loaded automatically via | `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | | `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | | `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | -| `IRC_LISTEN_ADDR` | string | `""` | TCP address for the traditional IRC protocol listener (e.g. `:6667`). Disabled if empty. | +| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. | | `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file diff --git a/cmd/neoircd/main.go b/cmd/neoircd/main.go index d5a3988..8a5b141 100644 --- a/cmd/neoircd/main.go +++ b/cmd/neoircd/main.go @@ -12,6 +12,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/server" + "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/internal/stats" "go.uber.org/fx" ) @@ -40,6 +41,7 @@ func main() { server.New, middleware.New, healthcheck.New, + service.New, stats.New, ), fx.Invoke(func( diff --git a/internal/config/config.go b/internal/config/config.go index c2f9be7..c3377d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -87,7 +87,7 @@ func New( viper.SetDefault("NEOIRC_OPER_PASSWORD", "") viper.SetDefault("LOGIN_RATE_LIMIT", "1") viper.SetDefault("LOGIN_RATE_BURST", "5") - viper.SetDefault("IRC_LISTEN_ADDR", "") + viper.SetDefault("IRC_LISTEN_ADDR", ":6667") err := viper.ReadInConfig() if err != nil { diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 6f06565..973d0de 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -3547,52 +3547,16 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { } // cleanupUser parts the user from all channels (notifying -// members) and deletes the session. +// members) and deletes the session via the shared service +// layer. 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) + hdlr.svc.BroadcastQuit( + ctx, sessionID, nick, "Connection closed", + ) } // HandleUsersMe returns the current user's session info. diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4a4412d..d185b5d 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -17,6 +17,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/ratelimit" + "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/internal/stats" "go.uber.org/fx" ) @@ -34,6 +35,7 @@ type Params struct { Healthcheck *healthcheck.Healthcheck Stats *stats.Tracker Broker *broker.Broker + Service *service.Service } const defaultIdleTimeout = 30 * 24 * time.Hour @@ -49,6 +51,7 @@ type Handlers struct { log *slog.Logger hc *healthcheck.Healthcheck broker *broker.Broker + svc *service.Service hashcashVal *hashcash.Validator channelHashcash *hashcash.ChannelValidator loginLimiter *ratelimit.Limiter @@ -81,6 +84,7 @@ func New( log: params.Logger.Get(), hc: params.Healthcheck, broker: params.Broker, + svc: params.Service, hashcashVal: hashcash.NewValidator(resource), channelHashcash: hashcash.NewChannelValidator(), loginLimiter: ratelimit.New(loginRate, loginBurst), diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 3874d4c..c89258e 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -3,13 +3,27 @@ package ircserver import ( "context" "encoding/json" + "errors" "strconv" "strings" "time" + "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/pkg/irc" ) +// sendIRCError maps a service.IRCError to an IRC numeric +// reply on the wire. +func (c *Conn) sendIRCError(err error) { + var ircErr *service.IRCError + if errors.As(err, &ircErr) { + args := make([]string, 0, len(ircErr.Params)+1) + args = append(args, ircErr.Params...) + args = append(args, ircErr.Message) + c.sendNumeric(ircErr.Code, args...) + } +} + // handleCAP silently acknowledges CAP negotiation. func (c *Conn) handleCAP(msg *Message) { if len(msg.Params) == 0 { @@ -37,8 +51,12 @@ func (c *Conn) handlePing(msg *Message) { c.sendFromServer("PONG", c.serverSfx, token) } -// handleNick changes the user's nickname. -func (c *Conn) handleNick(ctx context.Context, msg *Message) { +// handleNick changes the user's nickname via the shared +// service layer. +func (c *Conn) handleNick( + ctx context.Context, + msg *Message, +) { if len(msg.Params) < 1 { c.sendNumeric( irc.ErrNoNicknameGiven, "No nickname given", @@ -55,23 +73,11 @@ func (c *Conn) handleNick(ctx context.Context, msg *Message) { oldMask := c.hostmask() oldNick := c.nick - err := c.database.ChangeNick( - ctx, c.sessionID, newNick, + err := c.svc.ChangeNick( + ctx, c.sessionID, oldNick, newNick, ) if err != nil { - if strings.Contains(err.Error(), "UNIQUE") { - c.sendNumeric( - irc.ErrNicknameInUse, - newNick, "Nickname is already in use", - ) - - return - } - - c.sendNumeric( - irc.ErrErroneusNickname, - newNick, "Erroneous nickname", - ) + c.sendIRCError(err) return } @@ -80,68 +86,12 @@ func (c *Conn) handleNick(ctx context.Context, msg *Message) { c.nick = newNick c.mu.Unlock() - // Echo NICK change to the client. + // Echo NICK change to the client on wire. c.send(FormatMessage(oldMask, "NICK", newNick)) - - // Broadcast nick change to shared channels. - c.broadcastNickChange(ctx, oldNick, newNick) } -// broadcastNickChange notifies channel peers of a nick -// change. -func (c *Conn) broadcastNickChange( - ctx context.Context, - oldNick, newNick string, -) { - channels, err := c.database.GetSessionChannels( - ctx, c.sessionID, - ) - if err != nil { - return - } - - body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson - notified := make(map[int64]bool) - - for _, ch := range channels { - chID, getErr := c.database.GetChannelByName( - ctx, ch.Name, - ) - if getErr != nil { - continue - } - - memberIDs, memErr := c.database.GetChannelMemberIDs( - ctx, chID, - ) - if memErr != nil { - continue - } - - for _, mid := range memberIDs { - if mid == c.sessionID || notified[mid] { - continue - } - - notified[mid] = true - - dbID, _, insErr := c.database.InsertMessage( - ctx, irc.CmdNick, oldNick, "", - nil, body, nil, - ) - if insErr != nil { - continue - } - - _ = c.database.EnqueueToSession( - ctx, mid, dbID, - ) - c.brk.Notify(mid) - } - } -} - -// handlePrivmsg handles PRIVMSG and NOTICE commands. +// handlePrivmsg handles PRIVMSG and NOTICE commands via +// the shared service layer. func (c *Conn) handlePrivmsg( ctx context.Context, msg *Message, @@ -168,123 +118,38 @@ func (c *Conn) handlePrivmsg( body, _ := json.Marshal([]string{text}) //nolint:errchkjson if strings.HasPrefix(target, "#") { - c.handleChannelMsg(ctx, msg.Command, target, body) + _, err := c.svc.SendChannelMessage( + ctx, c.sessionID, c.nick, + msg.Command, target, body, nil, + ) + if err != nil { + c.sendIRCError(err) + } } else { - c.handleDirectMsg(ctx, msg.Command, target, body) - } -} - -// handleChannelMsg sends a message to a channel. -func (c *Conn) handleChannelMsg( - ctx context.Context, - command, channel string, - body json.RawMessage, -) { - chID, err := c.database.GetChannelByName(ctx, channel) - if err != nil { - c.sendNumeric( - irc.ErrNoSuchChannel, - channel, "No such channel", + result, err := c.svc.SendDirectMessage( + ctx, c.sessionID, c.nick, + msg.Command, target, body, nil, ) - - return - } - - isMember, _ := c.database.IsChannelMember( - ctx, chID, c.sessionID, - ) - if !isMember { - c.sendNumeric( - irc.ErrCannotSendToChan, - channel, "Cannot send to channel", - ) - - return - } - - // Check moderated mode. - moderated, _ := c.database.IsChannelModerated(ctx, chID) - if moderated { - isOp, _ := c.database.IsChannelOperator( - ctx, chID, c.sessionID, - ) - isVoiced, _ := c.database.IsChannelVoiced( - ctx, chID, c.sessionID, - ) - - if !isOp && !isVoiced { - c.sendNumeric( - irc.ErrCannotSendToChan, - channel, - "Cannot send to channel (+m)", - ) + if err != nil { + c.sendIRCError(err) return } - } - memberIDs, _ := c.database.GetChannelMemberIDs( - ctx, chID, - ) - - // Fan out to all members except sender. - for _, mid := range memberIDs { - if mid == c.sessionID { - continue + if result.AwayMsg != "" { + c.sendNumeric( + irc.RplAway, target, result.AwayMsg, + ) } - - dbID, _, insErr := c.database.InsertMessage( - ctx, command, c.nick, channel, - nil, body, nil, - ) - if insErr != nil { - continue - } - - _ = c.database.EnqueueToSession(ctx, mid, dbID) - c.brk.Notify(mid) } } -// handleDirectMsg sends a private message to a user. -func (c *Conn) handleDirectMsg( +// handleJoin joins one or more channels via the shared +// service layer. +func (c *Conn) handleJoin( ctx context.Context, - command, target string, - body json.RawMessage, + msg *Message, ) { - targetID, err := c.database.GetSessionByNick( - ctx, target, - ) - if err != nil { - c.sendNumeric( - irc.ErrNoSuchNick, target, "No such nick", - ) - - return - } - - // Check AWAY status. - away, _ := c.database.GetAway(ctx, targetID) - if away != "" { - c.sendNumeric( - irc.RplAway, target, away, - ) - } - - dbID, _, insErr := c.database.InsertMessage( - ctx, command, c.nick, target, - nil, body, nil, - ) - if insErr != nil { - return - } - - _ = c.database.EnqueueToSession(ctx, targetID, dbID) - c.brk.Notify(targetID) -} - -// handleJoin joins one or more channels. -func (c *Conn) handleJoin(ctx context.Context, msg *Message) { if len(msg.Params) < 1 { c.sendNumeric( irc.ErrNeedMoreParams, @@ -307,67 +172,35 @@ func (c *Conn) handleJoin(ctx context.Context, msg *Message) { } } -// joinChannel joins a single channel. +// joinChannel joins a single channel using the service +// and delivers topic/names on the wire. func (c *Conn) joinChannel( - ctx context.Context, channel string, + ctx context.Context, + channel string, ) { - chID, err := c.database.GetOrCreateChannel(ctx, channel) - if err != nil { - c.log.Error( - "get/create channel failed", "error", err, - ) - - return - } - - // First joiner becomes operator. - memberCount, countErr := c.database.CountChannelMembers( - ctx, chID, + result, err := c.svc.JoinChannel( + ctx, c.sessionID, c.nick, channel, ) - isCreator := countErr == nil && memberCount == 0 - - if isCreator { - err = c.database.JoinChannelAsOperator( - ctx, chID, c.sessionID, - ) - } else { - err = c.database.JoinChannel( - ctx, chID, c.sessionID, - ) - } - if err != nil { - return - } + c.sendIRCError(err) - // Fan out JOIN to all channel members. - memberIDs, _ := c.database.GetChannelMemberIDs( - ctx, chID, - ) - - joinBody, _ := json.Marshal([]string{channel}) //nolint:errchkjson - - for _, mid := range memberIDs { - dbID, _, insErr := c.database.InsertMessage( - ctx, irc.CmdJoin, c.nick, channel, - nil, joinBody, nil, - ) - if insErr != nil { - continue + if !errors.As(err, new(*service.IRCError)) { + c.log.Error( + "join channel failed", "error", err, + ) } - _ = c.database.EnqueueToSession(ctx, mid, dbID) - c.brk.Notify(mid) + return } // Send JOIN echo to this client directly on wire. c.send(FormatMessage(c.hostmask(), "JOIN", channel)) // Send topic. - c.deliverTopic(ctx, channel, chID) + c.deliverTopic(ctx, channel, result.ChannelID) // Send NAMES. - c.deliverNames(ctx, channel, chID) + c.deliverNames(ctx, channel, result.ChannelID) } // deliverTopic sends RPL_TOPIC or RPL_NOTOPIC. @@ -452,7 +285,8 @@ func (c *Conn) deliverNames( ) } -// handlePart leaves one or more channels. +// handlePart leaves one or more channels via the shared +// service layer. func (c *Conn) handlePart( ctx context.Context, msg *Message, @@ -479,58 +313,21 @@ func (c *Conn) handlePart( } } -// partChannel leaves a single channel. +// partChannel leaves a single channel using the service. func (c *Conn) partChannel( ctx context.Context, channel, reason string, ) { - chID, err := c.database.GetChannelByName(ctx, channel) + err := c.svc.PartChannel( + ctx, c.sessionID, c.nick, channel, reason, + ) if err != nil { - c.sendNumeric( - irc.ErrNoSuchChannel, - channel, "No such channel", - ) + c.sendIRCError(err) return } - isMember, _ := c.database.IsChannelMember( - ctx, chID, c.sessionID, - ) - if !isMember { - c.sendNumeric( - irc.ErrNotOnChannel, - channel, "You're not on that channel", - ) - - return - } - - // Broadcast PART to channel members before leaving. - memberIDs, _ := c.database.GetChannelMemberIDs( - ctx, chID, - ) - - body, _ := json.Marshal([]string{reason}) //nolint:errchkjson - - for _, mid := range memberIDs { - if mid == c.sessionID { - continue - } - - dbID, _, insErr := c.database.InsertMessage( - ctx, irc.CmdPart, c.nick, channel, - nil, body, nil, - ) - if insErr != nil { - continue - } - - _ = c.database.EnqueueToSession(ctx, mid, dbID) - c.brk.Notify(mid) - } - - // Echo PART to the client. + // Echo PART to the client on wire. if reason != "" { c.send(FormatMessage( c.hostmask(), "PART", channel, reason, @@ -540,9 +337,6 @@ func (c *Conn) partChannel( c.hostmask(), "PART", channel, )) } - - c.database.PartChannel(ctx, chID, c.sessionID) //nolint:errcheck,gosec - c.database.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec } // handleQuit handles the QUIT command. @@ -558,9 +352,8 @@ func (c *Conn) handleQuit(msg *Message) { c.closed = true } -// handleTopic gets or sets a channel topic. -// -//nolint:funlen // coherent flow +// handleTopic gets or sets a channel topic via the shared +// service layer. func (c *Conn) handleTopic( ctx context.Context, msg *Message, @@ -576,6 +369,36 @@ func (c *Conn) handleTopic( channel := msg.Params[0] + // If no second param, query the topic. + if len(msg.Params) < 2 { //nolint:mnd + c.queryTopic(ctx, channel) + + return + } + + // Set topic via service. + newTopic := msg.Params[1] + + err := c.svc.SetTopic( + ctx, c.sessionID, c.nick, channel, newTopic, + ) + if err != nil { + c.sendIRCError(err) + + return + } + + // Echo TOPIC to the setting client on wire. + c.send(FormatMessage( + c.hostmask(), "TOPIC", channel, newTopic, + )) +} + +// queryTopic sends the current topic for a channel. +func (c *Conn) queryTopic( + ctx context.Context, + channel string, +) { chID, err := c.database.GetChannelByName(ctx, channel) if err != nil { c.sendNumeric( @@ -586,79 +409,7 @@ func (c *Conn) handleTopic( return } - // If no second param, query the topic. - if len(msg.Params) < 2 { //nolint:mnd - c.deliverTopic(ctx, channel, chID) - - return - } - - // Set topic — check permissions. - isMember, _ := c.database.IsChannelMember( - ctx, chID, c.sessionID, - ) - if !isMember { - c.sendNumeric( - irc.ErrNotOnChannel, - channel, "You're not on that channel", - ) - - return - } - - topicLocked, _ := c.database.IsChannelTopicLocked( - ctx, chID, - ) - if topicLocked { - isOp, _ := c.database.IsChannelOperator( - ctx, chID, c.sessionID, - ) - if !isOp { - c.sendNumeric( - irc.ErrChanOpPrivsNeeded, - channel, - "You're not channel operator", - ) - - return - } - } - - newTopic := msg.Params[1] - - err = c.database.SetTopic(ctx, channel, newTopic) - if err != nil { - return - } - - _ = c.database.SetTopicMeta( - ctx, channel, newTopic, c.nick, - ) - - // Broadcast TOPIC to all members. - memberIDs, _ := c.database.GetChannelMemberIDs( - ctx, chID, - ) - - body, _ := json.Marshal([]string{newTopic}) //nolint:errchkjson - - for _, mid := range memberIDs { - dbID, _, insErr := c.database.InsertMessage( - ctx, irc.CmdTopic, c.nick, channel, - nil, body, nil, - ) - if insErr != nil { - continue - } - - _ = c.database.EnqueueToSession(ctx, mid, dbID) - c.brk.Notify(mid) - } - - // Echo to the setting client on wire. - c.send(FormatMessage( - c.hostmask(), "TOPIC", channel, newTopic, - )) + c.deliverTopic(ctx, channel, chID) } // handleMode handles MODE queries and changes. @@ -680,7 +431,7 @@ func (c *Conn) handleMode( if strings.HasPrefix(target, "#") { c.handleChannelMode(ctx, msg) } else { - c.handleUserMode(ctx, msg) + c.handleUserMode(msg) } } @@ -703,7 +454,7 @@ func (c *Conn) handleChannelMode( // Query mode if no mode string given. if len(msg.Params) < 2 { //nolint:mnd - modeStr := c.buildChannelModeString(ctx, chID) + modeStr := c.svc.QueryChannelMode(ctx, chID) c.sendNumeric( irc.RplChannelModeIs, channel, modeStr, ) @@ -721,15 +472,12 @@ func (c *Conn) handleChannelMode( return } - // Need ops to change modes. - isOp, _ := c.database.IsChannelOperator( - ctx, chID, c.sessionID, + // Need ops to change modes — validated by service. + _, opErr := c.svc.ValidateChannelOp( + ctx, c.sessionID, channel, ) - if !isOp { - c.sendNumeric( - irc.ErrChanOpPrivsNeeded, - channel, "You're not channel operator", - ) + if opErr != nil { + c.sendIRCError(opErr) return } @@ -737,39 +485,13 @@ func (c *Conn) handleChannelMode( modeStr := msg.Params[1] modeArgs := msg.Params[2:] - c.applyChannelModes(ctx, channel, chID, modeStr, modeArgs) -} - -// buildChannelModeString constructs the mode string for a -// channel. -func (c *Conn) buildChannelModeString( - ctx context.Context, - chID int64, -) string { - modes := "+" - - moderated, _ := c.database.IsChannelModerated(ctx, chID) - if moderated { - modes += "m" - } - - topicLocked, _ := c.database.IsChannelTopicLocked( - ctx, chID, + c.applyChannelModes( + ctx, channel, chID, modeStr, modeArgs, ) - if topicLocked { - modes += "t" - } - - if modes == "+" { - modes = "+" - } - - return modes } -// applyChannelModes applies mode changes. -// -//nolint:cyclop,funlen // mode parsing is inherently branchy +// applyChannelModes applies mode changes using the +// service for individual mode operations. func (c *Conn) applyChannelModes( ctx context.Context, channel string, @@ -788,25 +510,17 @@ func (c *Conn) applyChannelModes( adding = true case '-': adding = false - case 'm': - _ = c.database.SetChannelModerated( - ctx, chID, adding, + case 'm', 't': + _ = c.svc.SetChannelFlag( + ctx, chID, modeChar, adding, ) + if adding { - applied += "+m" + applied += "+" + string(modeChar) } else { - applied += "-m" + applied += "-" + string(modeChar) } - case 't': - _ = c.database.SetChannelTopicLocked( - ctx, chID, adding, - ) - if adding { - applied += "+t" - } else { - applied += "-t" - } - case 'o': + case 'o', 'v': if argIdx >= len(args) { break } @@ -814,35 +528,20 @@ func (c *Conn) applyChannelModes( targetNick := args[argIdx] argIdx++ - c.applyMemberMode( + err := c.svc.ApplyMemberMode( ctx, chID, channel, - targetNick, 'o', adding, + targetNick, modeChar, adding, ) + if err != nil { + c.sendIRCError(err) - if adding { - applied += "+o" - } else { - applied += "-o" + continue } - appliedArgs += " " + targetNick - case 'v': - if argIdx >= len(args) { - break - } - - targetNick := args[argIdx] - argIdx++ - - c.applyMemberMode( - ctx, chID, channel, - targetNick, 'v', adding, - ) - if adding { - applied += "+v" + applied += "+" + string(modeChar) } else { - applied += "-v" + applied += "-" + string(modeChar) } appliedArgs += " " + targetNick @@ -864,59 +563,15 @@ func (c *Conn) applyChannelModes( c.send(FormatMessage( c.hostmask(), "MODE", channel, modeReply, )) - } -} -// applyMemberMode applies +o/-o or +v/-v on a member. -func (c *Conn) applyMemberMode( - ctx context.Context, - chID int64, - channel, targetNick string, - mode rune, - adding bool, -) { - targetSessionID, err := c.database.GetSessionByNick( - ctx, targetNick, - ) - if err != nil { - c.sendNumeric( - irc.ErrNoSuchNick, - targetNick, "No such nick/channel", - ) - - return - } - - isMember, _ := c.database.IsChannelMember( - ctx, chID, targetSessionID, - ) - if !isMember { - c.sendNumeric( - irc.ErrUserNotInChannel, - targetNick, channel, - "They aren't on that channel", - ) - - return - } - - switch mode { - case 'o': - _ = c.database.SetChannelMemberOperator( - ctx, chID, targetSessionID, adding, - ) - case 'v': - _ = c.database.SetChannelMemberVoiced( - ctx, chID, targetSessionID, adding, + c.svc.BroadcastMode( + ctx, c.nick, channel, chID, modeReply, ) } } // handleUserMode handles MODE for users. -func (c *Conn) handleUserMode( - _ context.Context, - msg *Message, -) { +func (c *Conn) handleUserMode(msg *Message) { target := msg.Params[0] if !strings.EqualFold(target, c.nick) { @@ -928,12 +583,6 @@ func (c *Conn) handleUserMode( return } - if len(msg.Params) < 2 { //nolint:mnd - c.sendNumeric(irc.RplUmodeIs, "+") - - return - } - // We don't support user modes beyond the basics. c.sendNumeric(irc.RplUmodeIs, "+") } @@ -995,9 +644,8 @@ func (c *Conn) handleList(ctx context.Context) { c.sendNumeric(irc.RplListEnd, "End of /LIST") } -// handleWhois replies with user info. -// -//nolint:funlen // WHOIS has many reply fields +// handleWhois replies with user info. Individual numeric +// replies are split into focused helper methods. func (c *Conn) handleWhois( ctx context.Context, msg *Message, @@ -1010,8 +658,6 @@ func (c *Conn) handleWhois( return } - // The target nick may be the second param - // (WHOIS server nick). target := msg.Params[0] if len(msg.Params) > 1 { @@ -1033,7 +679,25 @@ func (c *Conn) handleWhois( return } - // Get host info. + c.whoisUser(ctx, target, targetID) + c.whoisServer(target) + c.whoisOper(ctx, target, targetID) + c.whoisChannels(ctx, target, targetID) + c.whoisIdle(ctx, target, targetID) + c.whoisAway(ctx, target, targetID) + + c.sendNumeric( + irc.RplEndOfWhois, + target, "End of /WHOIS list", + ) +} + +// whoisUser sends 311 RPL_WHOISUSER. +func (c *Conn) whoisUser( + ctx context.Context, + target string, + targetID int64, +) { hostInfo, _ := c.database.GetSessionHostInfo( ctx, targetID, ) @@ -1050,13 +714,22 @@ func (c *Conn) handleWhois( irc.RplWhoisUser, target, username, hostname, "*", target, ) +} +// whoisServer sends 312 RPL_WHOISSERVER. +func (c *Conn) whoisServer(target string) { c.sendNumeric( irc.RplWhoisServer, target, c.serverSfx, "neoirc server", ) +} - // Check oper status. +// whoisOper sends 313 RPL_WHOISOPERATOR if applicable. +func (c *Conn) whoisOper( + ctx context.Context, + target string, + targetID int64, +) { isOper, _ := c.database.IsSessionOper(ctx, targetID) if isOper { c.sendNumeric( @@ -1064,53 +737,62 @@ func (c *Conn) handleWhois( target, "is an IRC operator", ) } +} - // Get channels. +// whoisChannels sends 319 RPL_WHOISCHANNELS. +func (c *Conn) whoisChannels( + ctx context.Context, + target string, + targetID int64, +) { userChannels, _ := c.database.GetSessionChannels( ctx, targetID, ) - - if len(userChannels) > 0 { - var chanList []string - - for _, userChan := range userChannels { - chID, getErr := c.database.GetChannelByName( - ctx, userChan.Name, - ) - if getErr != nil { - chanList = append( - chanList, userChan.Name, - ) - - continue - } - - isChOp, _ := c.database.IsChannelOperator( - ctx, chID, targetID, - ) - isVoiced, _ := c.database.IsChannelVoiced( - ctx, chID, targetID, - ) - - prefix := "" - if isChOp { - prefix = "@" - } else if isVoiced { - prefix = "+" - } - - chanList = append( - chanList, prefix+userChan.Name, - ) - } - - c.sendNumeric( - irc.RplWhoisChannels, target, - strings.Join(chanList, " "), - ) + if len(userChannels) == 0 { + return } - // Idle time. + chanList := make([]string, 0, len(userChannels)) + + for _, userChan := range userChannels { + chID, getErr := c.database.GetChannelByName( + ctx, userChan.Name, + ) + if getErr != nil { + chanList = append(chanList, userChan.Name) + + continue + } + + isChOp, _ := c.database.IsChannelOperator( + ctx, chID, targetID, + ) + isVoiced, _ := c.database.IsChannelVoiced( + ctx, chID, targetID, + ) + + prefix := "" + if isChOp { + prefix = "@" + } else if isVoiced { + prefix = "+" + } + + chanList = append(chanList, prefix+userChan.Name) + } + + c.sendNumeric( + irc.RplWhoisChannels, target, + strings.Join(chanList, " "), + ) +} + +// whoisIdle sends 317 RPL_WHOISIDLE. +func (c *Conn) whoisIdle( + ctx context.Context, + target string, + targetID int64, +) { lastSeen, _ := c.database.GetSessionLastSeen( ctx, targetID, ) @@ -1118,32 +800,35 @@ func (c *Conn) handleWhois( ctx, targetID, ) - if !lastSeen.IsZero() { - idle := int64(time.Since(lastSeen).Seconds()) - - signonTS := int64(0) - if !created.IsZero() { - signonTS = created.Unix() - } - - c.sendNumeric( - irc.RplWhoisIdle, target, - strconv.FormatInt(idle, 10), - strconv.FormatInt(signonTS, 10), - "seconds idle, signon time", - ) + if lastSeen.IsZero() { + return } - // Away. + idle := int64(time.Since(lastSeen).Seconds()) + + signonTS := int64(0) + if !created.IsZero() { + signonTS = created.Unix() + } + + c.sendNumeric( + irc.RplWhoisIdle, target, + strconv.FormatInt(idle, 10), + strconv.FormatInt(signonTS, 10), + "seconds idle, signon time", + ) +} + +// whoisAway sends 301 RPL_AWAY if the target is away. +func (c *Conn) whoisAway( + ctx context.Context, + target string, + targetID int64, +) { away, _ := c.database.GetAway(ctx, targetID) if away != "" { c.sendNumeric(irc.RplAway, target, away) } - - c.sendNumeric( - irc.RplEndOfWhois, - target, "End of /WHOIS list", - ) } // handleWho sends WHO replies for a channel. @@ -1246,7 +931,8 @@ func (c *Conn) handleLusers(ctx context.Context) { c.deliverLusers(ctx) } -// handleOper handles the OPER command. +// handleOper handles the OPER command via the shared +// service layer. func (c *Conn) handleOper( ctx context.Context, msg *Message, @@ -1260,60 +946,57 @@ func (c *Conn) handleOper( return } - name := msg.Params[0] - password := msg.Params[1] - - cfgName := c.cfg.OperName - cfgPassword := c.cfg.OperPassword - - if cfgName == "" || cfgPassword == "" { - c.sendNumeric( - irc.ErrNoOperHost, "No O-lines for your host", - ) + err := c.svc.Oper( + ctx, c.sessionID, + msg.Params[0], msg.Params[1], + ) + if err != nil { + c.sendIRCError(err) return } - if name != cfgName || password != cfgPassword { - c.sendNumeric( - irc.ErrPasswdMismatch, "Password incorrect", - ) - - return - } - - _ = c.database.SetSessionOper(ctx, c.sessionID, true) c.sendNumeric( irc.RplYoureOper, "You are now an IRC operator", ) } -// handleAway sets or clears the AWAY status. +// handleAway sets or clears the AWAY status via the +// shared service layer. func (c *Conn) handleAway( ctx context.Context, msg *Message, ) { - if len(msg.Params) < 1 || msg.Params[0] == "" { - _ = c.database.SetAway(ctx, c.sessionID, "") - c.sendNumeric( - irc.RplUnaway, - "You are no longer marked as being away", - ) + message := "" + if len(msg.Params) > 0 { + message = msg.Params[0] + } + + cleared, err := c.svc.SetAway( + ctx, c.sessionID, message, + ) + if err != nil { + c.log.Error("set away failed", "error", err) return } - _ = c.database.SetAway(ctx, c.sessionID, msg.Params[0]) - c.sendNumeric( - irc.RplNowAway, - "You have been marked as being away", - ) + if cleared { + c.sendNumeric( + irc.RplUnaway, + "You are no longer marked as being away", + ) + } else { + c.sendNumeric( + irc.RplNowAway, + "You have been marked as being away", + ) + } } -// handleKick kicks a user from a channel. -// -//nolint:funlen // coherent flow +// handleKick kicks a user from a channel via the shared +// service layer. func (c *Conn) handleKick( ctx context.Context, msg *Message, @@ -1335,77 +1018,16 @@ func (c *Conn) handleKick( reason = msg.Params[2] } - chID, err := c.database.GetChannelByName(ctx, channel) - if err != nil { - c.sendNumeric( - irc.ErrNoSuchChannel, - channel, "No such channel", - ) - - return - } - - isOp, _ := c.database.IsChannelOperator( - ctx, chID, c.sessionID, - ) - if !isOp { - c.sendNumeric( - irc.ErrChanOpPrivsNeeded, - channel, "You're not channel operator", - ) - - return - } - - targetSessionID, err := c.database.GetSessionByNick( - ctx, targetNick, + err := c.svc.KickUser( + ctx, c.sessionID, c.nick, + channel, targetNick, reason, ) if err != nil { - c.sendNumeric( - irc.ErrNoSuchNick, - targetNick, "No such nick/channel", - ) + c.sendIRCError(err) return } - isMember, _ := c.database.IsChannelMember( - ctx, chID, targetSessionID, - ) - if !isMember { - c.sendNumeric( - irc.ErrUserNotInChannel, - targetNick, channel, - "They aren't on that channel", - ) - - return - } - - // Broadcast KICK to all channel members. - memberIDs, _ := c.database.GetChannelMemberIDs( - ctx, chID, - ) - - body, _ := json.Marshal([]string{reason}) //nolint:errchkjson - - for _, mid := range memberIDs { - dbID, _, insErr := c.database.InsertMessage( - ctx, irc.CmdKick, c.nick, channel, - nil, body, nil, - ) - if insErr != nil { - continue - } - - _ = c.database.EnqueueToSession(ctx, mid, dbID) - c.brk.Notify(mid) - } - - // Remove from channel. - c.database.PartChannel(ctx, chID, targetSessionID) //nolint:errcheck,gosec - c.database.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec - // Echo KICK on wire. c.send(FormatMessage( c.hostmask(), "KICK", channel, targetNick, reason, @@ -1499,21 +1121,15 @@ func (c *Conn) handleInvite( irc.RplInviting, targetNick, channel, ) - // Send INVITE notice to target. + // Send INVITE notice to target via service fan-out. body, _ := json.Marshal( //nolint:errchkjson []string{"You have been invited to " + channel}, ) - dbID, _, insErr := c.database.InsertMessage( + _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, "INVITE", c.nick, targetNick, - nil, body, nil, + nil, body, nil, []int64{targetID}, ) - if insErr == nil { - _ = c.database.EnqueueToSession( - ctx, targetID, dbID, - ) - c.brk.Notify(targetID) - } } // handleUserhost replies with USERHOST info. diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index eddc96d..173ac19 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -3,7 +3,6 @@ package ircserver import ( "bufio" "context" - "encoding/json" "fmt" "log/slog" "net" @@ -15,6 +14,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/broker" "git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/neoirc/internal/db" + "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/pkg/irc" ) @@ -30,6 +30,10 @@ const ( minPasswordLen = 8 ) +// cmdHandler is the signature for registered IRC command +// handlers. +type cmdHandler func(ctx context.Context, msg *Message) + // Conn represents a single IRC client TCP connection. type Conn struct { conn net.Conn @@ -37,7 +41,9 @@ type Conn struct { database *db.Database brk *broker.Broker cfg *config.Config + svc *service.Service serverSfx string + commands map[string]cmdHandler mu sync.Mutex nick string @@ -65,6 +71,7 @@ func newConn( database *db.Database, brk *broker.Broker, cfg *config.Config, + svc *service.Service, ) *Conn { host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String()) @@ -73,16 +80,57 @@ func newConn( srvName = "neoirc" } - return &Conn{ //nolint:exhaustruct // zero-value defaults + conn := &Conn{ //nolint:exhaustruct // zero-value defaults conn: tcpConn, log: log, database: database, brk: brk, cfg: cfg, + svc: svc, serverSfx: srvName, remoteIP: host, hostname: resolveHost(ctx, host), } + + conn.commands = conn.buildCommandMap() + + return conn +} + +// buildCommandMap returns a map from IRC command strings +// to handler functions. +func (c *Conn) buildCommandMap() map[string]cmdHandler { + return map[string]cmdHandler{ + irc.CmdPing: func(_ context.Context, msg *Message) { + c.handlePing(msg) + }, + "PONG": func(context.Context, *Message) {}, + irc.CmdNick: c.handleNick, + irc.CmdPrivmsg: c.handlePrivmsg, + irc.CmdNotice: c.handlePrivmsg, + irc.CmdJoin: c.handleJoin, + irc.CmdPart: c.handlePart, + irc.CmdQuit: func(_ context.Context, msg *Message) { + c.handleQuit(msg) + }, + irc.CmdTopic: c.handleTopic, + irc.CmdMode: c.handleMode, + irc.CmdNames: c.handleNames, + irc.CmdList: func(ctx context.Context, _ *Message) { c.handleList(ctx) }, + irc.CmdWhois: c.handleWhois, + irc.CmdWho: c.handleWho, + irc.CmdLusers: func(ctx context.Context, _ *Message) { c.handleLusers(ctx) }, + irc.CmdMotd: func(context.Context, *Message) { c.deliverMOTD() }, + irc.CmdOper: c.handleOper, + irc.CmdAway: c.handleAway, + irc.CmdKick: c.handleKick, + irc.CmdPass: c.handlePassPostReg, + "INVITE": c.handleInvite, + "CAP": func(_ context.Context, msg *Message) { + c.handleCAP(msg) + }, + "USERHOST": c.handleUserhost, + } } // resolveHost does a reverse DNS lookup, returning the IP @@ -145,71 +193,14 @@ func (c *Conn) cleanup(ctx context.Context) { c.mu.Unlock() if wasRegistered && sessID > 0 { - c.broadcastQuit(ctx, nick, "Connection closed") - c.database.DeleteSession(ctx, sessID) //nolint:errcheck,gosec + c.svc.BroadcastQuit( + ctx, sessID, nick, "Connection closed", + ) } c.conn.Close() //nolint:errcheck,gosec } -func (c *Conn) broadcastQuit( - ctx context.Context, - nick, reason string, -) { - channels, err := c.database.GetSessionChannels( - ctx, c.sessionID, - ) - if err != nil { - return - } - - notified := make(map[int64]bool) - - for _, ch := range channels { - chID, getErr := c.database.GetChannelByName( - ctx, ch.Name, - ) - if getErr != nil { - continue - } - - memberIDs, memErr := c.database.GetChannelMemberIDs( - ctx, chID, - ) - if memErr != nil { - continue - } - - for _, mid := range memberIDs { - if mid == c.sessionID || notified[mid] { - continue - } - - notified[mid] = true - } - } - - body, _ := json.Marshal([]string{reason}) //nolint:errchkjson - - for sid := range notified { - dbID, _, insErr := c.database.InsertMessage( - ctx, irc.CmdQuit, nick, "", nil, body, nil, - ) - if insErr != nil { - continue - } - - _ = c.database.EnqueueToSession(ctx, sid, dbID) - c.brk.Notify(sid) - } - - // Part from all channels so they get cleaned up. - for _, ch := range channels { - c.database.PartChannel(ctx, ch.ID, c.sessionID) //nolint:errcheck,gosec - c.database.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec - } -} - // send writes a formatted IRC line to the connection. func (c *Conn) send(line string) { _ = c.conn.SetWriteDeadline( @@ -261,9 +252,8 @@ func (c *Conn) hostmask() string { return c.nick + "!" + user + "@" + host } -// handleMessage dispatches a parsed IRC message. -// -//nolint:cyclop // dispatch table is inherently branchy +// handleMessage dispatches a parsed IRC message using +// the command handler map. func (c *Conn) handleMessage( ctx context.Context, msg *Message, @@ -276,57 +266,17 @@ func (c *Conn) handleMessage( return } - switch msg.Command { - case irc.CmdPing: - c.handlePing(msg) - case "PONG": - // Silently accept. - case irc.CmdNick: - c.handleNick(ctx, msg) - case irc.CmdPrivmsg, irc.CmdNotice: - c.handlePrivmsg(ctx, msg) - case irc.CmdJoin: - c.handleJoin(ctx, msg) - case irc.CmdPart: - c.handlePart(ctx, msg) - case irc.CmdQuit: - c.handleQuit(msg) - case irc.CmdTopic: - c.handleTopic(ctx, msg) - case irc.CmdMode: - c.handleMode(ctx, msg) - case irc.CmdNames: - c.handleNames(ctx, msg) - case irc.CmdList: - c.handleList(ctx) - case irc.CmdWhois: - c.handleWhois(ctx, msg) - case irc.CmdWho: - c.handleWho(ctx, msg) - case irc.CmdLusers: - c.handleLusers(ctx) - case irc.CmdMotd: - c.deliverMOTD() - case irc.CmdOper: - c.handleOper(ctx, msg) - case irc.CmdAway: - c.handleAway(ctx, msg) - case irc.CmdKick: - c.handleKick(ctx, msg) - case irc.CmdPass: - c.handlePassPostReg(ctx, msg) - case "INVITE": - c.handleInvite(ctx, msg) - case "CAP": - c.handleCAP(msg) - case "USERHOST": - c.handleUserhost(ctx, msg) - default: + handler, ok := c.commands[msg.Command] + if !ok { c.sendNumeric( irc.ErrUnknownCommand, msg.Command, "Unknown command", ) + + return } + + handler(ctx, msg) } // handlePreRegistration handles messages before the diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index be355a1..dc75ee7 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -8,6 +8,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/broker" "git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/neoirc/internal/db" + "git.eeqj.de/sneak/neoirc/internal/service" ) // NewTestServer creates a Server suitable for testing. @@ -18,11 +19,19 @@ func NewTestServer( database *db.Database, brk *broker.Broker, ) *Server { + svc := &service.Service{ + DB: database, + Broker: brk, + Config: cfg, + Log: log, + } + return &Server{ //nolint:exhaustruct log: log, cfg: cfg, database: database, brk: brk, + svc: svc, conns: make(map[*Conn]struct{}), } } diff --git a/internal/ircserver/server.go b/internal/ircserver/server.go index 56e3b7a..0ee9256 100644 --- a/internal/ircserver/server.go +++ b/internal/ircserver/server.go @@ -11,6 +11,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/logger" + "git.eeqj.de/sneak/neoirc/internal/service" "go.uber.org/fx" ) @@ -23,6 +24,7 @@ type Params struct { Config *config.Config Database *db.Database Broker *broker.Broker + Service *service.Service } // Server is the TCP IRC protocol server. @@ -31,6 +33,7 @@ type Server struct { cfg *config.Config database *db.Database brk *broker.Broker + svc *service.Service listener net.Listener mu sync.Mutex conns map[*Conn]struct{} @@ -49,6 +52,7 @@ func New( cfg: params.Config, database: params.Database, brk: params.Broker, + svc: params.Service, conns: make(map[*Conn]struct{}), listener: nil, cancel: nil, @@ -133,7 +137,7 @@ func (s *Server) acceptLoop(ctx context.Context) { client := newConn( ctx, tcpConn, s.log, - s.database, s.brk, s.cfg, + s.database, s.brk, s.cfg, s.svc, ) s.mu.Lock() diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..3e4e177 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,739 @@ +// Package service provides shared business logic for both +// the IRC wire protocol and HTTP/JSON transports. +package service + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "git.eeqj.de/sneak/neoirc/internal/broker" + "git.eeqj.de/sneak/neoirc/internal/config" + "git.eeqj.de/sneak/neoirc/internal/db" + "git.eeqj.de/sneak/neoirc/internal/logger" + "git.eeqj.de/sneak/neoirc/pkg/irc" + "go.uber.org/fx" +) + +// Params defines the dependencies for creating a Service. +type Params struct { + fx.In + + Logger *logger.Logger + Config *config.Config + Database *db.Database + Broker *broker.Broker +} + +// Service provides shared business logic for IRC commands. +type Service struct { + DB *db.Database + Broker *broker.Broker + Config *config.Config + Log *slog.Logger +} + +// New creates a new Service. +func New(params Params) *Service { + return &Service{ + DB: params.Database, + Broker: params.Broker, + Config: params.Config, + Log: params.Logger.Get(), + } +} + +// IRCError represents an IRC protocol-level error with a +// numeric code that both transports can map to responses. +type IRCError struct { + Code irc.IRCMessageType + Params []string + Message string +} + +func (e *IRCError) Error() string { return e.Message } + +// JoinResult contains the outcome of a channel join. +type JoinResult struct { + ChannelID int64 + IsCreator bool +} + +// DirectMsgResult contains the outcome of a direct message. +type DirectMsgResult struct { + UUID string + AwayMsg string +} + +// FanOut inserts a message and enqueues it to all given +// session IDs, notifying each via the broker. +func (s *Service) FanOut( + ctx context.Context, + command, from, to string, + params, body, meta json.RawMessage, + sessionIDs []int64, +) (int64, string, error) { + dbID, msgUUID, err := s.DB.InsertMessage( + ctx, command, from, to, params, body, meta, + ) + if err != nil { + return 0, "", fmt.Errorf("insert message: %w", err) + } + + for _, sid := range sessionIDs { + _ = s.DB.EnqueueToSession(ctx, sid, dbID) + s.Broker.Notify(sid) + } + + return dbID, msgUUID, nil +} + +// excludeSession returns a copy of ids without the given +// session. +func excludeSession( + ids []int64, + exclude int64, +) []int64 { + out := make([]int64, 0, len(ids)) + + for _, id := range ids { + if id != exclude { + out = append(out, id) + } + } + + return out +} + +// SendChannelMessage validates membership and moderation, +// then fans out a message to all channel members except +// the sender. +func (s *Service) SendChannelMessage( + ctx context.Context, + sessionID int64, + nick, command, channel string, + body, meta json.RawMessage, +) (string, error) { + chID, err := s.DB.GetChannelByName(ctx, channel) + if err != nil { + return "", &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + "No such channel", + } + } + + isMember, _ := s.DB.IsChannelMember( + ctx, chID, sessionID, + ) + if !isMember { + return "", &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Cannot send to channel", + } + } + + moderated, _ := s.DB.IsChannelModerated(ctx, chID) + if moderated { + isOp, _ := s.DB.IsChannelOperator( + ctx, chID, sessionID, + ) + isVoiced, _ := s.DB.IsChannelVoiced( + ctx, chID, sessionID, + ) + + if !isOp && !isVoiced { + return "", &IRCError{ + irc.ErrCannotSendToChan, + []string{channel}, + "Cannot send to channel (+m)", + } + } + } + + memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID) + recipients := excludeSession(memberIDs, sessionID) + + _, uuid, fanErr := s.FanOut( + ctx, command, nick, channel, + nil, body, meta, recipients, + ) + if fanErr != nil { + return "", fanErr + } + + return uuid, nil +} + +// SendDirectMessage validates the target and sends a +// direct message, returning the message UUID and any away +// message set on the target. +func (s *Service) SendDirectMessage( + ctx context.Context, + sessionID int64, + nick, command, target string, + body, meta json.RawMessage, +) (*DirectMsgResult, error) { + targetSID, err := s.DB.GetSessionByNick(ctx, target) + if err != nil { + return nil, &IRCError{ + irc.ErrNoSuchNick, + []string{target}, + "No such nick", + } + } + + away, _ := s.DB.GetAway(ctx, targetSID) + + recipients := []int64{targetSID} + if targetSID != sessionID { + recipients = append(recipients, sessionID) + } + + _, uuid, fanErr := s.FanOut( + ctx, command, nick, target, + nil, body, meta, recipients, + ) + if fanErr != nil { + return nil, fanErr + } + + return &DirectMsgResult{UUID: uuid, AwayMsg: away}, nil +} + +// JoinChannel creates or joins a channel, making the +// first joiner the operator. Fans out the JOIN to all +// channel members. +func (s *Service) JoinChannel( + ctx context.Context, + sessionID int64, + nick, channel string, +) (*JoinResult, error) { + chID, err := s.DB.GetOrCreateChannel(ctx, channel) + if err != nil { + return nil, fmt.Errorf("get/create channel: %w", err) + } + + memberCount, countErr := s.DB.CountChannelMembers( + ctx, chID, + ) + isCreator := countErr == nil && memberCount == 0 + + if isCreator { + err = s.DB.JoinChannelAsOperator( + ctx, chID, sessionID, + ) + } else { + err = s.DB.JoinChannel(ctx, chID, sessionID) + } + + if err != nil { + return nil, fmt.Errorf("join channel: %w", err) + } + + memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID) + body, _ := json.Marshal([]string{channel}) //nolint:errchkjson + + _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast + ctx, irc.CmdJoin, nick, channel, + nil, body, nil, memberIDs, + ) + + return &JoinResult{ + ChannelID: chID, + IsCreator: isCreator, + }, nil +} + +// PartChannel validates membership, broadcasts PART to +// remaining members, removes the user, and cleans up empty +// channels. +func (s *Service) PartChannel( + ctx context.Context, + sessionID int64, + nick, channel, reason string, +) error { + chID, err := s.DB.GetChannelByName(ctx, channel) + if err != nil { + return &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + "No such channel", + } + } + + isMember, _ := s.DB.IsChannelMember( + ctx, chID, sessionID, + ) + if !isMember { + return &IRCError{ + irc.ErrNotOnChannel, + []string{channel}, + "You're not on that channel", + } + } + + memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID) + recipients := excludeSession(memberIDs, sessionID) + body, _ := json.Marshal([]string{reason}) //nolint:errchkjson + + _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast + ctx, irc.CmdPart, nick, channel, + nil, body, nil, recipients, + ) + + s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec + s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec + + return nil +} + +// SetTopic validates membership and topic-lock, sets the +// topic, and broadcasts the change. +func (s *Service) SetTopic( + ctx context.Context, + sessionID int64, + nick, channel, topic string, +) error { + chID, err := s.DB.GetChannelByName(ctx, channel) + if err != nil { + return &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + "No such channel", + } + } + + isMember, _ := s.DB.IsChannelMember( + ctx, chID, sessionID, + ) + if !isMember { + return &IRCError{ + irc.ErrNotOnChannel, + []string{channel}, + "You're not on that channel", + } + } + + topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID) + if topicLocked { + isOp, _ := s.DB.IsChannelOperator( + ctx, chID, sessionID, + ) + if !isOp { + return &IRCError{ + irc.ErrChanOpPrivsNeeded, + []string{channel}, + "You're not channel operator", + } + } + } + + if setErr := s.DB.SetTopic( + ctx, channel, topic, + ); setErr != nil { + return fmt.Errorf("set topic: %w", setErr) + } + + _ = s.DB.SetTopicMeta(ctx, channel, topic, nick) + + memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID) + body, _ := json.Marshal([]string{topic}) //nolint:errchkjson + + _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast + ctx, irc.CmdTopic, nick, channel, + nil, body, nil, memberIDs, + ) + + return nil +} + +// KickUser validates operator status and target +// membership, broadcasts the KICK, removes the target, +// and cleans up empty channels. +func (s *Service) KickUser( + ctx context.Context, + sessionID int64, + nick, channel, targetNick, reason string, +) error { + chID, err := s.DB.GetChannelByName(ctx, channel) + if err != nil { + return &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + "No such channel", + } + } + + isOp, _ := s.DB.IsChannelOperator( + ctx, chID, sessionID, + ) + if !isOp { + return &IRCError{ + irc.ErrChanOpPrivsNeeded, + []string{channel}, + "You're not channel operator", + } + } + + targetSID, err := s.DB.GetSessionByNick( + ctx, targetNick, + ) + if err != nil { + return &IRCError{ + irc.ErrNoSuchNick, + []string{targetNick}, + "No such nick/channel", + } + } + + isMember, _ := s.DB.IsChannelMember( + ctx, chID, targetSID, + ) + if !isMember { + return &IRCError{ + irc.ErrUserNotInChannel, + []string{targetNick, channel}, + "They aren't on that channel", + } + } + + memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID) + body, _ := json.Marshal([]string{reason}) //nolint:errchkjson + params, _ := json.Marshal( //nolint:errchkjson + []string{targetNick}, + ) + + _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast + ctx, irc.CmdKick, nick, channel, + params, body, nil, memberIDs, + ) + + s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec + s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec + + return nil +} + +// ChangeNick changes a user's nickname and broadcasts the +// change to all users sharing channels. +func (s *Service) ChangeNick( + ctx context.Context, + sessionID int64, + oldNick, newNick string, +) error { + err := s.DB.ChangeNick(ctx, sessionID, newNick) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") || + db.IsUniqueConstraintError(err) { + return &IRCError{ + irc.ErrNicknameInUse, + []string{newNick}, + "Nickname is already in use", + } + } + + return &IRCError{ + irc.ErrErroneusNickname, + []string{newNick}, + "Erroneous nickname", + } + } + + s.broadcastNickChange(ctx, sessionID, oldNick, newNick) + + return nil +} + +// BroadcastQuit broadcasts a QUIT to all channel peers, +// parts all channels, and deletes the session. +func (s *Service) BroadcastQuit( + ctx context.Context, + sessionID int64, + nick, reason string, +) { + channels, err := s.DB.GetSessionChannels( + ctx, sessionID, + ) + if err != nil { + return + } + + notified := make(map[int64]bool) + + for _, ch := range channels { + memberIDs, memErr := s.DB.GetChannelMemberIDs( + ctx, ch.ID, + ) + if memErr != nil { + continue + } + + for _, mid := range memberIDs { + if mid == sessionID || notified[mid] { + continue + } + + notified[mid] = true + } + } + + body, _ := json.Marshal([]string{reason}) //nolint:errchkjson + + for sid := range notified { + dbID, _, insErr := s.DB.InsertMessage( + ctx, irc.CmdQuit, nick, "", + nil, body, nil, + ) + if insErr != nil { + continue + } + + _ = s.DB.EnqueueToSession(ctx, sid, dbID) + s.Broker.Notify(sid) + } + + for _, ch := range channels { + s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec + s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec + } + + s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec +} + +// SetAway sets or clears the away message. Returns true +// if the message was cleared (empty string). +func (s *Service) SetAway( + ctx context.Context, + sessionID int64, + message string, +) (bool, error) { + err := s.DB.SetAway(ctx, sessionID, message) + if err != nil { + return false, fmt.Errorf("set away: %w", err) + } + + return message == "", nil +} + +// Oper validates operator credentials and grants oper +// status to the session. +func (s *Service) Oper( + ctx context.Context, + sessionID int64, + name, password string, +) error { + cfgName := s.Config.OperName + cfgPassword := s.Config.OperPassword + + if cfgName == "" || cfgPassword == "" { + return &IRCError{ + irc.ErrNoOperHost, + nil, + "No O-lines for your host", + } + } + + if name != cfgName || password != cfgPassword { + return &IRCError{ + irc.ErrPasswdMismatch, + nil, + "Password incorrect", + } + } + + _ = s.DB.SetSessionOper(ctx, sessionID, true) + + return nil +} + +// ValidateChannelOp checks that the session is a channel +// operator. Returns the channel ID. +func (s *Service) ValidateChannelOp( + ctx context.Context, + sessionID int64, + channel string, +) (int64, error) { + chID, err := s.DB.GetChannelByName(ctx, channel) + if err != nil { + return 0, &IRCError{ + irc.ErrNoSuchChannel, + []string{channel}, + "No such channel", + } + } + + isOp, _ := s.DB.IsChannelOperator( + ctx, chID, sessionID, + ) + if !isOp { + return 0, &IRCError{ + irc.ErrChanOpPrivsNeeded, + []string{channel}, + "You're not channel operator", + } + } + + return chID, nil +} + +// ApplyMemberMode applies +o/-o or +v/-v on a channel +// member after validating the target. +func (s *Service) ApplyMemberMode( + ctx context.Context, + chID int64, + channel, targetNick string, + mode rune, + adding bool, +) error { + targetSID, err := s.DB.GetSessionByNick( + ctx, targetNick, + ) + if err != nil { + return &IRCError{ + irc.ErrNoSuchNick, + []string{targetNick}, + "No such nick/channel", + } + } + + isMember, _ := s.DB.IsChannelMember( + ctx, chID, targetSID, + ) + if !isMember { + return &IRCError{ + irc.ErrUserNotInChannel, + []string{targetNick, channel}, + "They aren't on that channel", + } + } + + switch mode { + case 'o': + _ = s.DB.SetChannelMemberOperator( + ctx, chID, targetSID, adding, + ) + case 'v': + _ = s.DB.SetChannelMemberVoiced( + ctx, chID, targetSID, adding, + ) + } + + return nil +} + +// SetChannelFlag applies +m/-m or +t/-t on a channel. +func (s *Service) SetChannelFlag( + ctx context.Context, + chID int64, + flag rune, + setting bool, +) error { + switch flag { + case 'm': + if err := s.DB.SetChannelModerated( + ctx, chID, setting, + ); err != nil { + return fmt.Errorf("set moderated: %w", err) + } + case 't': + if err := s.DB.SetChannelTopicLocked( + ctx, chID, setting, + ); err != nil { + return fmt.Errorf("set topic locked: %w", err) + } + } + + return nil +} + +// BroadcastMode fans out a MODE change to all channel +// members. +func (s *Service) BroadcastMode( + ctx context.Context, + nick, channel string, + chID int64, + modeText string, +) { + memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID) + body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson + + _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast + ctx, irc.CmdMode, nick, channel, + nil, body, nil, memberIDs, + ) +} + +// QueryChannelMode returns the channel mode string. +func (s *Service) QueryChannelMode( + ctx context.Context, + chID int64, +) string { + modes := "+" + + moderated, _ := s.DB.IsChannelModerated(ctx, chID) + if moderated { + modes += "m" + } + + topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID) + if topicLocked { + modes += "t" + } + + return modes +} + +// broadcastNickChange notifies channel peers of a nick +// change. +func (s *Service) broadcastNickChange( + ctx context.Context, + sessionID int64, + oldNick, newNick string, +) { + channels, err := s.DB.GetSessionChannels( + ctx, sessionID, + ) + if err != nil { + return + } + + body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson + notified := make(map[int64]bool) + + dbID, _, insErr := s.DB.InsertMessage( + ctx, irc.CmdNick, oldNick, "", + nil, body, nil, + ) + if insErr != nil { + return + } + + // Notify the user themselves (for multi-client sync). + _ = s.DB.EnqueueToSession(ctx, sessionID, dbID) + s.Broker.Notify(sessionID) + notified[sessionID] = true + + for _, ch := range channels { + memberIDs, memErr := s.DB.GetChannelMemberIDs( + ctx, ch.ID, + ) + if memErr != nil { + continue + } + + for _, mid := range memberIDs { + if notified[mid] { + continue + } + + notified[mid] = true + + _ = s.DB.EnqueueToSession(ctx, mid, dbID) + s.Broker.Notify(mid) + } + } +}