// Package service provides shared business logic for both // the IRC wire protocol and HTTP/JSON transports. package service import ( "context" "crypto/subtle" "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. Returns the database row ID, message UUID, // and any error. The dbID lets callers enqueue the same // message to the sender when echo is needed (HTTP // transport). func (s *Service) SendChannelMessage( ctx context.Context, sessionID int64, nick, command, channel string, body, meta json.RawMessage, ) (int64, string, error) { chID, err := s.DB.GetChannelByName(ctx, channel) if err != nil { return 0, "", &IRCError{ irc.ErrNoSuchChannel, []string{channel}, "No such channel", } } isMember, _ := s.DB.IsChannelMember( ctx, chID, sessionID, ) if !isMember { return 0, "", &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 0, "", &IRCError{ irc.ErrCannotSendToChan, []string{channel}, "Cannot send to channel (+m)", } } } memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID) recipients := excludeSession(memberIDs, sessionID) dbID, uuid, fanErr := s.FanOut( ctx, command, nick, channel, nil, body, meta, recipients, ) if fanErr != nil { return 0, "", fanErr } return dbID, 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. Uses the // FanOut pattern: one message row fanned out to all unique // peer sessions. 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 } } if len(notified) > 0 { recipients := make([]int64, 0, len(notified)) for sid := range notified { recipients = append(recipients, sid) } body, _ := json.Marshal([]string{reason}) //nolint:errchkjson _, _, _ = s.FanOut( ctx, irc.CmdQuit, nick, "", nil, body, nil, recipients, ) } 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 // Use constant-time comparison and return the same // error for all failures to prevent information // leakage about valid operator names. if cfgName == "" || cfgPassword == "" || subtle.ConstantTimeCompare( []byte(name), []byte(cfgName), ) != 1 || subtle.ConstantTimeCompare( []byte(password), []byte(cfgPassword), ) != 1 { return &IRCError{ irc.ErrNoOperHost, nil, "No O-lines for your host", } } _ = 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) } } }