refactor: migrate HTTP handlers to shared service layer
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:
user
2026-03-25 14:22:46 -07:00
parent 2853dc8a1f
commit ac89a99c35
7 changed files with 691 additions and 997 deletions

View File

@@ -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