All checks were successful
check / check (push) Successful in 59s
Implement all 7 utility IRC commands from issue #87: User commands: - USERHOST: quick lookup of user@host for up to 5 nicks (RPL 302) - VERSION: server version string using globals.Version (RPL 351) - ADMIN: server admin contact info (RPL 256-259) - INFO: server software info text (RPL 371/374) - TIME: server local time in RFC format (RPL 391) Oper commands: - KILL: forcibly disconnect a user (requires is_oper), broadcasts QUIT to all shared channels, cleans up sessions - WALLOPS: broadcast message to all users with +w usermode (requires is_oper) Supporting changes: - Add is_wallops column to sessions table in 001_initial.sql - Add user mode +w tracking via MODE nick +w/-w - User mode queries now return actual modes (+o, +w) - MODE -o allows de-opering yourself; MODE +o rejected - MODE for other users returns ERR_USERSDONTMATCH (502) - Extract dispatch helpers to reduce dispatchCommand complexity Tests cover all commands including error cases, oper checks, user mode set/unset, KILL broadcast, WALLOPS delivery, and edge cases (self-kill, nonexistent users, missing params). closes #87
728 lines
14 KiB
Go
728 lines
14 KiB
Go
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
|
|
}
|