refactor: migrate HTTP handlers to shared service layer
All checks were successful
check / check (push) Successful in 54s
All checks were successful
check / check (push) Successful in 54s
- Migrate all HTTP command handlers (PRIVMSG, JOIN, PART, NICK, TOPIC, KICK, QUIT, AWAY, OPER, MODE) to use hdlr.svc.* service methods instead of direct database calls. Both HTTP and IRC transports now share the same business logic path. - Fix BroadcastQuit bug: was inserting N separate message rows (one per recipient); now uses FanOut pattern with 1 InsertMessage + N EnqueueToSession calls. - Fix README: IRC listener is enabled by default on :6667, not disabled. Remove redundant -e IRC_LISTEN_ADDR from Docker example. - Add EXPOSE 6667 to Dockerfile alongside existing HTTP port. - Add service layer unit tests (JoinChannel, PartChannel, SendChannelMessage, FanOut, BroadcastQuit, moderated channel). - Update handler test setup to provide Service instance. - Use constant-time comparison in Oper credential validation to prevent timing attacks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -109,16 +110,19 @@ func excludeSession(
|
||||
|
||||
// SendChannelMessage validates membership and moderation,
|
||||
// then fans out a message to all channel members except
|
||||
// the sender.
|
||||
// the sender. Returns the database row ID, message UUID,
|
||||
// and any error. The dbID lets callers enqueue the same
|
||||
// message to the sender when echo is needed (HTTP
|
||||
// transport).
|
||||
func (s *Service) SendChannelMessage(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, command, channel string,
|
||||
body, meta json.RawMessage,
|
||||
) (string, error) {
|
||||
) (int64, string, error) {
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return "", &IRCError{
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
@@ -129,7 +133,7 @@ func (s *Service) SendChannelMessage(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
return "", &IRCError{
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel",
|
||||
@@ -146,7 +150,7 @@ func (s *Service) SendChannelMessage(
|
||||
)
|
||||
|
||||
if !isOp && !isVoiced {
|
||||
return "", &IRCError{
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel (+m)",
|
||||
@@ -157,15 +161,15 @@ func (s *Service) SendChannelMessage(
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
|
||||
_, uuid, fanErr := s.FanOut(
|
||||
dbID, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, channel,
|
||||
nil, body, meta, recipients,
|
||||
)
|
||||
if fanErr != nil {
|
||||
return "", fanErr
|
||||
return 0, "", fanErr
|
||||
}
|
||||
|
||||
return uuid, nil
|
||||
return dbID, uuid, nil
|
||||
}
|
||||
|
||||
// SendDirectMessage validates the target and sends a
|
||||
@@ -449,7 +453,9 @@ func (s *Service) ChangeNick(
|
||||
}
|
||||
|
||||
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
||||
// parts all channels, and deletes the session.
|
||||
// parts all channels, and deletes the session. Uses the
|
||||
// FanOut pattern: one message row fanned out to all unique
|
||||
// peer sessions.
|
||||
func (s *Service) BroadcastQuit(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
@@ -481,19 +487,18 @@ func (s *Service) BroadcastQuit(
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
for sid := range notified {
|
||||
dbID, _, insErr := s.DB.InsertMessage(
|
||||
ctx, irc.CmdQuit, nick, "",
|
||||
nil, body, nil,
|
||||
)
|
||||
if insErr != nil {
|
||||
continue
|
||||
if len(notified) > 0 {
|
||||
recipients := make([]int64, 0, len(notified))
|
||||
for sid := range notified {
|
||||
recipients = append(recipients, sid)
|
||||
}
|
||||
|
||||
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||
s.Broker.Notify(sid)
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut(
|
||||
ctx, irc.CmdQuit, nick, "",
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
@@ -529,7 +534,16 @@ func (s *Service) Oper(
|
||||
cfgName := s.Config.OperName
|
||||
cfgPassword := s.Config.OperPassword
|
||||
|
||||
if cfgName == "" || cfgPassword == "" {
|
||||
// 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,
|
||||
@@ -537,14 +551,6 @@ func (s *Service) Oper(
|
||||
}
|
||||
}
|
||||
|
||||
if name != cfgName || password != cfgPassword {
|
||||
return &IRCError{
|
||||
irc.ErrPasswdMismatch,
|
||||
nil,
|
||||
"Password incorrect",
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user