check / check (push) Waiting to run
The IRC listener's relay dropped every line sent under the connection's own nick, so a user on the web client and an IRC client at once never saw on IRC what they did from the web client. The relay now drops nothing by nick. Instead the fan-out skips only the sending client's own queue: the IRC listener passes its own client, whose handler already wrote the echo, and the HTTP API skips none. Channel messages and PARTs now reach the sender's other clients, so an HTTP client gets its own PART event; neoirc-cli ignores it, as it already shows its own part. README.md names the commands covered. Judgement call: a PRIVMSG or NOTICE sent on an IRC connection is not echoed back to it, as in IRC. Model: opus-5-5
3775 lines
76 KiB
Go
3775 lines
76 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
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. It is always Secure: the server runs behind a
|
|
// TLS-terminating reverse proxy.
|
|
func (hdlr *Handlers) setAuthCookie(
|
|
writer http.ResponseWriter,
|
|
token string,
|
|
) {
|
|
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
|
Name: authCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// clearAuthCookie removes the authentication cookie from
|
|
// the client.
|
|
func (hdlr *Handlers) clearAuthCookie(
|
|
writer http.ResponseWriter,
|
|
) {
|
|
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
|
Name: authCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
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{
|
|
errorKey: "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.checkHashcash(
|
|
writer, request, payload.Hashcash,
|
|
) {
|
|
return
|
|
}
|
|
|
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
|
|
|
if !service.ValidNick(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, payload.Hashcash,
|
|
)
|
|
}
|
|
|
|
func (hdlr *Handlers) executeCreateSession(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
nick, username, stamp string,
|
|
) {
|
|
remoteIP := clientIP(request)
|
|
|
|
hostname := resolveHostname(
|
|
request.Context(), remoteIP,
|
|
)
|
|
|
|
if !hdlr.claimHashcash(writer, request, stamp) {
|
|
return
|
|
}
|
|
|
|
sessionID, clientID, token, err :=
|
|
hdlr.params.Database.CreateSession(
|
|
request.Context(),
|
|
nick, username, hostname, remoteIP,
|
|
)
|
|
if err != nil {
|
|
hdlr.hashcashVal.Release(stamp)
|
|
hdlr.handleCreateSessionError(
|
|
writer, request, err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.stats.IncrSessions()
|
|
hdlr.stats.IncrConnections()
|
|
|
|
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
|
|
|
hdlr.setAuthCookie(writer, token)
|
|
|
|
hdlr.respondJSON(writer, request, map[string]any{
|
|
"id": sessionID,
|
|
nickKey: nick,
|
|
}, http.StatusCreated)
|
|
}
|
|
|
|
// checkHashcash checks a hashcash stamp if required,
|
|
// without spending it. Returns false if the check failed
|
|
// and a response was already sent.
|
|
func (hdlr *Handlers) checkHashcash(
|
|
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.Check(
|
|
stamp, hdlr.params.Config.HashcashBits,
|
|
)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"invalid hashcash stamp: "+err.Error(),
|
|
http.StatusPaymentRequired,
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// claimHashcash spends a stamp that checkHashcash accepted.
|
|
// Returns false if Claim refused the stamp, normally because
|
|
// it was already spent, and a response was already sent.
|
|
func (hdlr *Handlers) claimHashcash(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
stamp string,
|
|
) bool {
|
|
err := hdlr.hashcashVal.Claim(
|
|
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, 0, 0,
|
|
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,
|
|
nickKey: 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,
|
|
0, chanInfo.ID, 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
|
|
}
|
|
|
|
// Register with the broker before reading the queue.
|
|
// Notify wakes only the channels registered at that
|
|
// moment, so a message queued after the read below
|
|
// must find this one there already.
|
|
waitCh := hdlr.broker.Wait(sessionID)
|
|
defer hdlr.broker.Remove(sessionID, waitCh)
|
|
|
|
msgs, lastQID, err := hdlr.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,
|
|
waitCh, clientID, afterID, timeout,
|
|
)
|
|
}
|
|
}
|
|
|
|
// longPoll waits up to timeout seconds for the broker to
|
|
// signal waitCh, then answers with the messages queued for
|
|
// clientID after afterID. It answers nothing if the client
|
|
// goes away first.
|
|
func (hdlr *Handlers) longPoll(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
waitCh <-chan struct{},
|
|
clientID, afterID int64,
|
|
timeout int,
|
|
) {
|
|
timer := time.NewTimer(
|
|
time.Duration(timeout) * time.Second,
|
|
)
|
|
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-waitCh:
|
|
case <-timer.C:
|
|
case <-request.Context().Done():
|
|
return
|
|
}
|
|
|
|
msgs, lastQID, err := hdlr.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, irc.CmdKill, irc.CmdWallops:
|
|
hdlr.dispatchOperCommand(
|
|
writer, request,
|
|
sessionID, clientID, nick, command, 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,
|
|
)
|
|
case irc.CmdUserhost:
|
|
hdlr.handleUserhost(
|
|
writer, request,
|
|
sessionID, clientID, nick, bodyLines,
|
|
)
|
|
case irc.CmdVersion:
|
|
hdlr.handleVersion(
|
|
writer, request, sessionID, clientID, nick,
|
|
)
|
|
case irc.CmdAdmin:
|
|
hdlr.handleAdmin(
|
|
writer, request, sessionID, clientID, nick,
|
|
)
|
|
case irc.CmdInfo:
|
|
hdlr.handleInfo(
|
|
writer, request, sessionID, clientID, nick,
|
|
)
|
|
case irc.CmdTime:
|
|
hdlr.handleTime(
|
|
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{statusKey: statusError},
|
|
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{statusKey: statusError},
|
|
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{statusKey: statusError},
|
|
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{statusKey: statusError},
|
|
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()
|
|
|
|
// Delegate validation + fan-out to service layer. The
|
|
// fan-out includes the sender's own session, so all
|
|
// their clients see the message in long-poll responses.
|
|
uuid, err := hdlr.svc.SendChannelMessage(
|
|
ctx, sessionID, 0, nick,
|
|
command, target, body, meta,
|
|
)
|
|
if hdlr.handleServiceError(
|
|
writer, request,
|
|
clientID, sessionID, nick, err,
|
|
) {
|
|
return
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{"id": uuid, statusKey: "sent"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
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, 0, 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, statusKey: "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
|
|
}
|
|
|
|
// 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, 0, 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{
|
|
statusKey: "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",
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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, 0,
|
|
nick, channel, reason,
|
|
)
|
|
if hdlr.handleServiceError(
|
|
writer, request,
|
|
clientID, sessionID, nick, err,
|
|
) {
|
|
return
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{
|
|
statusKey: "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 !service.ValidNick(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{
|
|
statusKey: "ok", nickKey: 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, 0, nick, newNick,
|
|
)
|
|
if hdlr.handleServiceError(
|
|
writer, request,
|
|
clientID, sessionID, nick, err,
|
|
) {
|
|
return
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{
|
|
statusKey: "ok", nickKey: 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, 0,
|
|
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{
|
|
statusKey: "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{statusKey: "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)
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "quit"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// handleMode handles the MODE command: a target that starts
|
|
// with # is a channel, handled by handleChannelMode, and any
|
|
// other target a nick, handled by handleUserMode.
|
|
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, "#") {
|
|
hdlr.handleUserMode(
|
|
writer, request,
|
|
sessionID, clientID, nick, target,
|
|
bodyLines,
|
|
)
|
|
|
|
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 by delegating to the service
|
|
// layer's QueryChannelMode, which returns the complete
|
|
// mode string including all flags and parameters.
|
|
func (hdlr *Handlers) buildChannelModeString(
|
|
ctx context.Context,
|
|
chID int64,
|
|
) string {
|
|
return hdlr.svc.QueryChannelMode(ctx, chID)
|
|
}
|
|
|
|
// 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{statusKey: "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{statusKey: statusError},
|
|
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(), 0, nick, channel, chID,
|
|
modeText,
|
|
)
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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(), 0, nick, channel, chID,
|
|
modeStr,
|
|
)
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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 <bits>.
|
|
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{statusKey: "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{statusKey: "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, 0, chID,
|
|
json.RawMessage(modeBody), memberIDs,
|
|
)
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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{statusKey: "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// setChannelKeyMode handles MODE #channel +k <key>.
|
|
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, 0, chID,
|
|
json.RawMessage(modeBody), memberIDs,
|
|
)
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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, 0, chID,
|
|
json.RawMessage(modeBody), memberIDs,
|
|
)
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "ok"},
|
|
http.StatusOK)
|
|
}
|
|
|
|
// setChannelLimitMode handles MODE #channel +l <limit>.
|
|
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, 0, chID,
|
|
json.RawMessage(modeBody), memberIDs,
|
|
)
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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, 0, chID,
|
|
json.RawMessage(modeBody), memberIDs,
|
|
)
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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
|
|
}
|
|
|
|
ctx := request.Context()
|
|
|
|
targetSID, err := hdlr.svc.InviteUser(
|
|
ctx, sessionID, nick, targetNick, channel,
|
|
)
|
|
if hdlr.handleServiceError(
|
|
writer, request,
|
|
clientID, sessionID, nick, err,
|
|
) {
|
|
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,
|
|
targetSID, 0, json.RawMessage(invBody),
|
|
[]int64{targetSID},
|
|
)
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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{statusKey: "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{statusKey: "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{statusKey: "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{statusKey: "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{statusKey: "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{statusKey: "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{statusKey: "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
|
|
}
|
|
|
|
toSessionID, toChannelID, ok := hdlr.canAccessHistory(
|
|
writer, request, sessionID, nick, target,
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
limit, _ := strconv.Atoi(
|
|
request.URL.Query().Get("limit"),
|
|
)
|
|
if limit <= 0 || limit > maxHistLimit {
|
|
limit = defaultHistLimit
|
|
}
|
|
|
|
msgs, err := hdlr.params.Database.GetHistory(
|
|
request.Context(), toSessionID, toChannelID,
|
|
request.URL.Query().Get("before"), limit,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"unknown before id",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
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) and
|
|
// returns the session id and channel id to pass to
|
|
// GetHistory: the user's own session for its nick, or the
|
|
// channel's id, with 0 for the other. On false it has
|
|
// written the error response.
|
|
func (hdlr *Handlers) canAccessHistory(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID int64,
|
|
nick, target string,
|
|
) (int64, int64, bool) {
|
|
if strings.HasPrefix(target, "#") {
|
|
chID, ok := hdlr.canAccessChannelHistory(
|
|
writer, request, sessionID, target,
|
|
)
|
|
|
|
return 0, chID, ok
|
|
}
|
|
|
|
// 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 0, 0, false
|
|
}
|
|
|
|
return sessionID, 0, true
|
|
}
|
|
|
|
// canAccessChannelHistory returns the id of the channel
|
|
// named target if the user is a member of it.
|
|
func (hdlr *Handlers) canAccessChannelHistory(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID int64,
|
|
target string,
|
|
) (int64, bool) {
|
|
chID, err := hdlr.params.Database.GetChannelByName(
|
|
request.Context(), target,
|
|
)
|
|
if err != nil {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"channel not found",
|
|
http.StatusNotFound,
|
|
)
|
|
|
|
return 0, 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 0, false
|
|
}
|
|
|
|
if !isMember {
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"not a member of this channel",
|
|
http.StatusForbidden,
|
|
)
|
|
|
|
return 0, false
|
|
}
|
|
|
|
return chID, 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
|
|
}
|
|
|
|
err := hdlr.svc.Logout(
|
|
request.Context(), sessionID, clientID, nick,
|
|
)
|
|
if err != nil {
|
|
hdlr.log.Error("logout failed", "error", err)
|
|
hdlr.respondError(
|
|
writer, request,
|
|
"internal error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
hdlr.clearAuthCookie(writer)
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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{statusKey: "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{statusKey: "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, 0, nick,
|
|
channel, targetNick, reason,
|
|
)
|
|
if hdlr.handleServiceError(
|
|
writer, request,
|
|
clientID, sessionID, nick, err,
|
|
) {
|
|
return
|
|
}
|
|
|
|
hdlr.respondJSON(writer, request,
|
|
map[string]string{statusKey: "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 := max(int64(time.Since(lastSeen).Seconds()), 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",
|
|
)
|
|
}
|
|
|
|
// fanOut inserts a message and enqueues it to all given
|
|
// sessions. toSessionID and toChannelID are the recipient
|
|
// InsertMessage records for history, 0 when there is none.
|
|
func (hdlr *Handlers) fanOut(
|
|
request *http.Request,
|
|
command, from, target string,
|
|
toSessionID, toChannelID int64,
|
|
body json.RawMessage,
|
|
meta json.RawMessage,
|
|
sessionIDs []int64,
|
|
) (string, error) {
|
|
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
|
request.Context(), command, from, target,
|
|
toSessionID, toChannelID, nil, body, meta,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("insert message: %w", err)
|
|
}
|
|
|
|
for _, sid := range sessionIDs {
|
|
enqErr := hdlr.params.Database.EnqueueToSession(
|
|
request.Context(), sid, dbID, 0,
|
|
)
|
|
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,
|
|
toSessionID, toChannelID int64,
|
|
body json.RawMessage,
|
|
sessionIDs []int64,
|
|
) error {
|
|
_, err := hdlr.fanOut(
|
|
request, command, from, target,
|
|
toSessionID, toChannelID, body, nil, sessionIDs,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// requireChannelOp checks if the session is a channel
|
|
// operator and sends ERR_CHANOPRIVSNEEDED if not.
|
|
func (hdlr *Handlers) requireChannelOp(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
sessionID, clientID int64,
|
|
nick, channel string,
|
|
chID int64,
|
|
) bool {
|
|
isOp, err := hdlr.params.Database.IsChannelOperator(
|
|
request.Context(), chID, sessionID,
|
|
)
|
|
if err != nil || !isOp {
|
|
hdlr.respondIRCError(
|
|
writer, request, clientID, sessionID,
|
|
irc.ErrChanOpPrivsNeeded, nick,
|
|
[]string{channel},
|
|
"You're not channel operator",
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|