check / check (push) Waiting to run
A session request refused with 400 or 409, or a +H channel message refused for membership, a ban or +m, used to spend its stamp, so a retry with the same stamp was refused as already used. hashcash.Validator.Validate is split into Check, which spends nothing, and Claim, which records the stamp; Release undoes a claim. Session creation checks the stamp first, claims it just before CreateSession and releases it if CreateSession fails. SendChannelMessage checks the stamp after the membership, ban and +m checks. Model: opus-5-5
1423 lines
31 KiB
Go
1423 lines
31 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"
|
|
"regexp"
|
|
"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/hashcash"
|
|
"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"
|
|
msgNotOnChannel = "You're not on that channel"
|
|
)
|
|
|
|
// maxUserhostNicks is how many nicks one USERHOST answers
|
|
// for (RFC 2812).
|
|
const maxUserhostNicks = 5
|
|
|
|
// validChannelRe matches the channel names JOIN accepts: #
|
|
// followed by 1 to 63 letters, digits, _ or -.
|
|
var validChannelRe = regexp.MustCompile(
|
|
`^#[a-zA-Z0-9_\-]{1,63}$`,
|
|
)
|
|
|
|
// validNickRe matches the nicks both transports accept: a
|
|
// letter or _, then up to 31 letters, digits or
|
|
// _-[]\^{}|`.
|
|
var validNickRe = regexp.MustCompile(
|
|
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
|
)
|
|
|
|
// ValidNick reports whether validNickRe matches nick.
|
|
func ValidNick(nick string) bool {
|
|
return validNickRe.MatchString(nick)
|
|
}
|
|
|
|
// 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
|
|
channelHashcash *hashcash.ChannelValidator
|
|
|
|
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(),
|
|
channelHashcash: hashcash.NewChannelValidator(),
|
|
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,
|
|
channelHashcash: hashcash.NewChannelValidator(),
|
|
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),
|
|
}
|
|
}
|
|
|
|
// AdminLines returns the texts of the RPL_ADMINLOC1,
|
|
// RPL_ADMINLOC2 and RPL_ADMINEMAIL replies, in that order,
|
|
// that ADMIN sends on both transports.
|
|
func (s *Service) AdminLines(
|
|
serverName string,
|
|
) (string, string, string) {
|
|
return "neoirc server", "IRC over HTTP", "admin@" + serverName
|
|
}
|
|
|
|
// 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. An empty
|
|
// reason is given as "KILLed".
|
|
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",
|
|
}
|
|
}
|
|
|
|
if reason == "" {
|
|
reason = "KILLed"
|
|
}
|
|
|
|
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, "*", 0, 0,
|
|
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. toSessionID
|
|
// and toChannelID are the recipient InsertMessage records
|
|
// for history, 0 when there is none.
|
|
func (s *Service) FanOut(
|
|
ctx context.Context,
|
|
command, from, to string,
|
|
toSessionID, toChannelID int64,
|
|
params, body, meta json.RawMessage,
|
|
sessionIDs []int64,
|
|
) (int64, string, error) {
|
|
dbID, msgUUID, err := s.db.InsertMessage(
|
|
ctx, command, from, to,
|
|
toSessionID, toChannelID, 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 checks membership, bans and
|
|
// moderation, then the hashcash stamp of a PRIVMSG to a +H
|
|
// channel, then fans out a message to all channel members
|
|
// except the sender. The stamp is checked last so that a
|
|
// message refused for another reason does not spend it.
|
|
// 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) {
|
|
noSuchChannel := &IRCError{
|
|
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
|
|
}
|
|
|
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
return 0, "", noSuchChannel
|
|
}
|
|
|
|
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)",
|
|
}
|
|
}
|
|
}
|
|
|
|
if command == irc.CmdPrivmsg {
|
|
err = s.checkChannelHashcash(
|
|
ctx, chID, channel, body, meta,
|
|
)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
}
|
|
|
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
|
recipients := excludeSession(memberIDs, sessionID)
|
|
|
|
dbID, uuid, fanErr := s.FanOut(
|
|
ctx, command, nick, channel, 0, chID,
|
|
nil, body, meta, recipients,
|
|
)
|
|
// The channel was deleted after the lookup above.
|
|
if db.IsForeignKeyConstraintError(fanErr) {
|
|
return 0, "", noSuchChannel
|
|
}
|
|
|
|
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) {
|
|
noSuchNick := &IRCError{
|
|
irc.ErrNoSuchNick, []string{target}, "No such nick",
|
|
}
|
|
|
|
targetSID, err := s.db.GetSessionByNick(ctx, target)
|
|
if err != nil {
|
|
return nil, noSuchNick
|
|
}
|
|
|
|
away, _ := s.db.GetAway(ctx, targetSID)
|
|
|
|
recipients := []int64{targetSID}
|
|
if targetSID != sessionID {
|
|
recipients = append(recipients, sessionID)
|
|
}
|
|
|
|
_, uuid, fanErr := s.FanOut(
|
|
ctx, command, nick, target, targetSID, 0,
|
|
nil, body, meta, recipients,
|
|
)
|
|
// The target's session was deleted after the lookup above.
|
|
if db.IsForeignKeyConstraintError(fanErr) {
|
|
return nil, noSuchNick
|
|
}
|
|
|
|
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. A channel name that validChannelRe
|
|
// does not match gets ERR_NOSUCHCHANNEL.
|
|
func (s *Service) JoinChannel(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, channel, suppliedKey string,
|
|
) (*JoinResult, error) {
|
|
if !validChannelRe.MatchString(channel) {
|
|
return nil, &IRCError{
|
|
irc.ErrNoSuchChannel,
|
|
[]string{channel},
|
|
msgNoSuchChannel,
|
|
}
|
|
}
|
|
|
|
chID, isCreator, err := s.db.CreateOrJoinChannel(
|
|
ctx, channel, sessionID, suppliedKey,
|
|
)
|
|
if err != nil {
|
|
return nil, joinError(channel, 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, 0, chID,
|
|
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},
|
|
msgNotOnChannel,
|
|
}
|
|
}
|
|
|
|
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, 0, chID,
|
|
nil, body, nil, recipients,
|
|
)
|
|
|
|
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //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},
|
|
msgNotOnChannel,
|
|
}
|
|
}
|
|
|
|
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, 0, chID,
|
|
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, 0, chID,
|
|
params, body, nil, memberIDs,
|
|
)
|
|
|
|
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec
|
|
|
|
return nil
|
|
}
|
|
|
|
// InviteUser records an invite of targetNick to channel,
|
|
// which JoinChannel consumes, and returns the target's
|
|
// session ID. The inviter must be on the channel, and an
|
|
// operator if it is +i; the target must exist and not be
|
|
// on the channel already.
|
|
func (s *Service) InviteUser(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, targetNick, channel string,
|
|
) (int64, error) {
|
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
return 0, &IRCError{
|
|
irc.ErrNoSuchChannel,
|
|
[]string{channel},
|
|
msgNoSuchChannel,
|
|
}
|
|
}
|
|
|
|
isMember, memErr := s.db.IsChannelMember(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if memErr != nil || !isMember {
|
|
return 0, &IRCError{
|
|
irc.ErrNotOnChannel,
|
|
[]string{channel},
|
|
msgNotOnChannel,
|
|
}
|
|
}
|
|
|
|
isInviteOnly, ioErr := s.db.IsChannelInviteOnly(ctx, chID)
|
|
if ioErr == nil && isInviteOnly {
|
|
isOp, opErr := s.db.IsChannelOperator(
|
|
ctx, chID, sessionID,
|
|
)
|
|
if opErr != nil || !isOp {
|
|
return 0, &IRCError{
|
|
irc.ErrChanOpPrivsNeeded,
|
|
[]string{channel},
|
|
msgNotChannelOp,
|
|
}
|
|
}
|
|
}
|
|
|
|
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
|
|
if err != nil {
|
|
return 0, &IRCError{
|
|
irc.ErrNoSuchNick,
|
|
[]string{targetNick},
|
|
msgNoSuchNick,
|
|
}
|
|
}
|
|
|
|
alreadyIn, aiErr := s.db.IsChannelMember(
|
|
ctx, chID, targetSID,
|
|
)
|
|
if aiErr == nil && alreadyIn {
|
|
return 0, &IRCError{
|
|
irc.ErrUserOnChannel,
|
|
[]string{targetNick, channel},
|
|
"is already on channel",
|
|
}
|
|
}
|
|
|
|
err = s.db.AddChannelInvite(ctx, chID, targetSID, nick)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("add invite: %w", err)
|
|
}
|
|
|
|
return targetSID, 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 parts the session from all its channels,
|
|
// deletes it, and queues a QUIT to the users who shared a
|
|
// channel with it, in one transaction, so that the QUIT is
|
|
// queued before anyone else can take the nick, and then
|
|
// wakes those users' long polls. Of several calls for one
|
|
// session at once, only the one that deletes it sends the
|
|
// QUIT.
|
|
func (s *Service) BroadcastQuit(
|
|
ctx context.Context,
|
|
sessionID int64,
|
|
nick, reason string,
|
|
) {
|
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
|
|
|
peerIDs, err := s.db.QuitSession(ctx, sessionID, nick, body)
|
|
if err != nil {
|
|
s.log.Error("quit failed", "error", err)
|
|
|
|
return
|
|
}
|
|
|
|
for _, peerID := range peerIDs {
|
|
s.broker.Notify(peerID)
|
|
}
|
|
}
|
|
|
|
// Logout deletes a client of the session and, if that was
|
|
// the session's last client, does what BroadcastQuit does.
|
|
// If that fails, nothing changes and Logout returns the
|
|
// error.
|
|
func (s *Service) Logout(
|
|
ctx context.Context,
|
|
sessionID, clientID int64,
|
|
nick string,
|
|
) error {
|
|
body, _ := json.Marshal([]string{"Connection closed"}) //nolint:errchkjson
|
|
|
|
peerIDs, err := s.db.LogoutClient(
|
|
ctx, sessionID, clientID, nick, body,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("logout: %w", err)
|
|
}
|
|
|
|
for _, peerID := range peerIDs {
|
|
s.broker.Notify(peerID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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, 0, chID,
|
|
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
|
|
}
|
|
|
|
// checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless
|
|
// the channel has no +H, or meta carries a hashcash stamp
|
|
// for this channel and body with enough bits that has not
|
|
// been used before. Recording the stamp as spent is the
|
|
// check that it is unused, so a stamp sent in several
|
|
// concurrent requests passes in only one of them.
|
|
func (s *Service) checkChannelHashcash(
|
|
ctx context.Context,
|
|
chID int64,
|
|
channel string,
|
|
body, meta json.RawMessage,
|
|
) error {
|
|
bits, err := s.db.GetChannelHashcashBits(ctx, chID)
|
|
if err != nil {
|
|
return fmt.Errorf("channel hashcash bits: %w", err)
|
|
}
|
|
|
|
if bits <= 0 {
|
|
return nil
|
|
}
|
|
|
|
stamp := extractHashcashFromMeta(meta)
|
|
if stamp == "" {
|
|
return &IRCError{
|
|
irc.ErrCannotSendToChan,
|
|
[]string{channel},
|
|
"Channel requires hashcash proof-of-work",
|
|
}
|
|
}
|
|
|
|
err = s.channelHashcash.ValidateStamp(
|
|
stamp, bits, channel, hashcash.BodyHash(body),
|
|
)
|
|
if err != nil {
|
|
return &IRCError{
|
|
irc.ErrCannotSendToChan,
|
|
[]string{channel},
|
|
"Invalid hashcash: " + err.Error(),
|
|
}
|
|
}
|
|
|
|
stampKey := hashcash.StampHash(stamp)
|
|
|
|
recorded, err := s.db.RecordSpentHashcash(ctx, stampKey)
|
|
if err != nil {
|
|
return fmt.Errorf("record spent hashcash: %w", err)
|
|
}
|
|
|
|
if !recorded {
|
|
return &IRCError{
|
|
irc.ErrCannotSendToChan,
|
|
[]string{channel},
|
|
"Hashcash stamp already used",
|
|
}
|
|
}
|
|
|
|
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, "", 0, 0,
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// joinError returns the IRC error for a JOIN of channel
|
|
// that db.CreateOrJoinChannel refused with err, or err
|
|
// wrapped if it is not a refusal.
|
|
func joinError(channel string, err error) error {
|
|
switch {
|
|
case errors.Is(err, db.ErrBannedFromChannel):
|
|
return &IRCError{
|
|
Code: irc.ErrBannedFromChan,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+b)",
|
|
}
|
|
case errors.Is(err, db.ErrInviteOnlyChannel):
|
|
return &IRCError{
|
|
Code: irc.ErrInviteOnlyChan,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+i)",
|
|
}
|
|
case errors.Is(err, db.ErrBadChannelKey):
|
|
return &IRCError{
|
|
Code: irc.ErrBadChannelKey,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+k)",
|
|
}
|
|
case errors.Is(err, db.ErrChannelIsFull):
|
|
return &IRCError{
|
|
Code: irc.ErrChannelIsFull,
|
|
Params: []string{channel},
|
|
Message: "Cannot join channel (+l)",
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("join channel: %w", err)
|
|
}
|
|
|
|
// extractHashcashFromMeta parses the meta JSON and
|
|
// returns the hashcash stamp string, or empty string
|
|
// if not present.
|
|
func extractHashcashFromMeta(meta json.RawMessage) string {
|
|
if len(meta) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var metaMap map[string]json.RawMessage
|
|
|
|
err := json.Unmarshal(meta, &metaMap)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
raw, ok := metaMap["hashcash"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
var stamp string
|
|
|
|
err = json.Unmarshal(raw, &stamp)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return stamp
|
|
}
|