diff --git a/README.md b/README.md index 8f1f6e5..0ad9205 100644 --- a/README.md +++ b/README.md @@ -824,8 +824,9 @@ Set or change a channel's topic. - Updates the channel's topic in the database. - The TOPIC event is broadcast to all channel members. - If the channel doesn't exist, the server returns an error. -- If the channel has mode `+t` (topic lock), only operators can change the - topic (not yet enforced). +- If the channel has mode `+t` (topic lock, default: ON for new channels), + only operators (`+o`) can change the topic. Non-operators receive + `ERR_CHANOPRIVSNEEDED` (482). **Response:** `200 OK` ```json @@ -1020,16 +1021,23 @@ WHOIS responses (e.g., target user's current client IP and hostname). **IRC reference:** RFC 1459 §4.1.5 -#### KICK — Kick User (Planned) +#### KICK — Kick User -Remove a user from a channel. +Remove a user from a channel. Only channel operators (`+o`) can use this +command. The kicked user and all channel members receive the KICK message. **C2S:** ```json -{"command": "KICK", "to": "#general", "params": ["bob"], "body": ["misbehaving"]} +{"command": "KICK", "to": "#general", "body": ["bob", "misbehaving"]} ``` -**Status:** Not yet implemented. +The first element of `body` is the target nick, the second (optional) is the +reason. If no reason is provided, the kicker's nick is used as the default. + +**Errors:** +- `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator +- `441` (ERR_USERNOTINCHANNEL) — target is not in the channel +- `403` (ERR_NOSUCHCHANNEL) — channel does not exist **IRC reference:** RFC 1459 §4.2.8 @@ -1071,8 +1079,8 @@ the server to the client (never C2S) and use 3-digit string codes in the | `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | | `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | | `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | -| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` | -| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` | +| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","mnst"]}` | +| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=,,H,mnst","NETWORK=neoirc"],"body":["are supported by this server"]}` | | `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | | `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | | `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | @@ -1118,25 +1126,34 @@ carries IRC-style parameters (e.g., channel name, target nick). Inspired by IRC, simplified: -| Mode | Name | Meaning | -|------|----------------|---------| -| `+i` | Invite-only | Only invited users can join | -| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | -| `+s` | Secret | Channel hidden from LIST response | -| `+t` | Topic lock | Only operators can change the topic | -| `+n` | No external | Only channel members can send messages to the channel | -| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | +| Mode | Name | Meaning | Status | +|------|----------------|---------|--------| +| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | +| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | +| `+n` | No external | Only channel members can send messages to the channel | **Enforced** | +| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** | +| `+i` | Invite-only | Only invited users can join | Not yet enforced | +| `+s` | Secret | Channel hidden from LIST response | Not yet enforced | **User channel modes (set per-user per-channel):** -| Mode | Meaning | Display prefix | -|------|---------|----------------| -| `+o` | Operator | `@` in NAMES reply | -| `+v` | Voice | `+` in NAMES reply | +| Mode | Meaning | Display prefix | Status | +|------|---------|----------------|--------| +| `+o` | Operator | `@` in NAMES reply | **Enforced** | +| `+v` | Voice | `+` in NAMES reply | **Enforced** | -**Status:** Channel modes are defined but not yet enforced. The `modes` column -exists in the channels table but the server does not check modes on actions. -Exception: `+H` (hashcash) is fully enforced — see below. +**Channel creator auto-op:** The first user to JOIN a channel (creating it) +automatically receives `+o` operator status. + +**KICK command:** Channel operators can remove users with `KICK #channel nick +[:reason]`. The kicked user and all channel members receive the KICK message. + +**NOTICE:** Follows RFC 2812 — NOTICE never triggers auto-replies (including +RPL_AWAY), and skips hashcash validation on +H channels (servers and services +use NOTICE). + +**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and +`CHANMODES=,,H,mnst` in RPL_ISUPPORT (005). ### Per-Channel Hashcash (Anti-Spam) @@ -2677,17 +2694,19 @@ guess is borne by the server (bcrypt), not the client. - [x] **Hashcash proof-of-work** for session creation (abuse prevention) - [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` - [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` -- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` -- [ ] **User channel modes** — `+o` (operator), `+v` (voice) -- [x] **MODE command** — query channel and user modes (set not yet implemented) -- [x] **NAMES command** — query channel member list +- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external) +- [ ] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit) +- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes +- [x] **KICK command** — operator-only channel kick with broadcast +- [x] **MODE command** — query and set channel/user modes +- [x] **NAMES command** — query channel member list with @/+ prefixes - [x] **LIST command** — list all channels with member counts - [x] **WHOIS command** — query user information and channel membership - [x] **WHO command** — query channel user list - [x] **LUSERS command** — query server statistics - [x] **Connection registration numerics** — 001-005 sent on session creation - [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS -- [ ] **KICK command** — remove users from channels +- [x] **KICK command** — remove users from channels (operator-only) - [x] **Numeric replies** — send IRC numeric codes via the message queue (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) diff --git a/internal/db/queries.go b/internal/db/queries.go index 57f8eab..5000959 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -72,11 +72,13 @@ type ChannelInfo struct { // MemberInfo represents a channel member. type MemberInfo struct { - ID int64 `json:"id"` - Nick string `json:"nick"` - Username string `json:"username"` - Hostname string `json:"hostname"` - LastSeen time.Time `json:"lastSeen"` + ID int64 `json:"id"` + Nick string `json:"nick"` + Username string `json:"username"` + Hostname string `json:"hostname"` + IsOperator bool `json:"isOperator"` + IsVoiced bool `json:"isVoiced"` + LastSeen time.Time `json:"lastSeen"` } // Hostmask returns the IRC hostmask in @@ -436,6 +438,237 @@ func (database *Database) JoinChannel( return nil } +// JoinChannelAsOperator adds a session to a channel with +// operator status. Used when a user creates a new channel. +func (database *Database) JoinChannelAsOperator( + ctx context.Context, + channelID, sessionID int64, +) error { + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO channel_members + (channel_id, session_id, is_operator, joined_at) + VALUES (?, ?, 1, ?)`, + channelID, sessionID, time.Now()) + if err != nil { + return fmt.Errorf( + "join channel as operator: %w", err, + ) + } + + return nil +} + +// CountChannelMembers returns the number of members in +// a channel. +func (database *Database) CountChannelMembers( + ctx context.Context, + channelID int64, +) (int64, error) { + var count int64 + + err := database.conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_members + WHERE channel_id = ?`, + channelID, + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf( + "count channel members: %w", err, + ) + } + + return count, nil +} + +// IsChannelOperator checks if a session has operator +// status in a channel. +func (database *Database) IsChannelOperator( + ctx context.Context, + channelID, sessionID int64, +) (bool, error) { + var isOp int + + err := database.conn.QueryRowContext(ctx, + `SELECT is_operator FROM channel_members + WHERE channel_id = ? AND session_id = ?`, + channelID, sessionID, + ).Scan(&isOp) + if err != nil { + return false, fmt.Errorf( + "check channel operator: %w", err, + ) + } + + return isOp != 0, nil +} + +// IsChannelVoiced checks if a session has voice status +// in a channel. +func (database *Database) IsChannelVoiced( + ctx context.Context, + channelID, sessionID int64, +) (bool, error) { + var isVoiced int + + err := database.conn.QueryRowContext(ctx, + `SELECT is_voiced FROM channel_members + WHERE channel_id = ? AND session_id = ?`, + channelID, sessionID, + ).Scan(&isVoiced) + if err != nil { + return false, fmt.Errorf( + "check channel voiced: %w", err, + ) + } + + return isVoiced != 0, nil +} + +// SetChannelMemberOperator sets or clears operator status +// for a session in a channel. +func (database *Database) SetChannelMemberOperator( + ctx context.Context, + channelID, sessionID int64, + isOp bool, +) error { + val := 0 + if isOp { + val = 1 + } + + _, err := database.conn.ExecContext(ctx, + `UPDATE channel_members + SET is_operator = ? + WHERE channel_id = ? AND session_id = ?`, + val, channelID, sessionID) + if err != nil { + return fmt.Errorf( + "set channel member operator: %w", err, + ) + } + + return nil +} + +// SetChannelMemberVoiced sets or clears voice status +// for a session in a channel. +func (database *Database) SetChannelMemberVoiced( + ctx context.Context, + channelID, sessionID int64, + isVoiced bool, +) error { + val := 0 + if isVoiced { + val = 1 + } + + _, err := database.conn.ExecContext(ctx, + `UPDATE channel_members + SET is_voiced = ? + WHERE channel_id = ? AND session_id = ?`, + val, channelID, sessionID) + if err != nil { + return fmt.Errorf( + "set channel member voiced: %w", err, + ) + } + + return nil +} + +// IsChannelModerated returns whether a channel has +m set. +func (database *Database) IsChannelModerated( + ctx context.Context, + channelID int64, +) (bool, error) { + var isMod int + + err := database.conn.QueryRowContext(ctx, + `SELECT is_moderated FROM channels + WHERE id = ?`, + channelID, + ).Scan(&isMod) + if err != nil { + return false, fmt.Errorf( + "check channel moderated: %w", err, + ) + } + + return isMod != 0, nil +} + +// SetChannelModerated sets or clears +m on a channel. +func (database *Database) SetChannelModerated( + ctx context.Context, + channelID int64, + moderated bool, +) error { + val := 0 + if moderated { + val = 1 + } + + _, err := database.conn.ExecContext(ctx, + `UPDATE channels + SET is_moderated = ?, updated_at = ? + WHERE id = ?`, + val, time.Now(), channelID) + if err != nil { + return fmt.Errorf( + "set channel moderated: %w", err, + ) + } + + return nil +} + +// IsChannelTopicLocked returns whether a channel has +// +t set. +func (database *Database) IsChannelTopicLocked( + ctx context.Context, + channelID int64, +) (bool, error) { + var isLocked int + + err := database.conn.QueryRowContext(ctx, + `SELECT is_topic_locked FROM channels + WHERE id = ?`, + channelID, + ).Scan(&isLocked) + if err != nil { + return false, fmt.Errorf( + "check channel topic locked: %w", err, + ) + } + + return isLocked != 0, nil +} + +// SetChannelTopicLocked sets or clears +t on a channel. +func (database *Database) SetChannelTopicLocked( + ctx context.Context, + channelID int64, + locked bool, +) error { + val := 0 + if locked { + val = 1 + } + + _, err := database.conn.ExecContext(ctx, + `UPDATE channels + SET is_topic_locked = ?, updated_at = ? + WHERE id = ?`, + val, time.Now(), channelID) + if err != nil { + return fmt.Errorf( + "set channel topic locked: %w", err, + ) + } + + return nil +} + // PartChannel removes a session from a channel. func (database *Database) PartChannel( ctx context.Context, @@ -547,7 +780,8 @@ func (database *Database) ChannelMembers( ) ([]MemberInfo, error) { rows, err := database.conn.QueryContext(ctx, `SELECT s.id, s.nick, s.username, - s.hostname, s.last_seen + s.hostname, cm.is_operator, cm.is_voiced, + s.last_seen FROM sessions s INNER JOIN channel_members cm ON cm.session_id = s.id @@ -564,11 +798,16 @@ func (database *Database) ChannelMembers( var members []MemberInfo for rows.Next() { - var member MemberInfo + var ( + member MemberInfo + isOp int + isV int + ) err = rows.Scan( &member.ID, &member.Nick, &member.Username, &member.Hostname, + &isOp, &isV, &member.LastSeen, ) if err != nil { @@ -577,6 +816,9 @@ func (database *Database) ChannelMembers( ) } + member.IsOperator = isOp != 0 + member.IsVoiced = isV != 0 + members = append(members, member) } diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index b4e2cda..2ea9463 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS channels ( topic_set_by TEXT NOT NULL DEFAULT '', topic_set_at DATETIME, hashcash_bits INTEGER NOT NULL DEFAULT 0, + is_moderated INTEGER NOT NULL DEFAULT 0, + is_topic_locked INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -49,6 +51,8 @@ CREATE TABLE IF NOT EXISTS channel_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + is_operator INTEGER NOT NULL DEFAULT 0, + is_voiced INTEGER NOT NULL DEFAULT 0, joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(channel_id, session_id) ); diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 93e4194..6f06565 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -218,11 +218,10 @@ func (hdlr *Handlers) fanOutSilent( request *http.Request, command, from, target string, body json.RawMessage, - meta json.RawMessage, sessionIDs []int64, ) error { _, err := hdlr.fanOut( - request, command, from, target, body, meta, sessionIDs, + request, command, from, target, body, nil, sessionIDs, ) return err @@ -448,7 +447,7 @@ func (hdlr *Handlers) deliverWelcome( // 004 RPL_MYINFO hdlr.enqueueNumeric( ctx, clientID, irc.RplMyInfo, nick, - []string{srvName, version, "", "imnst"}, + []string{srvName, version, "", "mnst"}, "", ) @@ -458,7 +457,8 @@ func (hdlr *Handlers) deliverWelcome( []string{ "CHANTYPES=#", "NICKLEN=32", - "CHANMODES=,,H," + "imnst", + "PREFIX=(ov)@+", + "CHANMODES=,,H,mnst", "NETWORK=neoirc", "CASEMAPPING=ascii", }, @@ -1051,6 +1051,12 @@ func (hdlr *Handlers) dispatchCommand( sessionID, clientID, nick, target, body, bodyLines, ) + case irc.CmdKick: + hdlr.handleKick( + writer, request, + sessionID, clientID, nick, + target, body, bodyLines, + ) case irc.CmdQuit: hdlr.handleQuit( writer, request, sessionID, nick, body, @@ -1214,8 +1220,10 @@ func (hdlr *Handlers) handleChannelMsg( body json.RawMessage, meta json.RawMessage, ) { + ctx := request.Context() + chID, err := hdlr.params.Database.GetChannelByName( - request.Context(), target, + ctx, target, ) if err != nil { hdlr.respondIRCError( @@ -1228,7 +1236,7 @@ func (hdlr *Handlers) handleChannelMsg( } isMember, err := hdlr.params.Database.IsChannelMember( - request.Context(), chID, sessionID, + ctx, chID, sessionID, ) if err != nil { hdlr.log.Error( @@ -1253,20 +1261,73 @@ func (hdlr *Handlers) handleChannelMsg( return } - hashcashErr := hdlr.validateChannelHashcash( - request, clientID, sessionID, - writer, nick, target, body, meta, chID, - ) - if hashcashErr != nil { + // Enforce +m (moderated): only +o and +v can send. + if !hdlr.checkModeratedSend( + writer, request, + sessionID, clientID, nick, target, chID, + ) { return } + // NOTICE skips hashcash validation on +H channels + // (servers and services use NOTICE). + isNotice := command == irc.CmdNotice + + if !isNotice { + hashcashErr := hdlr.validateChannelHashcash( + request, clientID, sessionID, + writer, nick, target, body, meta, chID, + ) + if hashcashErr != nil { + return + } + } + hdlr.sendChannelMsg( writer, request, command, nick, target, body, meta, chID, ) } +// checkModeratedSend checks if a user can send to a +// moderated channel. Returns true if sending is allowed. +func (hdlr *Handlers) checkModeratedSend( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, + chID int64, +) bool { + ctx := request.Context() + + isModerated, err := hdlr.params.Database. + IsChannelModerated(ctx, chID) + if err != nil || !isModerated { + return true + } + + isOp, opErr := hdlr.params.Database. + IsChannelOperator(ctx, chID, sessionID) + if opErr == nil && isOp { + return true + } + + isVoiced, vErr := hdlr.params.Database. + IsChannelVoiced(ctx, chID, sessionID) + if vErr == nil && isVoiced { + return true + } + + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrCannotSendToChan, nick, + []string{target}, + "Cannot send to channel (+m)", + ) + + return false +} + // validateChannelHashcash checks whether the channel // requires hashcash proof-of-work for messages and // validates the stamp from the message meta field. @@ -1508,17 +1569,20 @@ func (hdlr *Handlers) handleDirectMsg( return } - // If the target is away, send RPL_AWAY to the sender. - awayMsg, awayErr := hdlr.params.Database.GetAway( - request.Context(), targetSID, - ) - if awayErr == nil && awayMsg != "" { - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.RplAway, nick, - []string{target}, awayMsg, + // Per RFC 2812: NOTICE must NOT trigger auto-replies + // including RPL_AWAY. + if command != irc.CmdNotice { + awayMsg, awayErr := hdlr.params.Database.GetAway( + request.Context(), targetSID, ) - hdlr.broker.Notify(sessionID) + if awayErr == nil && awayMsg != "" { + hdlr.enqueueNumeric( + request.Context(), clientID, + irc.RplAway, nick, + []string{target}, awayMsg, + ) + hdlr.broker.Notify(sessionID) + } } hdlr.respondJSON(writer, request, @@ -1569,8 +1633,10 @@ func (hdlr *Handlers) executeJoin( sessionID, clientID int64, nick, channel string, ) { + ctx := request.Context() + chID, err := hdlr.params.Database.GetOrCreateChannel( - request.Context(), channel, + ctx, channel, ) if err != nil { hdlr.log.Error( @@ -1585,9 +1651,28 @@ func (hdlr *Handlers) executeJoin( return } - err = hdlr.params.Database.JoinChannel( - request.Context(), chID, sessionID, - ) + // Check if channel is empty before joining — first + // joiner becomes operator. + memberCount, countErr := hdlr.params.Database. + CountChannelMembers(ctx, chID) + if countErr != nil { + hdlr.log.Error( + "count members failed", "error", countErr, + ) + } + + isCreator := countErr == nil && memberCount == 0 + + if isCreator { + err = hdlr.params.Database.JoinChannelAsOperator( + ctx, chID, sessionID, + ) + } else { + err = hdlr.params.Database.JoinChannel( + ctx, chID, sessionID, + ) + } + if err != nil { hdlr.log.Error( "join channel failed", "error", err, @@ -1602,11 +1687,11 @@ func (hdlr *Handlers) executeJoin( } memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( - request.Context(), chID, + ctx, chID, ) _ = hdlr.fanOutSilent( - request, irc.CmdJoin, nick, channel, nil, nil, memberIDs, + request, irc.CmdJoin, nick, channel, nil, memberIDs, ) hdlr.deliverJoinNumerics( @@ -1696,6 +1781,21 @@ func (hdlr *Handlers) deliverTopicNumerics( } } +// 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( @@ -1711,8 +1811,12 @@ func (hdlr *Handlers) deliverNamesNumerics( if memErr == nil && len(members) > 0 { entries := make([]string, 0, len(members)) - for _, mem := range members { - entries = append(entries, mem.Hostmask()) + for idx := range members { + prefix := memberPrefix(&members[idx]) + entries = append( + entries, + prefix+members[idx].Hostmask(), + ) } hdlr.enqueueNumeric( @@ -1776,7 +1880,7 @@ func (hdlr *Handlers) handlePart( ) _ = hdlr.fanOutSilent( - request, irc.CmdPart, nick, channel, body, nil, memberIDs, + request, irc.CmdPart, nick, channel, body, memberIDs, ) err = hdlr.params.Database.PartChannel( @@ -2035,6 +2139,27 @@ func (hdlr *Handlers) executeTopic( body json.RawMessage, chID int64, ) { + ctx := request.Context() + + // Enforce +t: only operators can change topic when + // topic lock is active. + isLocked, lockErr := hdlr.params.Database. + IsChannelTopicLocked(ctx, chID) + if lockErr == nil && isLocked { + isOp, opErr := hdlr.params.Database. + IsChannelOperator(ctx, chID, sessionID) + if opErr != nil || !isOp { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrChanOpPrivsNeeded, nick, + []string{channel}, + "You're not channel operator", + ) + + return + } + } + setErr := hdlr.params.Database.SetTopicMeta( request.Context(), channel, topic, nick, ) @@ -2056,7 +2181,7 @@ func (hdlr *Handlers) executeTopic( ) _ = hdlr.fanOutSilent( - request, irc.CmdTopic, nick, channel, body, nil, memberIDs, + request, irc.CmdTopic, nick, channel, body, memberIDs, ) hdlr.enqueueNumeric( @@ -2267,9 +2392,39 @@ func (hdlr *Handlers) handleChannelMode( ) } +// buildChannelModeString constructs the current mode +// string for a channel, including +n (always on), +t, +m, +// and +H with its parameter. +func (hdlr *Handlers) buildChannelModeString( + ctx context.Context, + chID int64, +) string { + modes := "+n" + + isTopicLocked, tlErr := hdlr.params.Database. + IsChannelTopicLocked(ctx, chID) + if tlErr == nil && isTopicLocked { + modes += "t" + } + + isModerated, modErr := hdlr.params.Database. + IsChannelModerated(ctx, chID) + if modErr == nil && isModerated { + modes += "m" + } + + bits, bitsErr := hdlr.params.Database. + GetChannelHashcashBits(ctx, chID) + if bitsErr == nil && bits > 0 { + modes += fmt.Sprintf("H %d", bits) + } + + return modes +} + // queryChannelMode sends RPL_CHANNELMODEIS and -// RPL_CREATIONTIME for a channel. Includes +H if -// the channel has a hashcash requirement. +// RPL_CREATIONTIME for a channel. Includes +t, +m, +H +// as appropriate. func (hdlr *Handlers) queryChannelMode( writer http.ResponseWriter, request *http.Request, @@ -2279,13 +2434,7 @@ func (hdlr *Handlers) queryChannelMode( ) { ctx := request.Context() - modeStr := "+n" - - bits, bitsErr := hdlr.params.Database. - GetChannelHashcashBits(ctx, chID) - if bitsErr == nil && bits > 0 { - modeStr = fmt.Sprintf("+nH %d", bits) - } + modeStr := hdlr.buildChannelModeString(ctx, chID) // 324 RPL_CHANNELMODEIS hdlr.enqueueNumeric( @@ -2315,8 +2464,35 @@ func (hdlr *Handlers) queryChannelMode( http.StatusOK) } +// requireChannelOp checks that the session has +o in the +// channel. If not, it sends ERR_CHANOPRIVSNEEDED and +// returns false. +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 +} + // applyChannelMode handles setting channel modes. -// Currently supports +H/-H for hashcash bits. +// Supports +o/-o, +v/-v, +m/-m, +t/-t, +H/-H. func (hdlr *Handlers) applyChannelMode( writer http.ResponseWriter, request *http.Request, @@ -2329,6 +2505,42 @@ func (hdlr *Handlers) applyChannelMode( modeStr := modeArgs[0] switch modeStr { + case "+o", "-o": + hdlr.applyUserMode( + writer, request, + sessionID, clientID, nick, + channel, chID, modeArgs, true, + ) + case "+v", "-v": + hdlr.applyUserMode( + writer, request, + sessionID, clientID, nick, + channel, chID, modeArgs, false, + ) + case "+m": + hdlr.setChannelFlag( + writer, request, + sessionID, clientID, nick, + channel, chID, "m", true, + ) + case "-m": + hdlr.setChannelFlag( + writer, request, + sessionID, clientID, nick, + channel, chID, "m", false, + ) + case "+t": + hdlr.setChannelFlag( + writer, request, + sessionID, clientID, nick, + channel, chID, "t", true, + ) + case "-t": + hdlr.setChannelFlag( + writer, request, + sessionID, clientID, nick, + channel, chID, "t", false, + ) case "+H": hdlr.setHashcashMode( writer, request, @@ -2355,6 +2567,224 @@ func (hdlr *Handlers) applyChannelMode( } } +// resolveUserModeTarget validates a user-mode change +// target and returns the target session ID if valid. +// Returns -1 on error (error response already sent). +func (hdlr *Handlers) resolveUserModeTarget( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, channel string, + chID int64, + modeArgs []string, +) (int64, string, bool) { + if !hdlr.requireChannelOp( + writer, request, + sessionID, clientID, nick, channel, chID, + ) { + return -1, "", false + } + + if len(modeArgs) < 2 { //nolint:mnd // mode + nick + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdMode}, + "Not enough parameters", + ) + + return -1, "", false + } + + ctx := request.Context() + targetNick := modeArgs[1] + + targetSID, err := hdlr.params.Database. + GetSessionByNick(ctx, targetNick) + if err != nil { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoSuchNick, nick, + []string{targetNick}, + "No such nick/channel", + ) + + return -1, "", false + } + + isMember, memErr := hdlr.params.Database. + IsChannelMember(ctx, chID, targetSID) + if memErr != nil || !isMember { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrUserNotInChannel, nick, + []string{targetNick, channel}, + "They aren't on that channel", + ) + + return -1, "", false + } + + return targetSID, targetNick, true +} + +// 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, +) { + ctx := request.Context() + + targetSID, targetNick, ok := hdlr.resolveUserModeTarget( + writer, request, + sessionID, clientID, nick, + channel, chID, modeArgs, + ) + if !ok { + return + } + + setting := strings.HasPrefix(modeArgs[0], "+") + + var err error + if isOperMode { + err = hdlr.params.Database.SetChannelMemberOperator( + ctx, chID, targetSID, setting, + ) + } else { + err = hdlr.params.Database.SetChannelMemberVoiced( + ctx, chID, targetSID, setting, + ) + } + + if err != nil { + hdlr.log.Error( + "set user mode failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + hdlr.broadcastUserModeChange( + request, nick, channel, chID, + modeArgs[0], targetNick, + ) + + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// broadcastUserModeChange fans out a user-mode change +// to all channel members. +func (hdlr *Handlers) broadcastUserModeChange( + request *http.Request, + nick, channel string, + chID int64, + modeStr, targetNick string, +) { + ctx := request.Context() + + memberIDs, _ := hdlr.params.Database. + GetChannelMemberIDs(ctx, chID) + + modeBody, err := json.Marshal( + []string{modeStr, targetNick}, + ) + if err != nil { + return + } + + _ = hdlr.fanOutSilent( + request, irc.CmdMode, nick, channel, + json.RawMessage(modeBody), memberIDs, + ) +} + +// 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, +) { + ctx := request.Context() + + if !hdlr.requireChannelOp( + writer, request, + sessionID, clientID, nick, channel, chID, + ) { + return + } + + var err error + + switch flag { + case "m": + err = hdlr.params.Database.SetChannelModerated( + ctx, chID, setting, + ) + case "t": + err = hdlr.params.Database.SetChannelTopicLocked( + ctx, chID, setting, + ) + } + + if err != nil { + hdlr.log.Error( + "set channel flag failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + // Broadcast the MODE change. + modeStr := "+" + flag + if !setting { + modeStr = "-" + flag + } + + memberIDs, _ := hdlr.params.Database. + GetChannelMemberIDs(ctx, chID) + + modeBody, mErr := json.Marshal([]string{modeStr}) + if mErr != nil { + hdlr.log.Error( + "marshal mode body", "error", mErr, + ) + + return + } + + _ = hdlr.fanOutSilent( + request, irc.CmdMode, nick, channel, + json.RawMessage(modeBody), memberIDs, + ) + + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + const ( // minHashcashBits is the minimum allowed hashcash // difficulty for channels. @@ -2416,12 +2846,11 @@ func (hdlr *Handlers) setHashcashMode( return } + modeStr := hdlr.buildChannelModeString(ctx, chID) + hdlr.enqueueNumeric( ctx, clientID, irc.RplChannelModeIs, nick, - []string{ - channel, - fmt.Sprintf("+H %d", bits), - }, "", + []string{channel, modeStr}, "", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, @@ -2455,9 +2884,11 @@ func (hdlr *Handlers) clearHashcashMode( return } + modeStr := hdlr.buildChannelModeString(ctx, chID) + hdlr.enqueueNumeric( ctx, clientID, irc.RplChannelModeIs, nick, - []string{channel, "+n"}, "", + []string{channel, modeStr}, "", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, @@ -2508,8 +2939,12 @@ func (hdlr *Handlers) handleNames( if memErr == nil && len(members) > 0 { entries := make([]string, 0, len(members)) - for _, mem := range members { - entries = append(entries, mem.Hostmask()) + for idx := range members { + prefix := memberPrefix(&members[idx]) + entries = append( + entries, + prefix+members[idx].Hostmask(), + ) } hdlr.enqueueNumeric( @@ -3326,6 +3761,222 @@ func (hdlr *Handlers) handleAway( 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, +) { + 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] + } + + hdlr.executeKick( + writer, request, + sessionID, clientID, nick, + channel, targetNick, reason, body, + ) +} + +func (hdlr *Handlers) executeKick( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, channel, targetNick, reason string, + _ json.RawMessage, +) { + ctx := request.Context() + + chID, targetSID, ok := hdlr.validateKick( + writer, request, + sessionID, clientID, nick, + channel, targetNick, + ) + if !ok { + return + } + + if !hdlr.broadcastKick( + writer, request, + nick, channel, targetNick, reason, chID, + ) { + return + } + + // Remove the kicked user from the channel. + _ = hdlr.params.Database.PartChannel( + ctx, chID, targetSID, + ) + + // Clean up empty channel. + _ = hdlr.params.Database.DeleteChannelIfEmpty( + ctx, chID, + ) + + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// validateKick checks the channel exists, the kicker is +// an operator, and the target is in the channel. +func (hdlr *Handlers) validateKick( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, channel, targetNick 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 + } + + if !hdlr.requireChannelOp( + writer, request, + sessionID, clientID, nick, channel, chID, + ) { + return 0, 0, false + } + + targetSID, err := hdlr.params.Database. + GetSessionByNick(ctx, targetNick) + if err != nil { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoSuchNick, nick, + []string{targetNick}, + "No such nick/channel", + ) + + return 0, 0, false + } + + isMember, memErr := hdlr.params.Database. + IsChannelMember(ctx, chID, targetSID) + if memErr != nil || !isMember { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrUserNotInChannel, nick, + []string{targetNick, channel}, + "They aren't on that channel", + ) + + return 0, 0, false + } + + return chID, targetSID, true +} + +// broadcastKick inserts a KICK message and fans it out +// to all channel members. +func (hdlr *Handlers) broadcastKick( + writer http.ResponseWriter, + request *http.Request, + nick, channel, targetNick, reason string, + chID int64, +) bool { + ctx := request.Context() + + memberIDs, _ := hdlr.params.Database. + GetChannelMemberIDs(ctx, chID) + + kickBody, bErr := json.Marshal([]string{reason}) + if bErr != nil { + hdlr.log.Error("marshal kick body", "error", bErr) + + return false + } + + kickParams, pErr := json.Marshal( + []string{targetNick}, + ) + if pErr != nil { + hdlr.log.Error( + "marshal kick params", "error", pErr, + ) + + return false + } + + dbID, _, insertErr := hdlr.params.Database. + InsertMessage( + ctx, irc.CmdKick, nick, channel, + json.RawMessage(kickParams), + json.RawMessage(kickBody), nil, + ) + if insertErr != nil { + hdlr.log.Error( + "insert kick message", "error", insertErr, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return false + } + + for _, sid := range memberIDs { + enqErr := hdlr.params.Database.EnqueueToSession( + ctx, sid, dbID, + ) + if enqErr != nil { + hdlr.log.Error("enqueue kick failed", + "error", enqErr, "session_id", sid) + } + + hdlr.broker.Notify(sid) + } + + return true +} + // deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle // time and signon time. func (hdlr *Handlers) deliverWhoisIdle( diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 2623a66..c2da05b 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -3353,3 +3353,1077 @@ func TestOperNoOlineConfigured(t *testing.T) { ) } } + +// --- Tier 1: Channel Modes Tests --- + +const kickCmd = "KICK" + +// TestOperatorAutoGrantOnCreate verifies that the first +// user to join a channel (the creator) gets +o. +func TestOperatorAutoGrantOnCreate(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("creator") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#opcreate", + }) + + _, lastID := tserver.pollMessages(token, 0) + + // Issue NAMES — the creator should have @prefix. + tserver.sendCommand(token, map[string]any{ + commandKey: "NAMES", toKey: "#opcreate", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + namesMsg := findNumericWithParams(msgs, "353") + if namesMsg == nil { + t.Fatalf( + "expected RPL_NAMREPLY (353), got %v", + msgs, + ) + } + + raw, exists := namesMsg["body"] + if !exists || raw == nil { + t.Fatal("expected body in NAMES reply") + } + + arr, isArr := raw.([]any) + if !isArr || len(arr) == 0 { + t.Fatal("expected non-empty body array") + } + + bodyStr, isStr := arr[0].(string) + if !isStr { + t.Fatal("expected string body") + } + + if !strings.Contains(bodyStr, "@creator!") { + t.Fatalf( + "expected @creator in NAMES, got %q", + bodyStr, + ) + } +} + +// TestSecondJoinerNotOperator verifies that subsequent +// joiners do NOT get +o. +func TestSecondJoinerNotOperator(t *testing.T) { + tserver := newTestServer(t) + creatorToken := tserver.createSession("op_first") + joinerToken := tserver.createSession("op_second") + + tserver.sendCommand(creatorToken, map[string]any{ + commandKey: joinCmd, toKey: "#optest2", + }) + + tserver.sendCommand(joinerToken, map[string]any{ + commandKey: joinCmd, toKey: "#optest2", + }) + + _, lastID := tserver.pollMessages(joinerToken, 0) + + tserver.sendCommand(joinerToken, map[string]any{ + commandKey: "NAMES", toKey: "#optest2", + }) + + msgs, _ := tserver.pollMessages(joinerToken, lastID) + + namesMsg := findNumericWithParams(msgs, "353") + if namesMsg == nil { + t.Fatalf( + "expected RPL_NAMREPLY (353), got %v", + msgs, + ) + } + + raw := namesMsg["body"] + + arr, _ := raw.([]any) + + bodyStr, _ := arr[0].(string) + + // op_first should have @, op_second should not. + if !strings.Contains(bodyStr, "@op_first!") { + t.Fatalf( + "expected @op_first in NAMES, got %q", + bodyStr, + ) + } + + if strings.Contains(bodyStr, "@op_second!") { + t.Fatalf( + "op_second should NOT have @, got %q", + bodyStr, + ) + } +} + +// TestModeGrantOperator tests MODE +o. +func TestModeGrantOperator(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("granter") + targetToken := tserver.createSession("grantee") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#modeop", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#modeop", + }) + + _, lastID := tserver.pollMessages(targetToken, 0) + + // granter (creator = +o) grants +o to grantee. + status, _ := tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#modeop", + bodyKey: []string{"+o", "grantee"}, + }) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + // grantee should receive MODE broadcast. + msgs, _ := tserver.pollMessages(targetToken, lastID) + + if !findMessage(msgs, modeCmd, "granter") { + t.Fatalf( + "grantee didn't get MODE broadcast: %v", + msgs, + ) + } + + // Verify grantee now shows @ in NAMES. + _, lastID = tserver.pollMessages(targetToken, 0) + + tserver.sendCommand(targetToken, map[string]any{ + commandKey: "NAMES", toKey: "#modeop", + }) + + msgs, _ = tserver.pollMessages(targetToken, lastID) + + namesMsg := findNumericWithParams(msgs, "353") + if namesMsg == nil { + t.Fatal("expected NAMES reply") + } + + raw := namesMsg["body"] + arr, _ := raw.([]any) + bodyStr, _ := arr[0].(string) + + if !strings.Contains(bodyStr, "@grantee!") { + t.Fatalf( + "expected @grantee in NAMES, got %q", + bodyStr, + ) + } +} + +// TestModeRevokeOperator tests MODE -o. +func TestModeRevokeOperator(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("revoker") + targetToken := tserver.createSession("revokee") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#revoke", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#revoke", + }) + + // Grant +o, then revoke it. + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#revoke", + bodyKey: []string{"+o", "revokee"}, + }) + + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#revoke", + bodyKey: []string{"-o", "revokee"}, + }) + + // Check NAMES — revokee should NOT have @. + _, lastID := tserver.pollMessages(opToken, 0) + + tserver.sendCommand(opToken, map[string]any{ + commandKey: "NAMES", toKey: "#revoke", + }) + + msgs, _ := tserver.pollMessages(opToken, lastID) + + namesMsg := findNumericWithParams(msgs, "353") + if namesMsg == nil { + t.Fatal("expected NAMES reply") + } + + raw := namesMsg["body"] + arr, _ := raw.([]any) + bodyStr, _ := arr[0].(string) + + if strings.Contains(bodyStr, "@revokee!") { + t.Fatalf( + "revokee should NOT have @ after -o, got %q", + bodyStr, + ) + } +} + +// TestModeVoice tests MODE +v/-v. +func TestModeVoice(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("voicer") + targetToken := tserver.createSession("voiced") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#voice", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#voice", + }) + + // Grant +v. + status, _ := tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#voice", + bodyKey: []string{"+v", "voiced"}, + }) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + // Check NAMES for + prefix. + _, lastID := tserver.pollMessages(opToken, 0) + + tserver.sendCommand(opToken, map[string]any{ + commandKey: "NAMES", toKey: "#voice", + }) + + msgs, _ := tserver.pollMessages(opToken, lastID) + + namesMsg := findNumericWithParams(msgs, "353") + raw := namesMsg["body"] + arr, _ := raw.([]any) + bodyStr, _ := arr[0].(string) + + if !strings.Contains(bodyStr, "+voiced!") { + t.Fatalf( + "expected +voiced in NAMES, got %q", + bodyStr, + ) + } + + // Revoke -v. + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#voice", + bodyKey: []string{"-v", "voiced"}, + }) + + _, lastID = tserver.pollMessages(opToken, 0) + + tserver.sendCommand(opToken, map[string]any{ + commandKey: "NAMES", toKey: "#voice", + }) + + msgs, _ = tserver.pollMessages(opToken, lastID) + + namesMsg = findNumericWithParams(msgs, "353") + raw = namesMsg["body"] + arr, _ = raw.([]any) + bodyStr, _ = arr[0].(string) + + if strings.Contains(bodyStr, "+voiced!") { + t.Fatalf( + "voiced should NOT have + after -v, got %q", + bodyStr, + ) + } +} + +// TestModeNonOpCannotGrant verifies non-operators +// cannot grant +o or +v. +func TestModeNonOpCannotGrant(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("realop") + nonOpToken := tserver.createSession("nonop") + targetToken := tserver.createSession("target3") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#noperm", + }) + tserver.sendCommand(nonOpToken, map[string]any{ + commandKey: joinCmd, toKey: "#noperm", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#noperm", + }) + + _, lastID := tserver.pollMessages(nonOpToken, 0) + + // Non-op tries +o. + tserver.sendCommand(nonOpToken, map[string]any{ + commandKey: modeCmd, + toKey: "#noperm", + bodyKey: []string{"+o", "target3"}, + }) + + msgs, _ := tserver.pollMessages(nonOpToken, lastID) + + // Should get 482 ERR_CHANOPRIVSNEEDED. + if !findNumeric(msgs, "482") { + t.Fatalf( + "expected ERR_CHANOPRIVSNEEDED (482), "+ + "got %v", + msgs, + ) + } + + // Non-op tries +v. + _, lastID = tserver.pollMessages(nonOpToken, 0) + + tserver.sendCommand(nonOpToken, map[string]any{ + commandKey: modeCmd, + toKey: "#noperm", + bodyKey: []string{"+v", "target3"}, + }) + + msgs, _ = tserver.pollMessages(nonOpToken, lastID) + + if !findNumeric(msgs, "482") { + t.Fatalf( + "expected ERR_CHANOPRIVSNEEDED (482) for +v, "+ + "got %v", + msgs, + ) + } +} + +// TestModeratedChannelBlocksNonVoiced tests +m mode. +func TestModeratedChannelBlocksNonVoiced(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("modop") + regularToken := tserver.createSession("regular2") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#moderated", + }) + tserver.sendCommand(regularToken, map[string]any{ + commandKey: joinCmd, toKey: "#moderated", + }) + + // Set +m. + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#moderated", + bodyKey: []string{"+m"}, + }) + + _, lastID := tserver.pollMessages(regularToken, 0) + + // Regular user tries to send — should be blocked. + tserver.sendCommand(regularToken, map[string]any{ + commandKey: privmsgCmd, + toKey: "#moderated", + bodyKey: []string{"blocked message"}, + }) + + msgs, _ := tserver.pollMessages(regularToken, lastID) + + if !findNumeric(msgs, "404") { + t.Fatalf( + "expected ERR_CANNOTSENDTOCHAN (404) "+ + "for +m, got %v", + msgs, + ) + } +} + +// TestModeratedChannelAllowsOp tests that operators can +// send in +m channels. +func TestModeratedChannelAllowsOp(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("modop2") + observerToken := tserver.createSession("observer2") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#modop", + }) + tserver.sendCommand(observerToken, map[string]any{ + commandKey: joinCmd, toKey: "#modop", + }) + + // Set +m. + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#modop", + bodyKey: []string{"+m"}, + }) + + _, lastID := tserver.pollMessages(observerToken, 0) + + // Op sends — should work. + status, result := tserver.sendCommand(opToken, map[string]any{ + commandKey: privmsgCmd, + toKey: "#modop", + bodyKey: []string{"op message"}, + }) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + msgs, _ := tserver.pollMessages(observerToken, lastID) + + if !findMessage(msgs, privmsgCmd, "modop2") { + t.Fatalf( + "observer didn't receive op's message: %v", + msgs, + ) + } +} + +// TestModeratedChannelAllowsVoiced tests that voiced +// users can send in +m channels. +func TestModeratedChannelAllowsVoiced(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("modop3") + voicedToken := tserver.createSession("modvoiced") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#modvoice", + }) + tserver.sendCommand(voicedToken, map[string]any{ + commandKey: joinCmd, toKey: "#modvoice", + }) + + // Set +m and +v on voiced user. + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#modvoice", + bodyKey: []string{"+m"}, + }) + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#modvoice", + bodyKey: []string{"+v", "modvoiced"}, + }) + + _, lastID := tserver.pollMessages(opToken, 0) + + // Voiced user sends — should work. + status, result := tserver.sendCommand( + voicedToken, map[string]any{ + commandKey: privmsgCmd, + toKey: "#modvoice", + bodyKey: []string{"voiced message"}, + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + msgs, _ := tserver.pollMessages(opToken, lastID) + + if !findMessage(msgs, privmsgCmd, "modvoiced") { + t.Fatalf( + "op didn't receive voiced user's message: %v", + msgs, + ) + } +} + +// TestTopicLockDefaultOn verifies new channels have +t. +func TestTopicLockDefaultOn(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("topiclock") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#tldefault", + }) + + _, lastID := tserver.pollMessages(token, 0) + + // Query mode — should show +nt. + tserver.sendCommand(token, map[string]any{ + commandKey: modeCmd, toKey: "#tldefault", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + modeMsg := findNumericWithParams(msgs, "324") + if modeMsg == nil { + t.Fatalf( + "expected RPL_CHANNELMODEIS (324), got %v", + msgs, + ) + } + + params := getNumericParams(modeMsg) + if len(params) < 2 { + t.Fatalf( + "expected at least 2 params, got %v", + params, + ) + } + + if !strings.Contains(params[1], "t") { + t.Fatalf( + "expected +t in mode string, got %q", + params[1], + ) + } +} + +// TestTopicLockEnforced verifies non-operators cannot +// change topic when +t is active. +func TestTopicLockEnforced(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("topicop") + regularToken := tserver.createSession("topicuser") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#tlock", + }) + tserver.sendCommand(regularToken, map[string]any{ + commandKey: joinCmd, toKey: "#tlock", + }) + + // +t is on by default. Non-op tries to set topic. + _, lastID := tserver.pollMessages(regularToken, 0) + + tserver.sendCommand(regularToken, map[string]any{ + commandKey: "TOPIC", + toKey: "#tlock", + bodyKey: []string{"unauthorized topic"}, + }) + + msgs, _ := tserver.pollMessages(regularToken, lastID) + + if !findNumeric(msgs, "482") { + t.Fatalf( + "expected ERR_CHANOPRIVSNEEDED (482) "+ + "for +t, got %v", + msgs, + ) + } +} + +// TestTopicLockOpCanChange verifies operators CAN change +// topic when +t is active. +func TestTopicLockOpCanChange(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("topicop2") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#tlock2", + }) + + // Op sets topic — should succeed (creator has +o). + status, result := tserver.sendCommand( + opToken, map[string]any{ + commandKey: "TOPIC", + toKey: "#tlock2", + bodyKey: []string{"op topic"}, + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["topic"] != "op topic" { + t.Fatalf( + "expected topic 'op topic', got %v", + result["topic"], + ) + } +} + +// TestTopicLockDisabled verifies that -t allows anyone +// to change topic. +func TestTopicLockDisabled(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("tloff_op") + regularToken := tserver.createSession("tloff_user") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#tloff", + }) + tserver.sendCommand(regularToken, map[string]any{ + commandKey: joinCmd, toKey: "#tloff", + }) + + // Disable +t. + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#tloff", + bodyKey: []string{"-t"}, + }) + + // Now regular user sets topic — should succeed. + status, result := tserver.sendCommand( + regularToken, map[string]any{ + commandKey: "TOPIC", + toKey: "#tloff", + bodyKey: []string{"user topic"}, + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["topic"] != "user topic" { + t.Fatalf( + "expected 'user topic', got %v", + result["topic"], + ) + } +} + +// TestKickByOperator tests that an operator can kick +// a user. +func TestKickByOperator(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("kicker") + targetToken := tserver.createSession("kicked") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#kicktest", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#kicktest", + }) + + _, lastID := tserver.pollMessages(targetToken, 0) + + // Kick the target. + status, _ := tserver.sendCommand(opToken, map[string]any{ + commandKey: kickCmd, + toKey: "#kicktest", + bodyKey: []string{"kicked", "misbehaving"}, + }) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + // Target should receive KICK message. + msgs, _ := tserver.pollMessages(targetToken, lastID) + + if !findMessage(msgs, kickCmd, "kicker") { + t.Fatalf( + "kicked user didn't receive KICK: %v", + msgs, + ) + } + + // Verify kicked user is no longer in channel: + // try to send — should fail. + _, lastID = tserver.pollMessages(targetToken, 0) + + tserver.sendCommand(targetToken, map[string]any{ + commandKey: privmsgCmd, + toKey: "#kicktest", + bodyKey: []string{"still here?"}, + }) + + msgs, _ = tserver.pollMessages(targetToken, lastID) + + if !findNumeric(msgs, "404") { + t.Fatalf( + "expected 404 after kick, got %v", msgs, + ) + } +} + +// TestKickByNonOperator tests that non-operators cannot +// kick. +func TestKickByNonOperator(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("op_kick2") + nonOpToken := tserver.createSession("nonop_kick") + targetToken := tserver.createSession("target_kick") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickperm", + }) + tserver.sendCommand(nonOpToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickperm", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickperm", + }) + + _, lastID := tserver.pollMessages(nonOpToken, 0) + + // Non-op tries to kick. + tserver.sendCommand(nonOpToken, map[string]any{ + commandKey: kickCmd, + toKey: "#kickperm", + bodyKey: []string{"target_kick", "nope"}, + }) + + msgs, _ := tserver.pollMessages(nonOpToken, lastID) + + if !findNumeric(msgs, "482") { + t.Fatalf( + "expected ERR_CHANOPRIVSNEEDED (482), "+ + "got %v", + msgs, + ) + } +} + +// TestKickTargetNotInChannel tests kicking a user not +// in the channel. +func TestKickTargetNotInChannel(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("op_kicknot") + _ = tserver.createSession("outsider") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#kicknotinchan", + }) + + _, lastID := tserver.pollMessages(opToken, 0) + + tserver.sendCommand(opToken, map[string]any{ + commandKey: kickCmd, + toKey: "#kicknotinchan", + bodyKey: []string{"outsider", "bye"}, + }) + + msgs, _ := tserver.pollMessages(opToken, lastID) + + if !findNumeric(msgs, "441") { + t.Fatalf( + "expected ERR_USERNOTINCHANNEL (441), "+ + "got %v", + msgs, + ) + } +} + +// TestKickBroadcastToChannel verifies the KICK message +// is broadcast to all channel members. +func TestKickBroadcastToChannel(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("op_kb") + targetToken := tserver.createSession("target_kb") + observerToken := tserver.createSession("obs_kb") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickbc", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickbc", + }) + tserver.sendCommand(observerToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickbc", + }) + + _, lastID := tserver.pollMessages(observerToken, 0) + + tserver.sendCommand(opToken, map[string]any{ + commandKey: kickCmd, + toKey: "#kickbc", + bodyKey: []string{"target_kb", "reason"}, + }) + + msgs, _ := tserver.pollMessages(observerToken, lastID) + + if !findMessage(msgs, kickCmd, "op_kb") { + t.Fatalf( + "observer didn't receive KICK: %v", msgs, + ) + } +} + +// TestNoticeNoAwayReply verifies NOTICE doesn't trigger +// RPL_AWAY. +func TestNoticeNoAwayReply(t *testing.T) { + tserver := newTestServer(t) + senderToken := tserver.createSession("noticesend") + awayToken := tserver.createSession("noticeaway") + + // Set away status. + tserver.sendCommand(awayToken, map[string]any{ + commandKey: "AWAY", + bodyKey: []string{"I am away"}, + }) + + _, lastID := tserver.pollMessages(senderToken, 0) + + // Send NOTICE — should NOT trigger RPL_AWAY. + tserver.sendCommand(senderToken, map[string]any{ + commandKey: "NOTICE", + toKey: "noticeaway", + bodyKey: []string{"notice message"}, + }) + + msgs, _ := tserver.pollMessages(senderToken, lastID) + + if findNumeric(msgs, "301") { + t.Fatalf( + "NOTICE should NOT trigger RPL_AWAY (301), "+ + "got %v", + msgs, + ) + } +} + +// TestPrivmsgTriggersAway verifies PRIVMSG DOES trigger +// RPL_AWAY. +func TestPrivmsgTriggersAway(t *testing.T) { + tserver := newTestServer(t) + senderToken := tserver.createSession("msgsend") + awayToken := tserver.createSession("msgaway") + + // Set away status. + tserver.sendCommand(awayToken, map[string]any{ + commandKey: "AWAY", + bodyKey: []string{"I am away"}, + }) + + _, lastID := tserver.pollMessages(senderToken, 0) + + // Send PRIVMSG — should trigger RPL_AWAY. + tserver.sendCommand(senderToken, map[string]any{ + commandKey: privmsgCmd, + toKey: "msgaway", + bodyKey: []string{"hello?"}, + }) + + msgs, _ := tserver.pollMessages(senderToken, lastID) + + if !findNumeric(msgs, "301") { + t.Fatalf( + "PRIVMSG should trigger RPL_AWAY (301), "+ + "got %v", + msgs, + ) + } +} + +// TestNoticeSkipsHashcash verifies NOTICE bypasses +// hashcash on +H channels. +func TestNoticeSkipsHashcash(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("hcnotice_op") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#hcnotice", + }) + + // Set hashcash requirement. + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#hcnotice", + bodyKey: []string{"+H", "2"}, + }) + + // Send NOTICE without hashcash — should succeed. + status, result := tserver.sendCommand( + opToken, map[string]any{ + commandKey: "NOTICE", + toKey: "#hcnotice", + bodyKey: []string{"server notice"}, + }, + ) + if status != http.StatusOK { + t.Fatalf( + "expected 200, got %d: %v", status, result, + ) + } + + if result["id"] == nil || result["id"] == "" { + t.Fatal("expected message id for NOTICE") + } +} + +// TestModeratedNoticeBlocked verifies +m blocks NOTICE +// from non-voiced/non-op users too. +func TestModeratedNoticeBlocked(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("modnotop") + regularToken := tserver.createSession("modnotice") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#modnotice", + }) + tserver.sendCommand(regularToken, map[string]any{ + commandKey: joinCmd, toKey: "#modnotice", + }) + + tserver.sendCommand(opToken, map[string]any{ + commandKey: modeCmd, + toKey: "#modnotice", + bodyKey: []string{"+m"}, + }) + + _, lastID := tserver.pollMessages(regularToken, 0) + + tserver.sendCommand(regularToken, map[string]any{ + commandKey: "NOTICE", + toKey: "#modnotice", + bodyKey: []string{"blocked notice"}, + }) + + msgs, _ := tserver.pollMessages(regularToken, lastID) + + if !findNumeric(msgs, "404") { + t.Fatalf( + "expected 404 for NOTICE in +m, got %v", + msgs, + ) + } +} + +// TestNonOpCannotSetModerated verifies non-operators +// cannot set +m. +func TestNonOpCannotSetModerated(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("setmod_op") + regularToken := tserver.createSession("setmod_reg") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#modperm", + }) + tserver.sendCommand(regularToken, map[string]any{ + commandKey: joinCmd, toKey: "#modperm", + }) + + _, lastID := tserver.pollMessages(regularToken, 0) + + tserver.sendCommand(regularToken, map[string]any{ + commandKey: modeCmd, + toKey: "#modperm", + bodyKey: []string{"+m"}, + }) + + msgs, _ := tserver.pollMessages(regularToken, lastID) + + if !findNumeric(msgs, "482") { + t.Fatalf( + "expected 482 for non-op +m, got %v", msgs, + ) + } +} + +// TestISupportPrefix verifies PREFIX=(ov)@+ is in 005. +func TestISupportPrefix(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("isupport") + + msgs, _ := tserver.pollMessages(token, 0) + + isupportMsg := findNumericWithParams(msgs, "005") + if isupportMsg == nil { + t.Fatalf( + "expected RPL_ISUPPORT (005), got %v", + msgs, + ) + } + + params := getNumericParams(isupportMsg) + + found := false + + for _, param := range params { + if param == "PREFIX=(ov)@+" { + found = true + + break + } + } + + if !found { + t.Fatalf( + "expected PREFIX=(ov)@+ in ISUPPORT, "+ + "got %v", + params, + ) + } +} + +// TestModeQueryShowsModerated verifies MODE query shows +// +m when set. +func TestModeQueryShowsModerated(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("mquery") + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#mqtest", + }) + + tserver.sendCommand(token, map[string]any{ + commandKey: modeCmd, + toKey: "#mqtest", + bodyKey: []string{"+m"}, + }) + + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: modeCmd, toKey: "#mqtest", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + modeMsg := findNumericWithParams(msgs, "324") + if modeMsg == nil { + t.Fatal("expected 324") + } + + params := getNumericParams(modeMsg) + + if len(params) < 2 || !strings.Contains(params[1], "m") { + t.Fatalf( + "expected +m in mode string, got %v", + params, + ) + } +} + +// TestKickDefaultReason verifies KICK uses kicker's +// nick as default reason. +func TestKickDefaultReason(t *testing.T) { + tserver := newTestServer(t) + opToken := tserver.createSession("kickdefop") + targetToken := tserver.createSession("kickdeftg") + + tserver.sendCommand(opToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickdef", + }) + tserver.sendCommand(targetToken, map[string]any{ + commandKey: joinCmd, toKey: "#kickdef", + }) + + _, lastID := tserver.pollMessages(targetToken, 0) + + // Kick with only nick, no reason. + tserver.sendCommand(opToken, map[string]any{ + commandKey: kickCmd, + toKey: "#kickdef", + bodyKey: []string{"kickdeftg"}, + }) + + msgs, _ := tserver.pollMessages(targetToken, lastID) + + if !findMessage(msgs, kickCmd, "kickdefop") { + t.Fatalf( + "expected KICK message, got %v", msgs, + ) + } +} diff --git a/pkg/irc/commands.go b/pkg/irc/commands.go index 3c9f36d..8b79e7d 100644 --- a/pkg/irc/commands.go +++ b/pkg/irc/commands.go @@ -4,6 +4,7 @@ package irc const ( CmdAway = "AWAY" CmdJoin = "JOIN" + CmdKick = "KICK" CmdList = "LIST" CmdLusers = "LUSERS" CmdMode = "MODE"