package handlers import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/pkg/irc" ) // maxUserhostNicks is the maximum number of nicks allowed // in a single USERHOST query (RFC 2812). const maxUserhostNicks = 5 // dispatchBodyOnlyCommand routes commands that take // (writer, request, sessionID, clientID, nick, bodyLines). func (hdlr *Handlers) dispatchBodyOnlyCommand( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command string, bodyLines func() []string, ) { switch command { case irc.CmdAway: hdlr.handleAway( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdNick: hdlr.handleNick( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdPass: hdlr.handlePass( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdInvite: hdlr.handleInvite( writer, request, sessionID, clientID, nick, bodyLines, ) } } // dispatchOperCommand routes oper-related commands (OPER, // KILL, WALLOPS) to their handlers. func (hdlr *Handlers) dispatchOperCommand( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command string, bodyLines func() []string, ) { switch command { case irc.CmdOper: hdlr.handleOper( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdKill: hdlr.handleKill( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdWallops: hdlr.handleWallops( writer, request, sessionID, clientID, nick, bodyLines, ) } } // handleUserhost handles the USERHOST command. // Returns user@host info for up to 5 nicks. func (hdlr *Handlers) handleUserhost( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, bodyLines func() []string, ) { ctx := request.Context() lines := bodyLines() if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdUserhost}, "Not enough parameters", ) return } // Limit to 5 nicks per RFC 2812. nicks := lines if len(nicks) > maxUserhostNicks { nicks = nicks[:maxUserhostNicks] } infos, err := hdlr.params.Database.GetUserhostInfo( ctx, nicks, ) if err != nil { hdlr.log.Error( "userhost query failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } replyStr := hdlr.buildUserhostReply(infos) hdlr.enqueueNumeric( ctx, clientID, irc.RplUserHost, nick, nil, replyStr, ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // buildUserhostReply builds the RPL_USERHOST reply // string per RFC 2812. func (hdlr *Handlers) buildUserhostReply( infos []db.UserhostInfo, ) string { replies := make([]string, 0, len(infos)) for idx := range infos { info := &infos[idx] username := info.Username if username == "" { username = info.Nick } hostname := info.Hostname if hostname == "" { hostname = hdlr.serverName() } operStar := "" if info.IsOper { operStar = "*" } awayPrefix := "+" if info.AwayMessage != "" { awayPrefix = "-" } replies = append(replies, info.Nick+operStar+"="+ awayPrefix+username+"@"+hostname, ) } return strings.Join(replies, " ") } // handleVersion handles the VERSION command. // Returns the server version string. func (hdlr *Handlers) handleVersion( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, ) { ctx := request.Context() srvName := hdlr.serverName() version := hdlr.serverVersion() // 351 RPL_VERSION hdlr.enqueueNumeric( ctx, clientID, irc.RplVersion, nick, []string{version + ".", srvName}, "", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleAdmin handles the ADMIN command. // Returns server admin contact info. func (hdlr *Handlers) handleAdmin( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, ) { ctx := request.Context() srvName := hdlr.serverName() // 256 RPL_ADMINME hdlr.enqueueNumeric( ctx, clientID, irc.RplAdminMe, nick, []string{srvName}, "Administrative info", ) // 257 RPL_ADMINLOC1 hdlr.enqueueNumeric( ctx, clientID, irc.RplAdminLoc1, nick, nil, "neoirc server", ) // 258 RPL_ADMINLOC2 hdlr.enqueueNumeric( ctx, clientID, irc.RplAdminLoc2, nick, nil, "IRC over HTTP", ) // 259 RPL_ADMINEMAIL hdlr.enqueueNumeric( ctx, clientID, irc.RplAdminEmail, nick, nil, "admin@"+srvName, ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleInfo handles the INFO command. // Returns server software information. func (hdlr *Handlers) handleInfo( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, ) { ctx := request.Context() version := hdlr.serverVersion() infoLines := []string{ "neoirc — IRC semantics over HTTP", "Version: " + version, "Written in Go", "Started: " + hdlr.params.Globals.StartTime. Format(time.RFC1123), } for _, line := range infoLines { // 371 RPL_INFO hdlr.enqueueNumeric( ctx, clientID, irc.RplInfo, nick, nil, line, ) } // 374 RPL_ENDOFINFO hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfInfo, nick, nil, "End of /INFO list", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleTime handles the TIME command. // Returns the server's local time in RFC format. func (hdlr *Handlers) handleTime( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, ) { ctx := request.Context() srvName := hdlr.serverName() // 391 RPL_TIME hdlr.enqueueNumeric( ctx, clientID, irc.RplTime, nick, []string{srvName}, time.Now().Format(time.RFC1123), ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleKill handles the KILL command. // Forcibly disconnects a user (oper only). func (hdlr *Handlers) handleKill( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, bodyLines func() []string, ) { ctx := request.Context() // Check oper status. isOper, err := hdlr.params.Database.IsSessionOper( ctx, sessionID, ) if err != nil || !isOper { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNoPrivileges, nick, nil, "Permission Denied- You're not an IRC operator", ) return } lines := bodyLines() if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdKill}, "Not enough parameters", ) return } targetNick := strings.TrimSpace(lines[0]) if targetNick == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdKill}, "Not enough parameters", ) return } reason := "KILLed" if len(lines) > 1 { reason = lines[1] } targetSID, lookupErr := hdlr.params.Database. GetSessionByNick(ctx, targetNick) if lookupErr != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNoSuchNick, nick, []string{targetNick}, "No such nick/channel", ) return } // Do not allow killing yourself. if targetSID == sessionID { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrCantKillServer, nick, nil, "You cannot KILL yourself", ) return } hdlr.executeKillUser( request, targetSID, targetNick, nick, reason, ) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // executeKillUser forcibly disconnects a user: broadcasts // QUIT to their channels, parts all channels, and deletes // the session. func (hdlr *Handlers) executeKillUser( request *http.Request, targetSID int64, targetNick, killerNick, reason string, ) { ctx := request.Context() quitMsg := "Killed (" + killerNick + " (" + reason + "))" quitBody, err := json.Marshal([]string{quitMsg}) if err != nil { hdlr.log.Error( "marshal kill quit body", "error", err, ) return } channels, _ := hdlr.params.Database. GetSessionChannels(ctx, targetSID) notified := map[int64]bool{} var dbID int64 if len(channels) > 0 { dbID, _, _ = hdlr.params.Database.InsertMessage( ctx, irc.CmdQuit, targetNick, "", nil, json.RawMessage(quitBody), nil, ) } for _, chanInfo := range channels { memberIDs, _ := hdlr.params.Database. GetChannelMemberIDs(ctx, chanInfo.ID) for _, mid := range memberIDs { if mid != targetSID && !notified[mid] { notified[mid] = true _ = hdlr.params.Database.EnqueueToSession( ctx, mid, dbID, ) hdlr.broker.Notify(mid) } } _ = hdlr.params.Database.PartChannel( ctx, chanInfo.ID, targetSID, ) _ = hdlr.params.Database.DeleteChannelIfEmpty( ctx, chanInfo.ID, ) } _ = hdlr.params.Database.DeleteSession( ctx, targetSID, ) } // handleWallops handles the WALLOPS command. // Broadcasts a message to all users with +w usermode // (oper only). func (hdlr *Handlers) handleWallops( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, bodyLines func() []string, ) { ctx := request.Context() // Check oper status. isOper, err := hdlr.params.Database.IsSessionOper( ctx, sessionID, ) if err != nil || !isOper { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNoPrivileges, nick, nil, "Permission Denied- You're not an IRC operator", ) return } lines := bodyLines() if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdWallops}, "Not enough parameters", ) return } message := strings.Join(lines, " ") wallopsSIDs, err := hdlr.params.Database. GetWallopsSessionIDs(ctx) if err != nil { hdlr.log.Error( "get wallops sessions failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } if len(wallopsSIDs) > 0 { body, mErr := json.Marshal([]string{message}) if mErr != nil { hdlr.log.Error( "marshal wallops body", "error", mErr, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } _ = hdlr.fanOutSilent( request, irc.CmdWallops, nick, "*", json.RawMessage(body), wallopsSIDs, ) } hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleUserMode handles user mode queries and changes // (e.g., MODE nick, MODE nick +w). func (hdlr *Handlers) handleUserMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, bodyLines func() []string, ) { ctx := request.Context() lines := bodyLines() // Mode change requested. if len(lines) > 0 { // Users can only change their own modes. if target != nick && target != "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrUsersDoNotMatch, nick, nil, "Can't change mode for other users", ) return } hdlr.applyUserModeChange( writer, request, sessionID, clientID, nick, lines[0], ) return } // Mode query — build the current mode string. modeStr := hdlr.buildUserModeString(ctx, sessionID) hdlr.enqueueNumeric( ctx, clientID, irc.RplUmodeIs, nick, nil, modeStr, ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, 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 }