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) + } +}