Files
neoirc/internal/service/service.go
T
sneak 53dd786d57
check / check (push) Waiting to run
Spend a hashcash stamp only when the request succeeds (closes #158)
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
2026-10-20 23:59:57 +00:00

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
}