diff --git a/Dockerfile b/Dockerfile index 273f53f..32acda7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,7 +53,7 @@ RUN apk add --no-cache ca-certificates \ COPY --from=builder /neoircd /usr/local/bin/neoircd USER neoirc -EXPOSE 8080 +EXPOSE 8080 6667 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1 ENTRYPOINT ["neoircd"] diff --git a/README.md b/README.md index 1899843..bfac1d8 100644 --- a/README.md +++ b/README.md @@ -2252,17 +2252,15 @@ neoirc includes an optional traditional IRC wire protocol listener (RFC backward compatibility with existing IRC clients like irssi, weechat, hexchat, and others. -### Enabling +### Configuration -Set the `IRC_LISTEN_ADDR` environment variable to a TCP address: +The IRC listener is **enabled by default** on `:6667`. To disable it, set +`IRC_LISTEN_ADDR` to an empty string: ```bash -IRC_LISTEN_ADDR=:6667 +IRC_LISTEN_ADDR= ``` -When unset or empty, the IRC listener is disabled and only the HTTP/JSON API is -available. - ### Supported Commands | Category | Commands | @@ -2297,13 +2295,13 @@ connected via the HTTP API can communicate in the same channels seamlessly. ### Docker Usage -To expose the IRC port in Docker: +To expose the IRC port in Docker (the listener is enabled by default on +`:6667`): ```bash docker run -d \ -p 8080:8080 \ -p 6667:6667 \ - -e IRC_LISTEN_ADDR=:6667 \ -v neoirc-data:/var/lib/neoirc \ neoirc ``` diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 973d0de..f01dc93 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "crypto/subtle" "encoding/json" "errors" "fmt" @@ -15,6 +14,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/hashcash" + "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/pkg/irc" "github.com/go-chi/chi/v5" ) @@ -182,51 +182,6 @@ func (hdlr *Handlers) requireAuth( return sessionID, clientID, nick, true } -// fanOut stores a message and enqueues it to all specified -// session IDs, then notifies them. -func (hdlr *Handlers) fanOut( - request *http.Request, - command, from, target string, - body json.RawMessage, - meta json.RawMessage, - sessionIDs []int64, -) (string, error) { - dbID, msgUUID, err := hdlr.params.Database.InsertMessage( - request.Context(), command, from, target, nil, body, meta, - ) - if err != nil { - return "", fmt.Errorf("insert message: %w", err) - } - - for _, sid := range sessionIDs { - enqErr := hdlr.params.Database.EnqueueToSession( - request.Context(), sid, dbID, - ) - if enqErr != nil { - hdlr.log.Error("enqueue failed", - "error", enqErr, "session_id", sid) - } - - hdlr.broker.Notify(sid) - } - - return msgUUID, nil -} - -// fanOutSilent calls fanOut and discards the UUID. -func (hdlr *Handlers) fanOutSilent( - request *http.Request, - command, from, target string, - body json.RawMessage, - sessionIDs []int64, -) error { - _, err := hdlr.fanOut( - request, command, from, target, body, nil, sessionIDs, - ) - - return err -} - // HandleCreateSession creates a new user session. func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc { return func( @@ -1212,6 +1167,43 @@ func (hdlr *Handlers) respondIRCError( http.StatusOK) } +// handleServiceError maps a service-layer error to an IRC +// numeric reply or a generic HTTP 500 response. Returns +// true if an error was handled (response sent). +func (hdlr *Handlers) handleServiceError( + writer http.ResponseWriter, + request *http.Request, + clientID, sessionID int64, + nick string, + err error, +) bool { + if err == nil { + return false + } + + var ircErr *service.IRCError + if errors.As(err, &ircErr) { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + ircErr.Code, nick, ircErr.Params, + ircErr.Message, + ) + + return true + } + + hdlr.log.Error( + "service error", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return true +} + func (hdlr *Handlers) handleChannelMsg( writer http.ResponseWriter, request *http.Request, @@ -1222,110 +1214,45 @@ func (hdlr *Handlers) handleChannelMsg( ) { ctx := request.Context() - chID, err := hdlr.params.Database.GetChannelByName( - ctx, target, + // Hashcash validation is HTTP-specific; needs chID. + isNotice := command == irc.CmdNotice + + if !isNotice { + chID, chErr := hdlr.params.Database. + GetChannelByName(ctx, target) + if chErr == nil { + hashcashErr := hdlr.validateChannelHashcash( + request, clientID, sessionID, + writer, nick, target, body, meta, chID, + ) + if hashcashErr != nil { + return + } + } + } + + // Delegate validation + fan-out to service layer. + dbID, uuid, err := hdlr.svc.SendChannelMessage( + ctx, sessionID, nick, + command, target, body, meta, ) - if err != nil { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNoSuchChannel, nick, []string{target}, - "No such channel", - ) - - return - } - - isMember, err := hdlr.params.Database.IsChannelMember( - ctx, chID, sessionID, - ) - if err != nil { - hdlr.log.Error( - "check membership failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return - } - - if !isMember { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrCannotSendToChan, nick, []string{target}, - "Cannot send to channel", - ) - - return - } - - // Enforce +m (moderated): only +o and +v can send. - if !hdlr.checkModeratedSend( + if hdlr.handleServiceError( writer, request, - sessionID, clientID, nick, target, chID, + clientID, sessionID, nick, err, ) { return } - // NOTICE skips hashcash validation on +H channels - // (servers and services use NOTICE). - isNotice := command == irc.CmdNotice - - if !isNotice { - hashcashErr := hdlr.validateChannelHashcash( - request, clientID, sessionID, - writer, nick, target, body, meta, chID, - ) - if hashcashErr != nil { - return - } - } - - hdlr.sendChannelMsg( - writer, request, command, nick, target, - body, meta, chID, + // HTTP echo: enqueue to sender so all their clients + // see the message in long-poll responses. + _ = hdlr.params.Database.EnqueueToSession( + ctx, sessionID, dbID, ) -} + hdlr.broker.Notify(sessionID) -// checkModeratedSend checks if a user can send to a -// moderated channel. Returns true if sending is allowed. -func (hdlr *Handlers) checkModeratedSend( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, target string, - chID int64, -) bool { - ctx := request.Context() - - isModerated, err := hdlr.params.Database. - IsChannelModerated(ctx, chID) - if err != nil || !isModerated { - return true - } - - isOp, opErr := hdlr.params.Database. - IsChannelOperator(ctx, chID, sessionID) - if opErr == nil && isOp { - return true - } - - isVoiced, vErr := hdlr.params.Database. - IsChannelVoiced(ctx, chID, sessionID) - if vErr == nil && isVoiced { - return true - } - - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrCannotSendToChan, nick, - []string{target}, - "Cannot send to channel (+m)", - ) - - return false + hdlr.respondJSON(writer, request, + map[string]string{"id": uuid, "status": "sent"}, + http.StatusOK) } // validateChannelHashcash checks whether the channel @@ -1482,49 +1409,6 @@ func (hdlr *Handlers) extractHashcashFromMeta( return stamp } -func (hdlr *Handlers) sendChannelMsg( - writer http.ResponseWriter, - request *http.Request, - command, nick, target string, - body json.RawMessage, - meta json.RawMessage, - chID int64, -) { - memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( - request.Context(), chID, - ) - if err != nil { - hdlr.log.Error( - "get channel members failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return - } - - msgUUID, err := hdlr.fanOut( - request, command, nick, target, body, meta, memberIDs, - ) - if err != nil { - hdlr.log.Error("send message failed", "error", err) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return - } - - hdlr.respondJSON(writer, request, - map[string]string{"id": msgUUID, "status": "sent"}, - http.StatusOK) -} - func (hdlr *Handlers) handleDirectMsg( writer http.ResponseWriter, request *http.Request, @@ -1533,60 +1417,32 @@ func (hdlr *Handlers) handleDirectMsg( body json.RawMessage, meta json.RawMessage, ) { - targetSID, err := hdlr.params.Database.GetSessionByNick( - request.Context(), target, + result, err := hdlr.svc.SendDirectMessage( + request.Context(), sessionID, nick, + command, target, body, meta, ) - if err != nil { - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.ErrNoSuchNick, nick, []string{target}, - "No such nick/channel", - ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, - http.StatusOK) - - return - } - - recipients := []int64{targetSID} - if targetSID != sessionID { - recipients = append(recipients, sessionID) - } - - msgUUID, err := hdlr.fanOut( - request, command, nick, target, body, meta, recipients, - ) - if err != nil { - hdlr.log.Error("send dm failed", "error", err) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } // Per RFC 2812: NOTICE must NOT trigger auto-replies // including RPL_AWAY. - if command != irc.CmdNotice { - awayMsg, awayErr := hdlr.params.Database.GetAway( - request.Context(), targetSID, + if command != irc.CmdNotice && result.AwayMsg != "" { + hdlr.enqueueNumeric( + request.Context(), clientID, + irc.RplAway, nick, + []string{target}, result.AwayMsg, ) - if awayErr == nil && awayMsg != "" { - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.RplAway, nick, - []string{target}, awayMsg, - ) - hdlr.broker.Notify(sessionID) - } + hdlr.broker.Notify(sessionID) } hdlr.respondJSON(writer, request, - map[string]string{"id": msgUUID, "status": "sent"}, + map[string]string{ + "id": result.UUID, "status": "sent", + }, http.StatusOK) } @@ -1633,69 +1489,19 @@ func (hdlr *Handlers) executeJoin( sessionID, clientID int64, nick, channel string, ) { - ctx := request.Context() - - chID, err := hdlr.params.Database.GetOrCreateChannel( - ctx, channel, + result, err := hdlr.svc.JoinChannel( + request.Context(), sessionID, nick, channel, ) - if err != nil { - hdlr.log.Error( - "get/create channel failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } - // Check if channel is empty before joining — first - // joiner becomes operator. - memberCount, countErr := hdlr.params.Database. - CountChannelMembers(ctx, chID) - if countErr != nil { - hdlr.log.Error( - "count members failed", "error", countErr, - ) - } - - isCreator := countErr == nil && memberCount == 0 - - if isCreator { - err = hdlr.params.Database.JoinChannelAsOperator( - ctx, chID, sessionID, - ) - } else { - err = hdlr.params.Database.JoinChannel( - ctx, chID, sessionID, - ) - } - - if err != nil { - hdlr.log.Error( - "join channel failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return - } - - memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( - ctx, chID, - ) - - _ = hdlr.fanOutSilent( - request, irc.CmdJoin, nick, channel, nil, memberIDs, - ) - hdlr.deliverJoinNumerics( - request, clientID, sessionID, nick, channel, chID, + request, clientID, sessionID, nick, + channel, result.ChannelID, ) hdlr.respondJSON(writer, request, @@ -1840,15 +1646,12 @@ func (hdlr *Handlers) handlePart( body json.RawMessage, ) { if target == "" { - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.ErrNeedMoreParams, nick, []string{irc.CmdPart}, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdPart}, "Not enough parameters", ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, - http.StatusOK) return } @@ -1858,51 +1661,27 @@ func (hdlr *Handlers) handlePart( channel = "#" + channel } - chID, err := hdlr.params.Database.GetChannelByName( - request.Context(), channel, - ) - if err != nil { - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.ErrNoSuchChannel, nick, []string{channel}, - "No such channel", - ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, - http.StatusOK) - - return + // Extract reason from body for the service call. + reason := "" + if body != nil { + var lines []string + if json.Unmarshal(body, &lines) == nil && + len(lines) > 0 { + reason = lines[0] + } } - memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( - request.Context(), chID, + err := hdlr.svc.PartChannel( + request.Context(), sessionID, + nick, channel, reason, ) - - _ = hdlr.fanOutSilent( - request, irc.CmdPart, nick, channel, body, memberIDs, - ) - - err = hdlr.params.Database.PartChannel( - request.Context(), chID, sessionID, - ) - if err != nil { - hdlr.log.Error( - "part channel failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } - _ = hdlr.params.Database.DeleteChannelIfEmpty( - request.Context(), chID, - ) - hdlr.respondJSON(writer, request, map[string]string{ "status": "parted", @@ -1963,34 +1742,16 @@ func (hdlr *Handlers) executeNickChange( sessionID, clientID int64, nick, newNick string, ) { - err := hdlr.params.Database.ChangeNick( - request.Context(), sessionID, newNick, + err := hdlr.svc.ChangeNick( + request.Context(), sessionID, nick, newNick, ) - if err != nil { - if db.IsUniqueConstraintError(err) { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNicknameInUse, nick, []string{newNick}, - "Nickname is already in use", - ) - - return - } - - hdlr.log.Error( - "change nick failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } - hdlr.broadcastNick(request, sessionID, nick, newNick) - hdlr.respondJSON(writer, request, map[string]string{ "status": "ok", "nick": newNick, @@ -1998,70 +1759,19 @@ func (hdlr *Handlers) executeNickChange( http.StatusOK) } -func (hdlr *Handlers) broadcastNick( - request *http.Request, - sessionID int64, - oldNick, newNick string, -) { - channels, _ := hdlr.params.Database. - GetSessionChannels( - request.Context(), sessionID, - ) - - notified := map[int64]bool{sessionID: true} - - nickBody, err := json.Marshal([]string{newNick}) - if err != nil { - hdlr.log.Error( - "marshal nick body", "error", err, - ) - - return - } - - dbID, _, _ := hdlr.params.Database.InsertMessage( - request.Context(), irc.CmdNick, oldNick, "", - nil, json.RawMessage(nickBody), nil, - ) - - _ = hdlr.params.Database.EnqueueToSession( - request.Context(), sessionID, dbID, - ) - - hdlr.broker.Notify(sessionID) - - for _, chanInfo := range channels { - memberIDs, _ := hdlr.params.Database. - GetChannelMemberIDs( - request.Context(), chanInfo.ID, - ) - - for _, mid := range memberIDs { - if !notified[mid] { - notified[mid] = true - - _ = hdlr.params.Database.EnqueueToSession( - request.Context(), mid, dbID, - ) - - hdlr.broker.Notify(mid) - } - } - } -} - func (hdlr *Handlers) handleTopic( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, - body json.RawMessage, + _ json.RawMessage, bodyLines func() []string, ) { if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdTopic}, "Not enough parameters", ) @@ -2072,7 +1782,8 @@ func (hdlr *Handlers) handleTopic( if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, - irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdTopic}, "Not enough parameters", ) @@ -2084,131 +1795,24 @@ func (hdlr *Handlers) handleTopic( channel = "#" + channel } - chID, err := hdlr.params.Database.GetChannelByName( - request.Context(), channel, + topic := strings.Join(lines, " ") + + err := hdlr.svc.SetTopic( + request.Context(), sessionID, + nick, channel, topic, ) - if err != nil { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNoSuchChannel, nick, []string{channel}, - "No such channel", - ) - - return - } - - isMember, err := hdlr.params.Database.IsChannelMember( - request.Context(), chID, sessionID, - ) - if err != nil { - hdlr.log.Error( - "check membership failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return - } - - if !isMember { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNotOnChannel, nick, []string{channel}, - "You're not on that channel", - ) - - return - } - - hdlr.executeTopic( + if hdlr.handleServiceError( writer, request, - sessionID, clientID, nick, - channel, strings.Join(lines, " "), - body, chID, - ) -} - -func (hdlr *Handlers) executeTopic( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, channel, topic string, - body json.RawMessage, - chID int64, -) { - ctx := request.Context() - - // Enforce +t: only operators can change topic when - // topic lock is active. - isLocked, lockErr := hdlr.params.Database. - IsChannelTopicLocked(ctx, chID) - if lockErr == nil && isLocked { - isOp, opErr := hdlr.params.Database. - IsChannelOperator(ctx, chID, sessionID) - if opErr != nil || !isOp { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrChanOpPrivsNeeded, nick, - []string{channel}, - "You're not channel operator", - ) - - return - } - } - - setErr := hdlr.params.Database.SetTopicMeta( - request.Context(), channel, topic, nick, - ) - if setErr != nil { - hdlr.log.Error( - "set topic failed", "error", setErr, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + clientID, sessionID, nick, err, + ) { return } - memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( - request.Context(), chID, + hdlr.deliverSetTopicNumerics( + request.Context(), clientID, sessionID, + nick, channel, topic, ) - _ = hdlr.fanOutSilent( - request, irc.CmdTopic, nick, channel, body, memberIDs, - ) - - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.RplTopic, nick, []string{channel}, topic, - ) - - // 333 RPL_TOPICWHOTIME - topicMeta, tmErr := hdlr.params.Database. - GetTopicMeta(request.Context(), chID) - if tmErr == nil && topicMeta != nil { - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.RplTopicWhoTime, nick, - []string{ - channel, - topicMeta.SetBy, - strconv.FormatInt( - topicMeta.SetAt.Unix(), 10, - ), - }, - "", - ) - } - - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, map[string]string{ "status": "ok", "topic": topic, @@ -2216,6 +1820,43 @@ func (hdlr *Handlers) executeTopic( http.StatusOK) } +// deliverSetTopicNumerics sends RPL_TOPIC and +// RPL_TOPICWHOTIME to the client after a topic change. +func (hdlr *Handlers) deliverSetTopicNumerics( + ctx context.Context, + clientID, sessionID int64, + nick, channel, topic string, +) { + hdlr.enqueueNumeric( + ctx, clientID, irc.RplTopic, nick, + []string{channel}, topic, + ) + + chID, chErr := hdlr.params.Database.GetChannelByName( + ctx, channel, + ) + if chErr == nil { + topicMeta, tmErr := hdlr.params.Database. + GetTopicMeta(ctx, chID) + if tmErr == nil && topicMeta != nil { + hdlr.enqueueNumeric( + ctx, clientID, + irc.RplTopicWhoTime, nick, + []string{ + channel, + topicMeta.SetBy, + strconv.FormatInt( + topicMeta.SetAt.Unix(), 10, + ), + }, + "", + ) + } + } + + hdlr.broker.Notify(sessionID) +} + // dispatchInfoCommand handles informational IRC commands // that produce server-side numerics (MOTD, PING). func (hdlr *Handlers) dispatchInfoCommand( @@ -2258,51 +1899,17 @@ func (hdlr *Handlers) handleQuit( nick string, body json.RawMessage, ) { - channels, _ := hdlr.params.Database. - GetSessionChannels( - request.Context(), sessionID, - ) - - notified := map[int64]bool{} - - var dbID int64 - - if len(channels) > 0 { - dbID, _, _ = hdlr.params.Database.InsertMessage( - request.Context(), irc.CmdQuit, nick, "", - nil, body, nil, - ) - } - - for _, chanInfo := range channels { - memberIDs, _ := hdlr.params.Database. - GetChannelMemberIDs( - request.Context(), chanInfo.ID, - ) - - for _, mid := range memberIDs { - if mid != sessionID && !notified[mid] { - notified[mid] = true - - _ = hdlr.params.Database.EnqueueToSession( - request.Context(), mid, dbID, - ) - - hdlr.broker.Notify(mid) - } + reason := "Client quit" + if body != nil { + var lines []string + if json.Unmarshal(body, &lines) == nil && + len(lines) > 0 { + reason = lines[0] } - - _ = hdlr.params.Database.PartChannel( - request.Context(), chanInfo.ID, sessionID, - ) - - _ = hdlr.params.Database.DeleteChannelIfEmpty( - request.Context(), chanInfo.ID, - ) } - _ = hdlr.params.Database.DeleteSession( - request.Context(), sessionID, + hdlr.svc.BroadcastQuit( + request.Context(), sessionID, nick, reason, ) hdlr.clearAuthCookie(writer, request) @@ -2467,30 +2074,6 @@ func (hdlr *Handlers) queryChannelMode( // requireChannelOp checks that the session has +o in the // channel. If not, it sends ERR_CHANOPRIVSNEEDED and // returns false. -func (hdlr *Handlers) requireChannelOp( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, channel string, - chID int64, -) bool { - isOp, err := hdlr.params.Database.IsChannelOperator( - request.Context(), chID, sessionID, - ) - if err != nil || !isOp { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrChanOpPrivsNeeded, nick, - []string{channel}, - "You're not channel operator", - ) - - return false - } - - return true -} - // applyChannelMode handles setting channel modes. // Supports +o/-o, +v/-v, +m/-m, +t/-t, +H/-H. func (hdlr *Handlers) applyChannelMode( @@ -2567,67 +2150,6 @@ func (hdlr *Handlers) applyChannelMode( } } -// resolveUserModeTarget validates a user-mode change -// target and returns the target session ID if valid. -// Returns -1 on error (error response already sent). -func (hdlr *Handlers) resolveUserModeTarget( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, channel string, - chID int64, - modeArgs []string, -) (int64, string, bool) { - if !hdlr.requireChannelOp( - writer, request, - sessionID, clientID, nick, channel, chID, - ) { - return -1, "", false - } - - if len(modeArgs) < 2 { //nolint:mnd // mode + nick - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNeedMoreParams, nick, - []string{irc.CmdMode}, - "Not enough parameters", - ) - - return -1, "", false - } - - ctx := request.Context() - targetNick := modeArgs[1] - - targetSID, err := hdlr.params.Database. - GetSessionByNick(ctx, targetNick) - if err != nil { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNoSuchNick, nick, - []string{targetNick}, - "No such nick/channel", - ) - - return -1, "", false - } - - isMember, memErr := hdlr.params.Database. - IsChannelMember(ctx, chID, targetSID) - if memErr != nil || !isMember { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrUserNotInChannel, nick, - []string{targetNick, channel}, - "They aren't on that channel", - ) - - return -1, "", false - } - - return targetSID, targetNick, true -} - // applyUserMode handles +o/-o and +v/-v mode changes. // isOperMode=true for +o/-o, false for +v/-v. func (hdlr *Handlers) applyUserMode( @@ -2639,46 +2161,53 @@ func (hdlr *Handlers) applyUserMode( modeArgs []string, isOperMode bool, ) { - ctx := request.Context() - - targetSID, targetNick, ok := hdlr.resolveUserModeTarget( - writer, request, - sessionID, clientID, nick, - channel, chID, modeArgs, + // Validate operator status via service. + _, opErr := hdlr.svc.ValidateChannelOp( + request.Context(), sessionID, channel, ) - if !ok { + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, opErr, + ) { return } + if len(modeArgs) < 2 { //nolint:mnd // mode + nick + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdMode}, + "Not enough parameters", + ) + + return + } + + targetNick := modeArgs[1] setting := strings.HasPrefix(modeArgs[0], "+") - var err error + var modeChar rune if isOperMode { - err = hdlr.params.Database.SetChannelMemberOperator( - ctx, chID, targetSID, setting, - ) + modeChar = 'o' } else { - err = hdlr.params.Database.SetChannelMemberVoiced( - ctx, chID, targetSID, setting, - ) + modeChar = 'v' } - if err != nil { - hdlr.log.Error( - "set user mode failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + err := hdlr.svc.ApplyMemberMode( + request.Context(), chID, channel, + targetNick, modeChar, setting, + ) + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } - hdlr.broadcastUserModeChange( - request, nick, channel, chID, - modeArgs[0], targetNick, + modeText := modeArgs[0] + " " + targetNick + hdlr.svc.BroadcastMode( + request.Context(), nick, channel, chID, + modeText, ) hdlr.respondJSON(writer, request, @@ -2686,32 +2215,6 @@ func (hdlr *Handlers) applyUserMode( http.StatusOK) } -// broadcastUserModeChange fans out a user-mode change -// to all channel members. -func (hdlr *Handlers) broadcastUserModeChange( - request *http.Request, - nick, channel string, - chID int64, - modeStr, targetNick string, -) { - ctx := request.Context() - - memberIDs, _ := hdlr.params.Database. - GetChannelMemberIDs(ctx, chID) - - modeBody, err := json.Marshal( - []string{modeStr, targetNick}, - ) - if err != nil { - return - } - - _ = hdlr.fanOutSilent( - request, irc.CmdMode, nick, channel, - json.RawMessage(modeBody), memberIDs, - ) -} - // setChannelFlag handles +m/-m and +t/-t mode changes. func (hdlr *Handlers) setChannelFlag( writer http.ResponseWriter, @@ -2722,62 +2225,35 @@ func (hdlr *Handlers) setChannelFlag( flag string, setting bool, ) { - ctx := request.Context() - - if !hdlr.requireChannelOp( + // Validate operator status via service. + _, opErr := hdlr.svc.ValidateChannelOp( + request.Context(), sessionID, channel, + ) + if hdlr.handleServiceError( writer, request, - sessionID, clientID, nick, channel, chID, + clientID, sessionID, nick, opErr, ) { return } - var err error - - switch flag { - case "m": - err = hdlr.params.Database.SetChannelModerated( - ctx, chID, setting, - ) - case "t": - err = hdlr.params.Database.SetChannelTopicLocked( - ctx, chID, setting, - ) - } - - if err != nil { - hdlr.log.Error( - "set channel flag failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + err := hdlr.svc.SetChannelFlag( + request.Context(), chID, rune(flag[0]), setting, + ) + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } - // Broadcast the MODE change. modeStr := "+" + flag if !setting { modeStr = "-" + flag } - memberIDs, _ := hdlr.params.Database. - GetChannelMemberIDs(ctx, chID) - - modeBody, mErr := json.Marshal([]string{modeStr}) - if mErr != nil { - hdlr.log.Error( - "marshal mode body", "error", mErr, - ) - - return - } - - _ = hdlr.fanOutSilent( - request, irc.CmdMode, nick, channel, - json.RawMessage(modeBody), memberIDs, + hdlr.svc.BroadcastMode( + request.Context(), nick, channel, chID, + modeStr, ) hdlr.respondJSON(writer, request, @@ -2805,6 +2281,17 @@ func (hdlr *Handlers) setHashcashMode( ) { ctx := request.Context() + // Validate operator status via service. + _, opErr := hdlr.svc.ValidateChannelOp( + ctx, sessionID, channel, + ) + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, opErr, + ) { + return + } + if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg hdlr.respondIRCError( writer, request, clientID, sessionID, @@ -2868,6 +2355,17 @@ func (hdlr *Handlers) clearHashcashMode( ) { ctx := request.Context() + // Validate operator status via service. + _, opErr := hdlr.svc.ValidateChannelOp( + ctx, sessionID, channel, + ) + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, opErr, + ) { + return + } + err := hdlr.params.Database.SetChannelHashcashBits( ctx, chID, 0, ) @@ -3603,7 +3101,8 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { } } -// handleOper handles the OPER command for server operator authentication. +// handleOper handles the OPER command for server operator +// authentication. func (hdlr *Handlers) handleOper( writer http.ResponseWriter, request *http.Request, @@ -3625,39 +3124,13 @@ func (hdlr *Handlers) handleOper( return } - operName := lines[0] - operPass := lines[1] - - cfgName := hdlr.params.Config.OperName - cfgPass := hdlr.params.Config.OperPassword - - if cfgName == "" || cfgPass == "" || - subtle.ConstantTimeCompare([]byte(operName), []byte(cfgName)) != 1 || - subtle.ConstantTimeCompare([]byte(operPass), []byte(cfgPass)) != 1 { - hdlr.enqueueNumeric( - ctx, clientID, irc.ErrNoOperHost, nick, - nil, "No O-lines for your host", - ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, - http.StatusOK) - - return - } - - err := hdlr.params.Database.SetSessionOper( - ctx, sessionID, true, + err := hdlr.svc.Oper( + ctx, sessionID, lines[0], lines[1], ) - if err != nil { - hdlr.log.Error( - "set oper failed", "error", err, - ) - hdlr.respondError( - writer, request, "internal error", - http.StatusInternalServerError, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } @@ -3691,21 +3164,17 @@ func (hdlr *Handlers) handleAway( awayMsg = strings.Join(lines, " ") } - err := hdlr.params.Database.SetAway( + cleared, err := hdlr.svc.SetAway( ctx, sessionID, awayMsg, ) - if err != nil { - hdlr.log.Error("set away failed", "error", err) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - + if hdlr.handleServiceError( + writer, request, + clientID, sessionID, nick, err, + ) { return } - if awayMsg == "" { + if cleared { // 305 RPL_UNAWAY hdlr.enqueueNumeric( ctx, clientID, irc.RplUnaway, nick, nil, @@ -3734,6 +3203,8 @@ func (hdlr *Handlers) handleKick( body json.RawMessage, bodyLines func() []string, ) { + _ = body + if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, @@ -3769,178 +3240,22 @@ func (hdlr *Handlers) handleKick( reason = lines[1] } - hdlr.executeKick( - writer, request, - sessionID, clientID, nick, - channel, targetNick, reason, body, + err := hdlr.svc.KickUser( + request.Context(), sessionID, nick, + channel, targetNick, reason, ) -} - -func (hdlr *Handlers) executeKick( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, channel, targetNick, reason string, - _ json.RawMessage, -) { - ctx := request.Context() - - chID, targetSID, ok := hdlr.validateKick( + if hdlr.handleServiceError( writer, request, - sessionID, clientID, nick, - channel, targetNick, - ) - if !ok { - return - } - - if !hdlr.broadcastKick( - writer, request, - nick, channel, targetNick, reason, chID, + clientID, sessionID, nick, err, ) { return } - // Remove the kicked user from the channel. - _ = hdlr.params.Database.PartChannel( - ctx, chID, targetSID, - ) - - // Clean up empty channel. - _ = hdlr.params.Database.DeleteChannelIfEmpty( - ctx, chID, - ) - hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } -// validateKick checks the channel exists, the kicker is -// an operator, and the target is in the channel. -func (hdlr *Handlers) validateKick( - writer http.ResponseWriter, - request *http.Request, - sessionID, clientID int64, - nick, channel, targetNick string, -) (int64, int64, bool) { - ctx := request.Context() - - chID, err := hdlr.params.Database.GetChannelByName( - ctx, channel, - ) - if err != nil { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNoSuchChannel, nick, - []string{channel}, - "No such channel", - ) - - return 0, 0, false - } - - if !hdlr.requireChannelOp( - writer, request, - sessionID, clientID, nick, channel, chID, - ) { - return 0, 0, false - } - - targetSID, err := hdlr.params.Database. - GetSessionByNick(ctx, targetNick) - if err != nil { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrNoSuchNick, nick, - []string{targetNick}, - "No such nick/channel", - ) - - return 0, 0, false - } - - isMember, memErr := hdlr.params.Database. - IsChannelMember(ctx, chID, targetSID) - if memErr != nil || !isMember { - hdlr.respondIRCError( - writer, request, clientID, sessionID, - irc.ErrUserNotInChannel, nick, - []string{targetNick, channel}, - "They aren't on that channel", - ) - - return 0, 0, false - } - - return chID, targetSID, true -} - -// broadcastKick inserts a KICK message and fans it out -// to all channel members. -func (hdlr *Handlers) broadcastKick( - writer http.ResponseWriter, - request *http.Request, - nick, channel, targetNick, reason string, - chID int64, -) bool { - ctx := request.Context() - - memberIDs, _ := hdlr.params.Database. - GetChannelMemberIDs(ctx, chID) - - kickBody, bErr := json.Marshal([]string{reason}) - if bErr != nil { - hdlr.log.Error("marshal kick body", "error", bErr) - - return false - } - - kickParams, pErr := json.Marshal( - []string{targetNick}, - ) - if pErr != nil { - hdlr.log.Error( - "marshal kick params", "error", pErr, - ) - - return false - } - - dbID, _, insertErr := hdlr.params.Database. - InsertMessage( - ctx, irc.CmdKick, nick, channel, - json.RawMessage(kickParams), - json.RawMessage(kickBody), nil, - ) - if insertErr != nil { - hdlr.log.Error( - "insert kick message", "error", insertErr, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, - ) - - return false - } - - for _, sid := range memberIDs { - enqErr := hdlr.params.Database.EnqueueToSession( - ctx, sid, dbID, - ) - if enqErr != nil { - hdlr.log.Error("enqueue kick failed", - "error", enqErr, "session_id", sid) - } - - hdlr.broker.Notify(sid) - } - - return true -} - // deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle // time and signon time. func (hdlr *Handlers) deliverWhoisIdle( diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 085b095..7792782 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -28,6 +28,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/server" + "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/internal/stats" "go.uber.org/fx" "go.uber.org/fx/fxtest" @@ -207,6 +208,14 @@ func newTestHandlers( hcheck *healthcheck.Healthcheck, tracker *stats.Tracker, ) (*handlers.Handlers, error) { + brk := broker.New() + svc := service.New(service.Params{ //nolint:exhaustruct + Logger: log, + Config: cfg, + Database: database, + Broker: brk, + }) + hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct Logger: log, Globals: globs, @@ -214,7 +223,8 @@ func newTestHandlers( Database: database, Healthcheck: hcheck, Stats: tracker, - Broker: broker.New(), + Broker: brk, + Service: svc, }) if err != nil { return nil, fmt.Errorf("test handlers: %w", err) diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index c89258e..9ba1e57 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -118,7 +118,7 @@ func (c *Conn) handlePrivmsg( body, _ := json.Marshal([]string{text}) //nolint:errchkjson if strings.HasPrefix(target, "#") { - _, err := c.svc.SendChannelMessage( + _, _, err := c.svc.SendChannelMessage( ctx, c.sessionID, c.nick, msg.Command, target, body, nil, ) diff --git a/internal/service/service.go b/internal/service/service.go index 3e4e177..11f3f19 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..6d407d5 --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,365 @@ +// Tests use a global viper instance for configuration, +// making parallel execution unsafe. +// +//nolint:paralleltest +package service_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "testing" + + "git.eeqj.de/sneak/neoirc/internal/broker" + "git.eeqj.de/sneak/neoirc/internal/config" + "git.eeqj.de/sneak/neoirc/internal/db" + "git.eeqj.de/sneak/neoirc/internal/globals" + "git.eeqj.de/sneak/neoirc/internal/logger" + "git.eeqj.de/sneak/neoirc/internal/service" + "git.eeqj.de/sneak/neoirc/pkg/irc" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" + "golang.org/x/crypto/bcrypt" +) + +func TestMain(m *testing.M) { + db.SetBcryptCost(bcrypt.MinCost) + os.Exit(m.Run()) +} + +// testEnv holds all dependencies for a service test. +type testEnv struct { + svc *service.Service + db *db.Database + broker *broker.Broker + app *fxtest.App +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + + dbURL := fmt.Sprintf( + "file:svc_test_%p?mode=memory&cache=shared", + t, + ) + + var ( + database *db.Database + svc *service.Service + ) + + brk := broker.New() + + app := fxtest.New(t, + fx.Provide( + func() *globals.Globals { + return &globals.Globals{ //nolint:exhaustruct + Appname: "neoirc-test", + Version: "test", + } + }, + logger.New, + func( + lifecycle fx.Lifecycle, + globs *globals.Globals, + log *logger.Logger, + ) (*config.Config, error) { + cfg, err := config.New( + lifecycle, config.Params{ //nolint:exhaustruct + Globals: globs, Logger: log, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "test config: %w", err, + ) + } + + cfg.DBURL = dbURL + cfg.Port = 0 + cfg.OperName = "admin" + cfg.OperPassword = "secret" + + return cfg, nil + }, + func( + lifecycle fx.Lifecycle, + log *logger.Logger, + cfg *config.Config, + ) (*db.Database, error) { + return db.New(lifecycle, db.Params{ //nolint:exhaustruct + Logger: log, Config: cfg, + }) + }, + func() *broker.Broker { return brk }, + service.New, + ), + fx.Populate(&database, &svc), + ) + + app.RequireStart() + + t.Cleanup(func() { + app.RequireStop() + }) + + return &testEnv{ + svc: svc, + db: database, + broker: brk, + app: app, + } +} + +// createSession is a test helper that creates a session +// and returns the session ID. +func createSession( + ctx context.Context, + t *testing.T, + database *db.Database, + nick string, +) int64 { + t.Helper() + + sessionID, _, _, err := database.CreateSession( + ctx, nick, nick, "localhost", "127.0.0.1", + ) + if err != nil { + t.Fatalf("create session %s: %v", nick, err) + } + + return sessionID +} + +func TestFanOut(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid1 := createSession(ctx, t, env.db, "alice") + sid2 := createSession(ctx, t, env.db, "bob") + + body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson + + dbID, uuid, err := env.svc.FanOut( + ctx, irc.CmdPrivmsg, "alice", "#test", + nil, body, nil, + []int64{sid1, sid2}, + ) + if err != nil { + t.Fatalf("FanOut: %v", err) + } + + if dbID == 0 { + t.Error("expected non-zero dbID") + } + + if uuid == "" { + t.Error("expected non-empty UUID") + } +} + +func TestJoinChannel(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + result, err := env.svc.JoinChannel( + ctx, sid, "alice", "#general", + ) + if err != nil { + t.Fatalf("JoinChannel: %v", err) + } + + if result.ChannelID == 0 { + t.Error("expected non-zero channel ID") + } + + if !result.IsCreator { + t.Error("first joiner should be creator") + } + + // Second user joins — not creator. + sid2 := createSession(ctx, t, env.db, "bob") + + result2, err := env.svc.JoinChannel( + ctx, sid2, "bob", "#general", + ) + if err != nil { + t.Fatalf("JoinChannel bob: %v", err) + } + + if result2.IsCreator { + t.Error("second joiner should not be creator") + } + + if result2.ChannelID != result.ChannelID { + t.Error("both should join the same channel") + } +} + +func TestPartChannel(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid := createSession(ctx, t, env.db, "alice") + + _, err := env.svc.JoinChannel( + ctx, sid, "alice", "#general", + ) + if err != nil { + t.Fatalf("JoinChannel: %v", err) + } + + err = env.svc.PartChannel( + ctx, sid, "alice", "#general", "bye", + ) + if err != nil { + t.Fatalf("PartChannel: %v", err) + } + + // Parting a non-existent channel returns error. + err = env.svc.PartChannel( + ctx, sid, "alice", "#nonexistent", "", + ) + if err == nil { + t.Error("expected error for non-existent channel") + } + + var ircErr *service.IRCError + if !errors.As(err, &ircErr) { + t.Errorf("expected IRCError, got %T", err) + } +} + +func TestSendChannelMessage(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid1 := createSession(ctx, t, env.db, "alice") + sid2 := createSession(ctx, t, env.db, "bob") + + _, err := env.svc.JoinChannel( + ctx, sid1, "alice", "#chat", + ) + if err != nil { + t.Fatalf("join alice: %v", err) + } + + _, err = env.svc.JoinChannel( + ctx, sid2, "bob", "#chat", + ) + if err != nil { + t.Fatalf("join bob: %v", err) + } + + body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson + + dbID, uuid, err := env.svc.SendChannelMessage( + ctx, sid1, "alice", + irc.CmdPrivmsg, "#chat", body, nil, + ) + if err != nil { + t.Fatalf("SendChannelMessage: %v", err) + } + + if dbID == 0 { + t.Error("expected non-zero dbID") + } + + if uuid == "" { + t.Error("expected non-empty UUID") + } + + // Non-member cannot send. + sid3 := createSession(ctx, t, env.db, "charlie") + + _, _, err = env.svc.SendChannelMessage( + ctx, sid3, "charlie", + irc.CmdPrivmsg, "#chat", body, nil, + ) + if err == nil { + t.Error("expected error for non-member send") + } +} + +func TestBroadcastQuit(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid1 := createSession(ctx, t, env.db, "alice") + sid2 := createSession(ctx, t, env.db, "bob") + + _, err := env.svc.JoinChannel( + ctx, sid1, "alice", "#room", + ) + if err != nil { + t.Fatalf("join alice: %v", err) + } + + _, err = env.svc.JoinChannel( + ctx, sid2, "bob", "#room", + ) + if err != nil { + t.Fatalf("join bob: %v", err) + } + + // BroadcastQuit should not panic and should clean up. + env.svc.BroadcastQuit( + ctx, sid1, "alice", "Goodbye", + ) + + // Session should be deleted. + _, lookupErr := env.db.GetSessionByNick(ctx, "alice") + if lookupErr == nil { + t.Error("expected session to be deleted after quit") + } +} + +func TestSendChannelMessage_Moderated(t *testing.T) { + env := newTestEnv(t) + ctx := t.Context() + + sid1 := createSession(ctx, t, env.db, "alice") + sid2 := createSession(ctx, t, env.db, "bob") + + result, err := env.svc.JoinChannel( + ctx, sid1, "alice", "#modchat", + ) + if err != nil { + t.Fatalf("join alice: %v", err) + } + + _, err = env.svc.JoinChannel( + ctx, sid2, "bob", "#modchat", + ) + if err != nil { + t.Fatalf("join bob: %v", err) + } + + // Set channel to moderated. + chID := result.ChannelID + _ = env.svc.SetChannelFlag(ctx, chID, 'm', true) + + body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson + + // Bob (non-op, non-voiced) should fail to send. + _, _, err = env.svc.SendChannelMessage( + ctx, sid2, "bob", + irc.CmdPrivmsg, "#modchat", body, nil, + ) + if err == nil { + t.Error("expected error for non-voiced user in moderated channel") + } + + // Alice (operator) should succeed. + _, _, err = env.svc.SendChannelMessage( + ctx, sid1, "alice", + irc.CmdPrivmsg, "#modchat", body, nil, + ) + if err != nil { + t.Errorf("operator should be able to send in moderated channel: %v", err) + } +}