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.
|
- 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)
|
||||||
|
|||||||
@@ -72,11 +72,13 @@ type ChannelInfo struct {
|
|||||||
|
|
||||||
// MemberInfo represents a channel member.
|
// MemberInfo represents a channel member.
|
||||||
type MemberInfo struct {
|
type MemberInfo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
LastSeen time.Time `json:"lastSeen"`
|
IsOperator bool `json:"isOperator"`
|
||||||
|
IsVoiced bool `json:"isVoiced"`
|
||||||
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hostmask returns the IRC hostmask in
|
// Hostmask returns the IRC hostmask in
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,20 +1261,73 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hashcashErr := hdlr.validateChannelHashcash(
|
// Enforce +m (moderated): only +o and +v can send.
|
||||||
request, clientID, sessionID,
|
if !hdlr.checkModeratedSend(
|
||||||
writer, nick, target, body, meta, chID,
|
writer, request,
|
||||||
)
|
sessionID, clientID, nick, target, chID,
|
||||||
if hashcashErr != nil {
|
) {
|
||||||
return
|
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(
|
hdlr.sendChannelMsg(
|
||||||
writer, request, command, nick, target,
|
writer, request, command, nick, target,
|
||||||
body, meta, chID,
|
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
|
// 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,17 +1569,20 @@ 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
|
||||||
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
// including RPL_AWAY.
|
||||||
request.Context(), targetSID,
|
if command != irc.CmdNotice {
|
||||||
)
|
awayMsg, awayErr := hdlr.params.Database.GetAway(
|
||||||
if awayErr == nil && awayMsg != "" {
|
request.Context(), targetSID,
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
request.Context(), clientID,
|
|
||||||
irc.RplAway, nick,
|
|
||||||
[]string{target}, awayMsg,
|
|
||||||
)
|
)
|
||||||
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,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user