From abe0cc2c30d3785c0acad702cbb561bcf378ae87 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 2 Apr 2026 06:46:34 -0700 Subject: [PATCH] refactor: unify user mode processing into shared service layer Both the HTTP API and IRC wire protocol handlers now call service.ApplyUserMode/service.QueryUserMode for all user mode operations. The service layer iterates mode strings character by character (the correct IRC approach), ensuring identical behavior regardless of transport. Removed duplicate mode logic from internal/handlers/utility.go (buildUserModeString, applyUserModeChange, applyModeChar) and internal/ircserver/commands.go (buildUmodeString, inline iteration). Added service-level tests for QueryUserMode, ApplyUserMode (single-char, multi-char, invalid input, de-oper, +o rejection). --- internal/handlers/utility.go | 190 +++++++------------------------ internal/ircserver/commands.go | 77 +++---------- internal/service/service.go | 103 +++++++++++++++++ internal/service/service_test.go | 163 ++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 213 deletions(-) diff --git a/internal/handlers/utility.go b/internal/handlers/utility.go index c49ae60..7d0fc3d 100644 --- a/internal/handlers/utility.go +++ b/internal/handlers/utility.go @@ -1,14 +1,14 @@ package handlers import ( - "context" "encoding/json" - "fmt" + "errors" "net/http" "strings" "time" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/pkg/irc" ) @@ -471,7 +471,10 @@ func (hdlr *Handlers) handleWallops( } // handleUserMode handles user mode queries and changes -// (e.g., MODE nick, MODE nick +w). +// (e.g., MODE nick, MODE nick +w). Delegates to the +// shared service.ApplyUserMode / service.QueryUserMode so +// that mode string processing is identical for both the +// HTTP API and IRC wire protocol. func (hdlr *Handlers) handleUserMode( writer http.ResponseWriter, request *http.Request, @@ -496,16 +499,46 @@ func (hdlr *Handlers) handleUserMode( return } - hdlr.applyUserModeChange( - writer, request, - sessionID, clientID, nick, lines[0], + newModes, err := hdlr.svc.ApplyUserMode( + ctx, sessionID, lines[0], ) + if err != nil { + var ircErr *service.IRCError + if errors.As(err, &ircErr) { + hdlr.respondIRCError( + writer, request, + clientID, sessionID, + ircErr.Code, nick, ircErr.Params, + ircErr.Message, + ) + + return + } + + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplUmodeIs, nick, nil, + newModes, + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) return } - // Mode query — build the current mode string. - modeStr := hdlr.buildUserModeString(ctx, sessionID) + // Mode query — delegate to shared service. + modeStr := hdlr.svc.QueryUserMode(ctx, sessionID) hdlr.enqueueNumeric( ctx, clientID, irc.RplUmodeIs, nick, nil, @@ -516,144 +549,3 @@ func (hdlr *Handlers) handleUserMode( map[string]string{"status": "ok"}, http.StatusOK) } - -// buildUserModeString constructs the mode string for a -// user (e.g., "+ow" for oper+wallops). -func (hdlr *Handlers) buildUserModeString( - ctx context.Context, - sessionID int64, -) string { - modes := "+" - - isOper, err := hdlr.params.Database.IsSessionOper( - ctx, sessionID, - ) - if err == nil && isOper { - modes += "o" - } - - isWallops, err := hdlr.params.Database.IsSessionWallops( - ctx, sessionID, - ) - if err == nil && isWallops { - modes += "w" - } - - return modes -} - -// applyUserModeChange applies a user mode change string -// (e.g., "+w", "-w"). -func (hdlr *Handlers) applyUserModeChange( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, modeStr string, -) { - ctx := request.Context() - - if len(modeStr) < 2 { //nolint:mnd // +/- and mode char - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrUmodeUnknownFlag, nick, nil, - "Unknown MODE flag", - ) - - return - } - - adding := modeStr[0] == '+' - modeChar := modeStr[1:] - - applied, err := hdlr.applyModeChar( - ctx, writer, request, - sessionID, clientID, nick, - modeChar, adding, - ) - if err != nil || !applied { - return - } - - newModes := hdlr.buildUserModeString(ctx, sessionID) - - hdlr.enqueueNumeric( - ctx, clientID, irc.RplUmodeIs, nick, nil, - newModes, - ) - - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, - http.StatusOK) -} - -// applyModeChar applies a single user mode character. -// Returns (applied, error). -func (hdlr *Handlers) applyModeChar( - ctx context.Context, - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, modeChar string, - adding bool, -) (bool, error) { - switch modeChar { - case "w": - err := hdlr.params.Database.SetSessionWallops( - ctx, sessionID, adding, - ) - if err != nil { - hdlr.log.Error( - "set wallops mode failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return false, fmt.Errorf( - "set wallops: %w", err, - ) - } - case "o": - // +o cannot be set via MODE, only via OPER. - if adding { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrUmodeUnknownFlag, nick, nil, - "Unknown MODE flag", - ) - - return false, nil - } - - err := hdlr.params.Database.SetSessionOper( - ctx, sessionID, false, - ) - if err != nil { - hdlr.log.Error( - "clear oper mode failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return false, fmt.Errorf( - "clear oper: %w", err, - ) - } - default: - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrUmodeUnknownFlag, nick, nil, - "Unknown MODE flag", - ) - - return false, nil - } - - return true, nil -} diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 9b8bead..f06592a 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -730,17 +730,23 @@ func (c *Conn) handleUserMode( // Mode query (no mode string). if len(msg.Params) < 2 { //nolint:mnd - c.sendNumeric( - irc.RplUmodeIs, - c.buildUmodeString(ctx), - ) + modes := c.svc.QueryUserMode(ctx, c.sessionID) + c.sendNumeric(irc.RplUmodeIs, modes) return } - modeStr := msg.Params[1] + newModes, err := c.svc.ApplyUserMode( + ctx, c.sessionID, msg.Params[1], + ) + if err != nil { + var ircErr *service.IRCError + if errors.As(err, &ircErr) { + c.sendNumeric(ircErr.Code, ircErr.Message) + + return + } - if len(modeStr) < 2 { //nolint:mnd c.sendNumeric( irc.ErrUmodeUnknownFlag, "Unknown MODE flag", @@ -749,64 +755,7 @@ func (c *Conn) handleUserMode( return } - adding := modeStr[0] == '+' - - for _, ch := range modeStr[1:] { - switch ch { - case 'w': - _ = c.database.SetSessionWallops( - ctx, c.sessionID, adding, - ) - case 'o': - if adding { - c.sendNumeric( - irc.ErrUmodeUnknownFlag, - "Unknown MODE flag", - ) - - return - } - - _ = c.database.SetSessionOper( - ctx, c.sessionID, false, - ) - default: - c.sendNumeric( - irc.ErrUmodeUnknownFlag, - "Unknown MODE flag", - ) - - return - } - } - - c.sendNumeric( - irc.RplUmodeIs, - c.buildUmodeString(ctx), - ) -} - -// buildUmodeString returns the current user mode string. -func (c *Conn) buildUmodeString( - ctx context.Context, -) string { - modes := "+" - - isOper, err := c.database.IsSessionOper( - ctx, c.sessionID, - ) - if err == nil && isOper { - modes += "o" - } - - isWallops, err := c.database.IsSessionWallops( - ctx, c.sessionID, - ) - if err == nil && isWallops { - modes += "w" - } - - return modes + c.sendNumeric(irc.RplUmodeIs, newModes) } // handleNames replies with channel member list. diff --git a/internal/service/service.go b/internal/service/service.go index bc6b09c..a788db8 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -791,6 +791,109 @@ func (s *Service) QueryChannelMode( return modes + modeParams } +// QueryUserMode returns the current user mode string for +// the given session (e.g. "+ow", "+w", "+"). +func (s *Service) QueryUserMode( + ctx context.Context, + sessionID int64, +) string { + modes := "+" + + isOper, err := s.db.IsSessionOper(ctx, sessionID) + if err == nil && isOper { + modes += "o" + } + + isWallops, err := s.db.IsSessionWallops( + ctx, sessionID, + ) + if err == nil && isWallops { + modes += "w" + } + + return modes +} + +// ApplyUserMode parses a mode string character by +// character (e.g. "+wo", "-w") and applies each mode +// change to the session. Returns the resulting mode string +// after all changes, or an IRCError on failure. +func (s *Service) ApplyUserMode( + ctx context.Context, + sessionID int64, + modeStr string, +) (string, error) { + if len(modeStr) < 2 { //nolint:mnd // +/- prefix + ≥1 char + return "", &IRCError{ + Code: irc.ErrUmodeUnknownFlag, + Params: nil, + Message: "Unknown MODE flag", + } + } + + adding := modeStr[0] == '+' + + for _, ch := range modeStr[1:] { + if err := s.applySingleUserMode( + ctx, sessionID, ch, adding, + ); err != nil { + return "", err + } + } + + return s.QueryUserMode(ctx, sessionID), nil +} + +// applySingleUserMode applies one user mode character. +func (s *Service) applySingleUserMode( + ctx context.Context, + sessionID int64, + modeChar rune, + adding bool, +) error { + switch modeChar { + case 'w': + err := s.db.SetSessionWallops( + ctx, sessionID, adding, + ) + if err != nil { + s.log.Error( + "set wallops mode failed", "error", err, + ) + + return fmt.Errorf("set wallops: %w", err) + } + case 'o': + // +o cannot be set via MODE; use OPER command. + if adding { + return &IRCError{ + Code: irc.ErrUmodeUnknownFlag, + Params: nil, + Message: "Unknown MODE flag", + } + } + + err := s.db.SetSessionOper( + ctx, sessionID, false, + ) + if err != nil { + s.log.Error( + "clear oper mode failed", "error", err, + ) + + return fmt.Errorf("clear oper: %w", err) + } + default: + return &IRCError{ + Code: irc.ErrUmodeUnknownFlag, + Params: nil, + Message: "Unknown MODE flag", + } + } + + return nil +} + // broadcastNickChange notifies channel peers of a nick // change. func (s *Service) broadcastNickChange( diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 864a8a8..016c174 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -363,3 +363,166 @@ func TestSendChannelMessage_Moderated(t *testing.T) { t.Errorf("operator should be able to send in moderated channel: %v", err) } } + +func TestQueryUserMode(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + // Fresh session has no modes. + modes := env.svc.QueryUserMode(ctx, sid) + if modes != "+" { + t.Errorf("expected +, got %s", modes) + } + + // Set wallops. + _ = env.db.SetSessionWallops(ctx, sid, true) + + modes = env.svc.QueryUserMode(ctx, sid) + if modes != "+w" { + t.Errorf("expected +w, got %s", modes) + } + + // Set oper. + _ = env.db.SetSessionOper(ctx, sid, true) + + modes = env.svc.QueryUserMode(ctx, sid) + if modes != "+ow" { + t.Errorf("expected +ow, got %s", modes) + } +} + +func TestApplyUserModeSingleChar(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + // Apply +w. + result, err := env.svc.ApplyUserMode(ctx, sid, "+w") + if err != nil { + t.Fatalf("apply +w: %v", err) + } + + if result != "+w" { + t.Errorf("expected +w, got %s", result) + } + + // Apply -w. + result, err = env.svc.ApplyUserMode(ctx, sid, "-w") + if err != nil { + t.Fatalf("apply -w: %v", err) + } + + if result != "+" { + t.Errorf("expected +, got %s", result) + } +} + +func TestApplyUserModeMultiChar(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + // Set oper first so we can test +wo (w applied, o + // rejected because +o is not allowed via MODE). + _ = env.db.SetSessionOper(ctx, sid, true) + + // Apply +w alone should work. + result, err := env.svc.ApplyUserMode(ctx, sid, "+w") + if err != nil { + t.Fatalf("apply +w: %v", err) + } + + if result != "+ow" { + t.Errorf("expected +ow, got %s", result) + } + + // Reset wallops. + _ = env.db.SetSessionWallops(ctx, sid, false) + + // Multi-char -ow: should de-oper and remove wallops. + _ = env.db.SetSessionWallops(ctx, sid, true) + + result, err = env.svc.ApplyUserMode(ctx, sid, "-ow") + if err != nil { + t.Fatalf("apply -ow: %v", err) + } + + if result != "+" { + t.Errorf("expected +, got %s", result) + } + + // +wo should fail because +o is not allowed. + _, err = env.svc.ApplyUserMode(ctx, sid, "+wo") + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) { + t.Fatalf("expected IRCError, got %v", err) + } + + if ircErr.Code != irc.ErrUmodeUnknownFlag { + t.Errorf( + "expected ErrUmodeUnknownFlag, got %d", + ircErr.Code, + ) + } +} + +func TestApplyUserModeInvalidInput(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + // Too short. + _, err := env.svc.ApplyUserMode(ctx, sid, "+") + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) { + t.Fatalf("expected IRCError for short input, got %v", err) + } + + // Unknown flag. + _, err = env.svc.ApplyUserMode(ctx, sid, "+x") + if !errors.As(err, &ircErr) { + t.Fatalf("expected IRCError for unknown flag, got %v", err) + } + + if ircErr.Code != irc.ErrUmodeUnknownFlag { + t.Errorf( + "expected ErrUmodeUnknownFlag, got %d", + ircErr.Code, + ) + } +} + +func TestApplyUserModeDeoper(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + // Make oper via DB directly. + _ = env.db.SetSessionOper(ctx, sid, true) + + // -o should work. + result, err := env.svc.ApplyUserMode(ctx, sid, "-o") + if err != nil { + t.Fatalf("apply -o: %v", err) + } + + if result != "+" { + t.Errorf("expected +, got %s", result) + } + + // +o should fail. + _, err = env.svc.ApplyUserMode(ctx, sid, "+o") + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) { + t.Fatalf("expected IRCError for +o, got %v", err) + } +}