Some checks failed
check / check (push) Failing after 2m28s
Both the HTTP API and IRC wire protocol handlers now call service.ApplyUserMode/service.QueryUserMode for all user mode operations. The service layer iterates mode strings character by character (the correct IRC approach), ensuring identical behavior regardless of transport. Removed duplicate mode logic from internal/handlers/utility.go (buildUserModeString, applyUserModeChange, applyModeChar) and internal/ircserver/commands.go (buildUmodeString, inline iteration). Added service-level tests for QueryUserMode, ApplyUserMode (single-char, multi-char, invalid input, de-oper, +o rejection).
552 lines
11 KiB
Go
552 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/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()
|
|
|
|
var targetNick string
|
|
if len(lines) > 0 {
|
|
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
|
|
}
|
|
|
|
quitReason := "Killed (" + nick + " (" + reason + "))"
|
|
|
|
hdlr.svc.BroadcastQuit(
|
|
request.Context(), targetSID,
|
|
targetNick, quitReason,
|
|
)
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// 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). Delegates to the
|
|
// shared service.ApplyUserMode / service.QueryUserMode so
|
|
// that mode string processing is identical for both the
|
|
// HTTP API and IRC wire protocol.
|
|
func (hdlr *Handlers) handleUserMode(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
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
|
|
}
|
|
|
|
newModes, err := hdlr.svc.ApplyUserMode(
|
|
ctx, sessionID, lines[0],
|
|
)
|
|
if err != nil {
|
|
var ircErr *service.IRCError
|
|
if errors.As(err, &ircErr) {
|
|
hdlr.respondIRCError(
|
|
writer, request,
|
|
clientID, sessionID,
|
|
ircErr.Code, nick, ircErr.Params,
|
|
ircErr.Message,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.enqueueNumeric(
|
|
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
newModes,
|
|
)
|
|
|
|
hdlr.broker.Notify(sessionID)
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"status": "ok"},
|
|
http.StatusOK)
|
|
|
|
return
|
|
}
|
|
|
|
// Mode query — delegate to shared service.
|
|
modeStr := hdlr.svc.QueryUserMode(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)
|
|
}
|