feat: implement Tier 1 channel modes (+o/+v/+m/+t), KICK, NOTICE #88
75
README.md
75
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ package irc
|
||||
const (
|
||||
CmdAway = "AWAY"
|
||||
CmdJoin = "JOIN"
|
||||
CmdKick = "KICK"
|
||||
CmdList = "LIST"
|
||||
CmdLusers = "LUSERS"
|
||||
CmdMode = "MODE"
|
||||
|
||||
Reference in New Issue
Block a user