package handlers import ( "context" "encoding/json" "fmt" "net" "net/http" "regexp" "strconv" "strings" "time" "git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/pkg/irc" "github.com/go-chi/chi/v5" ) var validNickRe = regexp.MustCompile( `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, ) var validChannelRe = regexp.MustCompile( `^#[a-zA-Z0-9_\-]{1,63}$`, ) var validUsernameRe = regexp.MustCompile( `^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`, ) const dnsLookupTimeout = 3 * time.Second const ( maxLongPollTimeout = 30 pollMessageLimit = 100 defaultMaxBodySize = 4096 defaultHistLimit = 50 maxHistLimit = 500 ) func (hdlr *Handlers) maxBodySize() int64 { if hdlr.params.Config.MaxMessageSize > 0 { return int64(hdlr.params.Config.MaxMessageSize) } return defaultMaxBodySize } // clientIP extracts the connecting client's IP address // from the request, checking X-Forwarded-For and // X-Real-IP headers before falling back to RemoteAddr. func clientIP(request *http.Request) string { if forwarded := request.Header.Get("X-Forwarded-For"); forwarded != "" { // X-Forwarded-For can contain a comma-separated list; // the first entry is the original client. parts := strings.SplitN(forwarded, ",", 2) //nolint:mnd ip := strings.TrimSpace(parts[0]) if ip != "" { return ip } } if realIP := request.Header.Get("X-Real-IP"); realIP != "" { return strings.TrimSpace(realIP) } host, _, err := net.SplitHostPort(request.RemoteAddr) if err != nil { return request.RemoteAddr } return host } // resolveHostname performs a reverse DNS lookup on the // given IP address. Returns the first PTR record with the // trailing dot stripped, or the raw IP if lookup fails. func resolveHostname( reqCtx context.Context, addr string, ) string { resolver := &net.Resolver{} //nolint:exhaustruct // using default resolver ctx, cancel := context.WithTimeout( reqCtx, dnsLookupTimeout, ) defer cancel() names, err := resolver.LookupAddr(ctx, addr) if err != nil || len(names) == 0 { return addr } return strings.TrimSuffix(names[0], ".") } // authSession extracts the session from the client token. func (hdlr *Handlers) authSession( request *http.Request, ) (int64, int64, string, error) { auth := request.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { return 0, 0, "", errUnauthorized } token := strings.TrimPrefix(auth, "Bearer ") if token == "" { return 0, 0, "", errUnauthorized } sessionID, clientID, nick, err := hdlr.params.Database.GetSessionByToken( request.Context(), token, ) if err != nil { return 0, 0, "", fmt.Errorf("auth: %w", err) } return sessionID, clientID, nick, nil } func (hdlr *Handlers) requireAuth( writer http.ResponseWriter, request *http.Request, ) (int64, int64, string, bool) { sessionID, clientID, nick, err := hdlr.authSession(request) if err != nil { hdlr.respondJSON(writer, request, map[string]any{ "error": "not registered", "numeric": irc.ErrNotRegistered, }, http.StatusUnauthorized) return 0, 0, "", false } 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, sessionIDs []int64, ) (string, error) { dbID, msgUUID, err := hdlr.params.Database.InsertMessage( request.Context(), command, from, target, nil, body, nil, ) 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, sessionIDs, ) return err } // HandleCreateSession creates a new user session. func (hdlr *Handlers) HandleCreateSession() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { request.Body = http.MaxBytesReader( writer, request.Body, hdlr.maxBodySize(), ) hdlr.handleCreateSession(writer, request) } } func (hdlr *Handlers) handleCreateSession( writer http.ResponseWriter, request *http.Request, ) { type createRequest struct { Nick string `json:"nick"` Username string `json:"username,omitempty"` Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle } var payload createRequest err := json.NewDecoder(request.Body).Decode(&payload) if err != nil { hdlr.respondError( writer, request, "invalid request body", http.StatusBadRequest, ) return } if !hdlr.validateHashcash( writer, request, payload.Hashcash, ) { return } payload.Nick = strings.TrimSpace(payload.Nick) if !validNickRe.MatchString(payload.Nick) { hdlr.respondError( writer, request, "invalid nick format", http.StatusBadRequest, ) return } username := resolveUsername( payload.Username, payload.Nick, ) if !validUsernameRe.MatchString(username) { hdlr.respondError( writer, request, "invalid username format", http.StatusBadRequest, ) return } hdlr.executeCreateSession( writer, request, payload.Nick, username, ) } func (hdlr *Handlers) executeCreateSession( writer http.ResponseWriter, request *http.Request, nick, username string, ) { remoteIP := clientIP(request) hostname := resolveHostname( request.Context(), remoteIP, ) sessionID, clientID, token, err := hdlr.params.Database.CreateSession( request.Context(), nick, username, hostname, remoteIP, ) if err != nil { hdlr.handleCreateSessionError( writer, request, err, ) return } hdlr.stats.IncrSessions() hdlr.stats.IncrConnections() hdlr.deliverMOTD(request, clientID, sessionID, nick) hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, "nick": nick, "token": token, }, http.StatusCreated) } // validateHashcash validates a hashcash stamp if required. // Returns false if validation failed and a response was // already sent. func (hdlr *Handlers) validateHashcash( writer http.ResponseWriter, request *http.Request, stamp string, ) bool { if hdlr.params.Config.HashcashBits == 0 { return true } if stamp == "" { hdlr.respondError( writer, request, "hashcash proof-of-work required", http.StatusPaymentRequired, ) return false } err := hdlr.hashcashVal.Validate( stamp, hdlr.params.Config.HashcashBits, ) if err != nil { hdlr.respondError( writer, request, "invalid hashcash stamp: "+err.Error(), http.StatusPaymentRequired, ) return false } return true } // resolveUsername returns the trimmed username, defaulting // to the nick if empty. func resolveUsername(username, nick string) string { username = strings.TrimSpace(username) if username == "" { return nick } return username } func (hdlr *Handlers) handleCreateSessionError( writer http.ResponseWriter, request *http.Request, err error, ) { if db.IsUniqueConstraintError(err) { hdlr.respondError( writer, request, "nick already taken", http.StatusConflict, ) return } hdlr.log.Error( "create session failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) } // deliverWelcome sends connection registration numerics // (001-005) to a new client. func (hdlr *Handlers) deliverWelcome( request *http.Request, clientID int64, nick string, ) { ctx := request.Context() srvName := hdlr.serverName() version := hdlr.serverVersion() // 001 RPL_WELCOME hdlr.enqueueNumeric( ctx, clientID, irc.RplWelcome, nick, nil, "Welcome to the network, "+nick, ) // 002 RPL_YOURHOST hdlr.enqueueNumeric( ctx, clientID, irc.RplYourHost, nick, nil, "Your host is "+srvName+ ", running version "+version, ) // 003 RPL_CREATED hdlr.enqueueNumeric( ctx, clientID, irc.RplCreated, nick, nil, "This server was created "+ hdlr.params.Globals.StartTime. Format("2006-01-02"), ) // 004 RPL_MYINFO hdlr.enqueueNumeric( ctx, clientID, irc.RplMyInfo, nick, []string{srvName, version, "", "imnst"}, "", ) // 005 RPL_ISUPPORT hdlr.enqueueNumeric( ctx, clientID, irc.RplIsupport, nick, []string{ "CHANTYPES=#", "NICKLEN=32", "CHANMODES=,,," + "imnst", "NETWORK=neoirc", "CASEMAPPING=ascii", }, "are supported by this server", ) // LUSERS hdlr.deliverLusers(ctx, clientID, nick) } // deliverLusers sends RPL_LUSERCLIENT (251), // RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and // RPL_LUSERME (255) to the client. func (hdlr *Handlers) deliverLusers( ctx context.Context, clientID int64, nick string, ) { userCount, err := hdlr.params.Database.GetUserCount(ctx) if err != nil { hdlr.log.Error( "lusers user count", "error", err, ) userCount = 0 } chanCount, err := hdlr.params.Database.GetChannelCount( ctx, ) if err != nil { hdlr.log.Error( "lusers channel count", "error", err, ) chanCount = 0 } // 251 RPL_LUSERCLIENT hdlr.enqueueNumeric( ctx, clientID, irc.RplLuserClient, nick, nil, fmt.Sprintf( "There are %d users and 0 invisible on 1 servers", userCount, ), ) // 252 RPL_LUSEROP operCount, operErr := hdlr.params.Database. GetOperCount(ctx) if operErr != nil { hdlr.log.Error( "lusers oper count", "error", operErr, ) operCount = 0 } hdlr.enqueueNumeric( ctx, clientID, irc.RplLuserOp, nick, []string{strconv.FormatInt(operCount, 10)}, "operator(s) online", ) // 254 RPL_LUSERCHANNELS hdlr.enqueueNumeric( ctx, clientID, irc.RplLuserChannels, nick, []string{strconv.FormatInt(chanCount, 10)}, "channels formed", ) // 255 RPL_LUSERME hdlr.enqueueNumeric( ctx, clientID, irc.RplLuserMe, nick, nil, fmt.Sprintf( "I have %d clients and 1 servers", userCount, ), ) } func (hdlr *Handlers) serverVersion() string { ver := hdlr.params.Globals.Version if ver == "" { return "dev" } return ver } // deliverMOTD sends the MOTD as IRC numeric messages to a // new client. func (hdlr *Handlers) deliverMOTD( request *http.Request, clientID, sessionID int64, nick string, ) { motd := hdlr.params.Config.MOTD srvName := hdlr.serverName() ctx := request.Context() hdlr.deliverWelcome(request, clientID, nick) if motd == "" { hdlr.broker.Notify(sessionID) return } hdlr.enqueueNumeric( ctx, clientID, irc.RplMotdStart, nick, nil, "- "+srvName+" Message of the Day -", ) for line := range strings.SplitSeq(motd, "\n") { hdlr.enqueueNumeric( ctx, clientID, irc.RplMotd, nick, nil, "- "+line, ) } hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfMotd, nick, nil, "End of /MOTD command.", ) hdlr.broker.Notify(sessionID) } func (hdlr *Handlers) serverName() string { name := hdlr.params.Config.ServerName if name == "" { return "neoirc" } return name } func (hdlr *Handlers) enqueueNumeric( ctx context.Context, clientID int64, code irc.IRCMessageType, nick string, params []string, text string, ) { command := code.Code() body, err := json.Marshal([]string{text}) if err != nil { hdlr.log.Error( "marshal numeric body", "error", err, ) return } var paramsJSON json.RawMessage if len(params) > 0 { paramsJSON, err = json.Marshal(params) if err != nil { hdlr.log.Error( "marshal numeric params", "error", err, ) return } } dbID, _, insertErr := hdlr.params.Database.InsertMessage( ctx, command, hdlr.serverName(), nick, paramsJSON, json.RawMessage(body), nil, ) if insertErr != nil { hdlr.log.Error( "insert numeric message", "error", insertErr, ) return } _ = hdlr.params.Database.EnqueueToClient( ctx, clientID, dbID, ) } // HandleState returns the current session's info and // channels. When called with ?initChannelState=1, it also // enqueues synthetic JOIN + TOPIC + NAMES messages for // every channel the session belongs to so that a // reconnecting client can rebuild its channel tabs from // the message stream. func (hdlr *Handlers) HandleState() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { sessionID, clientID, nick, ok := hdlr.requireAuth(writer, request) if !ok { return } channels, err := hdlr.params.Database.ListChannels( request.Context(), sessionID, ) if err != nil { hdlr.log.Error( "list channels failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } if request.URL.Query().Get("initChannelState") == "1" { hdlr.initChannelState( request, clientID, sessionID, nick, ) } hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, "nick": nick, "channels": channels, }, http.StatusOK) } } // initChannelState enqueues synthetic JOIN messages and // join-numerics (TOPIC, NAMES) for every channel the // session belongs to. Messages are enqueued only to the // specified client so other clients/sessions are not // affected. func (hdlr *Handlers) initChannelState( request *http.Request, clientID, sessionID int64, nick string, ) { ctx := request.Context() channels, err := hdlr.params.Database. GetSessionChannels(ctx, sessionID) if err != nil || len(channels) == 0 { return } for _, chanInfo := range channels { // Enqueue a synthetic JOIN (only to this client). dbID, _, insErr := hdlr.params.Database. InsertMessage( ctx, "JOIN", nick, chanInfo.Name, nil, nil, nil, ) if insErr != nil { hdlr.log.Error( "initChannelState: insert JOIN", "error", insErr, ) continue } _ = hdlr.params.Database.EnqueueToClient( ctx, clientID, dbID, ) // Enqueue TOPIC + NAMES numerics. hdlr.deliverJoinNumerics( request, clientID, sessionID, nick, chanInfo.Name, chanInfo.ID, ) } } // HandleListAllChannels returns all channels on the server. func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { _, _, _, ok := hdlr.requireAuth(writer, request) if !ok { return } channels, err := hdlr.params.Database.ListAllChannels( request.Context(), ) if err != nil { hdlr.log.Error( "list all channels failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } hdlr.respondJSON( writer, request, channels, http.StatusOK, ) } } // HandleChannelMembers returns members of a channel. func (hdlr *Handlers) HandleChannelMembers() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { _, _, _, ok := hdlr.requireAuth(writer, request) if !ok { return } name := "#" + chi.URLParam(request, "channel") chID, err := hdlr.params.Database.GetChannelByName( request.Context(), name, ) if err != nil { hdlr.respondError( writer, request, "channel not found", http.StatusNotFound, ) return } members, err := hdlr.params.Database.ChannelMembers( request.Context(), chID, ) if err != nil { hdlr.log.Error( "channel members failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } hdlr.respondJSON( writer, request, members, http.StatusOK, ) } } // HandleGetMessages returns messages via long-polling. func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { sessionID, clientID, _, ok := hdlr.requireAuth(writer, request) if !ok { return } afterID, _ := strconv.ParseInt( request.URL.Query().Get("after"), 10, 64, ) timeout, _ := strconv.Atoi( request.URL.Query().Get("timeout"), ) if timeout < 0 { timeout = 0 } if timeout > maxLongPollTimeout { timeout = maxLongPollTimeout } msgs, lastQID, err := hdlr.params.Database.PollMessages( request.Context(), clientID, afterID, pollMessageLimit, ) if err != nil { hdlr.log.Error( "poll messages failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } if len(msgs) > 0 || timeout == 0 { hdlr.respondJSON(writer, request, map[string]any{ "messages": msgs, "last_id": lastQID, }, http.StatusOK) return } hdlr.longPoll( writer, request, sessionID, clientID, afterID, timeout, ) } } func (hdlr *Handlers) longPoll( writer http.ResponseWriter, request *http.Request, sessionID, clientID, afterID int64, timeout int, ) { waitCh := hdlr.broker.Wait(sessionID) timer := time.NewTimer( time.Duration(timeout) * time.Second, ) defer timer.Stop() select { case <-waitCh: case <-timer.C: case <-request.Context().Done(): hdlr.broker.Remove(sessionID, waitCh) return } hdlr.broker.Remove(sessionID, waitCh) msgs, lastQID, err := hdlr.params.Database.PollMessages( request.Context(), clientID, afterID, pollMessageLimit, ) if err != nil { hdlr.log.Error( "poll messages failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } hdlr.respondJSON(writer, request, map[string]any{ "messages": msgs, "last_id": lastQID, }, http.StatusOK) } // HandleSendCommand handles all C2S commands. func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { type commandRequest struct { Command string `json:"command"` To string `json:"to"` Body json.RawMessage `json:"body,omitempty"` Meta json.RawMessage `json:"meta,omitempty"` } return func( writer http.ResponseWriter, request *http.Request, ) { request.Body = http.MaxBytesReader( writer, request.Body, hdlr.maxBodySize(), ) sessionID, clientID, nick, ok := hdlr.requireAuth(writer, request) if !ok { return } var payload commandRequest err := json.NewDecoder(request.Body).Decode(&payload) if err != nil { hdlr.respondError( writer, request, "invalid request body", http.StatusBadRequest, ) return } payload.Command = strings.ToUpper( strings.TrimSpace(payload.Command), ) payload.To = strings.TrimSpace(payload.To) if payload.Command == "" { hdlr.respondError( writer, request, "command required", http.StatusBadRequest, ) return } bodyLines := func() []string { if payload.Body == nil { return nil } var lines []string decErr := json.Unmarshal(payload.Body, &lines) if decErr != nil { return nil } return lines } hdlr.dispatchCommand( writer, request, sessionID, clientID, nick, payload.Command, payload.To, payload.Body, bodyLines, ) } } func (hdlr *Handlers) dispatchCommand( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, body json.RawMessage, bodyLines func() []string, ) { switch command { case irc.CmdAway: hdlr.handleAway( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdPrivmsg, irc.CmdNotice: hdlr.handlePrivmsg( writer, request, sessionID, clientID, nick, command, target, body, bodyLines, ) case irc.CmdJoin: hdlr.handleJoin( writer, request, sessionID, clientID, nick, target, ) case irc.CmdPart: hdlr.handlePart( writer, request, sessionID, clientID, nick, target, body, ) case irc.CmdNick: hdlr.handleNick( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdTopic: hdlr.handleTopic( writer, request, sessionID, clientID, nick, target, body, bodyLines, ) case irc.CmdQuit: hdlr.handleQuit( writer, request, sessionID, nick, body, ) case irc.CmdOper: hdlr.handleOper( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdMotd, irc.CmdPing: hdlr.dispatchInfoCommand( writer, request, sessionID, clientID, nick, command, target, bodyLines, ) default: hdlr.dispatchQueryCommand( writer, request, sessionID, clientID, nick, command, target, bodyLines, ) } } func (hdlr *Handlers) dispatchQueryCommand( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, bodyLines func() []string, ) { switch command { case irc.CmdMode: hdlr.handleMode( writer, request, sessionID, clientID, nick, target, bodyLines, ) case irc.CmdNames: hdlr.handleNames( writer, request, sessionID, clientID, nick, target, ) case irc.CmdList: hdlr.handleList( writer, request, sessionID, clientID, nick, ) case irc.CmdWhois: hdlr.handleWhois( writer, request, sessionID, clientID, nick, target, bodyLines, ) case irc.CmdWho: hdlr.handleWho( writer, request, sessionID, clientID, nick, target, ) case irc.CmdLusers: hdlr.handleLusers( writer, request, sessionID, clientID, nick, ) default: hdlr.enqueueNumeric( request.Context(), clientID, irc.ErrUnknownCommand, nick, []string{command}, "Unknown command", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "error"}, http.StatusOK) } } func (hdlr *Handlers) handlePrivmsg( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, body json.RawMessage, bodyLines func() []string, ) { if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, irc.ErrNoRecipient, nick, []string{command}, "No recipient given", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "error"}, http.StatusOK) return } lines := bodyLines() if len(lines) == 0 { hdlr.enqueueNumeric( request.Context(), clientID, irc.ErrNoTextToSend, nick, []string{command}, "No text to send", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "error"}, http.StatusOK) return } hdlr.stats.IncrMessages() if strings.HasPrefix(target, "#") { hdlr.handleChannelMsg( writer, request, sessionID, clientID, nick, command, target, body, ) return } hdlr.handleDirectMsg( writer, request, sessionID, clientID, nick, command, target, body, ) } // respondIRCError enqueues a numeric error reply, notifies // the broker, and sends HTTP 200 with {"status":"error"}. func (hdlr *Handlers) respondIRCError( writer http.ResponseWriter, request *http.Request, clientID, sessionID int64, code irc.IRCMessageType, nick string, params []string, text string, ) { hdlr.enqueueNumeric( request.Context(), clientID, code, nick, params, text, ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "error"}, http.StatusOK) } func (hdlr *Handlers) handleChannelMsg( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, body json.RawMessage, ) { chID, err := hdlr.params.Database.GetChannelByName( request.Context(), target, ) if err != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNoSuchChannel, nick, []string{target}, "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.ErrCannotSendToChan, nick, []string{target}, "Cannot send to channel", ) return } hdlr.sendChannelMsg( writer, request, command, nick, target, body, chID, ) } func (hdlr *Handlers) sendChannelMsg( writer http.ResponseWriter, request *http.Request, command, nick, target string, body 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, 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, sessionID, clientID int64, nick, command, target string, body json.RawMessage, ) { targetSID, err := hdlr.params.Database.GetSessionByNick( request.Context(), target, ) 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, recipients, ) if err != nil { hdlr.log.Error("send dm failed", "error", err) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } // If the target is away, send RPL_AWAY to the sender. awayMsg, awayErr := hdlr.params.Database.GetAway( request.Context(), targetSID, ) if awayErr == nil && awayMsg != "" { hdlr.enqueueNumeric( request.Context(), clientID, irc.RplAway, nick, []string{target}, awayMsg, ) hdlr.broker.Notify(sessionID) } hdlr.respondJSON(writer, request, map[string]string{"id": msgUUID, "status": "sent"}, http.StatusOK) } func (hdlr *Handlers) handleJoin( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, ) { if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin}, "Not enough parameters", ) return } channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } if !validChannelRe.MatchString(channel) { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNoSuchChannel, nick, []string{channel}, "No such channel", ) return } hdlr.executeJoin( writer, request, sessionID, clientID, nick, channel, ) } func (hdlr *Handlers) executeJoin( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, ) { chID, err := hdlr.params.Database.GetOrCreateChannel( request.Context(), channel, ) if err != nil { hdlr.log.Error( "get/create channel failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } err = hdlr.params.Database.JoinChannel( request.Context(), 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( request.Context(), chID, ) _ = hdlr.fanOutSilent( request, irc.CmdJoin, nick, channel, nil, memberIDs, ) hdlr.deliverJoinNumerics( request, clientID, sessionID, nick, channel, chID, ) hdlr.respondJSON(writer, request, map[string]string{ "status": "joined", "channel": channel, }, http.StatusOK) } // deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC, // RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client. func (hdlr *Handlers) deliverJoinNumerics( request *http.Request, clientID, sessionID int64, nick, channel string, chID int64, ) { ctx := request.Context() hdlr.deliverTopicNumerics( ctx, clientID, sessionID, nick, channel, chID, ) hdlr.deliverNamesNumerics( ctx, clientID, nick, channel, chID, ) hdlr.broker.Notify(sessionID) } // deliverTopicNumerics sends RPL_TOPIC or RPL_NOTOPIC, // plus RPL_TOPICWHOTIME when topic metadata is available. func (hdlr *Handlers) deliverTopicNumerics( ctx context.Context, clientID, sessionID int64, nick, channel string, chID int64, ) { channels, listErr := hdlr.params.Database.ListChannels( ctx, sessionID, ) topic := "" if listErr == nil { for _, ch := range channels { if ch.Name == channel { topic = ch.Topic break } } } if topic != "" { hdlr.enqueueNumeric( ctx, clientID, irc.RplTopic, nick, []string{channel}, topic, ) 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, ), }, "", ) } } else { hdlr.enqueueNumeric( ctx, clientID, irc.RplNoTopic, nick, []string{channel}, "No topic is set", ) } } // deliverNamesNumerics sends RPL_NAMREPLY and // RPL_ENDOFNAMES for a channel. func (hdlr *Handlers) deliverNamesNumerics( ctx context.Context, clientID int64, nick, channel string, chID int64, ) { members, memErr := hdlr.params.Database.ChannelMembers( ctx, chID, ) if memErr == nil && len(members) > 0 { entries := make([]string, 0, len(members)) for _, mem := range members { entries = append(entries, mem.Hostmask()) } hdlr.enqueueNumeric( ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, strings.Join(entries, " "), ) } hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfNames, nick, []string{channel}, "End of /NAMES list", ) } func (hdlr *Handlers) handlePart( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, body json.RawMessage, ) { if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, 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 } channel := target if !strings.HasPrefix(channel, "#") { 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 } memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( request.Context(), chID, ) _ = 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, ) return } _ = hdlr.params.Database.DeleteChannelIfEmpty( request.Context(), chID, ) hdlr.respondJSON(writer, request, map[string]string{ "status": "parted", "channel": channel, }, http.StatusOK) } func (hdlr *Handlers) handleNick( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, bodyLines func() []string, ) { lines := bodyLines() if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdNick}, "Not enough parameters", ) return } newNick := strings.TrimSpace(lines[0]) if !validNickRe.MatchString(newNick) { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrErroneusNickname, nick, []string{newNick}, "Erroneous nickname", ) return } if newNick == nick { hdlr.respondJSON(writer, request, map[string]string{ "status": "ok", "nick": newNick, }, http.StatusOK) return } hdlr.executeNickChange( writer, request, sessionID, clientID, nick, newNick, ) } func (hdlr *Handlers) executeNickChange( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, newNick string, ) { err := hdlr.params.Database.ChangeNick( request.Context(), sessionID, 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, ) return } hdlr.broadcastNick(request, sessionID, nick, newNick) hdlr.respondJSON(writer, request, map[string]string{ "status": "ok", "nick": newNick, }, 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, bodyLines func() []string, ) { if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, "Not enough parameters", ) return } lines := bodyLines() if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, "Not enough parameters", ) return } channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } chID, err := hdlr.params.Database.GetChannelByName( request.Context(), channel, ) 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( 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, ) { 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, ) return } memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( request.Context(), chID, ) _ = 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, }, http.StatusOK) } // dispatchInfoCommand handles informational IRC commands // that produce server-side numerics (MOTD, PING). func (hdlr *Handlers) dispatchInfoCommand( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, bodyLines func() []string, ) { _ = target _ = bodyLines okResp := map[string]string{"status": "ok"} switch command { case irc.CmdMotd: hdlr.deliverMOTD( request, clientID, sessionID, nick, ) case irc.CmdPing: hdlr.respondJSON(writer, request, map[string]string{ "command": irc.CmdPong, "from": hdlr.serverName(), }, http.StatusOK) return } hdlr.respondJSON( writer, request, okResp, http.StatusOK, ) } func (hdlr *Handlers) handleQuit( writer http.ResponseWriter, request *http.Request, sessionID int64, 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) } } _ = 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.respondJSON(writer, request, map[string]string{"status": "quit"}, http.StatusOK) } // handleMode handles the MODE command for channels and // users. Currently supports query-only (no mode changes). func (hdlr *Handlers) handleMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, bodyLines func() []string, ) { if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, "Not enough parameters", ) return } channel := target if !strings.HasPrefix(channel, "#") { // User mode query — return empty modes. hdlr.enqueueNumeric( request.Context(), clientID, irc.RplUmodeIs, nick, nil, "+", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) return } _ = bodyLines hdlr.handleChannelMode( writer, request, sessionID, clientID, nick, channel, ) } func (hdlr *Handlers) handleChannelMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, ) { 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 } // 324 RPL_CHANNELMODEIS hdlr.enqueueNumeric( ctx, clientID, irc.RplChannelModeIs, nick, []string{channel, "+n"}, "", ) // 329 RPL_CREATIONTIME createdAt, timeErr := hdlr.params.Database. GetChannelCreatedAt(ctx, chID) if timeErr == nil { hdlr.enqueueNumeric( ctx, clientID, irc.RplCreationTime, nick, []string{ channel, strconv.FormatInt( createdAt.Unix(), 10, ), }, "", ) } hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleNames sends NAMES reply for a channel. func (hdlr *Handlers) handleNames( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, ) { if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdNames}, "Not enough parameters", ) return } channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } 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 } members, memErr := hdlr.params.Database.ChannelMembers( ctx, chID, ) if memErr == nil && len(members) > 0 { entries := make([]string, 0, len(members)) for _, mem := range members { entries = append(entries, mem.Hostmask()) } hdlr.enqueueNumeric( ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, strings.Join(entries, " "), ) } hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfNames, nick, []string{channel}, "End of /NAMES list", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleList sends the LIST response with 322/323 // numerics. func (hdlr *Handlers) handleList( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, ) { ctx := request.Context() channels, err := hdlr.params.Database. ListAllChannelsWithCounts(ctx) if err != nil { hdlr.log.Error( "list channels failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } for _, chanInfo := range channels { // 322 RPL_LIST hdlr.enqueueNumeric( ctx, clientID, irc.RplList, nick, []string{ chanInfo.Name, strconv.FormatInt( chanInfo.MemberCount, 10, ), }, chanInfo.Topic, ) } // 323 — end of channel list. hdlr.enqueueNumeric( ctx, clientID, irc.RplListEnd, nick, nil, "End of /LIST", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleWhois handles the WHOIS command. func (hdlr *Handlers) handleWhois( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, bodyLines func() []string, ) { queryNick := target // If target is empty, check body for the nick. if queryNick == "" { lines := bodyLines() if len(lines) > 0 { queryNick = strings.TrimSpace(lines[0]) } } if queryNick == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois}, "Not enough parameters", ) return } hdlr.executeWhois( writer, request, sessionID, clientID, nick, queryNick, ) } func (hdlr *Handlers) executeWhois( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, queryNick string, ) { ctx := request.Context() targetSID, err := hdlr.params.Database.GetSessionByNick( ctx, queryNick, ) if err != nil { hdlr.whoisNotFound( ctx, writer, request, sessionID, clientID, nick, queryNick, ) return } hdlr.deliverWhoisUser( ctx, clientID, nick, queryNick, targetSID, ) // 313 RPL_WHOISOPERATOR — show if target is oper. hdlr.deliverWhoisOperator( ctx, clientID, nick, queryNick, targetSID, ) hdlr.deliverWhoisIdle( ctx, clientID, nick, queryNick, targetSID, ) hdlr.deliverWhoisChannels( ctx, clientID, nick, queryNick, targetSID, ) // 338 RPL_WHOISACTUALLY — oper-only. hdlr.deliverWhoisActually( ctx, clientID, nick, queryNick, sessionID, targetSID, ) hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfWhois, nick, []string{queryNick}, "End of /WHOIS list", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // whoisNotFound sends the error+end numerics when the // target nick is not found. func (hdlr *Handlers) whoisNotFound( ctx context.Context, writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, queryNick string, ) { hdlr.enqueueNumeric( ctx, clientID, irc.ErrNoSuchNick, nick, []string{queryNick}, "No such nick/channel", ) hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfWhois, nick, []string{queryNick}, "End of /WHOIS list", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // deliverWhoisUser sends RPL_WHOISUSER (311) and // RPL_WHOISSERVER (312). func (hdlr *Handlers) deliverWhoisUser( ctx context.Context, clientID int64, nick, queryNick string, targetSID int64, ) { srvName := hdlr.serverName() username := queryNick hostname := srvName hostInfo, hostErr := hdlr.params.Database. GetSessionHostInfo(ctx, targetSID) if hostErr == nil && hostInfo != nil { if hostInfo.Username != "" { username = hostInfo.Username } if hostInfo.Hostname != "" { hostname = hostInfo.Hostname } } hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisUser, nick, []string{queryNick, username, hostname, "*"}, queryNick, ) hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisServer, nick, []string{queryNick, srvName}, "neoirc server", ) } // deliverWhoisOperator sends RPL_WHOISOPERATOR (313) if // the target has server oper status. func (hdlr *Handlers) deliverWhoisOperator( ctx context.Context, clientID int64, nick, queryNick string, targetSID int64, ) { targetIsOper, err := hdlr.params.Database. IsSessionOper(ctx, targetSID) if err != nil || !targetIsOper { return } hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisOperator, nick, []string{queryNick}, "is an IRC operator", ) } func (hdlr *Handlers) deliverWhoisChannels( ctx context.Context, clientID int64, nick, queryNick string, targetSID int64, ) { channels, chanErr := hdlr.params.Database. GetSessionChannels(ctx, targetSID) if chanErr != nil || len(channels) == 0 { return } chanNames := make([]string, 0, len(channels)) for _, chanInfo := range channels { chanNames = append(chanNames, chanInfo.Name) } hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisChannels, nick, []string{queryNick}, strings.Join(chanNames, " "), ) } // deliverWhoisActually sends RPL_WHOISACTUALLY (338) // with the target's current client IP and hostname, but // only when the querying session has server oper status // (o-line). Non-opers see nothing extra. func (hdlr *Handlers) deliverWhoisActually( ctx context.Context, clientID int64, nick, queryNick string, querierSID, targetSID int64, ) { isOper, err := hdlr.params.Database.IsSessionOper( ctx, querierSID, ) if err != nil || !isOper { return } clientInfo, clErr := hdlr.params.Database. GetLatestClientForSession(ctx, targetSID) if clErr != nil { return } actualHost := clientInfo.Hostname if actualHost == "" { actualHost = clientInfo.IP } hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisActually, nick, []string{ queryNick, clientInfo.IP, }, "is actually using host "+actualHost, ) } // handleWho handles the WHO command. func (hdlr *Handlers) handleWho( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, ) { if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdWho}, "Not enough parameters", ) return } ctx := request.Context() srvName := hdlr.serverName() channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } chID, err := hdlr.params.Database.GetChannelByName( ctx, channel, ) if err != nil { // 315 RPL_ENDOFWHO (empty result) hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfWho, nick, []string{target}, "End of /WHO list", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) return } members, memErr := hdlr.params.Database.ChannelMembers( ctx, chID, ) if memErr == nil { for _, mem := range members { username := mem.Username if username == "" { username = mem.Nick } hostname := mem.Hostname if hostname == "" { hostname = srvName } // 352 RPL_WHOREPLY hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoReply, nick, []string{ channel, username, hostname, srvName, mem.Nick, "H", }, "0 "+mem.Nick, ) } } // 315 RPL_ENDOFWHO hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfWho, nick, []string{channel}, "End of /WHO list", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleLusers handles the LUSERS command. func (hdlr *Handlers) handleLusers( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, ) { hdlr.deliverLusers( request.Context(), clientID, nick, ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // HandleGetHistory returns message history for a target. func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { sessionID, _, nick, ok := hdlr.requireAuth(writer, request) if !ok { return } target := request.URL.Query().Get("target") if target == "" { hdlr.respondError( writer, request, "target required", http.StatusBadRequest, ) return } if !hdlr.canAccessHistory( writer, request, sessionID, nick, target, ) { return } beforeID, _ := strconv.ParseInt( request.URL.Query().Get("before"), 10, 64, ) limit, _ := strconv.Atoi( request.URL.Query().Get("limit"), ) if limit <= 0 || limit > maxHistLimit { limit = defaultHistLimit } msgs, err := hdlr.params.Database.GetHistory( request.Context(), target, beforeID, limit, ) if err != nil { hdlr.log.Error( "get history failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } hdlr.respondJSON( writer, request, msgs, http.StatusOK, ) } } // canAccessHistory verifies the user can read history // for the given target (channel or DM participant). func (hdlr *Handlers) canAccessHistory( writer http.ResponseWriter, request *http.Request, sessionID int64, nick, target string, ) bool { if strings.HasPrefix(target, "#") { return hdlr.canAccessChannelHistory( writer, request, sessionID, target, ) } // DM history: only allow if the target is the // requester's own nick (messages sent to them). if target != nick { hdlr.respondError( writer, request, "forbidden", http.StatusForbidden, ) return false } return true } func (hdlr *Handlers) canAccessChannelHistory( writer http.ResponseWriter, request *http.Request, sessionID int64, target string, ) bool { chID, err := hdlr.params.Database.GetChannelByName( request.Context(), target, ) if err != nil { hdlr.respondError( writer, request, "channel not found", http.StatusNotFound, ) return false } 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 false } if !isMember { hdlr.respondError( writer, request, "not a member of this channel", http.StatusForbidden, ) return false } return true } // HandleLogout deletes the authenticated client's token // and cleans up the user (session) if no clients remain. func (hdlr *Handlers) HandleLogout() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { sessionID, clientID, nick, ok := hdlr.requireAuth(writer, request) if !ok { return } ctx := request.Context() err := hdlr.params.Database.DeleteClient( ctx, clientID, ) if err != nil { hdlr.log.Error( "delete client failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } // If no clients remain, clean up the user fully: // part all channels (notifying members) and // delete the session. remaining, err := hdlr.params.Database. ClientCountForSession(ctx, sessionID) if err != nil { hdlr.log.Error( "client count check failed", "error", err, ) } if remaining == 0 { hdlr.cleanupUser( ctx, sessionID, nick, ) } hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } } // cleanupUser parts the user from all channels (notifying // members) and deletes the session. func (hdlr *Handlers) cleanupUser( ctx context.Context, sessionID int64, nick string, ) { channels, _ := hdlr.params.Database. GetSessionChannels(ctx, sessionID) notified := map[int64]bool{} var quitDBID int64 if len(channels) > 0 { quitDBID, _, _ = hdlr.params.Database.InsertMessage( ctx, irc.CmdQuit, nick, "", nil, nil, nil, ) } for _, chanInfo := range channels { memberIDs, _ := hdlr.params.Database. GetChannelMemberIDs(ctx, chanInfo.ID) for _, mid := range memberIDs { if mid != sessionID && !notified[mid] { notified[mid] = true _ = hdlr.params.Database.EnqueueToSession( ctx, mid, quitDBID, ) hdlr.broker.Notify(mid) } } _ = hdlr.params.Database.PartChannel( ctx, chanInfo.ID, sessionID, ) _ = hdlr.params.Database.DeleteChannelIfEmpty( ctx, chanInfo.ID, ) } _ = hdlr.params.Database.DeleteSession(ctx, sessionID) } // HandleUsersMe returns the current user's session info. func (hdlr *Handlers) HandleUsersMe() http.HandlerFunc { return hdlr.HandleState() } // HandleServerInfo returns server metadata. func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { return func( writer http.ResponseWriter, request *http.Request, ) { users, err := hdlr.params.Database.GetUserCount( request.Context(), ) if err != nil { hdlr.log.Error( "get user count failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } resp := map[string]any{ "name": hdlr.params.Config.ServerName, "version": hdlr.params.Globals.Version, "motd": hdlr.params.Config.MOTD, "users": users, } if hdlr.params.Config.HashcashBits > 0 { resp["hashcash_bits"] = hdlr.params.Config.HashcashBits } hdlr.respondJSON( writer, request, resp, http.StatusOK, ) } } // handleOper handles the OPER command for server operator authentication. func (hdlr *Handlers) handleOper( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, bodyLines func() []string, ) { ctx := request.Context() lines := bodyLines() if len(lines) < 2 { //nolint:mnd // name + password hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdOper}, "Not enough parameters", ) return } operName := lines[0] operPass := lines[1] cfgName := hdlr.params.Config.OperName cfgPass := hdlr.params.Config.OperPassword if cfgName == "" || cfgPass == "" || operName != cfgName || operPass != cfgPass { 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, ) if err != nil { hdlr.log.Error( "set oper failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } // 381 RPL_YOUREOPER hdlr.enqueueNumeric( ctx, clientID, irc.RplYoureOper, nick, nil, "You are now an IRC operator", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleAway handles the AWAY command. An empty body // clears the away status; a non-empty body sets it. func (hdlr *Handlers) handleAway( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, bodyLines func() []string, ) { ctx := request.Context() lines := bodyLines() awayMsg := "" if len(lines) > 0 { awayMsg = strings.Join(lines, " ") } err := hdlr.params.Database.SetAway( ctx, sessionID, awayMsg, ) if err != nil { hdlr.log.Error("set away failed", "error", err) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } if awayMsg == "" { // 305 RPL_UNAWAY hdlr.enqueueNumeric( ctx, clientID, irc.RplUnaway, nick, nil, "You are no longer marked as being away", ) } else { // 306 RPL_NOWAWAY hdlr.enqueueNumeric( ctx, clientID, irc.RplNowAway, nick, nil, "You have been marked as being away", ) } hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle // time and signon time. func (hdlr *Handlers) deliverWhoisIdle( ctx context.Context, clientID int64, nick, queryNick string, targetSID int64, ) { lastSeen, lsErr := hdlr.params.Database. GetSessionLastSeen(ctx, targetSID) if lsErr != nil { return } createdAt, caErr := hdlr.params.Database. GetSessionCreatedAt(ctx, targetSID) if caErr != nil { return } idleSeconds := int64(time.Since(lastSeen).Seconds()) if idleSeconds < 0 { idleSeconds = 0 } signonUnix := strconv.FormatInt( createdAt.Unix(), 10, ) hdlr.enqueueNumeric( ctx, clientID, irc.RplWhoisIdle, nick, []string{ queryNick, strconv.FormatInt(idleSeconds, 10), signonUnix, }, "seconds idle, signon time", ) }