package handlers import ( "context" "encoding/json" "errors" "fmt" "net" "net/http" "regexp" "strconv" "strings" "time" "git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/pkg/irc" "github.com/go-chi/chi/v5" ) var ( errHashcashRequired = errors.New("hashcash required") errHashcashReused = errors.New("hashcash reused") ) 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 authCookieName = "neoirc_auth" ) 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 auth cookie. func (hdlr *Handlers) authSession( request *http.Request, ) (int64, int64, string, error) { cookie, err := request.Cookie(authCookieName) if err != nil || cookie.Value == "" { return 0, 0, "", errUnauthorized } sessionID, clientID, nick, err := hdlr.params.Database.GetSessionByToken( request.Context(), cookie.Value, ) if err != nil { return 0, 0, "", fmt.Errorf("auth: %w", err) } return sessionID, clientID, nick, nil } // setAuthCookie sets the authentication cookie on the // response. func (hdlr *Handlers) setAuthCookie( writer http.ResponseWriter, request *http.Request, token string, ) { secure := request.TLS != nil || request.Header.Get("X-Forwarded-Proto") == "https" http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields Name: authCookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) } // clearAuthCookie removes the authentication cookie from // the client. func (hdlr *Handlers) clearAuthCookie( writer http.ResponseWriter, request *http.Request, ) { secure := request.TLS != nil || request.Header.Get("X-Forwarded-Proto") == "https" http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields Name: authCookieName, Value: "", Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: -1, }) } 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 } // 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.setAuthCookie(writer, request, token) hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, "nick": nick, }, 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, "", "ikmnostl"}, "", ) // 005 RPL_ISUPPORT hdlr.enqueueNumeric( ctx, clientID, irc.RplIsupport, nick, []string{ "CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,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, payload.Meta, bodyLines, ) } } func (hdlr *Handlers) dispatchCommand( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, body json.RawMessage, meta 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, meta, bodyLines, ) case irc.CmdJoin: hdlr.handleJoin( writer, request, sessionID, clientID, nick, target, bodyLines, ) 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.CmdPass: hdlr.handlePass( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdTopic: hdlr.handleTopic( writer, request, sessionID, clientID, nick, target, body, bodyLines, ) case irc.CmdInvite: hdlr.handleInvite( writer, request, sessionID, clientID, nick, bodyLines, ) case irc.CmdKick: hdlr.handleKick( 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, meta 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, meta, ) return } hdlr.handleDirectMsg( writer, request, sessionID, clientID, nick, command, target, body, meta, ) } // 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) } // handleServiceError maps a service-layer error to an IRC // numeric reply or a generic HTTP 500 response. Returns // true if an error was handled (response sent). func (hdlr *Handlers) handleServiceError( writer http.ResponseWriter, request *http.Request, clientID, sessionID int64, nick string, err error, ) bool { if err == nil { return false } var ircErr *service.IRCError if errors.As(err, &ircErr) { hdlr.respondIRCError( writer, request, clientID, sessionID, ircErr.Code, nick, ircErr.Params, ircErr.Message, ) return true } hdlr.log.Error( "service error", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return true } func (hdlr *Handlers) handleChannelMsg( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, body json.RawMessage, meta json.RawMessage, ) { ctx := request.Context() // Hashcash validation is HTTP-specific; needs chID. isNotice := command == irc.CmdNotice if !isNotice { chID, chErr := hdlr.params.Database. GetChannelByName(ctx, target) if chErr == nil { hashcashErr := hdlr.validateChannelHashcash( request, clientID, sessionID, writer, nick, target, body, meta, chID, ) if hashcashErr != nil { return } } } // Delegate validation + fan-out to service layer. dbID, uuid, err := hdlr.svc.SendChannelMessage( ctx, sessionID, nick, command, target, body, meta, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } // HTTP echo: enqueue to sender so all their clients // see the message in long-poll responses. _ = hdlr.params.Database.EnqueueToSession( ctx, sessionID, dbID, ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"id": uuid, "status": "sent"}, http.StatusOK) } // validateChannelHashcash checks whether the channel // requires hashcash proof-of-work for messages and // validates the stamp from the message meta field. // Returns nil on success or if the channel has no // hashcash requirement. On failure, it sends the // appropriate IRC error and returns a non-nil error. func (hdlr *Handlers) validateChannelHashcash( request *http.Request, clientID, sessionID int64, writer http.ResponseWriter, nick, target string, body json.RawMessage, meta json.RawMessage, chID int64, ) error { ctx := request.Context() bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits( ctx, chID, ) if bitsErr != nil { hdlr.log.Error( "get channel hashcash bits", "error", bitsErr, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return fmt.Errorf("channel hashcash bits: %w", bitsErr) } if bits <= 0 { return nil } stamp := hdlr.extractHashcashFromMeta(meta) if stamp == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrCannotSendToChan, nick, []string{target}, "Channel requires hashcash proof-of-work", ) return errHashcashRequired } return hdlr.verifyChannelStamp( request, writer, clientID, sessionID, nick, target, body, stamp, bits, ) } // verifyChannelStamp validates a channel hashcash stamp // and checks for replay attacks. func (hdlr *Handlers) verifyChannelStamp( request *http.Request, writer http.ResponseWriter, clientID, sessionID int64, nick, target string, body json.RawMessage, stamp string, bits int, ) error { ctx := request.Context() bodyHashStr := hashcash.BodyHash(body) valErr := hdlr.channelHashcash.ValidateStamp( stamp, bits, target, bodyHashStr, ) if valErr != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrCannotSendToChan, nick, []string{target}, "Invalid hashcash: "+valErr.Error(), ) return fmt.Errorf("channel hashcash: %w", valErr) } stampKey := hashcash.StampHash(stamp) spent, spentErr := hdlr.params.Database.IsHashcashSpent( ctx, stampKey, ) if spentErr != nil { hdlr.log.Error( "check spent hashcash", "error", spentErr, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return fmt.Errorf("check spent hashcash: %w", spentErr) } if spent { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrCannotSendToChan, nick, []string{target}, "Hashcash stamp already used", ) return errHashcashReused } recordErr := hdlr.params.Database.RecordSpentHashcash( ctx, stampKey, ) if recordErr != nil { hdlr.log.Error( "record spent hashcash", "error", recordErr, ) } return nil } // extractHashcashFromMeta parses the meta JSON and // returns the hashcash stamp string, or empty string // if not present. func (hdlr *Handlers) extractHashcashFromMeta( meta json.RawMessage, ) string { if len(meta) == 0 { return "" } var metaMap map[string]json.RawMessage err := json.Unmarshal(meta, &metaMap) if err != nil { return "" } raw, ok := metaMap["hashcash"] if !ok { return "" } var stamp string err = json.Unmarshal(raw, &stamp) if err != nil { return "" } return stamp } func (hdlr *Handlers) handleDirectMsg( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, command, target string, body json.RawMessage, meta json.RawMessage, ) { result, err := hdlr.svc.SendDirectMessage( request.Context(), sessionID, nick, command, target, body, meta, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } // Per RFC 2812: NOTICE must NOT trigger auto-replies // including RPL_AWAY. if command != irc.CmdNotice && result.AwayMsg != "" { hdlr.enqueueNumeric( request.Context(), clientID, irc.RplAway, nick, []string{target}, result.AwayMsg, ) hdlr.broker.Notify(sessionID) } hdlr.respondJSON(writer, request, map[string]string{ "id": result.UUID, "status": "sent", }, http.StatusOK) } func (hdlr *Handlers) handleJoin( 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.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 } // Extract key from body lines (JOIN #channel key). var suppliedKey string lines := bodyLines() if len(lines) > 0 { suppliedKey = lines[0] } hdlr.executeJoin( writer, request, sessionID, clientID, nick, channel, suppliedKey, ) } func (hdlr *Handlers) executeJoin( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel, suppliedKey string, ) { result, err := hdlr.svc.JoinChannel( request.Context(), sessionID, nick, channel, suppliedKey, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } hdlr.deliverJoinNumerics( request, clientID, sessionID, nick, channel, result.ChannelID, ) hdlr.respondJSON(writer, request, map[string]string{ "status": "joined", "channel": channel, }, http.StatusOK) } // checkJoinAllowed runs Tier 2 restrictions for an // existing channel. Returns true if join is allowed. func (hdlr *Handlers) checkJoinAllowed( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, suppliedKey string, ) bool { ctx := request.Context() // 1. Ban check — prevents banned users from joining. isBanned, banErr := hdlr.params.Database. IsSessionBanned(ctx, chID, sessionID) if banErr == nil && isBanned { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrBannedFromChan, nick, []string{channel}, "Cannot join channel (+b)", ) return false } // 2. Invite-only check (+i). isInviteOnly, ioErr := hdlr.params.Database. IsChannelInviteOnly(ctx, chID) if ioErr == nil && isInviteOnly { hasInvite, invErr := hdlr.params.Database. HasChannelInvite(ctx, chID, sessionID) if invErr != nil || !hasInvite { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrInviteOnlyChan, nick, []string{channel}, "Cannot join channel (+i)", ) return false } } // 3. Channel key check (+k). key, keyErr := hdlr.params.Database. GetChannelKey(ctx, chID) if keyErr == nil && key != "" { if suppliedKey != key { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrBadChannelKey, nick, []string{channel}, "Cannot join channel (+k)", ) return false } } // 4. User limit check (+l). limit, limErr := hdlr.params.Database. GetChannelUserLimit(ctx, chID) if limErr == nil && limit > 0 { count, cntErr := hdlr.params.Database. CountChannelMembers(ctx, chID) if cntErr == nil && count >= int64(limit) { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrChannelIsFull, nick, []string{channel}, "Cannot join channel (+l)", ) return false } } return true } // 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", ) } } // memberPrefix returns the IRC prefix character for a // channel member: "@" for operators, "+" for voiced, or // "" for regular members. func memberPrefix(mem *db.MemberInfo) string { if mem.IsOperator { return "@" } if mem.IsVoiced { return "+" } return "" } // 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 idx := range members { prefix := memberPrefix(&members[idx]) entries = append( entries, prefix+members[idx].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.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdPart}, "Not enough parameters", ) return } channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } // Extract reason from body for the service call. reason := "" if body != nil { var lines []string if json.Unmarshal(body, &lines) == nil && len(lines) > 0 { reason = lines[0] } } err := hdlr.svc.PartChannel( request.Context(), sessionID, nick, channel, reason, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } 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.svc.ChangeNick( request.Context(), sessionID, nick, newNick, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } hdlr.respondJSON(writer, request, map[string]string{ "status": "ok", "nick": newNick, }, http.StatusOK) } func (hdlr *Handlers) handleTopic( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, _ 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 } topic := strings.Join(lines, " ") err := hdlr.svc.SetTopic( request.Context(), sessionID, nick, channel, topic, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } hdlr.deliverSetTopicNumerics( request.Context(), clientID, sessionID, nick, channel, topic, ) hdlr.respondJSON(writer, request, map[string]string{ "status": "ok", "topic": topic, }, http.StatusOK) } // deliverSetTopicNumerics sends RPL_TOPIC and // RPL_TOPICWHOTIME to the client after a topic change. func (hdlr *Handlers) deliverSetTopicNumerics( ctx context.Context, clientID, sessionID int64, nick, channel, topic string, ) { hdlr.enqueueNumeric( ctx, clientID, irc.RplTopic, nick, []string{channel}, topic, ) chID, chErr := hdlr.params.Database.GetChannelByName( ctx, channel, ) if chErr == nil { topicMeta, tmErr := hdlr.params.Database. GetTopicMeta(ctx, chID) if tmErr == nil && topicMeta != nil { hdlr.enqueueNumeric( ctx, clientID, irc.RplTopicWhoTime, nick, []string{ channel, topicMeta.SetBy, strconv.FormatInt( topicMeta.SetAt.Unix(), 10, ), }, "", ) } } hdlr.broker.Notify(sessionID) } // dispatchInfoCommand handles informational IRC commands // that produce server-side numerics (MOTD, PING). func (hdlr *Handlers) dispatchInfoCommand( 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, ) { reason := "Client quit" if body != nil { var lines []string if json.Unmarshal(body, &lines) == nil && len(lines) > 0 { reason = lines[0] } } hdlr.svc.BroadcastQuit( request.Context(), sessionID, nick, reason, ) hdlr.clearAuthCookie(writer, request) 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 } hdlr.handleChannelMode( writer, request, sessionID, clientID, nick, channel, bodyLines, ) } func (hdlr *Handlers) handleChannelMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, bodyLines func() []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 } lines := bodyLines() if len(lines) > 0 { hdlr.applyChannelMode( writer, request, sessionID, clientID, nick, channel, chID, lines, ) return } hdlr.queryChannelMode( writer, request, sessionID, clientID, nick, channel, chID, ) } // buildChannelModeString constructs the current mode // string for a channel, including +n (always on), +t, +m, // +i, +s, +k, +l, and +H with their parameters. func (hdlr *Handlers) buildChannelModeString( ctx context.Context, chID int64, ) string { modes := "+n" isInviteOnly, ioErr := hdlr.params.Database. IsChannelInviteOnly(ctx, chID) if ioErr == nil && isInviteOnly { modes += "i" } isModerated, modErr := hdlr.params.Database. IsChannelModerated(ctx, chID) if modErr == nil && isModerated { modes += "m" } isSecret, secErr := hdlr.params.Database. IsChannelSecret(ctx, chID) if secErr == nil && isSecret { modes += "s" } isTopicLocked, tlErr := hdlr.params.Database. IsChannelTopicLocked(ctx, chID) if tlErr == nil && isTopicLocked { modes += "t" } var modeParams string key, keyErr := hdlr.params.Database. GetChannelKey(ctx, chID) if keyErr == nil && key != "" { modes += "k" modeParams += " " + key } limit, limErr := hdlr.params.Database. GetChannelUserLimit(ctx, chID) if limErr == nil && limit > 0 { modes += "l" modeParams += " " + strconv.Itoa(limit) } bits, bitsErr := hdlr.params.Database. GetChannelHashcashBits(ctx, chID) if bitsErr == nil && bits > 0 { modes += "H" modeParams += " " + strconv.Itoa(bits) } return modes + modeParams } // queryChannelMode sends RPL_CHANNELMODEIS and // RPL_CREATIONTIME for a channel. Includes +t, +m, +H // as appropriate. func (hdlr *Handlers) queryChannelMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, ) { ctx := request.Context() modeStr := hdlr.buildChannelModeString(ctx, chID) // 324 RPL_CHANNELMODEIS hdlr.enqueueNumeric( ctx, clientID, irc.RplChannelModeIs, nick, []string{channel, modeStr}, "", ) // 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) } // requireChannelOp checks that the session has +o in the // channel. If not, it sends ERR_CHANOPRIVSNEEDED and // returns false. // applyChannelMode handles setting channel modes. // Supports +o/-o, +v/-v, +m/-m, +t/-t, +H/-H. func (hdlr *Handlers) applyChannelMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeArgs []string, ) { modeStr := modeArgs[0] if hdlr.applyUserModeIfMatched( writer, request, sessionID, clientID, nick, channel, chID, modeStr, modeArgs, ) { return } if hdlr.applyChannelFlagIfMatched( writer, request, sessionID, clientID, nick, channel, chID, modeStr, ) { return } hdlr.applyParameterizedMode( writer, request, sessionID, clientID, nick, channel, chID, modeStr, modeArgs, ) } // applyUserModeIfMatched handles +o/-o and +v/-v. // Returns true if the mode was handled. func (hdlr *Handlers) applyUserModeIfMatched( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeStr string, modeArgs []string, ) bool { switch modeStr { case "+o", "-o": hdlr.applyUserMode( writer, request, sessionID, clientID, nick, channel, chID, modeArgs, true, ) return true case "+v", "-v": hdlr.applyUserMode( writer, request, sessionID, clientID, nick, channel, chID, modeArgs, false, ) return true default: return false } } // applyChannelFlagIfMatched handles simple boolean modes // (+m/-m, +t/-t, +i/-i, +s/-s). // Returns true if the mode was handled. func (hdlr *Handlers) applyChannelFlagIfMatched( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeStr string, ) bool { flagMap := map[string]struct { flag string setting bool }{ "+m": {"m", true}, "-m": {"m", false}, "+t": {"t", true}, "-t": {"t", false}, "+i": {"i", true}, "-i": {"i", false}, "+s": {"s", true}, "-s": {"s", false}, } entry, exists := flagMap[modeStr] if !exists { return false } hdlr.setChannelFlag( writer, request, sessionID, clientID, nick, channel, chID, entry.flag, entry.setting, ) return true } // applyParameterizedMode handles modes that take // parameters (+k/-k, +l/-l, +b/-b, +H/-H) and // unknown modes. func (hdlr *Handlers) applyParameterizedMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeStr string, modeArgs []string, ) { switch modeStr { case "+k": hdlr.setChannelKeyMode( writer, request, sessionID, clientID, nick, channel, chID, modeArgs, ) case "-k": hdlr.clearChannelKeyMode( writer, request, sessionID, clientID, nick, channel, chID, ) case "+l": hdlr.setChannelLimitMode( writer, request, sessionID, clientID, nick, channel, chID, modeArgs, ) case "-l": hdlr.clearChannelLimitMode( writer, request, sessionID, clientID, nick, channel, chID, ) case "+b": hdlr.handleBanMode( writer, request, sessionID, clientID, nick, channel, chID, modeArgs, true, ) case "-b": hdlr.handleBanMode( writer, request, sessionID, clientID, nick, channel, chID, modeArgs, false, ) case "+H": hdlr.setHashcashMode( writer, request, sessionID, clientID, nick, channel, chID, modeArgs, ) case "-H": hdlr.clearHashcashMode( writer, request, sessionID, clientID, nick, channel, chID, ) default: hdlr.enqueueNumeric( request.Context(), clientID, irc.ErrUnknownMode, nick, []string{modeStr}, "is unknown mode char to me", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "error"}, http.StatusOK) } } // applyUserMode handles +o/-o and +v/-v mode changes. // isOperMode=true for +o/-o, false for +v/-v. func (hdlr *Handlers) applyUserMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeArgs []string, isOperMode bool, ) { // Validate operator status via service. _, opErr := hdlr.svc.ValidateChannelOp( request.Context(), sessionID, channel, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, opErr, ) { return } if len(modeArgs) < 2 { //nolint:mnd // mode + nick hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, "Not enough parameters", ) return } targetNick := modeArgs[1] setting := strings.HasPrefix(modeArgs[0], "+") var modeChar rune if isOperMode { modeChar = 'o' } else { modeChar = 'v' } err := hdlr.svc.ApplyMemberMode( request.Context(), chID, channel, targetNick, modeChar, setting, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } modeText := modeArgs[0] + " " + targetNick hdlr.svc.BroadcastMode( request.Context(), nick, channel, chID, modeText, ) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // setChannelFlag handles +m/-m and +t/-t mode changes. func (hdlr *Handlers) setChannelFlag( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, flag string, setting bool, ) { // Validate operator status via service. _, opErr := hdlr.svc.ValidateChannelOp( request.Context(), sessionID, channel, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, opErr, ) { return } err := hdlr.svc.SetChannelFlag( request.Context(), chID, rune(flag[0]), setting, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } modeStr := "+" + flag if !setting { modeStr = "-" + flag } hdlr.svc.BroadcastMode( request.Context(), nick, channel, chID, modeStr, ) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } const ( // minHashcashBits is the minimum allowed hashcash // difficulty for channels. minHashcashBits = 1 // maxHashcashBits is the maximum allowed hashcash // difficulty for channels. maxHashcashBits = 40 ) // setHashcashMode handles MODE #channel +H . func (hdlr *Handlers) setHashcashMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeArgs []string, ) { ctx := request.Context() // Validate operator status via service. _, opErr := hdlr.svc.ValidateChannelOp( ctx, sessionID, channel, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, opErr, ) { return } if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, "Not enough parameters (+H requires bits)", ) return } bits, err := strconv.Atoi(modeArgs[1]) if err != nil || bits < minHashcashBits || bits > maxHashcashBits { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrUnknownMode, nick, []string{"+H"}, fmt.Sprintf( "Invalid hashcash bits (must be %d-%d)", minHashcashBits, maxHashcashBits, ), ) return } err = hdlr.params.Database.SetChannelHashcashBits( ctx, chID, bits, ) if err != nil { hdlr.log.Error( "set channel hashcash bits", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } modeStr := hdlr.buildChannelModeString(ctx, chID) hdlr.enqueueNumeric( ctx, clientID, irc.RplChannelModeIs, nick, []string{channel, modeStr}, "", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // clearHashcashMode handles MODE #channel -H. func (hdlr *Handlers) clearHashcashMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, ) { ctx := request.Context() // Validate operator status via service. _, opErr := hdlr.svc.ValidateChannelOp( ctx, sessionID, channel, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, opErr, ) { return } err := hdlr.params.Database.SetChannelHashcashBits( ctx, chID, 0, ) if err != nil { hdlr.log.Error( "clear channel hashcash bits", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } modeStr := hdlr.buildChannelModeString(ctx, chID) hdlr.enqueueNumeric( ctx, clientID, irc.RplChannelModeIs, nick, []string{channel, modeStr}, "", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleBanMode handles MODE #channel +b/-b [mask]. // +b with no argument lists bans; +b with argument adds // a ban; -b removes a ban. func (hdlr *Handlers) handleBanMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeArgs []string, adding bool, ) { // +b with no argument: list bans. if adding && len(modeArgs) < 2 { hdlr.listBans( writer, request, sessionID, clientID, nick, channel, chID, ) return } if !hdlr.requireChannelOp( writer, request, sessionID, clientID, nick, channel, chID, ) { return } if len(modeArgs) < 2 { //nolint:mnd // mode + mask hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, "Not enough parameters", ) return } hdlr.executeBanChange( writer, request, nick, channel, chID, modeArgs[1], adding, ) } // executeBanChange applies a ban add/remove and // broadcasts the mode change. func (hdlr *Handlers) executeBanChange( writer http.ResponseWriter, request *http.Request, nick, channel string, chID int64, mask string, adding bool, ) { ctx := request.Context() var err error if adding { err = hdlr.params.Database.AddChannelBan( ctx, chID, mask, nick, ) } else { err = hdlr.params.Database.RemoveChannelBan( ctx, chID, mask, ) } if err != nil { hdlr.log.Error( "ban mode change failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } modePrefix := "+" if !adding { modePrefix = "-" } memberIDs, _ := hdlr.params.Database. GetChannelMemberIDs(ctx, chID) modeBody, mErr := json.Marshal( []string{modePrefix + "b", mask}, ) if mErr == nil { _ = hdlr.fanOutSilent( request, irc.CmdMode, nick, channel, json.RawMessage(modeBody), memberIDs, ) } hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // listBans sends RPL_BANLIST (367) and // RPL_ENDOFBANLIST (368) for a channel. func (hdlr *Handlers) listBans( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, ) { ctx := request.Context() bans, err := hdlr.params.Database.ListChannelBans( ctx, chID, ) if err != nil { hdlr.log.Error( "list bans failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } for _, ban := range bans { hdlr.enqueueNumeric( ctx, clientID, irc.RplBanList, nick, []string{ channel, ban.Mask, ban.SetBy, strconv.FormatInt( ban.CreatedAt.Unix(), 10, ), }, "", ) } hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfBanList, nick, []string{channel}, "End of channel ban list", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // setChannelKeyMode handles MODE #channel +k . func (hdlr *Handlers) setChannelKeyMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeArgs []string, ) { ctx := request.Context() if !hdlr.requireChannelOp( writer, request, sessionID, clientID, nick, channel, chID, ) { return } if len(modeArgs) < 2 { //nolint:mnd // +k requires key arg hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, "Not enough parameters (+k requires a key)", ) return } key := modeArgs[1] err := hdlr.params.Database.SetChannelKey( ctx, chID, key, ) if err != nil { hdlr.log.Error( "set channel key failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } // Broadcast +k mode change. memberIDs, _ := hdlr.params.Database. GetChannelMemberIDs(ctx, chID) modeBody, mErr := json.Marshal([]string{"+k", key}) if mErr == nil { _ = hdlr.fanOutSilent( request, irc.CmdMode, nick, channel, json.RawMessage(modeBody), memberIDs, ) } hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // clearChannelKeyMode handles MODE #channel -k *. func (hdlr *Handlers) clearChannelKeyMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, ) { ctx := request.Context() if !hdlr.requireChannelOp( writer, request, sessionID, clientID, nick, channel, chID, ) { return } err := hdlr.params.Database.SetChannelKey( ctx, chID, "", ) if err != nil { hdlr.log.Error( "clear channel key failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } memberIDs, _ := hdlr.params.Database. GetChannelMemberIDs(ctx, chID) modeBody, mErr := json.Marshal([]string{"-k", "*"}) if mErr == nil { _ = hdlr.fanOutSilent( request, irc.CmdMode, nick, channel, json.RawMessage(modeBody), memberIDs, ) } hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // setChannelLimitMode handles MODE #channel +l . func (hdlr *Handlers) setChannelLimitMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, modeArgs []string, ) { ctx := request.Context() if !hdlr.requireChannelOp( writer, request, sessionID, clientID, nick, channel, chID, ) { return } if len(modeArgs) < 2 { //nolint:mnd // +l requires limit arg hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, "Not enough parameters (+l requires a limit)", ) return } limit, parseErr := strconv.Atoi(modeArgs[1]) if parseErr != nil || limit <= 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrUnknownMode, nick, []string{"+l"}, "Invalid user limit (must be positive integer)", ) return } err := hdlr.params.Database.SetChannelUserLimit( ctx, chID, limit, ) if err != nil { hdlr.log.Error( "set channel user limit failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } // Broadcast +l mode change. memberIDs, _ := hdlr.params.Database. GetChannelMemberIDs(ctx, chID) modeBody, mErr := json.Marshal( []string{"+l", modeArgs[1]}, ) if mErr == nil { _ = hdlr.fanOutSilent( request, irc.CmdMode, nick, channel, json.RawMessage(modeBody), memberIDs, ) } hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // clearChannelLimitMode handles MODE #channel -l. func (hdlr *Handlers) clearChannelLimitMode( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, channel string, chID int64, ) { ctx := request.Context() if !hdlr.requireChannelOp( writer, request, sessionID, clientID, nick, channel, chID, ) { return } err := hdlr.params.Database.SetChannelUserLimit( ctx, chID, 0, ) if err != nil { hdlr.log.Error( "clear channel user limit failed", "error", err, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } memberIDs, _ := hdlr.params.Database. GetChannelMemberIDs(ctx, chID) modeBody, mErr := json.Marshal([]string{"-l"}) if mErr == nil { _ = hdlr.fanOutSilent( request, irc.CmdMode, nick, channel, json.RawMessage(modeBody), memberIDs, ) } hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } // handleInvite processes the INVITE command. func (hdlr *Handlers) handleInvite( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick string, bodyLines func() []string, ) { lines := bodyLines() if len(lines) < 2 { //nolint:mnd // nick + channel hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdInvite}, "Not enough parameters", ) return } targetNick := lines[0] channel := lines[1] if !strings.HasPrefix(channel, "#") { channel = "#" + channel } chID, targetSID, ok := hdlr.validateInvite( writer, request, sessionID, clientID, nick, targetNick, channel, ) if !ok { return } hdlr.executeInvite( writer, request, sessionID, clientID, nick, targetNick, channel, chID, targetSID, ) } // validateInvite checks channel, membership, permissions, // and target for an INVITE command. func (hdlr *Handlers) validateInvite( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, targetNick, channel string, ) (int64, int64, bool) { ctx := request.Context() chID, err := hdlr.params.Database.GetChannelByName( ctx, channel, ) if err != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNoSuchChannel, nick, []string{channel}, "No such channel", ) return 0, 0, false } isMember, memErr := hdlr.params.Database.IsChannelMember( ctx, chID, sessionID, ) if memErr != nil || !isMember { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNotOnChannel, nick, []string{channel}, "You're not on that channel", ) return 0, 0, false } isInviteOnly, ioErr := hdlr.params.Database. IsChannelInviteOnly(ctx, chID) if ioErr == nil && isInviteOnly { if !hdlr.requireChannelOp( writer, request, sessionID, clientID, nick, channel, chID, ) { return 0, 0, false } } targetSID, nickErr := hdlr.params.Database. GetSessionByNick(ctx, targetNick) if nickErr != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNoSuchNick, nick, []string{targetNick}, "No such nick/channel", ) return 0, 0, false } alreadyIn, aiErr := hdlr.params.Database. IsChannelMember(ctx, chID, targetSID) if aiErr == nil && alreadyIn { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrUserOnChannel, nick, []string{targetNick, channel}, "is already on channel", ) return 0, 0, false } return chID, targetSID, true } // executeInvite records the invite and sends // notifications. func (hdlr *Handlers) executeInvite( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, targetNick, channel string, chID, targetSID int64, ) { ctx := request.Context() invErr := hdlr.params.Database.AddChannelInvite( ctx, chID, targetSID, nick, ) if invErr != nil { hdlr.log.Error( "add invite failed", "error", invErr, ) hdlr.respondError( writer, request, "internal error", http.StatusInternalServerError, ) return } hdlr.enqueueNumeric( ctx, clientID, irc.RplInviting, nick, []string{targetNick, channel}, "", ) hdlr.broker.Notify(sessionID) invBody, mErr := json.Marshal([]string{channel}) if mErr == nil { _ = hdlr.fanOutSilent( request, irc.CmdInvite, nick, targetNick, json.RawMessage(invBody), []int64{targetSID}, ) } 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 idx := range members { prefix := memberPrefix(&members[idx]) entries = append( entries, prefix+members[idx].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() // Use filtered list that hides +s channels // from non-members. channels, err := hdlr.params.Database. ListAllChannelsWithCountsFiltered(ctx, sessionID) 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, sessionID, 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, querierSID, targetSID int64, ) { // Use filtered query that hides +s channels from // non-members. channels, chanErr := hdlr.params.Database. GetSessionChannelsFiltered( ctx, targetSID, querierSID, ) 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.clearAuthCookie(writer, request) hdlr.respondJSON(writer, request, map[string]string{"status": "ok"}, http.StatusOK) } } // cleanupUser parts the user from all channels (notifying // members) and deletes the session via the shared service // layer. func (hdlr *Handlers) cleanupUser( ctx context.Context, sessionID int64, nick string, ) { hdlr.svc.BroadcastQuit( ctx, sessionID, nick, "Connection closed", ) } // 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 } err := hdlr.svc.Oper( ctx, sessionID, lines[0], lines[1], ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { 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, " ") } cleared, err := hdlr.svc.SetAway( ctx, sessionID, awayMsg, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } if cleared { // 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) } // handleKick handles the KICK command. func (hdlr *Handlers) handleKick( writer http.ResponseWriter, request *http.Request, sessionID, clientID int64, nick, target string, body json.RawMessage, bodyLines func() []string, ) { _ = body if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdKick}, "Not enough parameters", ) return } channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } lines := bodyLines() if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, irc.ErrNeedMoreParams, nick, []string{irc.CmdKick}, "Not enough parameters", ) return } targetNick := lines[0] reason := nick if len(lines) > 1 { reason = lines[1] } err := hdlr.svc.KickUser( request.Context(), sessionID, nick, channel, targetNick, reason, ) if hdlr.handleServiceError( writer, request, clientID, sessionID, nick, err, ) { return } 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", ) }