All checks were successful
check / check (push) Successful in 59s
1. ISUPPORT/applyChannelModes: extend IRC MODE handler to support +i/-i, +s/-s, +n/-n (routed through svc.SetChannelFlag), and +H/-H (hashcash bits with parameter parsing). Add 'n' (no external messages) as a proper DB-backed channel flag with is_no_external column (default: on). Update IRC ISUPPORT to CHANMODES=,,H,imnst to match actual support. 2. QueryChannelMode: rewrite to return complete mode string including all boolean flags (n, i, m, s, t) and parameterized modes (k, l, H), matching the HTTP handler's buildChannelModeString logic. Simplify buildChannelModeString to delegate to QueryChannelMode for consistency. 3. Service struct encapsulation: change exported fields (DB, Broker, Config, Log) to unexported (db, broker, config, log). Add NewTestService constructor for use by external test packages. Update ircserver export_test.go to use the new constructor. Closes #89
902 lines
19 KiB
Go
902 lines
19 KiB
Go
// 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"
|
|
"strconv"
|
|
"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(),
|
|
}
|
|
}
|
|
|
|
// NewTestService creates a Service for use in tests
|
|
// outside the service package.
|
|
func NewTestService(
|
|
database *db.Database,
|
|
brk *broker.Broker,
|
|
cfg *config.Config,
|
|
log *slog.Logger,
|
|
) *Service {
|
|
return &Service{
|
|
db: database,
|
|
broker: brk,
|
|
config: cfg,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
}
|
|
|
|
// Ban check — banned users cannot send messages.
|
|
isBanned, banErr := s.db.IsSessionBanned(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if banErr == nil && isBanned {
|
|
return 0, "", &IRCError{
|
|
irc.ErrCannotSendToChan,
|
|
[]string{channel},
|
|
"Cannot send to channel (+b)",
|
|
}
|
|
}
|
|
|
|
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, suppliedKey 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 {
|
|
if joinErr := checkJoinRestrictions(
|
|
ctx, s.db, chID, sessionID,
|
|
channel, suppliedKey, memberCount,
|
|
); joinErr != nil {
|
|
return nil, joinErr
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Clear invite after successful join.
|
|
_ = s.db.ClearChannelInvite(ctx, chID, sessionID)
|
|
|
|
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 a simple boolean channel mode
|
|
// (+m/-m, +t/-t, +i/-i, +s/-s, +n/-n).
|
|
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)
|
|
}
|
|
case 'i':
|
|
if err := s.db.SetChannelInviteOnly(
|
|
ctx, chID, setting,
|
|
); err != nil {
|
|
return fmt.Errorf("set invite only: %w", err)
|
|
}
|
|
case 's':
|
|
if err := s.db.SetChannelSecret(
|
|
ctx, chID, setting,
|
|
); err != nil {
|
|
return fmt.Errorf("set secret: %w", err)
|
|
}
|
|
case 'n':
|
|
if err := s.db.SetChannelNoExternal(
|
|
ctx, chID, setting,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"set no external: %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 complete channel mode
|
|
// string including all flags and parameterized modes.
|
|
func (s *Service) QueryChannelMode(
|
|
ctx context.Context,
|
|
chID int64,
|
|
) string {
|
|
modes := "+"
|
|
|
|
noExternal, _ := s.db.IsChannelNoExternal(ctx, chID)
|
|
if noExternal {
|
|
modes += "n"
|
|
}
|
|
|
|
inviteOnly, _ := s.db.IsChannelInviteOnly(ctx, chID)
|
|
if inviteOnly {
|
|
modes += "i"
|
|
}
|
|
|
|
moderated, _ := s.db.IsChannelModerated(ctx, chID)
|
|
if moderated {
|
|
modes += "m"
|
|
}
|
|
|
|
secret, _ := s.db.IsChannelSecret(ctx, chID)
|
|
if secret {
|
|
modes += "s"
|
|
}
|
|
|
|
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
|
|
if topicLocked {
|
|
modes += "t"
|
|
}
|
|
|
|
var modeParams string
|
|
|
|
key, _ := s.db.GetChannelKey(ctx, chID)
|
|
if key != "" {
|
|
modes += "k"
|
|
modeParams += " " + key
|
|
}
|
|
|
|
limit, _ := s.db.GetChannelUserLimit(ctx, chID)
|
|
if limit > 0 {
|
|
modes += "l"
|
|
modeParams += " " + strconv.Itoa(limit)
|
|
}
|
|
|
|
bits, _ := s.db.GetChannelHashcashBits(ctx, chID)
|
|
if bits > 0 {
|
|
modes += "H"
|
|
modeParams += " " + strconv.Itoa(bits)
|
|
}
|
|
|
|
return modes + modeParams
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkJoinRestrictions validates Tier 2 join conditions:
|
|
// bans, invite-only, channel key, and user limit.
|
|
func checkJoinRestrictions(
|
|
ctx context.Context,
|
|
database *db.Database,
|
|
chID, sessionID int64,
|
|
channel, suppliedKey string,
|
|
memberCount int64,
|
|
) error {
|
|
isBanned, banErr := database.IsSessionBanned(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if banErr == nil && isBanned {
|
|
return &IRCError{
|
|
Code: irc.ErrBannedFromChan,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+b)",
|
|
}
|
|
}
|
|
|
|
isInviteOnly, ioErr := database.IsChannelInviteOnly(
|
|
ctx, chID,
|
|
)
|
|
if ioErr == nil && isInviteOnly {
|
|
hasInvite, invErr := database.HasChannelInvite(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if invErr != nil || !hasInvite {
|
|
return &IRCError{
|
|
Code: irc.ErrInviteOnlyChan,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+i)",
|
|
}
|
|
}
|
|
}
|
|
|
|
key, keyErr := database.GetChannelKey(ctx, chID)
|
|
if keyErr == nil && key != "" && suppliedKey != key {
|
|
return &IRCError{
|
|
Code: irc.ErrBadChannelKey,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+k)",
|
|
}
|
|
}
|
|
|
|
limit, limErr := database.GetChannelUserLimit(ctx, chID)
|
|
if limErr == nil && limit > 0 &&
|
|
memberCount >= int64(limit) {
|
|
return &IRCError{
|
|
Code: irc.ErrChannelIsFull,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+l)",
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|