feat: implement Tier 1 channel modes (+o/+v/+m/+t), KICK, NOTICE #88

Merged
sneak merged 2 commits from feature/tier1-channel-modes-85 into main 2026-03-25 02:08:29 +01:00
6 changed files with 2074 additions and 83 deletions

View File

@@ -824,8 +824,9 @@ Set or change a channel's topic.
- Updates the channel's topic in the database. - Updates the channel's topic in the database.
- The TOPIC event is broadcast to all channel members. - The TOPIC event is broadcast to all channel members.
- If the channel doesn't exist, the server returns an error. - If the channel doesn't exist, the server returns an error.
- If the channel has mode `+t` (topic lock), only operators can change the - If the channel has mode `+t` (topic lock, default: ON for new channels),
topic (not yet enforced). only operators (`+o`) can change the topic. Non-operators receive
`ERR_CHANOPRIVSNEEDED` (482).
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
@@ -1020,16 +1021,23 @@ WHOIS responses (e.g., target user's current client IP and hostname).
**IRC reference:** RFC 1459 §4.1.5 **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:** **C2S:**
```json ```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 **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"]}` | | `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"]}` | | `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"]}` | | `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"]}` | | `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","NETWORK=neoirc"],"body":["are supported by this server"]}` | | `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":["+"]}` | | `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"]}` | | `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"]}` | | `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: Inspired by IRC, simplified:
| Mode | Name | Meaning | | Mode | Name | Meaning | Status |
|------|----------------|---------| |------|----------------|---------|--------|
| `+i` | Invite-only | Only invited users can join | | `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | | `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
| `+s` | Secret | Channel hidden from LIST response | | `+n` | No external | Only channel members can send messages to the channel | **Enforced** |
| `+t` | Topic lock | Only operators can change the topic | | `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** |
| `+n` | No external | Only channel members can send messages to the channel | | `+i` | Invite-only | Only invited users can join | Not yet enforced |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | | `+s` | Secret | Channel hidden from LIST response | Not yet enforced |
**User channel modes (set per-user per-channel):** **User channel modes (set per-user per-channel):**
| Mode | Meaning | Display prefix | | Mode | Meaning | Display prefix | Status |
|------|---------|----------------| |------|---------|----------------|--------|
| `+o` | Operator | `@` in NAMES reply | | `+o` | Operator | `@` in NAMES reply | **Enforced** |
| `+v` | Voice | `+` in NAMES reply | | `+v` | Voice | `+` in NAMES reply | **Enforced** |
**Status:** Channel modes are defined but not yet enforced. The `modes` column **Channel creator auto-op:** The first user to JOIN a channel (creating it)
exists in the channels table but the server does not check modes on actions. automatically receives `+o` operator status.
Exception: `+H` (hashcash) is fully enforced — see below.
**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) ### 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] **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] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE`
- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` - [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
- [ ] **User channel modes** — `+o` (operator), `+v` (voice) - [ ] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
- [x] **MODE command** — query channel and user modes (set not yet implemented) - [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
- [x] **NAMES command** — query channel member list - [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] **LIST command** — list all channels with member counts
- [x] **WHOIS command** — query user information and channel membership - [x] **WHOIS command** — query user information and channel membership
- [x] **WHO command** — query channel user list - [x] **WHO command** — query channel user list
- [x] **LUSERS command** — query server statistics - [x] **LUSERS command** — query server statistics
- [x] **Connection registration numerics** — 001-005 sent on session creation - [x] **Connection registration numerics** — 001-005 sent on session creation
- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS - [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 - [x] **Numeric replies** — send IRC numeric codes via the message queue
(001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, (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) 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)

View File

@@ -76,6 +76,8 @@ type MemberInfo struct {
Nick string `json:"nick"` Nick string `json:"nick"`
Username string `json:"username"` Username string `json:"username"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
LastSeen time.Time `json:"lastSeen"` LastSeen time.Time `json:"lastSeen"`
} }
@@ -436,6 +438,237 @@ func (database *Database) JoinChannel(
return nil 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. // PartChannel removes a session from a channel.
func (database *Database) PartChannel( func (database *Database) PartChannel(
ctx context.Context, ctx context.Context,
@@ -547,7 +780,8 @@ func (database *Database) ChannelMembers(
) ([]MemberInfo, error) { ) ([]MemberInfo, error) {
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT s.id, s.nick, s.username, `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 FROM sessions s
INNER JOIN channel_members cm INNER JOIN channel_members cm
ON cm.session_id = s.id ON cm.session_id = s.id
@@ -564,11 +798,16 @@ func (database *Database) ChannelMembers(
var members []MemberInfo var members []MemberInfo
for rows.Next() { for rows.Next() {
var member MemberInfo var (
member MemberInfo
isOp int
isV int
)
err = rows.Scan( err = rows.Scan(
&member.ID, &member.Nick, &member.ID, &member.Nick,
&member.Username, &member.Hostname, &member.Username, &member.Hostname,
&isOp, &isV,
&member.LastSeen, &member.LastSeen,
) )
if err != nil { if err != nil {
@@ -577,6 +816,9 @@ func (database *Database) ChannelMembers(
) )
} }
member.IsOperator = isOp != 0
member.IsVoiced = isV != 0
members = append(members, member) members = append(members, member)
} }

View File

@@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS channels (
topic_set_by TEXT NOT NULL DEFAULT '', topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME, topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0, 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, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(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, joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(channel_id, session_id) UNIQUE(channel_id, session_id)
); );

View File

@@ -218,11 +218,10 @@ func (hdlr *Handlers) fanOutSilent(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) error { ) error {
_, err := hdlr.fanOut( _, err := hdlr.fanOut(
request, command, from, target, body, meta, sessionIDs, request, command, from, target, body, nil, sessionIDs,
) )
return err return err
@@ -448,7 +447,7 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO // 004 RPL_MYINFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick, ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, "", "imnst"}, []string{srvName, version, "", "mnst"},
"", "",
) )
@@ -458,7 +457,8 @@ func (hdlr *Handlers) deliverWelcome(
[]string{ []string{
"CHANTYPES=#", "CHANTYPES=#",
"NICKLEN=32", "NICKLEN=32",
"CHANMODES=,,H," + "imnst", "PREFIX=(ov)@+",
"CHANMODES=,,H,mnst",
"NETWORK=neoirc", "NETWORK=neoirc",
"CASEMAPPING=ascii", "CASEMAPPING=ascii",
}, },
@@ -1051,6 +1051,12 @@ func (hdlr *Handlers) dispatchCommand(
sessionID, clientID, nick, sessionID, clientID, nick,
target, body, bodyLines, target, body, bodyLines,
) )
case irc.CmdKick:
hdlr.handleKick(
writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
)
case irc.CmdQuit: case irc.CmdQuit:
hdlr.handleQuit( hdlr.handleQuit(
writer, request, sessionID, nick, body, writer, request, sessionID, nick, body,
@@ -1214,8 +1220,10 @@ func (hdlr *Handlers) handleChannelMsg(
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) { ) {
ctx := request.Context()
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target, ctx, target,
) )
if err != nil { if err != nil {
hdlr.respondIRCError( hdlr.respondIRCError(
@@ -1228,7 +1236,7 @@ func (hdlr *Handlers) handleChannelMsg(
} }
isMember, err := hdlr.params.Database.IsChannelMember( isMember, err := hdlr.params.Database.IsChannelMember(
request.Context(), chID, sessionID, ctx, chID, sessionID,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -1253,6 +1261,19 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
// 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( hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID, request, clientID, sessionID,
writer, nick, target, body, meta, chID, writer, nick, target, body, meta, chID,
@@ -1260,6 +1281,7 @@ func (hdlr *Handlers) handleChannelMsg(
if hashcashErr != nil { if hashcashErr != nil {
return return
} }
}
hdlr.sendChannelMsg( hdlr.sendChannelMsg(
writer, request, command, nick, target, writer, request, command, nick, target,
@@ -1267,6 +1289,45 @@ func (hdlr *Handlers) handleChannelMsg(
) )
} }
// 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 // validateChannelHashcash checks whether the channel
// requires hashcash proof-of-work for messages and // requires hashcash proof-of-work for messages and
// validates the stamp from the message meta field. // validates the stamp from the message meta field.
@@ -1508,7 +1569,9 @@ func (hdlr *Handlers) handleDirectMsg(
return return
} }
// If the target is away, send RPL_AWAY to the sender. // Per RFC 2812: NOTICE must NOT trigger auto-replies
// including RPL_AWAY.
if command != irc.CmdNotice {
awayMsg, awayErr := hdlr.params.Database.GetAway( awayMsg, awayErr := hdlr.params.Database.GetAway(
request.Context(), targetSID, request.Context(), targetSID,
) )
@@ -1520,6 +1583,7 @@ func (hdlr *Handlers) handleDirectMsg(
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
} }
}
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": msgUUID, "status": "sent"}, map[string]string{"id": msgUUID, "status": "sent"},
@@ -1569,8 +1633,10 @@ func (hdlr *Handlers) executeJoin(
sessionID, clientID int64, sessionID, clientID int64,
nick, channel string, nick, channel string,
) { ) {
ctx := request.Context()
chID, err := hdlr.params.Database.GetOrCreateChannel( chID, err := hdlr.params.Database.GetOrCreateChannel(
request.Context(), channel, ctx, channel,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -1585,9 +1651,28 @@ func (hdlr *Handlers) executeJoin(
return return
} }
err = hdlr.params.Database.JoinChannel( // Check if channel is empty before joining — first
request.Context(), chID, sessionID, // 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 { if err != nil {
hdlr.log.Error( hdlr.log.Error(
"join channel failed", "error", err, "join channel failed", "error", err,
@@ -1602,11 +1687,11 @@ func (hdlr *Handlers) executeJoin(
} }
memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs( memberIDs, _ := hdlr.params.Database.GetChannelMemberIDs(
request.Context(), chID, ctx, chID,
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs, request, irc.CmdJoin, nick, channel, nil, memberIDs,
) )
hdlr.deliverJoinNumerics( 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 // deliverNamesNumerics sends RPL_NAMREPLY and
// RPL_ENDOFNAMES for a channel. // RPL_ENDOFNAMES for a channel.
func (hdlr *Handlers) deliverNamesNumerics( func (hdlr *Handlers) deliverNamesNumerics(
@@ -1711,8 +1811,12 @@ func (hdlr *Handlers) deliverNamesNumerics(
if memErr == nil && len(members) > 0 { if memErr == nil && len(members) > 0 {
entries := make([]string, 0, len(members)) entries := make([]string, 0, len(members))
for _, mem := range members { for idx := range members {
entries = append(entries, mem.Hostmask()) prefix := memberPrefix(&members[idx])
entries = append(
entries,
prefix+members[idx].Hostmask(),
)
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
@@ -1776,7 +1880,7 @@ func (hdlr *Handlers) handlePart(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdPart, nick, channel, body, nil, memberIDs, request, irc.CmdPart, nick, channel, body, memberIDs,
) )
err = hdlr.params.Database.PartChannel( err = hdlr.params.Database.PartChannel(
@@ -2035,6 +2139,27 @@ func (hdlr *Handlers) executeTopic(
body json.RawMessage, body json.RawMessage,
chID int64, 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( setErr := hdlr.params.Database.SetTopicMeta(
request.Context(), channel, topic, nick, request.Context(), channel, topic, nick,
) )
@@ -2056,7 +2181,7 @@ func (hdlr *Handlers) executeTopic(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdTopic, nick, channel, body, nil, memberIDs, request, irc.CmdTopic, nick, channel, body, memberIDs,
) )
hdlr.enqueueNumeric( 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 // queryChannelMode sends RPL_CHANNELMODEIS and
// RPL_CREATIONTIME for a channel. Includes +H if // RPL_CREATIONTIME for a channel. Includes +t, +m, +H
// the channel has a hashcash requirement. // as appropriate.
func (hdlr *Handlers) queryChannelMode( func (hdlr *Handlers) queryChannelMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -2279,13 +2434,7 @@ func (hdlr *Handlers) queryChannelMode(
) { ) {
ctx := request.Context() ctx := request.Context()
modeStr := "+n" modeStr := hdlr.buildChannelModeString(ctx, chID)
bits, bitsErr := hdlr.params.Database.
GetChannelHashcashBits(ctx, chID)
if bitsErr == nil && bits > 0 {
modeStr = fmt.Sprintf("+nH %d", bits)
}
// 324 RPL_CHANNELMODEIS // 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
@@ -2315,8 +2464,35 @@ func (hdlr *Handlers) queryChannelMode(
http.StatusOK) 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. // 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( func (hdlr *Handlers) applyChannelMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -2329,6 +2505,42 @@ func (hdlr *Handlers) applyChannelMode(
modeStr := modeArgs[0] modeStr := modeArgs[0]
switch modeStr { 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": case "+H":
hdlr.setHashcashMode( hdlr.setHashcashMode(
writer, request, 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 ( const (
// minHashcashBits is the minimum allowed hashcash // minHashcashBits is the minimum allowed hashcash
// difficulty for channels. // difficulty for channels.
@@ -2416,12 +2846,11 @@ func (hdlr *Handlers) setHashcashMode(
return return
} }
modeStr := hdlr.buildChannelModeString(ctx, chID)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick, ctx, clientID, irc.RplChannelModeIs, nick,
[]string{ []string{channel, modeStr}, "",
channel,
fmt.Sprintf("+H %d", bits),
}, "",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -2455,9 +2884,11 @@ func (hdlr *Handlers) clearHashcashMode(
return return
} }
modeStr := hdlr.buildChannelModeString(ctx, chID)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick, ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, "+n"}, "", []string{channel, modeStr}, "",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -2508,8 +2939,12 @@ func (hdlr *Handlers) handleNames(
if memErr == nil && len(members) > 0 { if memErr == nil && len(members) > 0 {
entries := make([]string, 0, len(members)) entries := make([]string, 0, len(members))
for _, mem := range members { for idx := range members {
entries = append(entries, mem.Hostmask()) prefix := memberPrefix(&members[idx])
entries = append(
entries,
prefix+members[idx].Hostmask(),
)
} }
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
@@ -3326,6 +3761,222 @@ func (hdlr *Handlers) handleAway(
http.StatusOK) 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 // deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle
// time and signon time. // time and signon time.
func (hdlr *Handlers) deliverWhoisIdle( func (hdlr *Handlers) deliverWhoisIdle(

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ package irc
const ( const (
CmdAway = "AWAY" CmdAway = "AWAY"
CmdJoin = "JOIN" CmdJoin = "JOIN"
CmdKick = "KICK"
CmdList = "LIST" CmdList = "LIST"
CmdLusers = "LUSERS" CmdLusers = "LUSERS"
CmdMode = "MODE" CmdMode = "MODE"