check / check (push) Waiting to run
USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the HTTP API and on the IRC listener. The USERHOST reply, the VERSION and INFO text, KILL and WALLOPS are each one service function that both transports call. User mode +w selects who receives WALLOPS. MODE on your own nick queries or changes your user modes through one parser on both transports: the whole mode string is checked before anything is stored, and both flags are stored in one transaction. KILL closes the victim's IRC connection after sending it KILL and ERROR. The sessions table gains is_wallops in 001_initial.sql, as the pre-1.0 rule in REPO_POLICIES.md asks. Model: opus-5-5
1250 lines
26 KiB
Go
1250 lines
26 KiB
Go
// Package service provides shared business logic for both
|
|
// the IRC wire protocol and HTTP/JSON transports.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"sneak.berlin/go/neoirc/internal/broker"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
// Error texts that several commands reply with.
|
|
const (
|
|
msgNoSuchChannel = "No such channel"
|
|
msgNoSuchNick = "No such nick/channel"
|
|
msgNotChannelOp = "You're not channel operator"
|
|
)
|
|
|
|
// maxUserhostNicks is how many nicks one USERHOST answers
|
|
// for (RFC 2812).
|
|
const maxUserhostNicks = 5
|
|
|
|
// 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
|
|
Globals *globals.Globals
|
|
}
|
|
|
|
// WireConn is a registered IRC connection, which KILL closes.
|
|
// HTTP clients hold no connection and register none.
|
|
type WireConn interface {
|
|
// Disconnect tells the client why and closes the
|
|
// connection.
|
|
Disconnect(reason string)
|
|
}
|
|
|
|
// Service provides shared business logic for IRC commands.
|
|
type Service struct {
|
|
db *db.Database
|
|
broker *broker.Broker
|
|
config *config.Config
|
|
globals *globals.Globals
|
|
log *slog.Logger
|
|
|
|
wireMu sync.Mutex
|
|
wireConns map[int64]WireConn
|
|
}
|
|
|
|
// New creates a new Service.
|
|
func New(params Params) *Service {
|
|
return &Service{
|
|
db: params.Database,
|
|
broker: params.Broker,
|
|
config: params.Config,
|
|
globals: params.Globals,
|
|
log: params.Logger.Get(),
|
|
wireConns: make(map[int64]WireConn),
|
|
}
|
|
}
|
|
|
|
// NewTestService creates a Service for use in tests
|
|
// outside the service package.
|
|
func NewTestService(
|
|
database *db.Database,
|
|
brk *broker.Broker,
|
|
cfg *config.Config,
|
|
globs *globals.Globals,
|
|
log *slog.Logger,
|
|
) *Service {
|
|
return &Service{
|
|
db: database,
|
|
broker: brk,
|
|
config: cfg,
|
|
globals: globs,
|
|
log: log,
|
|
wireConns: make(map[int64]WireConn),
|
|
}
|
|
}
|
|
|
|
// ServerVersion returns the version that VERSION and INFO
|
|
// report on both transports, such as "neoirc-1.2.3".
|
|
func (s *Service) ServerVersion() string {
|
|
version := s.globals.Version
|
|
if version == "" {
|
|
version = "dev"
|
|
}
|
|
|
|
return s.globals.Appname + "-" + version
|
|
}
|
|
|
|
// InfoLines returns the lines that INFO sends on both
|
|
// transports.
|
|
func (s *Service) InfoLines() []string {
|
|
return []string{
|
|
"neoirc — IRC semantics over HTTP",
|
|
"Version: " + s.ServerVersion(),
|
|
"Written in Go",
|
|
"Started: " + s.globals.StartTime.Format(time.RFC1123),
|
|
}
|
|
}
|
|
|
|
// UserhostReply returns the RPL_USERHOST text for the first
|
|
// five of the given nicks: "nick=+user@host" entries joined
|
|
// by spaces, with * after the nick of an operator and - in
|
|
// place of + for a user who is away. Nicks with no session
|
|
// are left out. An empty hostname is reported as
|
|
// serverName.
|
|
func (s *Service) UserhostReply(
|
|
ctx context.Context,
|
|
nicks []string,
|
|
serverName string,
|
|
) (string, error) {
|
|
if len(nicks) > maxUserhostNicks {
|
|
nicks = nicks[:maxUserhostNicks]
|
|
}
|
|
|
|
infos, err := s.db.GetUserhostInfo(ctx, nicks)
|
|
if err != nil {
|
|
return "", fmt.Errorf("userhost: %w", err)
|
|
}
|
|
|
|
replies := make([]string, 0, len(infos))
|
|
|
|
for _, info := range infos {
|
|
operStar := ""
|
|
if info.IsOper {
|
|
operStar = "*"
|
|
}
|
|
|
|
away := "+"
|
|
if info.AwayMessage != "" {
|
|
away = "-"
|
|
}
|
|
|
|
username := info.Username
|
|
if username == "" {
|
|
username = info.Nick
|
|
}
|
|
|
|
hostname := info.Hostname
|
|
if hostname == "" {
|
|
hostname = serverName
|
|
}
|
|
|
|
replies = append(replies,
|
|
info.Nick+operStar+"="+away+username+"@"+hostname,
|
|
)
|
|
}
|
|
|
|
return strings.Join(replies, " "), nil
|
|
}
|
|
|
|
// RegisterWireConn records the IRC connection of a session,
|
|
// so that KillUser can close it.
|
|
func (s *Service) RegisterWireConn(
|
|
sessionID int64,
|
|
conn WireConn,
|
|
) {
|
|
s.wireMu.Lock()
|
|
defer s.wireMu.Unlock()
|
|
|
|
s.wireConns[sessionID] = conn
|
|
}
|
|
|
|
// UnregisterWireConn removes what RegisterWireConn recorded,
|
|
// unless the session has since registered another
|
|
// connection.
|
|
func (s *Service) UnregisterWireConn(
|
|
sessionID int64,
|
|
conn WireConn,
|
|
) {
|
|
s.wireMu.Lock()
|
|
defer s.wireMu.Unlock()
|
|
|
|
if s.wireConns[sessionID] == conn {
|
|
delete(s.wireConns, sessionID)
|
|
}
|
|
}
|
|
|
|
// KillUser carries out an operator's KILL: the target's
|
|
// channel peers see it quit, its session is deleted, and
|
|
// its IRC connection, if it has one, is closed.
|
|
func (s *Service) KillUser(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, targetNick, reason string,
|
|
) error {
|
|
err := s.requireOper(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return &IRCError{
|
|
irc.ErrNoSuchNick,
|
|
[]string{targetNick},
|
|
msgNoSuchNick,
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("kill: %w", err)
|
|
}
|
|
|
|
if targetSID == sessionID {
|
|
return &IRCError{
|
|
irc.ErrCantKillServer,
|
|
nil,
|
|
"You cannot KILL yourself",
|
|
}
|
|
}
|
|
|
|
quitReason := "Killed (" + nick + " (" + reason + "))"
|
|
|
|
s.BroadcastQuit(ctx, targetSID, targetNick, quitReason)
|
|
|
|
s.wireMu.Lock()
|
|
conn := s.wireConns[targetSID]
|
|
s.wireMu.Unlock()
|
|
|
|
if conn != nil {
|
|
conn.Disconnect(quitReason)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendWallops carries out an operator's WALLOPS: the
|
|
// message goes to every user with user mode +w.
|
|
func (s *Service) SendWallops(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, message string,
|
|
) error {
|
|
err := s.requireOper(ctx, sessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
recipients, err := s.db.GetWallopsSessionIDs(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("wallops: %w", err)
|
|
}
|
|
|
|
if len(recipients) == 0 {
|
|
return nil
|
|
}
|
|
|
|
body, err := json.Marshal([]string{message})
|
|
if err != nil {
|
|
return fmt.Errorf("wallops: %w", err)
|
|
}
|
|
|
|
_, _, err = s.FanOut(
|
|
ctx, irc.CmdWallops, nick, "*",
|
|
nil, body, nil, recipients,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("wallops: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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},
|
|
msgNoSuchChannel,
|
|
}
|
|
}
|
|
|
|
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 {
|
|
joinErr := checkJoinRestrictions(
|
|
ctx, s.db, chID, sessionID,
|
|
channel, suppliedKey, memberCount,
|
|
)
|
|
if 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},
|
|
msgNoSuchChannel,
|
|
}
|
|
}
|
|
|
|
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},
|
|
msgNoSuchChannel,
|
|
}
|
|
}
|
|
|
|
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},
|
|
msgNotChannelOp,
|
|
}
|
|
}
|
|
}
|
|
|
|
setErr := s.db.SetTopic(ctx, channel, topic)
|
|
if 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},
|
|
msgNoSuchChannel,
|
|
}
|
|
}
|
|
|
|
isOp, _ := s.db.IsChannelOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isOp {
|
|
return &IRCError{
|
|
irc.ErrChanOpPrivsNeeded,
|
|
[]string{channel},
|
|
msgNotChannelOp,
|
|
}
|
|
}
|
|
|
|
targetSID, err := s.db.GetSessionByNick(
|
|
ctx, targetNick,
|
|
)
|
|
if err != nil {
|
|
return &IRCError{
|
|
irc.ErrNoSuchNick,
|
|
[]string{targetNick},
|
|
msgNoSuchNick,
|
|
}
|
|
}
|
|
|
|
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},
|
|
msgNoSuchChannel,
|
|
}
|
|
}
|
|
|
|
isOp, _ := s.db.IsChannelOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if !isOp {
|
|
return 0, &IRCError{
|
|
irc.ErrChanOpPrivsNeeded,
|
|
[]string{channel},
|
|
msgNotChannelOp,
|
|
}
|
|
}
|
|
|
|
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},
|
|
msgNoSuchNick,
|
|
}
|
|
}
|
|
|
|
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':
|
|
err := s.db.SetChannelModerated(ctx, chID, setting)
|
|
if err != nil {
|
|
return fmt.Errorf("set moderated: %w", err)
|
|
}
|
|
case 't':
|
|
err := s.db.SetChannelTopicLocked(ctx, chID, setting)
|
|
if err != nil {
|
|
return fmt.Errorf("set topic locked: %w", err)
|
|
}
|
|
case 'i':
|
|
err := s.db.SetChannelInviteOnly(ctx, chID, setting)
|
|
if err != nil {
|
|
return fmt.Errorf("set invite only: %w", err)
|
|
}
|
|
case 's':
|
|
err := s.db.SetChannelSecret(ctx, chID, setting)
|
|
if err != nil {
|
|
return fmt.Errorf("set secret: %w", err)
|
|
}
|
|
case 'n':
|
|
err := s.db.SetChannelNoExternal(ctx, chID, setting)
|
|
if 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
|
|
}
|
|
|
|
// QueryUserMode returns the session's user mode string,
|
|
// such as "+", "+w" or "+ow".
|
|
func (s *Service) QueryUserMode(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
) (string, error) {
|
|
modes := "+"
|
|
|
|
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"query oper flag: %w", err,
|
|
)
|
|
}
|
|
|
|
if isOper {
|
|
modes += "o"
|
|
}
|
|
|
|
isWallops, err := s.db.IsSessionWallops(
|
|
ctx, sessionID,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"query wallops flag: %w", err,
|
|
)
|
|
}
|
|
|
|
if isWallops {
|
|
modes += "w"
|
|
}
|
|
|
|
return modes, nil
|
|
}
|
|
|
|
// ApplyUserMode applies a user mode string to the session
|
|
// and returns the resulting mode string. The whole string
|
|
// is parsed before anything is written, and the flags are
|
|
// written in one transaction, so a change that is rejected
|
|
// or fails leaves the modes as they were.
|
|
func (s *Service) ApplyUserMode(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
modeStr string,
|
|
) (string, error) {
|
|
wallops, oper, err := parseUserModeString(modeStr)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
err = s.db.SetSessionUserModes(
|
|
ctx, sessionID, wallops, oper,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("apply user modes: %w", err)
|
|
}
|
|
|
|
return s.QueryUserMode(ctx, sessionID)
|
|
}
|
|
|
|
// parseUserModeString parses a user mode string such as
|
|
// "+w", "-o" or "+w-o" into the new value of each flag it
|
|
// names, or nil for a flag it does not name; a later letter
|
|
// overrides an earlier one. The string must start with + or
|
|
// - and name at least one mode. w may be set or unset, o
|
|
// only unset (OPER sets it). Anything else is rejected with
|
|
// ERR_UMODEUNKNOWNFLAG.
|
|
func parseUserModeString(
|
|
modeStr string,
|
|
) (*bool, *bool, error) {
|
|
unknownFlag := &IRCError{
|
|
irc.ErrUmodeUnknownFlag, nil, "Unknown MODE flag",
|
|
}
|
|
|
|
if !strings.HasPrefix(modeStr, "+") &&
|
|
!strings.HasPrefix(modeStr, "-") {
|
|
return nil, nil, unknownFlag
|
|
}
|
|
|
|
var wallops, oper *bool
|
|
|
|
adding := true
|
|
|
|
for _, modeChar := range modeStr {
|
|
switch modeChar {
|
|
case '+':
|
|
adding = true
|
|
case '-':
|
|
adding = false
|
|
case 'w':
|
|
value := adding
|
|
wallops = &value
|
|
case 'o':
|
|
if adding {
|
|
return nil, nil, unknownFlag
|
|
}
|
|
|
|
value := false
|
|
oper = &value
|
|
default:
|
|
return nil, nil, unknownFlag
|
|
}
|
|
}
|
|
|
|
if wallops == nil && oper == nil {
|
|
return nil, nil, unknownFlag
|
|
}
|
|
|
|
return wallops, oper, nil
|
|
}
|
|
|
|
// requireOper returns ERR_NOPRIVILEGES unless the session
|
|
// is a server operator.
|
|
func (s *Service) requireOper(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
) error {
|
|
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("check oper: %w", err)
|
|
}
|
|
|
|
if !isOper {
|
|
return &IRCError{
|
|
irc.ErrNoPrivileges,
|
|
nil,
|
|
"Permission Denied- You're not an IRC operator",
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|