1 Commits

Author SHA1 Message Date
92d5145ac6 feat: add IRC wire protocol listener with shared service layer
Some checks failed
check / check (push) Failing after 46s
Add a traditional IRC wire protocol listener (RFC 1459/2812) on
configurable port (default :6667), sharing business logic with
the HTTP API via a new service layer.

- IRC listener: NICK, USER, PASS, JOIN, PART, PRIVMSG, NOTICE,
  TOPIC, MODE, KICK, QUIT, NAMES, LIST, WHOIS, WHO, AWAY, OPER,
  INVITE, LUSERS, MOTD, PING/PONG, CAP
- Service layer: shared logic for both transports including
  channel join (with Tier 2 checks: ban/invite/key/limit),
  message send (with ban + moderation checks), nick change,
  topic, kick, mode, quit broadcast, away, oper, invite
- BroadcastQuit uses FanOut pattern (one insert, N enqueues)
- HTTP handlers delegate to service for all command logic
- Tier 2 mode operations (+b/+i/+s/+k/+l) use service methods

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:01:36 -07:00
10 changed files with 266 additions and 553 deletions

View File

@@ -135,21 +135,13 @@ type migration struct {
func (database *Database) runMigrations(
ctx context.Context,
) error {
bootstrap, err := SchemaFiles.ReadFile(
"schema/000.sql",
)
_, err := database.conn.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
if err != nil {
return fmt.Errorf(
"read bootstrap migration: %w", err,
)
}
_, err = database.conn.ExecContext(
ctx, string(bootstrap),
)
if err != nil {
return fmt.Errorf(
"execute bootstrap migration: %w", err,
"create schema_migrations: %w", err,
)
}
@@ -278,11 +270,6 @@ func (database *Database) loadMigrations() (
continue
}
// Skip bootstrap migration; it is executed separately.
if version == 0 {
continue
}
content, readErr := SchemaFiles.ReadFile(
"schema/" + entry.Name(),
)

View File

@@ -2165,52 +2165,6 @@ func (database *Database) SetChannelSecret(
return nil
}
// --- No External Messages (+n) ---
// IsChannelNoExternal checks if a channel has +n mode.
func (database *Database) IsChannelNoExternal(
ctx context.Context,
channelID int64,
) (bool, error) {
var isNoExternal int
err := database.conn.QueryRowContext(ctx,
`SELECT is_no_external FROM channels
WHERE id = ?`,
channelID,
).Scan(&isNoExternal)
if err != nil {
return false, fmt.Errorf(
"check no external: %w", err,
)
}
return isNoExternal != 0, nil
}
// SetChannelNoExternal sets or unsets +n mode.
func (database *Database) SetChannelNoExternal(
ctx context.Context,
channelID int64,
noExternal bool,
) error {
val := 0
if noExternal {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_no_external = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf("set no external: %w", err)
}
return nil
}
// ListAllChannelsWithCountsFiltered returns all channels
// with member counts, excluding secret channels that
// the given session is not a member of.

View File

@@ -1,6 +0,0 @@
-- Bootstrap: create the schema_migrations table itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);

View File

@@ -44,7 +44,6 @@ CREATE TABLE IF NOT EXISTS channels (
is_topic_locked INTEGER NOT NULL DEFAULT 1,
is_invite_only INTEGER NOT NULL DEFAULT 0,
is_secret INTEGER NOT NULL DEFAULT 0,
is_no_external INTEGER NOT NULL DEFAULT 1,
channel_key TEXT NOT NULL DEFAULT '',
user_limit INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

View File

@@ -1528,6 +1528,87 @@ func (hdlr *Handlers) executeJoin(
http.StatusOK)
}
// checkJoinAllowed runs Tier 2 restrictions for an
// existing channel. Returns true if join is allowed.
func (hdlr *Handlers) checkJoinAllowed(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
suppliedKey string,
) bool {
ctx := request.Context()
// 1. Ban check — prevents banned users from joining.
isBanned, banErr := hdlr.params.Database.
IsSessionBanned(ctx, chID, sessionID)
if banErr == nil && isBanned {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrBannedFromChan, nick,
[]string{channel},
"Cannot join channel (+b)",
)
return false
}
// 2. Invite-only check (+i).
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
hasInvite, invErr := hdlr.params.Database.
HasChannelInvite(ctx, chID, sessionID)
if invErr != nil || !hasInvite {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrInviteOnlyChan, nick,
[]string{channel},
"Cannot join channel (+i)",
)
return false
}
}
// 3. Channel key check (+k).
key, keyErr := hdlr.params.Database.
GetChannelKey(ctx, chID)
if keyErr == nil && key != "" {
if suppliedKey != key {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrBadChannelKey, nick,
[]string{channel},
"Cannot join channel (+k)",
)
return false
}
}
// 4. User limit check (+l).
limit, limErr := hdlr.params.Database.
GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 {
count, cntErr := hdlr.params.Database.
CountChannelMembers(ctx, chID)
if cntErr == nil && count >= int64(limit) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrChannelIsFull, nick,
[]string{channel},
"Cannot join channel (+l)",
)
return false
}
}
return true
}
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
func (hdlr *Handlers) deliverJoinNumerics(
@@ -2016,14 +2097,62 @@ func (hdlr *Handlers) handleChannelMode(
}
// buildChannelModeString constructs the current mode
// string for a channel by delegating to the service
// layer's QueryChannelMode, which returns the complete
// mode string including all flags and parameters.
// string for a channel, including +n (always on), +t, +m,
// +i, +s, +k, +l, and +H with their parameters.
func (hdlr *Handlers) buildChannelModeString(
ctx context.Context,
chID int64,
) string {
return hdlr.svc.QueryChannelMode(ctx, chID)
modes := "+n"
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
modes += "i"
}
isModerated, modErr := hdlr.params.Database.
IsChannelModerated(ctx, chID)
if modErr == nil && isModerated {
modes += "m"
}
isSecret, secErr := hdlr.params.Database.
IsChannelSecret(ctx, chID)
if secErr == nil && isSecret {
modes += "s"
}
isTopicLocked, tlErr := hdlr.params.Database.
IsChannelTopicLocked(ctx, chID)
if tlErr == nil && isTopicLocked {
modes += "t"
}
var modeParams string
key, keyErr := hdlr.params.Database.
GetChannelKey(ctx, chID)
if keyErr == nil && key != "" {
modes += "k"
modeParams += " " + key
}
limit, limErr := hdlr.params.Database.
GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 {
modes += "l"
modeParams += " " + strconv.Itoa(limit)
}
bits, bitsErr := hdlr.params.Database.
GetChannelHashcashBits(ctx, chID)
if bitsErr == nil && bits > 0 {
modes += "H"
modeParams += " " + strconv.Itoa(bits)
}
return modes + modeParams
}
// queryChannelMode sends RPL_CHANNELMODEIS and
@@ -3962,76 +4091,3 @@ func (hdlr *Handlers) deliverWhoisIdle(
"seconds idle, signon time",
)
}
// fanOut inserts a message and enqueues it to all given
// sessions.
func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target,
nil, body, meta,
)
if err != nil {
return "", fmt.Errorf("insert message: %w", err)
}
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
"error", enqErr, "session_id", sid)
}
hdlr.broker.Notify(sid)
}
return msgUUID, nil
}
// fanOutSilent calls fanOut and discards the UUID.
func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
body json.RawMessage,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target,
body, nil, sessionIDs,
)
return err
}
// requireChannelOp checks if the session is a channel
// operator and sends ERR_CHANOPRIVSNEEDED if not.
func (hdlr *Handlers) requireChannelOp(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
) bool {
isOp, err := hdlr.params.Database.IsChannelOperator(
request.Context(), chID, sessionID,
)
if err != nil || !isOp {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrChanOpPrivsNeeded, nick,
[]string{channel},
"You're not channel operator",
)
return false
}
return true
}

View File

@@ -179,7 +179,7 @@ func (c *Conn) joinChannel(
channel string,
) {
result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.nick, channel, "",
ctx, c.sessionID, c.nick, channel,
)
if err != nil {
c.sendIRCError(err)
@@ -490,124 +490,6 @@ func (c *Conn) handleChannelMode(
)
}
// modeResult holds the delta strings produced by a
// single mode-char application.
type modeResult struct {
applied string
appliedArgs string
consumed int
skip bool
}
// applyHashcashMode handles +H/-H (hashcash difficulty).
func (c *Conn) applyHashcashMode(
ctx context.Context,
chID int64,
adding bool,
args []string,
argIdx int,
) modeResult {
if !adding {
_ = c.database.SetChannelHashcashBits(
ctx, chID, 0,
)
return modeResult{
applied: "-H",
appliedArgs: "",
consumed: 0,
skip: false,
}
}
if argIdx >= len(args) {
return modeResult{
applied: "",
appliedArgs: "",
consumed: 0,
skip: true,
}
}
bitsStr := args[argIdx]
bits, parseErr := strconv.Atoi(bitsStr)
if parseErr != nil ||
bits < 1 || bits > maxHashcashBits {
c.sendNumeric(
irc.ErrUnknownMode, "H",
"is unknown mode char to me",
)
return modeResult{
applied: "",
appliedArgs: "",
consumed: 1,
skip: true,
}
}
_ = c.database.SetChannelHashcashBits(
ctx, chID, bits,
)
return modeResult{
applied: "+H",
appliedArgs: " " + bitsStr,
consumed: 1,
skip: false,
}
}
// applyMemberMode handles +o/-o and +v/-v.
func (c *Conn) applyMemberMode(
ctx context.Context,
chID int64,
channel string,
modeChar rune,
adding bool,
args []string,
argIdx int,
) modeResult {
if argIdx >= len(args) {
return modeResult{
applied: "",
appliedArgs: "",
consumed: 0,
skip: true,
}
}
targetNick := args[argIdx]
err := c.svc.ApplyMemberMode(
ctx, chID, channel,
targetNick, modeChar, adding,
)
if err != nil {
c.sendIRCError(err)
return modeResult{
applied: "",
appliedArgs: "",
consumed: 1,
skip: true,
}
}
prefix := "+"
if !adding {
prefix = "-"
}
return modeResult{
applied: prefix + string(modeChar),
appliedArgs: " " + targetNick,
consumed: 1,
skip: false,
}
}
// applyChannelModes applies mode changes using the
// service for individual mode operations.
func (c *Conn) applyChannelModes(
@@ -623,57 +505,52 @@ func (c *Conn) applyChannelModes(
appliedArgs := ""
for _, modeChar := range modeStr {
var res modeResult
switch modeChar {
case '+':
adding = true
continue
case '-':
adding = false
continue
case 'i', 'm', 'n', 's', 't':
case 'm', 't':
_ = c.svc.SetChannelFlag(
ctx, chID, modeChar, adding,
)
prefix := "+"
if !adding {
prefix = "-"
if adding {
applied += "+" + string(modeChar)
} else {
applied += "-" + string(modeChar)
}
case 'o', 'v':
if argIdx >= len(args) {
break
}
res = modeResult{
applied: prefix + string(modeChar),
appliedArgs: "",
consumed: 0,
skip: false,
}
case 'H':
res = c.applyHashcashMode(
ctx, chID, adding, args, argIdx,
)
case 'o', 'v':
res = c.applyMemberMode(
targetNick := args[argIdx]
argIdx++
err := c.svc.ApplyMemberMode(
ctx, chID, channel,
modeChar, adding, args, argIdx,
targetNick, modeChar, adding,
)
if err != nil {
c.sendIRCError(err)
continue
}
if adding {
applied += "+" + string(modeChar)
} else {
applied += "-" + string(modeChar)
}
appliedArgs += " " + targetNick
default:
c.sendNumeric(
irc.ErrUnknownMode,
string(modeChar),
"is unknown mode char to me",
)
continue
}
argIdx += res.consumed
if !res.skip {
applied += res.applied
appliedArgs += res.appliedArgs
}
}

View File

@@ -19,16 +19,15 @@ import (
)
const (
maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second
dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8
maxHashcashBits = 40
maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second
dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8
)
// cmdHandler is the signature for registered IRC command
@@ -435,7 +434,7 @@ func (c *Conn) deliverWelcome() {
"CHANTYPES=#",
"NICKLEN=32",
"PREFIX=(ov)@+",
"CHANMODES=,,H,imnst",
"CHANMODES=,,H,mnst",
"NETWORK="+c.serverSfx,
"are supported by this server",
)

View File

@@ -19,9 +19,12 @@ func NewTestServer(
database *db.Database,
brk *broker.Broker,
) *Server {
svc := service.NewTestService(
database, brk, cfg, log,
)
svc := &service.Service{
DB: database,
Broker: brk,
Config: cfg,
Log: log,
}
return &Server{ //nolint:exhaustruct
log: log,

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"git.eeqj.de/sneak/neoirc/internal/broker"
@@ -31,35 +30,19 @@ type Params struct {
// Service provides shared business logic for IRC commands.
type Service struct {
db *db.Database
broker *broker.Broker
config *config.Config
log *slog.Logger
DB *db.Database
Broker *broker.Broker
Config *config.Config
Log *slog.Logger
}
// New creates a new Service.
func New(params Params) *Service {
return &Service{
db: params.Database,
broker: params.Broker,
config: params.Config,
log: params.Logger.Get(),
}
}
// NewTestService creates a Service for use in tests
// outside the service package.
func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
log *slog.Logger,
) *Service {
return &Service{
db: database,
broker: brk,
config: cfg,
log: log,
DB: params.Database,
Broker: params.Broker,
Config: params.Config,
Log: params.Logger.Get(),
}
}
@@ -93,7 +76,7 @@ func (s *Service) FanOut(
params, body, meta json.RawMessage,
sessionIDs []int64,
) (int64, string, error) {
dbID, msgUUID, err := s.db.InsertMessage(
dbID, msgUUID, err := s.DB.InsertMessage(
ctx, command, from, to, params, body, meta,
)
if err != nil {
@@ -101,8 +84,8 @@ func (s *Service) FanOut(
}
for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID)
s.broker.Notify(sid)
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
s.Broker.Notify(sid)
}
return dbID, msgUUID, nil
@@ -137,7 +120,7 @@ func (s *Service) SendChannelMessage(
nick, command, channel string,
body, meta json.RawMessage,
) (int64, string, error) {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return 0, "", &IRCError{
irc.ErrNoSuchChannel,
@@ -146,7 +129,7 @@ func (s *Service) SendChannelMessage(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -157,24 +140,12 @@ func (s *Service) SendChannelMessage(
}
}
// 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)
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
if moderated {
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
isVoiced, _ := s.db.IsChannelVoiced(
isVoiced, _ := s.DB.IsChannelVoiced(
ctx, chID, sessionID,
)
@@ -187,7 +158,7 @@ func (s *Service) SendChannelMessage(
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
dbID, uuid, fanErr := s.FanOut(
@@ -210,7 +181,7 @@ func (s *Service) SendDirectMessage(
nick, command, target string,
body, meta json.RawMessage,
) (*DirectMsgResult, error) {
targetSID, err := s.db.GetSessionByNick(ctx, target)
targetSID, err := s.DB.GetSessionByNick(ctx, target)
if err != nil {
return nil, &IRCError{
irc.ErrNoSuchNick,
@@ -219,7 +190,7 @@ func (s *Service) SendDirectMessage(
}
}
away, _ := s.db.GetAway(ctx, targetSID)
away, _ := s.DB.GetAway(ctx, targetSID)
recipients := []int64{targetSID}
if targetSID != sessionID {
@@ -243,43 +214,31 @@ func (s *Service) SendDirectMessage(
func (s *Service) JoinChannel(
ctx context.Context,
sessionID int64,
nick, channel, suppliedKey string,
nick, channel string,
) (*JoinResult, error) {
chID, err := s.db.GetOrCreateChannel(ctx, channel)
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
if err != nil {
return nil, fmt.Errorf("get/create channel: %w", err)
}
memberCount, countErr := s.db.CountChannelMembers(
memberCount, countErr := s.DB.CountChannelMembers(
ctx, chID,
)
isCreator := countErr == nil && memberCount == 0
if !isCreator {
if joinErr := checkJoinRestrictions(
ctx, s.db, chID, sessionID,
channel, suppliedKey, memberCount,
); joinErr != nil {
return nil, joinErr
}
}
if isCreator {
err = s.db.JoinChannelAsOperator(
err = s.DB.JoinChannelAsOperator(
ctx, chID, sessionID,
)
} else {
err = s.db.JoinChannel(ctx, chID, sessionID)
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)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -301,7 +260,7 @@ func (s *Service) PartChannel(
sessionID int64,
nick, channel, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -310,7 +269,7 @@ func (s *Service) PartChannel(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -321,7 +280,7 @@ func (s *Service) PartChannel(
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
@@ -330,8 +289,8 @@ func (s *Service) PartChannel(
nil, body, nil, recipients,
)
s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil
}
@@ -343,7 +302,7 @@ func (s *Service) SetTopic(
sessionID int64,
nick, channel, topic string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -352,7 +311,7 @@ func (s *Service) SetTopic(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -363,9 +322,9 @@ func (s *Service) SetTopic(
}
}
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
if topicLocked {
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -377,15 +336,15 @@ func (s *Service) SetTopic(
}
}
if setErr := s.db.SetTopic(
if setErr := s.DB.SetTopic(
ctx, channel, topic,
); setErr != nil {
return fmt.Errorf("set topic: %w", setErr)
}
_ = s.db.SetTopicMeta(ctx, channel, topic, nick)
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -404,7 +363,7 @@ func (s *Service) KickUser(
sessionID int64,
nick, channel, targetNick, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -413,7 +372,7 @@ func (s *Service) KickUser(
}
}
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -424,7 +383,7 @@ func (s *Service) KickUser(
}
}
targetSID, err := s.db.GetSessionByNick(
targetSID, err := s.DB.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
@@ -435,7 +394,7 @@ func (s *Service) KickUser(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -446,7 +405,7 @@ func (s *Service) KickUser(
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
params, _ := json.Marshal( //nolint:errchkjson
[]string{targetNick},
@@ -457,8 +416,8 @@ func (s *Service) KickUser(
params, body, nil, memberIDs,
)
s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil
}
@@ -470,7 +429,7 @@ func (s *Service) ChangeNick(
sessionID int64,
oldNick, newNick string,
) error {
err := s.db.ChangeNick(ctx, sessionID, newNick)
err := s.DB.ChangeNick(ctx, sessionID, newNick)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") ||
db.IsUniqueConstraintError(err) {
@@ -502,7 +461,7 @@ func (s *Service) BroadcastQuit(
sessionID int64,
nick, reason string,
) {
channels, err := s.db.GetSessionChannels(
channels, err := s.DB.GetSessionChannels(
ctx, sessionID,
)
if err != nil {
@@ -512,7 +471,7 @@ func (s *Service) BroadcastQuit(
notified := make(map[int64]bool)
for _, ch := range channels {
memberIDs, memErr := s.db.GetChannelMemberIDs(
memberIDs, memErr := s.DB.GetChannelMemberIDs(
ctx, ch.ID,
)
if memErr != nil {
@@ -543,11 +502,11 @@ func (s *Service) BroadcastQuit(
}
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.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
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
}
// SetAway sets or clears the away message. Returns true
@@ -557,7 +516,7 @@ func (s *Service) SetAway(
sessionID int64,
message string,
) (bool, error) {
err := s.db.SetAway(ctx, sessionID, message)
err := s.DB.SetAway(ctx, sessionID, message)
if err != nil {
return false, fmt.Errorf("set away: %w", err)
}
@@ -572,8 +531,8 @@ func (s *Service) Oper(
sessionID int64,
name, password string,
) error {
cfgName := s.config.OperName
cfgPassword := s.config.OperPassword
cfgName := s.Config.OperName
cfgPassword := s.Config.OperPassword
// Use constant-time comparison and return the same
// error for all failures to prevent information
@@ -592,7 +551,7 @@ func (s *Service) Oper(
}
}
_ = s.db.SetSessionOper(ctx, sessionID, true)
_ = s.DB.SetSessionOper(ctx, sessionID, true)
return nil
}
@@ -604,7 +563,7 @@ func (s *Service) ValidateChannelOp(
sessionID int64,
channel string,
) (int64, error) {
chID, err := s.db.GetChannelByName(ctx, channel)
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return 0, &IRCError{
irc.ErrNoSuchChannel,
@@ -613,7 +572,7 @@ func (s *Service) ValidateChannelOp(
}
}
isOp, _ := s.db.IsChannelOperator(
isOp, _ := s.DB.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -636,7 +595,7 @@ func (s *Service) ApplyMemberMode(
mode rune,
adding bool,
) error {
targetSID, err := s.db.GetSessionByNick(
targetSID, err := s.DB.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
@@ -647,7 +606,7 @@ func (s *Service) ApplyMemberMode(
}
}
isMember, _ := s.db.IsChannelMember(
isMember, _ := s.DB.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -660,11 +619,11 @@ func (s *Service) ApplyMemberMode(
switch mode {
case 'o':
_ = s.db.SetChannelMemberOperator(
_ = s.DB.SetChannelMemberOperator(
ctx, chID, targetSID, adding,
)
case 'v':
_ = s.db.SetChannelMemberVoiced(
_ = s.DB.SetChannelMemberVoiced(
ctx, chID, targetSID, adding,
)
}
@@ -672,8 +631,7 @@ func (s *Service) ApplyMemberMode(
return nil
}
// SetChannelFlag applies a simple boolean channel mode
// (+m/-m, +t/-t, +i/-i, +s/-s, +n/-n).
// SetChannelFlag applies +m/-m or +t/-t on a channel.
func (s *Service) SetChannelFlag(
ctx context.Context,
chID int64,
@@ -682,37 +640,17 @@ func (s *Service) SetChannelFlag(
) error {
switch flag {
case 'm':
if err := s.db.SetChannelModerated(
if err := s.DB.SetChannelModerated(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set moderated: %w", err)
}
case 't':
if err := s.db.SetChannelTopicLocked(
if err := s.DB.SetChannelTopicLocked(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set topic locked: %w", err)
}
case 'i':
if err := s.db.SetChannelInviteOnly(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set invite only: %w", err)
}
case 's':
if err := s.db.SetChannelSecret(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set secret: %w", err)
}
case 'n':
if err := s.db.SetChannelNoExternal(
ctx, chID, setting,
); err != nil {
return fmt.Errorf(
"set no external: %w", err,
)
}
}
return nil
@@ -726,7 +664,7 @@ func (s *Service) BroadcastMode(
chID int64,
modeText string,
) {
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -735,60 +673,24 @@ func (s *Service) BroadcastMode(
)
}
// QueryChannelMode returns the complete channel mode
// string including all flags and parameterized modes.
// QueryChannelMode returns the channel mode string.
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)
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)
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
return modes
}
// broadcastNickChange notifies channel peers of a nick
@@ -798,7 +700,7 @@ func (s *Service) broadcastNickChange(
sessionID int64,
oldNick, newNick string,
) {
channels, err := s.db.GetSessionChannels(
channels, err := s.DB.GetSessionChannels(
ctx, sessionID,
)
if err != nil {
@@ -808,7 +710,7 @@ func (s *Service) broadcastNickChange(
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
notified := make(map[int64]bool)
dbID, _, insErr := s.db.InsertMessage(
dbID, _, insErr := s.DB.InsertMessage(
ctx, irc.CmdNick, oldNick, "",
nil, body, nil,
)
@@ -817,12 +719,12 @@ func (s *Service) broadcastNickChange(
}
// Notify the user themselves (for multi-client sync).
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
s.broker.Notify(sessionID)
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
s.Broker.Notify(sessionID)
notified[sessionID] = true
for _, ch := range channels {
memberIDs, memErr := s.db.GetChannelMemberIDs(
memberIDs, memErr := s.DB.GetChannelMemberIDs(
ctx, ch.ID,
)
if memErr != nil {
@@ -836,66 +738,8 @@ func (s *Service) broadcastNickChange(
notified[mid] = true
_ = s.db.EnqueueToSession(ctx, mid, dbID)
s.broker.Notify(mid)
_ = 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
}

View File

@@ -167,7 +167,7 @@ func TestJoinChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
ctx, sid, "alice", "#general",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -185,7 +185,7 @@ func TestJoinChannel(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel(
ctx, sid2, "bob", "#general", "",
ctx, sid2, "bob", "#general",
)
if err != nil {
t.Fatalf("JoinChannel bob: %v", err)
@@ -207,7 +207,7 @@ func TestPartChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
ctx, sid, "alice", "#general",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -242,14 +242,14 @@ func TestSendChannelMessage(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#chat", "",
ctx, sid1, "alice", "#chat",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#chat", "",
ctx, sid2, "bob", "#chat",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -293,14 +293,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#room", "",
ctx, sid1, "alice", "#room",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#room", "",
ctx, sid2, "bob", "#room",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -326,14 +326,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#modchat", "",
ctx, sid1, "alice", "#modchat",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#modchat", "",
ctx, sid2, "bob", "#modchat",
)
if err != nil {
t.Fatalf("join bob: %v", err)