diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 5ed1e18..2e4d209 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -28,6 +28,18 @@ const ( defaultHistLimit = 50 maxHistLimit = 500 cmdPrivmsg = "PRIVMSG" + + // IRC numeric reply codes per RFC 1459/2812. + ircErrNoSuchNick = "401" + ircErrNoSuchChannel = "403" + ircErrNoRecipient = "411" + ircErrNoTextToSend = "412" + ircErrUnknownCommand = "421" + ircErrNoNicknameGiven = "431" + ircErrErroneusNickname = "432" + ircErrNicknameInUse = "433" + ircErrNotOnChannel = "442" + ircErrNeedMoreParams = "461" ) func (hdlr *Handlers) maxBodySize() int64 { @@ -63,6 +75,43 @@ func (hdlr *Handlers) authSession( return sessionID, clientID, nick, nil } +// serverName returns the configured server name or the +// default "neoirc". +func (hdlr *Handlers) serverName() string { + if hdlr.params.Config.ServerName != "" { + return hdlr.params.Config.ServerName + } + + return "neoirc" +} + +// respondIRCError sends an IRC numeric error reply as a +// JSON response with HTTP 200 OK. This is used for errors +// in IRC command processing (as opposed to HTTP transport +// errors). The params slice contains IRC-style positional +// parameters (e.g. the target nick or channel). +func (hdlr *Handlers) respondIRCError( + writer http.ResponseWriter, + request *http.Request, + nick, numeric, msg string, + params ...string, +) { + resp := map[string]any{ + "command": numeric, + "from": hdlr.serverName(), + "to": nick, + "body": []string{msg}, + } + + if len(params) > 0 { + resp["params"] = params + } + + hdlr.respondJSON( + writer, request, resp, http.StatusOK, + ) +} + func (hdlr *Handlers) requireAuth( writer http.ResponseWriter, request *http.Request, @@ -226,11 +275,7 @@ func (hdlr *Handlers) deliverMOTD( clientID, sessionID int64, ) { motd := hdlr.params.Config.MOTD - serverName := hdlr.params.Config.ServerName - - if serverName == "" { - serverName = "neoirc" - } + serverName := hdlr.serverName() if motd == "" { return @@ -557,10 +602,10 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { payload.To = strings.TrimSpace(payload.To) if payload.Command == "" { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "command required", - http.StatusBadRequest, + nick, ircErrUnknownCommand, + "No command given", ) return @@ -631,10 +676,11 @@ func (hdlr *Handlers) dispatchCommand( }, http.StatusOK) default: - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "unknown command: "+command, - http.StatusBadRequest, + nick, ircErrUnknownCommand, + command+" :Unknown command", + command, ) } } @@ -648,10 +694,10 @@ func (hdlr *Handlers) handlePrivmsg( bodyLines func() []string, ) { if target == "" { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "to field required", - http.StatusBadRequest, + nick, ircErrNoRecipient, + "No recipient given ("+command+")", ) return @@ -659,10 +705,10 @@ func (hdlr *Handlers) handlePrivmsg( lines := bodyLines() if len(lines) == 0 { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "body required", - http.StatusBadRequest, + nick, ircErrNoTextToSend, + "No text to send", ) return @@ -694,10 +740,11 @@ func (hdlr *Handlers) handleChannelMsg( request.Context(), target, ) if err != nil { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "channel not found", - http.StatusNotFound, + nick, ircErrNoSuchChannel, + "No such channel", + target, ) return @@ -720,10 +767,11 @@ func (hdlr *Handlers) handleChannelMsg( } if !isMember { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "not a member of this channel", - http.StatusForbidden, + nick, ircErrNotOnChannel, + "You're not on that channel", + target, ) return @@ -761,7 +809,7 @@ func (hdlr *Handlers) handleChannelMsg( hdlr.respondJSON(writer, request, map[string]string{"id": msgUUID, "status": "sent"}, - http.StatusCreated) + http.StatusOK) } func (hdlr *Handlers) handleDirectMsg( @@ -775,10 +823,11 @@ func (hdlr *Handlers) handleDirectMsg( request.Context(), target, ) if err != nil { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "user not found", - http.StatusNotFound, + nick, ircErrNoSuchNick, + "No such nick/channel", + target, ) return @@ -805,7 +854,7 @@ func (hdlr *Handlers) handleDirectMsg( hdlr.respondJSON(writer, request, map[string]string{"id": msgUUID, "status": "sent"}, - http.StatusCreated) + http.StatusOK) } func (hdlr *Handlers) handleJoin( @@ -815,10 +864,11 @@ func (hdlr *Handlers) handleJoin( nick, target string, ) { if target == "" { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "to field required", - http.StatusBadRequest, + nick, ircErrNeedMoreParams, + "Not enough parameters", + "JOIN", ) return @@ -830,10 +880,11 @@ func (hdlr *Handlers) handleJoin( } if !validChannelRe.MatchString(channel) { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "invalid channel name", - http.StatusBadRequest, + nick, ircErrNoSuchChannel, + "No such channel", + channel, ) return @@ -895,10 +946,11 @@ func (hdlr *Handlers) handlePart( body json.RawMessage, ) { if target == "" { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "to field required", - http.StatusBadRequest, + nick, ircErrNeedMoreParams, + "Not enough parameters", + "PART", ) return @@ -913,10 +965,11 @@ func (hdlr *Handlers) handlePart( request.Context(), channel, ) if err != nil { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "channel not found", - http.StatusNotFound, + nick, ircErrNoSuchChannel, + "No such channel", + channel, ) return @@ -967,10 +1020,10 @@ func (hdlr *Handlers) handleNick( ) { lines := bodyLines() if len(lines) == 0 { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "body required (new nick)", - http.StatusBadRequest, + nick, ircErrNoNicknameGiven, + "No nickname given", ) return @@ -979,10 +1032,11 @@ func (hdlr *Handlers) handleNick( newNick := strings.TrimSpace(lines[0]) if !validNickRe.MatchString(newNick) { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "invalid nick", - http.StatusBadRequest, + nick, ircErrErroneusNickname, + "Erroneous nickname", + newNick, ) return @@ -1003,10 +1057,11 @@ func (hdlr *Handlers) handleNick( ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "nick already in use", - http.StatusConflict, + nick, ircErrNicknameInUse, + "Nickname is already in use", + newNick, ) return @@ -1093,10 +1148,11 @@ func (hdlr *Handlers) handleTopic( bodyLines func() []string, ) { if target == "" { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "to field required", - http.StatusBadRequest, + nick, ircErrNeedMoreParams, + "Not enough parameters", + "TOPIC", ) return @@ -1104,10 +1160,11 @@ func (hdlr *Handlers) handleTopic( lines := bodyLines() if len(lines) == 0 { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "body required (topic text)", - http.StatusBadRequest, + nick, ircErrNeedMoreParams, + "Not enough parameters", + "TOPIC", ) return @@ -1140,10 +1197,11 @@ func (hdlr *Handlers) handleTopic( request.Context(), channel, ) if err != nil { - hdlr.respondError( + hdlr.respondIRCError( writer, request, - "channel not found", - http.StatusNotFound, + nick, ircErrNoSuchChannel, + "No such channel", + channel, ) return diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index adfc77e..3176ad9 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -668,11 +668,18 @@ func TestJoinMissingTo(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("joiner3") - status, _ := tserver.sendCommand( + status, result := tserver.sendCommand( token, map[string]any{commandKey: joinCmd}, ) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "461" { + t.Fatalf( + "expected IRC 461, got %v", + result[commandKey], + ) } } @@ -699,9 +706,9 @@ func TestChannelMessage(t *testing.T) { bodyKey: []string{"hello world"}, }, ) - if status != http.StatusCreated { + if status != http.StatusOK { t.Fatalf( - "expected 201, got %d: %v", status, result, + "expected 200, got %d: %v", status, result, ) } @@ -728,11 +735,18 @@ func TestMessageMissingBody(t *testing.T) { commandKey: joinCmd, toKey: "#test", }) - status, _ := tserver.sendCommand(token, map[string]any{ + status, result := tserver.sendCommand(token, map[string]any{ commandKey: privmsgCmd, toKey: "#test", }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "412" { + t.Fatalf( + "expected IRC 412, got %v", + result[commandKey], + ) } } @@ -740,12 +754,19 @@ func TestMessageMissingTo(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("noto") - status, _ := tserver.sendCommand(token, map[string]any{ + status, result := tserver.sendCommand(token, map[string]any{ commandKey: privmsgCmd, bodyKey: []string{"hello"}, }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "411" { + t.Fatalf( + "expected IRC 411, got %v", + result[commandKey], + ) } } @@ -760,7 +781,7 @@ func TestNonMemberCannotSend(t *testing.T) { }) // Alice tries to send without joining. - status, _ := tserver.sendCommand( + status, result := tserver.sendCommand( aliceToken, map[string]any{ commandKey: privmsgCmd, @@ -768,8 +789,15 @@ func TestNonMemberCannotSend(t *testing.T) { bodyKey: []string{"sneaky"}, }, ) - if status != http.StatusForbidden { - t.Fatalf("expected 403, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "442" { + t.Fatalf( + "expected IRC 442, got %v", + result[commandKey], + ) } } @@ -786,9 +814,9 @@ func TestDirectMessage(t *testing.T) { bodyKey: []string{"hey bob"}, }, ) - if status != http.StatusCreated { + if status != http.StatusOK { t.Fatalf( - "expected 201, got %d: %v", status, result, + "expected 200, got %d: %v", status, result, ) } @@ -818,13 +846,20 @@ func TestDMToNonexistentUser(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("dmsender") - status, _ := tserver.sendCommand(token, map[string]any{ + status, result := tserver.sendCommand(token, map[string]any{ commandKey: privmsgCmd, toKey: "nobody", bodyKey: []string{"hello?"}, }) - if status != http.StatusNotFound { - t.Fatalf("expected 404, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "401" { + t.Fatalf( + "expected IRC 401, got %v", + result[commandKey], + ) } } @@ -871,12 +906,19 @@ func TestNickCollision(t *testing.T) { tserver.createSession("taken_nick") - status, _ := tserver.sendCommand(token, map[string]any{ + status, result := tserver.sendCommand(token, map[string]any{ commandKey: "NICK", bodyKey: []string{"taken_nick"}, }) - if status != http.StatusConflict { - t.Fatalf("expected 409, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "433" { + t.Fatalf( + "expected IRC 433, got %v", + result[commandKey], + ) } } @@ -884,12 +926,19 @@ func TestNickInvalid(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("nickval") - status, _ := tserver.sendCommand(token, map[string]any{ + status, result := tserver.sendCommand(token, map[string]any{ commandKey: "NICK", bodyKey: []string{"bad nick!"}, }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "432" { + t.Fatalf( + "expected IRC 432, got %v", + result[commandKey], + ) } } @@ -897,11 +946,18 @@ func TestNickEmptyBody(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("nicknobody") - status, _ := tserver.sendCommand( + status, result := tserver.sendCommand( token, map[string]any{commandKey: "NICK"}, ) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "431" { + t.Fatalf( + "expected IRC 431, got %v", + result[commandKey], + ) } } @@ -938,12 +994,19 @@ func TestTopicMissingTo(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("topicnoto") - status, _ := tserver.sendCommand(token, map[string]any{ + status, result := tserver.sendCommand(token, map[string]any{ commandKey: "TOPIC", bodyKey: []string{"topic"}, }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "461" { + t.Fatalf( + "expected IRC 461, got %v", + result[commandKey], + ) } } @@ -955,11 +1018,18 @@ func TestTopicMissingBody(t *testing.T) { commandKey: joinCmd, toKey: "#topictest", }) - status, _ := tserver.sendCommand(token, map[string]any{ + status, result := tserver.sendCommand(token, map[string]any{ commandKey: "TOPIC", toKey: "#topictest", }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "461" { + t.Fatalf( + "expected IRC 461, got %v", + result[commandKey], + ) } } @@ -1027,11 +1097,18 @@ func TestUnknownCommand(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("cmdtest") - status, _ := tserver.sendCommand( + status, result := tserver.sendCommand( token, map[string]any{commandKey: "BOGUS"}, ) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "421" { + t.Fatalf( + "expected IRC 421, got %v", + result[commandKey], + ) } } @@ -1039,11 +1116,18 @@ func TestEmptyCommand(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("emptycmd") - status, _ := tserver.sendCommand( + status, result := tserver.sendCommand( token, map[string]any{commandKey: ""}, ) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + if result[commandKey] != "421" { + t.Fatalf( + "expected IRC 421, got %v", + result[commandKey], + ) } }