Compare commits
2 Commits
feature/87
...
2853dc8a1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2853dc8a1f | ||
|
|
42157a7b23 |
120
README.md
120
README.md
@@ -27,6 +27,7 @@ web client as a convenience/reference implementation, but the API comes first.
|
|||||||
- [Federation](#federation-server-to-server)
|
- [Federation](#federation-server-to-server)
|
||||||
- [Storage](#storage)
|
- [Storage](#storage)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
|
- [IRC Protocol Listener](#irc-protocol-listener)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Client Development Guide](#client-development-guide)
|
- [Client Development Guide](#client-development-guide)
|
||||||
- [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention)
|
- [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention)
|
||||||
@@ -1079,8 +1080,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","","ikmnostl"]}` |
|
| `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=b,k,Hl,imnst","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"]}` |
|
||||||
@@ -1128,15 +1129,12 @@ Inspired by IRC, simplified:
|
|||||||
|
|
||||||
| Mode | Name | Meaning | Status |
|
| Mode | Name | Meaning | Status |
|
||||||
|------|----------------|---------|--------|
|
|------|----------------|---------|--------|
|
||||||
| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
|
|
||||||
| `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** |
|
|
||||||
| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** |
|
|
||||||
| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
|
|
||||||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
||||||
| `+n` | No external | Only channel members can send messages to the channel | **Enforced** |
|
|
||||||
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** |
|
|
||||||
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **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** |
|
| `+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):**
|
**User channel modes (set per-user per-channel):**
|
||||||
|
|
||||||
@@ -1148,42 +1146,6 @@ Inspired by IRC, simplified:
|
|||||||
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
|
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
|
||||||
automatically receives `+o` operator status.
|
automatically receives `+o` operator status.
|
||||||
|
|
||||||
**Ban system (+b):** Operators can ban users by hostmask pattern with wildcard
|
|
||||||
matching (`*` and `?`). `MODE #channel +b` with no argument lists current bans.
|
|
||||||
Bans prevent both joining and sending messages.
|
|
||||||
|
|
||||||
```
|
|
||||||
MODE #channel +b *!*@*.example.com — ban all users from example.com
|
|
||||||
MODE #channel -b *!*@*.example.com — remove the ban
|
|
||||||
MODE #channel +b — list all bans (RPL_BANLIST 367/368)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Invite-only (+i):** When set, users must be invited by an operator before
|
|
||||||
joining. The `INVITE` command records an invite that is consumed on JOIN.
|
|
||||||
|
|
||||||
```
|
|
||||||
MODE #channel +i — set invite-only
|
|
||||||
INVITE nick #channel — invite a user (operator only on +i channels)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Channel key (+k):** Requires a password to join the channel.
|
|
||||||
|
|
||||||
```
|
|
||||||
MODE #channel +k secretpass — set a channel key
|
|
||||||
MODE #channel -k * — remove the key
|
|
||||||
JOIN #channel secretpass — join with key
|
|
||||||
```
|
|
||||||
|
|
||||||
**User limit (+l):** Caps the number of members in the channel.
|
|
||||||
|
|
||||||
```
|
|
||||||
MODE #channel +l 50 — set limit to 50 members
|
|
||||||
MODE #channel -l — remove the limit
|
|
||||||
```
|
|
||||||
|
|
||||||
**Secret (+s):** Hides the channel from `LIST` for non-members and from
|
|
||||||
`WHOIS` channel lists when the querier is not in the same channel.
|
|
||||||
|
|
||||||
**KICK command:** Channel operators can remove users with `KICK #channel nick
|
**KICK command:** Channel operators can remove users with `KICK #channel nick
|
||||||
[:reason]`. The kicked user and all channel members receive the KICK message.
|
[:reason]`. The kicked user and all channel members receive the KICK message.
|
||||||
|
|
||||||
@@ -1192,7 +1154,7 @@ RPL_AWAY), and skips hashcash validation on +H channels (servers and services
|
|||||||
use NOTICE).
|
use NOTICE).
|
||||||
|
|
||||||
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and
|
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and
|
||||||
`CHANMODES=b,k,Hl,imnst` in RPL_ISUPPORT (005).
|
`CHANMODES=,,H,mnst` in RPL_ISUPPORT (005).
|
||||||
|
|
||||||
### Per-Channel Hashcash (Anti-Spam)
|
### Per-Channel Hashcash (Anti-Spam)
|
||||||
|
|
||||||
@@ -2266,6 +2228,7 @@ directory is also loaded automatically via
|
|||||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||||||
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||||||
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||||||
|
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
@@ -2282,6 +2245,71 @@ NEOIRC_HASHCASH_BITS=20
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## IRC Protocol Listener
|
||||||
|
|
||||||
|
neoirc includes an optional traditional IRC wire protocol listener (RFC
|
||||||
|
1459/2812) that allows standard IRC clients to connect directly. This enables
|
||||||
|
backward compatibility with existing IRC clients like irssi, weechat, hexchat,
|
||||||
|
and others.
|
||||||
|
|
||||||
|
### Enabling
|
||||||
|
|
||||||
|
Set the `IRC_LISTEN_ADDR` environment variable to a TCP address:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IRC_LISTEN_ADDR=:6667
|
||||||
|
```
|
||||||
|
|
||||||
|
When unset or empty, the IRC listener is disabled and only the HTTP/JSON API is
|
||||||
|
available.
|
||||||
|
|
||||||
|
### Supported Commands
|
||||||
|
|
||||||
|
| Category | Commands |
|
||||||
|
|------------|------------------------------------------------------|
|
||||||
|
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
||||||
|
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
||||||
|
| Messaging | `PRIVMSG`, `NOTICE` |
|
||||||
|
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` |
|
||||||
|
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||||
|
|
||||||
|
### Protocol Details
|
||||||
|
|
||||||
|
- **Wire format**: CR-LF delimited lines, max 512 bytes per line
|
||||||
|
- **Connection registration**: Clients must send `NICK` and `USER` to register.
|
||||||
|
An optional `PASS` before registration sets the session password (minimum 8
|
||||||
|
characters).
|
||||||
|
- **CAP negotiation**: `CAP LS` and `CAP END` are silently handled for
|
||||||
|
compatibility with modern clients. No capabilities are advertised.
|
||||||
|
- **Channel prefixes**: Channels must start with `#`. If omitted, it's
|
||||||
|
automatically prepended.
|
||||||
|
- **First joiner**: The first user to join a channel is automatically granted
|
||||||
|
operator status (`@`).
|
||||||
|
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
|
||||||
|
`+v` (voice)
|
||||||
|
|
||||||
|
### Bridge to HTTP API
|
||||||
|
|
||||||
|
Messages sent by IRC clients appear in channels visible to HTTP/JSON API
|
||||||
|
clients and vice versa. The IRC listener and HTTP API share the same database,
|
||||||
|
broker, and session infrastructure. A user connected via IRC and a user
|
||||||
|
connected via the HTTP API can communicate in the same channels seamlessly.
|
||||||
|
|
||||||
|
### Docker Usage
|
||||||
|
|
||||||
|
To expose the IRC port in Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-p 6667:6667 \
|
||||||
|
-e IRC_LISTEN_ADDR=:6667 \
|
||||||
|
-v neoirc-data:/var/lib/neoirc \
|
||||||
|
neoirc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (Recommended)
|
||||||
@@ -2734,7 +2762,7 @@ guess is borne by the server (bcrypt), not the client.
|
|||||||
- [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`
|
||||||
- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
|
- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
|
||||||
- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
|
- [ ] **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] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
|
||||||
- [x] **KICK command** — operator-only channel kick with broadcast
|
- [x] **KICK command** — operator-only channel kick with broadcast
|
||||||
- [x] **MODE command** — query and set channel/user modes
|
- [x] **MODE command** — query and set channel/user modes
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
@@ -28,16 +31,23 @@ func main() {
|
|||||||
|
|
||||||
fx.New(
|
fx.New(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
|
broker.New,
|
||||||
config.New,
|
config.New,
|
||||||
db.New,
|
db.New,
|
||||||
globals.New,
|
globals.New,
|
||||||
handlers.New,
|
handlers.New,
|
||||||
|
ircserver.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
server.New,
|
server.New,
|
||||||
middleware.New,
|
middleware.New,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
service.New,
|
||||||
stats.New,
|
stats.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(func(*server.Server) {}),
|
fx.Invoke(func(
|
||||||
|
_ *server.Server,
|
||||||
|
_ *ircserver.Server,
|
||||||
|
) {
|
||||||
|
}),
|
||||||
).Run()
|
).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type Config struct {
|
|||||||
OperPassword string
|
OperPassword string
|
||||||
LoginRateLimit float64
|
LoginRateLimit float64
|
||||||
LoginRateBurst int
|
LoginRateBurst int
|
||||||
|
IRCListenAddr string
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -86,6 +87,7 @@ func New(
|
|||||||
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
||||||
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
||||||
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
||||||
|
viper.SetDefault("IRC_LISTEN_ADDR", ":6667")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -116,6 +118,7 @@ func New(
|
|||||||
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
||||||
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
||||||
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
||||||
|
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
@@ -1836,663 +1835,3 @@ func (database *Database) PruneSpentHashcash(
|
|||||||
|
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tier 2: Ban system (+b) ---
|
|
||||||
|
|
||||||
// BanInfo represents a channel ban entry.
|
|
||||||
type BanInfo struct {
|
|
||||||
Mask string
|
|
||||||
SetBy string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddChannelBan inserts a ban mask for a channel.
|
|
||||||
func (database *Database) AddChannelBan(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
mask, setBy string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`INSERT OR IGNORE INTO channel_bans
|
|
||||||
(channel_id, mask, set_by, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
channelID, mask, setBy, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("add channel ban: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveChannelBan removes a ban mask from a channel.
|
|
||||||
func (database *Database) RemoveChannelBan(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
mask string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`DELETE FROM channel_bans
|
|
||||||
WHERE channel_id = ? AND mask = ?`,
|
|
||||||
channelID, mask)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("remove channel ban: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListChannelBans returns all bans for a channel.
|
|
||||||
//
|
|
||||||
//nolint:dupl // different query+type vs filtered variant
|
|
||||||
func (database *Database) ListChannelBans(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) ([]BanInfo, error) {
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT mask, set_by, created_at
|
|
||||||
FROM channel_bans
|
|
||||||
WHERE channel_id = ?
|
|
||||||
ORDER BY created_at ASC`,
|
|
||||||
channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list channel bans: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var bans []BanInfo
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var ban BanInfo
|
|
||||||
|
|
||||||
if scanErr := rows.Scan(
|
|
||||||
&ban.Mask, &ban.SetBy, &ban.CreatedAt,
|
|
||||||
); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel ban: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans = append(bans, ban)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowErr := rows.Err(); rowErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate channel bans: %w", rowErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return bans, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSessionBanned checks if a session's hostmask matches
|
|
||||||
// any ban in the channel. Returns true if banned.
|
|
||||||
func (database *Database) IsSessionBanned(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
// Get the session's hostmask parts.
|
|
||||||
var nick, username, hostname string
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT nick, username, hostname
|
|
||||||
FROM sessions WHERE id = ?`,
|
|
||||||
sessionID,
|
|
||||||
).Scan(&nick, &username, &hostname)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"get session hostmask: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostmask := FormatHostmask(nick, username, hostname)
|
|
||||||
|
|
||||||
// Get all ban masks for the channel.
|
|
||||||
bans, banErr := database.ListChannelBans(ctx, channelID)
|
|
||||||
if banErr != nil {
|
|
||||||
return false, banErr
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ban := range bans {
|
|
||||||
if MatchBanMask(ban.Mask, hostmask) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatchBanMask checks if hostmask matches a ban pattern
|
|
||||||
// using IRC-style wildcard matching (* and ?).
|
|
||||||
func MatchBanMask(pattern, hostmask string) bool {
|
|
||||||
return wildcardMatch(
|
|
||||||
strings.ToLower(pattern),
|
|
||||||
strings.ToLower(hostmask),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wildcardMatch implements simple glob-style matching
|
|
||||||
// with * (any sequence) and ? (any single character).
|
|
||||||
func wildcardMatch(pattern, str string) bool {
|
|
||||||
for len(pattern) > 0 {
|
|
||||||
switch pattern[0] {
|
|
||||||
case '*':
|
|
||||||
// Skip consecutive asterisks.
|
|
||||||
for len(pattern) > 0 && pattern[0] == '*' {
|
|
||||||
pattern = pattern[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pattern) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i <= len(str); i++ {
|
|
||||||
if wildcardMatch(pattern, str[i:]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
case '?':
|
|
||||||
if len(str) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern = pattern[1:]
|
|
||||||
str = str[1:]
|
|
||||||
default:
|
|
||||||
if len(str) == 0 || pattern[0] != str[0] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern = pattern[1:]
|
|
||||||
str = str[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(str) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: Invite-only (+i) ---
|
|
||||||
|
|
||||||
// IsChannelInviteOnly checks if a channel has +i mode.
|
|
||||||
func (database *Database) IsChannelInviteOnly(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isInviteOnly int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_invite_only FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&isInviteOnly)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check invite only: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInviteOnly != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelInviteOnly sets or unsets +i mode.
|
|
||||||
func (database *Database) SetChannelInviteOnly(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
inviteOnly bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if inviteOnly {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET is_invite_only = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
val, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set invite only: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddChannelInvite records that a session has been
|
|
||||||
// invited to a channel.
|
|
||||||
func (database *Database) AddChannelInvite(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
invitedBy string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`INSERT OR IGNORE INTO channel_invites
|
|
||||||
(channel_id, session_id, invited_by, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
channelID, sessionID, invitedBy, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("add channel invite: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasChannelInvite checks if a session has been invited
|
|
||||||
// to a channel.
|
|
||||||
func (database *Database) HasChannelInvite(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM channel_invites
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
channelID, sessionID,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check invite: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearChannelInvite removes a session's invite to a
|
|
||||||
// channel (called after successful JOIN).
|
|
||||||
func (database *Database) ClearChannelInvite(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`DELETE FROM channel_invites
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
channelID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("clear invite: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: Secret (+s) ---
|
|
||||||
|
|
||||||
// IsChannelSecret checks if a channel has +s mode.
|
|
||||||
func (database *Database) IsChannelSecret(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isSecret int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_secret FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&isSecret)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check secret: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isSecret != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelSecret sets or unsets +s mode.
|
|
||||||
func (database *Database) SetChannelSecret(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
secret bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if secret {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET is_secret = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
val, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set secret: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAllChannelsWithCountsFiltered returns all channels
|
|
||||||
// with member counts, excluding secret channels that
|
|
||||||
// the given session is not a member of.
|
|
||||||
//
|
|
||||||
//nolint:dupl // different query+type vs ListChannelBans
|
|
||||||
func (database *Database) ListAllChannelsWithCountsFiltered(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) ([]ChannelInfoFull, error) {
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT c.name, COUNT(cm.id) AS member_count,
|
|
||||||
c.topic
|
|
||||||
FROM channels c
|
|
||||||
LEFT JOIN channel_members cm
|
|
||||||
ON cm.channel_id = c.id
|
|
||||||
WHERE c.is_secret = 0
|
|
||||||
OR c.id IN (
|
|
||||||
SELECT channel_id FROM channel_members
|
|
||||||
WHERE session_id = ?
|
|
||||||
)
|
|
||||||
GROUP BY c.id
|
|
||||||
ORDER BY c.name ASC`,
|
|
||||||
sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"list channels filtered: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var channels []ChannelInfoFull
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var chanInfo ChannelInfoFull
|
|
||||||
|
|
||||||
if scanErr := rows.Scan(
|
|
||||||
&chanInfo.Name,
|
|
||||||
&chanInfo.MemberCount,
|
|
||||||
&chanInfo.Topic,
|
|
||||||
); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
channels = append(channels, chanInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowErr := rows.Err(); rowErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate channels: %w", rowErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionChannelsFiltered returns channels a session
|
|
||||||
// belongs to, optionally excluding secret channels for
|
|
||||||
// WHOIS (when the querier is not in the same channel).
|
|
||||||
// If querierID == targetID, returns all channels.
|
|
||||||
func (database *Database) GetSessionChannelsFiltered(
|
|
||||||
ctx context.Context,
|
|
||||||
targetSID, querierSID int64,
|
|
||||||
) ([]ChannelInfo, error) {
|
|
||||||
// If querying yourself, return all channels.
|
|
||||||
if targetSID == querierSID {
|
|
||||||
return database.GetSessionChannels(ctx, targetSID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT c.id, c.name, c.topic
|
|
||||||
FROM channels c
|
|
||||||
JOIN channel_members cm
|
|
||||||
ON cm.channel_id = c.id
|
|
||||||
WHERE cm.session_id = ?
|
|
||||||
AND (c.is_secret = 0
|
|
||||||
OR c.id IN (
|
|
||||||
SELECT channel_id FROM channel_members
|
|
||||||
WHERE session_id = ?
|
|
||||||
))
|
|
||||||
ORDER BY c.name ASC`,
|
|
||||||
targetSID, querierSID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"get session channels filtered: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var channels []ChannelInfo
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var chanInfo ChannelInfo
|
|
||||||
|
|
||||||
if scanErr := rows.Scan(
|
|
||||||
&chanInfo.ID,
|
|
||||||
&chanInfo.Name,
|
|
||||||
&chanInfo.Topic,
|
|
||||||
); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
channels = append(channels, chanInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowErr := rows.Err(); rowErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate channels: %w", rowErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: Channel Key (+k) ---
|
|
||||||
|
|
||||||
// GetChannelKey returns the key for a channel (empty
|
|
||||||
// string means no key set).
|
|
||||||
func (database *Database) GetChannelKey(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (string, error) {
|
|
||||||
var key string
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT channel_key FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&key)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("get channel key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelKey sets or clears the key for a channel.
|
|
||||||
func (database *Database) SetChannelKey(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
key string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET channel_key = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
key, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set channel key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: User Limit (+l) ---
|
|
||||||
|
|
||||||
// GetChannelUserLimit returns the user limit for a
|
|
||||||
// channel (0 means no limit).
|
|
||||||
func (database *Database) GetChannelUserLimit(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (int, error) {
|
|
||||||
var limit int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT user_limit FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&limit)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get channel user limit: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return limit, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelUserLimit sets the user limit for a channel.
|
|
||||||
func (database *Database) SetChannelUserLimit(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
limit int,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET user_limit = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
limit, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set channel user limit: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSessionWallops sets the wallops (+w) flag on a
|
|
||||||
// session.
|
|
||||||
func (database *Database) SetSessionWallops(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
enabled bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if enabled {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(
|
|
||||||
ctx,
|
|
||||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
|
||||||
val, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set session wallops: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSessionWallops returns whether the session has the
|
|
||||||
// wallops (+w) usermode set.
|
|
||||||
func (database *Database) IsSessionWallops(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isWallops int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
|
||||||
sessionID,
|
|
||||||
).Scan(&isWallops)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check session wallops: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isWallops != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWallopsSessionIDs returns all session IDs that have
|
|
||||||
// the wallops (+w) usermode set.
|
|
||||||
func (database *Database) GetWallopsSessionIDs(
|
|
||||||
ctx context.Context,
|
|
||||||
) ([]int64, error) {
|
|
||||||
rows, err := database.conn.QueryContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"get wallops sessions: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var ids []int64
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var sessionID int64
|
|
||||||
if scanErr := rows.Scan(&sessionID); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan wallops session: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ids = append(ids, sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate wallops sessions: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserhostInfo holds the data needed for RPL_USERHOST.
|
|
||||||
type UserhostInfo struct {
|
|
||||||
Nick string
|
|
||||||
Username string
|
|
||||||
Hostname string
|
|
||||||
IsOper bool
|
|
||||||
AwayMessage string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserhostInfo returns USERHOST info for the given
|
|
||||||
// nicks. Only nicks that exist are returned.
|
|
||||||
func (database *Database) GetUserhostInfo(
|
|
||||||
ctx context.Context,
|
|
||||||
nicks []string,
|
|
||||||
) ([]UserhostInfo, error) {
|
|
||||||
if len(nicks) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]UserhostInfo, 0, len(nicks))
|
|
||||||
|
|
||||||
for _, nick := range nicks {
|
|
||||||
var info UserhostInfo
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT nick, username, hostname,
|
|
||||||
is_oper, away_message
|
|
||||||
FROM sessions WHERE nick = ?`,
|
|
||||||
nick,
|
|
||||||
).Scan(
|
|
||||||
&info.Nick, &info.Username, &info.Hostname,
|
|
||||||
&info.IsOper, &info.AwayMessage,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue // nick not found, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1017,474 +1017,3 @@ func TestGetOperCount(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 oper, got %d", count)
|
t.Fatalf("expected 1 oper, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tier 2 Tests ---
|
|
||||||
|
|
||||||
func TestWildcardMatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
pattern string
|
|
||||||
input string
|
|
||||||
match bool
|
|
||||||
}{
|
|
||||||
{"*!*@*", "nick!user@host", true},
|
|
||||||
{"*!*@*.example.com", "nick!user@foo.example.com", true},
|
|
||||||
{"*!*@*.example.com", "nick!user@other.net", false},
|
|
||||||
{"badnick!*@*", "badnick!user@host", true},
|
|
||||||
{"badnick!*@*", "goodnick!user@host", false},
|
|
||||||
{"nick!user@host", "nick!user@host", true},
|
|
||||||
{"nick!user@host", "nick!user@other", false},
|
|
||||||
{"*", "anything", true},
|
|
||||||
{"?ick!*@*", "nick!user@host", true},
|
|
||||||
{"?ick!*@*", "nn!user@host", false},
|
|
||||||
// Case-insensitive.
|
|
||||||
{"Nick!*@*", "nick!user@host", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
result := db.MatchBanMask(tc.pattern, tc.input)
|
|
||||||
if result != tc.match {
|
|
||||||
t.Errorf(
|
|
||||||
"MatchBanMask(%q, %q) = %v, want %v",
|
|
||||||
tc.pattern, tc.input, result, tc.match,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelBanCRUD(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No bans initially.
|
|
||||||
bans, err := database.ListChannelBans(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(bans) != 0 {
|
|
||||||
t.Fatalf("expected 0 bans, got %d", len(bans))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a ban.
|
|
||||||
err = database.AddChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com", "op",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans, err = database.ListChannelBans(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(bans) != 1 {
|
|
||||||
t.Fatalf("expected 1 ban, got %d", len(bans))
|
|
||||||
}
|
|
||||||
|
|
||||||
if bans[0].Mask != "*!*@evil.com" {
|
|
||||||
t.Fatalf("wrong mask: %s", bans[0].Mask)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate add is ignored (OR IGNORE).
|
|
||||||
err = database.AddChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com", "op2",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans, _ = database.ListChannelBans(ctx, chID)
|
|
||||||
if len(bans) != 1 {
|
|
||||||
t.Fatalf("expected 1 ban after dup, got %d", len(bans))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove ban.
|
|
||||||
err = database.RemoveChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans, _ = database.ListChannelBans(ctx, chID)
|
|
||||||
if len(bans) != 0 {
|
|
||||||
t.Fatalf("expected 0 bans after remove, got %d", len(bans))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsSessionBanned(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
ctx, "victim", "victim", "evil.com", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#bantest")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not banned initially.
|
|
||||||
banned, err := database.IsSessionBanned(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if banned {
|
|
||||||
t.Fatal("should not be banned initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ban matching the hostmask.
|
|
||||||
err = database.AddChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com", "op",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
banned, err = database.IsSessionBanned(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !banned {
|
|
||||||
t.Fatal("should be banned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelInviteOnly(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#invite")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: not invite-only.
|
|
||||||
isIO, err := database.IsChannelInviteOnly(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isIO {
|
|
||||||
t.Fatal("should not be invite-only by default")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set invite-only.
|
|
||||||
err = database.SetChannelInviteOnly(ctx, chID, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
|
|
||||||
if !isIO {
|
|
||||||
t.Fatal("should be invite-only")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unset.
|
|
||||||
err = database.SetChannelInviteOnly(ctx, chID, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
|
|
||||||
if isIO {
|
|
||||||
t.Fatal("should not be invite-only")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelInviteCRUD(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
ctx, "invited", "", "", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#inv")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No invite initially.
|
|
||||||
has, err := database.HasChannelInvite(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if has {
|
|
||||||
t.Fatal("should not have invite")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add invite.
|
|
||||||
err = database.AddChannelInvite(ctx, chID, sid, "op")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
has, _ = database.HasChannelInvite(ctx, chID, sid)
|
|
||||||
if !has {
|
|
||||||
t.Fatal("should have invite")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear invite.
|
|
||||||
err = database.ClearChannelInvite(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
has, _ = database.HasChannelInvite(ctx, chID, sid)
|
|
||||||
if has {
|
|
||||||
t.Fatal("invite should be cleared")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelSecret(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#secret")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: not secret.
|
|
||||||
isSec, err := database.IsChannelSecret(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSec {
|
|
||||||
t.Fatal("should not be secret by default")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.SetChannelSecret(ctx, chID, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSec, _ = database.IsChannelSecret(ctx, chID)
|
|
||||||
if !isSec {
|
|
||||||
t.Fatal("should be secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTestSession is a helper to create a session and
|
|
||||||
// return only the session ID.
|
|
||||||
func createTestSession(
|
|
||||||
t *testing.T,
|
|
||||||
database *db.Database,
|
|
||||||
nick string,
|
|
||||||
) int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
t.Context(), nick, "", "", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create session %s: %v", nick, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sid
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecretChannelFiltering(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
// Create two sessions.
|
|
||||||
sid1 := createTestSession(t, database, "member")
|
|
||||||
sid2 := createTestSession(t, database, "outsider")
|
|
||||||
|
|
||||||
// Create a secret channel.
|
|
||||||
chID, _ := database.GetOrCreateChannel(ctx, "#secret")
|
|
||||||
_ = database.SetChannelSecret(ctx, chID, true)
|
|
||||||
_ = database.JoinChannel(ctx, chID, sid1)
|
|
||||||
|
|
||||||
// Create a non-secret channel.
|
|
||||||
chID2, _ := database.GetOrCreateChannel(ctx, "#public")
|
|
||||||
_ = database.JoinChannel(ctx, chID2, sid1)
|
|
||||||
|
|
||||||
// Member should see both.
|
|
||||||
list, err := database.ListAllChannelsWithCountsFiltered(
|
|
||||||
ctx, sid1,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) != 2 {
|
|
||||||
t.Fatalf("member should see 2 channels, got %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outsider should only see public.
|
|
||||||
list, _ = database.ListAllChannelsWithCountsFiltered(
|
|
||||||
ctx, sid2,
|
|
||||||
)
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Fatalf("outsider should see 1 channel, got %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
if list[0].Name != "#public" {
|
|
||||||
t.Fatalf("outsider should see #public, got %s", list[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoisChannelFiltering(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid1 := createTestSession(t, database, "target")
|
|
||||||
sid2 := createTestSession(t, database, "querier")
|
|
||||||
|
|
||||||
// Create secret channel, target joins it.
|
|
||||||
chID, _ := database.GetOrCreateChannel(ctx, "#hidden")
|
|
||||||
_ = database.SetChannelSecret(ctx, chID, true)
|
|
||||||
_ = database.JoinChannel(ctx, chID, sid1)
|
|
||||||
|
|
||||||
// Querier (non-member) should not see the channel.
|
|
||||||
channels, err := database.GetSessionChannelsFiltered(
|
|
||||||
ctx, sid1, sid2,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(channels) != 0 {
|
|
||||||
t.Fatalf(
|
|
||||||
"querier should see 0 channels, got %d",
|
|
||||||
len(channels),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Target querying self should see it.
|
|
||||||
channels, _ = database.GetSessionChannelsFiltered(
|
|
||||||
ctx, sid1, sid1,
|
|
||||||
)
|
|
||||||
if len(channels) != 1 {
|
|
||||||
t.Fatalf(
|
|
||||||
"self-query should see 1 channel, got %d",
|
|
||||||
len(channels),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:dupl // structurally similar to TestChannelUserLimit
|
|
||||||
func TestChannelKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#keyed")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: no key.
|
|
||||||
key, err := database.GetChannelKey(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if key != "" {
|
|
||||||
t.Fatalf("expected empty key, got %q", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set key.
|
|
||||||
err = database.SetChannelKey(ctx, chID, "secret123")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, _ = database.GetChannelKey(ctx, chID)
|
|
||||||
if key != "secret123" {
|
|
||||||
t.Fatalf("expected secret123, got %q", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear key.
|
|
||||||
err = database.SetChannelKey(ctx, chID, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, _ = database.GetChannelKey(ctx, chID)
|
|
||||||
if key != "" {
|
|
||||||
t.Fatalf("expected empty key, got %q", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:dupl // structurally similar to TestChannelKey
|
|
||||||
func TestChannelUserLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#limited")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: no limit.
|
|
||||||
limit, err := database.GetChannelUserLimit(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if limit != 0 {
|
|
||||||
t.Fatalf("expected 0 limit, got %d", limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set limit.
|
|
||||||
err = database.SetChannelUserLimit(ctx, chID, 50)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, _ = database.GetChannelUserLimit(ctx, chID)
|
|
||||||
if limit != 50 {
|
|
||||||
t.Fatalf("expected 50, got %d", limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear limit.
|
|
||||||
err = database.SetChannelUserLimit(ctx, chID, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, _ = database.GetChannelUserLimit(ctx, chID)
|
|
||||||
if limit != 0 {
|
|
||||||
t.Fatalf("expected 0, got %d", limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
hostname TEXT NOT NULL DEFAULT '',
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||||
is_wallops INTEGER NOT NULL DEFAULT 0,
|
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
away_message TEXT NOT NULL DEFAULT '',
|
||||||
@@ -43,36 +42,10 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||||
is_moderated INTEGER NOT NULL DEFAULT 0,
|
is_moderated INTEGER NOT NULL DEFAULT 0,
|
||||||
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
||||||
is_invite_only INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
|
||||||
channel_key TEXT NOT NULL DEFAULT '',
|
|
||||||
user_limit INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Channel bans
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_bans (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
mask TEXT NOT NULL,
|
|
||||||
set_by TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(channel_id, mask)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_bans_channel ON channel_bans(channel_id);
|
|
||||||
|
|
||||||
-- Channel invites (in-memory would be simpler but DB survives restarts)
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_invites (
|
|
||||||
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,
|
|
||||||
invited_by TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(channel_id, session_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_invites_channel ON channel_invites(channel_id);
|
|
||||||
|
|
||||||
-- Channel members
|
-- Channel members
|
||||||
CREATE TABLE IF NOT EXISTS channel_members (
|
CREATE TABLE IF NOT EXISTS channel_members (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
25
internal/db/testing.go
Normal file
25
internal/db/testing.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewTestDatabaseFromConn creates a Database wrapping an
|
||||||
|
// existing *sql.DB connection. Intended for integration
|
||||||
|
// tests in other packages.
|
||||||
|
func NewTestDatabaseFromConn(conn *sql.DB) *Database {
|
||||||
|
return &Database{ //nolint:exhaustruct
|
||||||
|
conn: conn,
|
||||||
|
log: slog.Default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations applies all schema migrations. Exposed
|
||||||
|
// for integration tests in other packages.
|
||||||
|
func (database *Database) RunMigrations(
|
||||||
|
ctx context.Context,
|
||||||
|
) error {
|
||||||
|
return database.runMigrations(ctx)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
@@ -213,6 +214,7 @@ func newTestHandlers(
|
|||||||
Database: database,
|
Database: database,
|
||||||
Healthcheck: hcheck,
|
Healthcheck: hcheck,
|
||||||
Stats: tracker,
|
Stats: tracker,
|
||||||
|
Broker: broker.New(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test handlers: %w", err)
|
return nil, fmt.Errorf("test handlers: %w", err)
|
||||||
@@ -4378,486 +4380,3 @@ func TestKickDefaultReason(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tier 2 Handler Tests ---
|
|
||||||
|
|
||||||
const (
|
|
||||||
inviteCmd = "INVITE"
|
|
||||||
joinedStatus = "joined"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestBanAddRemoveList verifies +b add, list, and -b
|
|
||||||
// remove via MODE commands.
|
|
||||||
func TestBanAddRemoveList(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("banop")
|
|
||||||
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#bans",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add a ban.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#bans",
|
|
||||||
bodyKey: []string{"+b", "*!*@evil.com"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(opToken, 0)
|
|
||||||
|
|
||||||
// List bans (+b with no argument).
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#bans",
|
|
||||||
bodyKey: []string{"+b"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
||||||
|
|
||||||
// Should have RPL_BANLIST (367).
|
|
||||||
banMsg := findNumericWithParams(msgs, "367")
|
|
||||||
if banMsg == nil {
|
|
||||||
t.Fatalf("expected 367 RPL_BANLIST, got %v", msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have RPL_ENDOFBANLIST (368).
|
|
||||||
if !findNumeric(msgs, "368") {
|
|
||||||
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the ban.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#bans",
|
|
||||||
bodyKey: []string{"-b", "*!*@evil.com"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(opToken, lastID)
|
|
||||||
|
|
||||||
// List again — should be empty (just end-of-list).
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#bans",
|
|
||||||
bodyKey: []string{"+b"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ = tserver.pollMessages(opToken, lastID)
|
|
||||||
banMsg = findNumericWithParams(msgs, "367")
|
|
||||||
if banMsg != nil {
|
|
||||||
t.Fatal("expected no 367 after ban removal")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "368") {
|
|
||||||
t.Fatal("expected 368 RPL_ENDOFBANLIST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBanBlocksJoin verifies that a banned user cannot
|
|
||||||
// join a channel.
|
|
||||||
func TestBanBlocksJoin(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("banop2")
|
|
||||||
userToken := tserver.createSession("banned2")
|
|
||||||
|
|
||||||
// Op creates channel and sets a ban.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#banjoin",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#banjoin",
|
|
||||||
bodyKey: []string{"+b", "banned2!*@*"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Banned user tries to join.
|
|
||||||
_, lastID := tserver.pollMessages(userToken, 0)
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#banjoin",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
||||||
|
|
||||||
// Should get ERR_BANNEDFROMCHAN (474).
|
|
||||||
if !findNumeric(msgs, "474") {
|
|
||||||
t.Fatalf("expected 474 ERR_BANNEDFROMCHAN, got %v", msgs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBanBlocksPrivmsg verifies that a banned user who
|
|
||||||
// is already in a channel cannot send messages.
|
|
||||||
func TestBanBlocksPrivmsg(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("banmsgop")
|
|
||||||
userToken := tserver.createSession("banmsgusr")
|
|
||||||
|
|
||||||
// Both join.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#banmsg",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#banmsg",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Op bans the user.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#banmsg",
|
|
||||||
bodyKey: []string{"+b", "banmsgusr!*@*"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// User tries to send a message.
|
|
||||||
_, lastID := tserver.pollMessages(userToken, 0)
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: privmsgCmd,
|
|
||||||
toKey: "#banmsg",
|
|
||||||
bodyKey: []string{"hello"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
||||||
|
|
||||||
// Should get ERR_CANNOTSENDTOCHAN (404).
|
|
||||||
if !findNumeric(msgs, "404") {
|
|
||||||
t.Fatalf("expected 404 ERR_CANNOTSENDTOCHAN, got %v", msgs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInviteOnlyJoin verifies +i behavior: join rejected
|
|
||||||
// without invite, accepted with invite.
|
|
||||||
func TestInviteOnlyJoin(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("invop")
|
|
||||||
userToken := tserver.createSession("invusr")
|
|
||||||
|
|
||||||
// Op creates channel and sets +i.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#invonly",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#invonly",
|
|
||||||
bodyKey: []string{"+i"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// User tries to join without invite.
|
|
||||||
_, lastID := tserver.pollMessages(userToken, 0)
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#invonly",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "473") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 473 ERR_INVITEONLYCHAN, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Op invites user.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: inviteCmd,
|
|
||||||
bodyKey: []string{"invusr", "#invonly"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// User tries again — should succeed with invite.
|
|
||||||
_, result := tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#invonly",
|
|
||||||
})
|
|
||||||
|
|
||||||
if result[statusKey] != joinedStatus {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected join to succeed with invite, got %v",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecretChannelHiddenFromList verifies +s hides a
|
|
||||||
// channel from LIST for non-members.
|
|
||||||
func TestSecretChannelHiddenFromList(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("secop")
|
|
||||||
outsiderToken := tserver.createSession("secout")
|
|
||||||
|
|
||||||
// Op creates secret channel.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#secret",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#secret",
|
|
||||||
bodyKey: []string{"+s"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Outsider does LIST.
|
|
||||||
_, lastID := tserver.pollMessages(outsiderToken, 0)
|
|
||||||
tserver.sendCommand(outsiderToken, map[string]any{
|
|
||||||
commandKey: "LIST",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(outsiderToken, lastID)
|
|
||||||
|
|
||||||
// Should NOT see #secret in any 322 (RPL_LIST).
|
|
||||||
for _, msg := range msgs {
|
|
||||||
code, ok := msg["code"].(float64)
|
|
||||||
if !ok || int(code) != 322 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
params := getNumericParams(msg)
|
|
||||||
for _, p := range params {
|
|
||||||
if p == "#secret" {
|
|
||||||
t.Fatal("outsider should not see #secret in LIST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Member does LIST — should see it.
|
|
||||||
_, lastID = tserver.pollMessages(opToken, 0)
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: "LIST",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ = tserver.pollMessages(opToken, lastID)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
code, ok := msg["code"].(float64)
|
|
||||||
if !ok || int(code) != 322 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
params := getNumericParams(msg)
|
|
||||||
for _, p := range params {
|
|
||||||
if p == "#secret" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
t.Fatal("member should see #secret in LIST")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestChannelKeyJoin verifies +k behavior: wrong/missing
|
|
||||||
// key is rejected, correct key allows join.
|
|
||||||
func TestChannelKeyJoin(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("keyop")
|
|
||||||
userToken := tserver.createSession("keyusr")
|
|
||||||
|
|
||||||
// Op creates keyed channel.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#keyed",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#keyed",
|
|
||||||
bodyKey: []string{"+k", "mykey"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// User tries without key.
|
|
||||||
_, lastID := tserver.pollMessages(userToken, 0)
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#keyed",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "475") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 475 ERR_BADCHANNELKEY, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// User tries with wrong key.
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd,
|
|
||||||
toKey: "#keyed",
|
|
||||||
bodyKey: []string{"wrongkey"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ = tserver.pollMessages(userToken, lastID)
|
|
||||||
if !findNumeric(msgs, "475") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 475 ERR_BADCHANNELKEY for wrong key, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// User tries with correct key.
|
|
||||||
_, result := tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd,
|
|
||||||
toKey: "#keyed",
|
|
||||||
bodyKey: []string{"mykey"},
|
|
||||||
})
|
|
||||||
|
|
||||||
if result[statusKey] != joinedStatus {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected join to succeed with correct key, got %v",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUserLimitEnforcement verifies +l behavior: blocks
|
|
||||||
// join when at capacity.
|
|
||||||
func TestUserLimitEnforcement(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("limop")
|
|
||||||
user1Token := tserver.createSession("limusr1")
|
|
||||||
user2Token := tserver.createSession("limusr2")
|
|
||||||
|
|
||||||
// Op creates channel with limit 2.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#limited",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#limited",
|
|
||||||
bodyKey: []string{"+l", "2"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// User1 joins — should succeed (2 members now: op + user1).
|
|
||||||
_, result := tserver.sendCommand(user1Token, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#limited",
|
|
||||||
})
|
|
||||||
if result[statusKey] != joinedStatus {
|
|
||||||
t.Fatalf("user1 should join, got %v", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// User2 tries to join — should fail (at limit: 2/2).
|
|
||||||
_, lastID := tserver.pollMessages(user2Token, 0)
|
|
||||||
tserver.sendCommand(user2Token, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#limited",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(user2Token, lastID)
|
|
||||||
|
|
||||||
if !findNumeric(msgs, "471") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 471 ERR_CHANNELISFULL, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestModeStringIncludesNewModes verifies that querying
|
|
||||||
// channel mode returns the new modes (+i, +s, +k, +l).
|
|
||||||
func TestModeStringIncludesNewModes(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("modestrop")
|
|
||||||
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#modestr",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set all tier 2 modes.
|
|
||||||
for _, modeChange := range [][]string{
|
|
||||||
{"+i"}, {"+s"}, {"+k", "pw"}, {"+l", "50"},
|
|
||||||
} {
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#modestr",
|
|
||||||
bodyKey: modeChange,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(opToken, 0)
|
|
||||||
|
|
||||||
// Query mode.
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: modeCmd, toKey: "#modestr",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(opToken, lastID)
|
|
||||||
modeMsg := findNumericWithParams(msgs, "324")
|
|
||||||
|
|
||||||
if modeMsg == nil {
|
|
||||||
t.Fatal("expected 324 RPL_CHANNELMODEIS")
|
|
||||||
}
|
|
||||||
|
|
||||||
params := getNumericParams(modeMsg)
|
|
||||||
if len(params) < 2 {
|
|
||||||
t.Fatalf("too few params in 324: %v", params)
|
|
||||||
}
|
|
||||||
|
|
||||||
modeString := params[1]
|
|
||||||
|
|
||||||
for _, c := range []string{"i", "s", "k", "l"} {
|
|
||||||
if !strings.Contains(modeString, c) {
|
|
||||||
t.Fatalf(
|
|
||||||
"mode string %q missing %q",
|
|
||||||
modeString, c,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestISUPPORT verifies the 005 numeric includes the
|
|
||||||
// updated CHANMODES string.
|
|
||||||
func TestISUPPORT(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
token := tserver.createSession("isupport")
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
isupp := findNumericWithParams(msgs, "005")
|
|
||||||
if isupp == nil {
|
|
||||||
t.Fatal("expected 005 RPL_ISUPPORT")
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := isupp["body"].(string)
|
|
||||||
params := getNumericParams(isupp)
|
|
||||||
|
|
||||||
combined := body + " " + strings.Join(params, " ")
|
|
||||||
|
|
||||||
if !strings.Contains(combined, "CHANMODES=b,k,Hl,imnst") {
|
|
||||||
t.Fatalf(
|
|
||||||
"ISUPPORT missing updated CHANMODES, got body=%q params=%v",
|
|
||||||
body, params,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNonOpCannotSetModes verifies non-operators
|
|
||||||
// cannot set +i, +s, +k, +l, +b.
|
|
||||||
func TestNonOpCannotSetModes(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
opToken := tserver.createSession("modeopx")
|
|
||||||
userToken := tserver.createSession("modeusrx")
|
|
||||||
|
|
||||||
tserver.sendCommand(opToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#noperm",
|
|
||||||
})
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: joinCmd, toKey: "#noperm",
|
|
||||||
})
|
|
||||||
|
|
||||||
modes := [][]string{
|
|
||||||
{"+i"}, {"+s"}, {"+k", "key"}, {"+l", "10"},
|
|
||||||
{"+b", "bad!*@*"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, modeChange := range modes {
|
|
||||||
_, lastID := tserver.pollMessages(userToken, 0)
|
|
||||||
tserver.sendCommand(userToken, map[string]any{
|
|
||||||
commandKey: modeCmd,
|
|
||||||
toKey: "#noperm",
|
|
||||||
bodyKey: modeChange,
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(userToken, lastID)
|
|
||||||
|
|
||||||
// Should get 482 ERR_CHANOPRIVSNEEDED.
|
|
||||||
if !findNumeric(msgs, "482") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 482 for %v, got %v",
|
|
||||||
modeChange, msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,8 @@ type Params struct {
|
|||||||
Database *db.Database
|
Database *db.Database
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
Stats *stats.Tracker
|
Stats *stats.Tracker
|
||||||
|
Broker *broker.Broker
|
||||||
|
Service *service.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
@@ -48,6 +51,7 @@ type Handlers struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
|
svc *service.Service
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
channelHashcash *hashcash.ChannelValidator
|
channelHashcash *hashcash.ChannelValidator
|
||||||
loginLimiter *ratelimit.Limiter
|
loginLimiter *ratelimit.Limiter
|
||||||
@@ -79,7 +83,8 @@ func New(
|
|||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: params.Broker,
|
||||||
|
svc: params.Service,
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
channelHashcash: hashcash.NewChannelValidator(),
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||||
|
|||||||
@@ -1,727 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxUserhostNicks is the maximum number of nicks allowed
|
|
||||||
// in a single USERHOST query (RFC 2812).
|
|
||||||
const maxUserhostNicks = 5
|
|
||||||
|
|
||||||
// dispatchBodyOnlyCommand routes commands that take
|
|
||||||
// (writer, request, sessionID, clientID, nick, bodyLines).
|
|
||||||
func (hdlr *Handlers) dispatchBodyOnlyCommand(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, command string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
switch command {
|
|
||||||
case irc.CmdAway:
|
|
||||||
hdlr.handleAway(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdNick:
|
|
||||||
hdlr.handleNick(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdPass:
|
|
||||||
hdlr.handlePass(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdInvite:
|
|
||||||
hdlr.handleInvite(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dispatchOperCommand routes oper-related commands (OPER,
|
|
||||||
// KILL, WALLOPS) to their handlers.
|
|
||||||
func (hdlr *Handlers) dispatchOperCommand(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, command string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
switch command {
|
|
||||||
case irc.CmdOper:
|
|
||||||
hdlr.handleOper(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdKill:
|
|
||||||
hdlr.handleKill(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdWallops:
|
|
||||||
hdlr.handleWallops(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUserhost handles the USERHOST command.
|
|
||||||
// Returns user@host info for up to 5 nicks.
|
|
||||||
func (hdlr *Handlers) handleUserhost(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdUserhost},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit to 5 nicks per RFC 2812.
|
|
||||||
nicks := lines
|
|
||||||
if len(nicks) > maxUserhostNicks {
|
|
||||||
nicks = nicks[:maxUserhostNicks]
|
|
||||||
}
|
|
||||||
|
|
||||||
infos, err := hdlr.params.Database.GetUserhostInfo(
|
|
||||||
ctx, nicks,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"userhost query failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
replyStr := hdlr.buildUserhostReply(infos)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUserHost, nick, nil,
|
|
||||||
replyStr,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUserhostReply builds the RPL_USERHOST reply
|
|
||||||
// string per RFC 2812.
|
|
||||||
func (hdlr *Handlers) buildUserhostReply(
|
|
||||||
infos []db.UserhostInfo,
|
|
||||||
) string {
|
|
||||||
replies := make([]string, 0, len(infos))
|
|
||||||
|
|
||||||
for idx := range infos {
|
|
||||||
info := &infos[idx]
|
|
||||||
|
|
||||||
username := info.Username
|
|
||||||
if username == "" {
|
|
||||||
username = info.Nick
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname := info.Hostname
|
|
||||||
if hostname == "" {
|
|
||||||
hostname = hdlr.serverName()
|
|
||||||
}
|
|
||||||
|
|
||||||
operStar := ""
|
|
||||||
if info.IsOper {
|
|
||||||
operStar = "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
awayPrefix := "+"
|
|
||||||
if info.AwayMessage != "" {
|
|
||||||
awayPrefix = "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
replies = append(replies,
|
|
||||||
info.Nick+operStar+"="+
|
|
||||||
awayPrefix+username+"@"+hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(replies, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleVersion handles the VERSION command.
|
|
||||||
// Returns the server version string.
|
|
||||||
func (hdlr *Handlers) handleVersion(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
srvName := hdlr.serverName()
|
|
||||||
version := hdlr.serverVersion()
|
|
||||||
|
|
||||||
// 351 RPL_VERSION
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplVersion, nick,
|
|
||||||
[]string{version + ".", srvName},
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdmin handles the ADMIN command.
|
|
||||||
// Returns server admin contact info.
|
|
||||||
func (hdlr *Handlers) handleAdmin(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
srvName := hdlr.serverName()
|
|
||||||
|
|
||||||
// 256 RPL_ADMINME
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminMe, nick,
|
|
||||||
[]string{srvName},
|
|
||||||
"Administrative info",
|
|
||||||
)
|
|
||||||
|
|
||||||
// 257 RPL_ADMINLOC1
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
|
||||||
"neoirc server",
|
|
||||||
)
|
|
||||||
|
|
||||||
// 258 RPL_ADMINLOC2
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
|
||||||
"IRC over HTTP",
|
|
||||||
)
|
|
||||||
|
|
||||||
// 259 RPL_ADMINEMAIL
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
|
||||||
"admin@"+srvName,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInfo handles the INFO command.
|
|
||||||
// Returns server software information.
|
|
||||||
func (hdlr *Handlers) handleInfo(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
version := hdlr.serverVersion()
|
|
||||||
|
|
||||||
infoLines := []string{
|
|
||||||
"neoirc — IRC semantics over HTTP",
|
|
||||||
"Version: " + version,
|
|
||||||
"Written in Go",
|
|
||||||
"Started: " +
|
|
||||||
hdlr.params.Globals.StartTime.
|
|
||||||
Format(time.RFC1123),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range infoLines {
|
|
||||||
// 371 RPL_INFO
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplInfo, nick, nil,
|
|
||||||
line,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 374 RPL_ENDOFINFO
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
|
||||||
"End of /INFO list",
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTime handles the TIME command.
|
|
||||||
// Returns the server's local time in RFC format.
|
|
||||||
func (hdlr *Handlers) handleTime(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
srvName := hdlr.serverName()
|
|
||||||
|
|
||||||
// 391 RPL_TIME
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplTime, nick,
|
|
||||||
[]string{srvName},
|
|
||||||
time.Now().Format(time.RFC1123),
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleKill handles the KILL command.
|
|
||||||
// Forcibly disconnects a user (oper only).
|
|
||||||
func (hdlr *Handlers) handleKill(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
// Check oper status.
|
|
||||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil || !isOper {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNoPrivileges, nick, nil,
|
|
||||||
"Permission Denied- You're not an IRC operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdKill},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetNick := strings.TrimSpace(lines[0])
|
|
||||||
if targetNick == "" {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdKill},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := "KILLed"
|
|
||||||
if len(lines) > 1 {
|
|
||||||
reason = lines[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
targetSID, lookupErr := hdlr.params.Database.
|
|
||||||
GetSessionByNick(ctx, targetNick)
|
|
||||||
if lookupErr != nil {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNoSuchNick, nick,
|
|
||||||
[]string{targetNick},
|
|
||||||
"No such nick/channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not allow killing yourself.
|
|
||||||
if targetSID == sessionID {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrCantKillServer, nick, nil,
|
|
||||||
"You cannot KILL yourself",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.executeKillUser(
|
|
||||||
request, targetSID, targetNick, nick, reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeKillUser forcibly disconnects a user: broadcasts
|
|
||||||
// QUIT to their channels, parts all channels, and deletes
|
|
||||||
// the session.
|
|
||||||
func (hdlr *Handlers) executeKillUser(
|
|
||||||
request *http.Request,
|
|
||||||
targetSID int64,
|
|
||||||
targetNick, killerNick, reason string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
quitMsg := "Killed (" + killerNick + " (" + reason + "))"
|
|
||||||
|
|
||||||
quitBody, err := json.Marshal([]string{quitMsg})
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"marshal kill quit body", "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channels, _ := hdlr.params.Database.
|
|
||||||
GetSessionChannels(ctx, targetSID)
|
|
||||||
|
|
||||||
notified := map[int64]bool{}
|
|
||||||
|
|
||||||
var dbID int64
|
|
||||||
|
|
||||||
if len(channels) > 0 {
|
|
||||||
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
|
||||||
ctx, irc.CmdQuit, targetNick, "",
|
|
||||||
nil, json.RawMessage(quitBody), nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, chanInfo := range channels {
|
|
||||||
memberIDs, _ := hdlr.params.Database.
|
|
||||||
GetChannelMemberIDs(ctx, chanInfo.ID)
|
|
||||||
|
|
||||||
for _, mid := range memberIDs {
|
|
||||||
if mid != targetSID && !notified[mid] {
|
|
||||||
notified[mid] = true
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.EnqueueToSession(
|
|
||||||
ctx, mid, dbID,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(mid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.PartChannel(
|
|
||||||
ctx, chanInfo.ID, targetSID,
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.DeleteChannelIfEmpty(
|
|
||||||
ctx, chanInfo.ID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.DeleteSession(
|
|
||||||
ctx, targetSID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWallops handles the WALLOPS command.
|
|
||||||
// Broadcasts a message to all users with +w usermode
|
|
||||||
// (oper only).
|
|
||||||
func (hdlr *Handlers) handleWallops(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
// Check oper status.
|
|
||||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil || !isOper {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNoPrivileges, nick, nil,
|
|
||||||
"Permission Denied- You're not an IRC operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdWallops},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
message := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
wallopsSIDs, err := hdlr.params.Database.
|
|
||||||
GetWallopsSessionIDs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"get wallops sessions failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(wallopsSIDs) > 0 {
|
|
||||||
body, mErr := json.Marshal([]string{message})
|
|
||||||
if mErr != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"marshal wallops body", "error", mErr,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
|
||||||
request, irc.CmdWallops, nick, "*",
|
|
||||||
json.RawMessage(body), wallopsSIDs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUserMode handles user mode queries and changes
|
|
||||||
// (e.g., MODE nick, MODE nick +w).
|
|
||||||
func (hdlr *Handlers) handleUserMode(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, target string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
|
|
||||||
// Mode change requested.
|
|
||||||
if len(lines) > 0 {
|
|
||||||
// Users can only change their own modes.
|
|
||||||
if target != nick && target != "" {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUsersDoNotMatch, nick, nil,
|
|
||||||
"Can't change mode for other users",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.applyUserModeChange(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, lines[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode query — build the current mode string.
|
|
||||||
modeStr := hdlr.buildUserModeString(ctx, sessionID)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
||||||
modeStr,
|
|
||||||
)
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUserModeString constructs the mode string for a
|
|
||||||
// user (e.g., "+ow" for oper+wallops).
|
|
||||||
func (hdlr *Handlers) buildUserModeString(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) string {
|
|
||||||
modes := "+"
|
|
||||||
|
|
||||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err == nil && isOper {
|
|
||||||
modes += "o"
|
|
||||||
}
|
|
||||||
|
|
||||||
isWallops, err := hdlr.params.Database.IsSessionWallops(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err == nil && isWallops {
|
|
||||||
modes += "w"
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyUserModeChange applies a user mode change string
|
|
||||||
// (e.g., "+w", "-w").
|
|
||||||
func (hdlr *Handlers) applyUserModeChange(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, modeStr string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
adding := modeStr[0] == '+'
|
|
||||||
modeChar := modeStr[1:]
|
|
||||||
|
|
||||||
applied, err := hdlr.applyModeChar(
|
|
||||||
ctx, writer, request,
|
|
||||||
sessionID, clientID, nick,
|
|
||||||
modeChar, adding,
|
|
||||||
)
|
|
||||||
if err != nil || !applied {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newModes := hdlr.buildUserModeString(ctx, sessionID)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
||||||
newModes,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyModeChar applies a single user mode character.
|
|
||||||
// Returns (applied, error).
|
|
||||||
func (hdlr *Handlers) applyModeChar(
|
|
||||||
ctx context.Context,
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, modeChar string,
|
|
||||||
adding bool,
|
|
||||||
) (bool, error) {
|
|
||||||
switch modeChar {
|
|
||||||
case "w":
|
|
||||||
err := hdlr.params.Database.SetSessionWallops(
|
|
||||||
ctx, sessionID, adding,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"set wallops mode failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"set wallops: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "o":
|
|
||||||
// +o cannot be set via MODE, only via OPER.
|
|
||||||
if adding {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hdlr.params.Database.SetSessionOper(
|
|
||||||
ctx, sessionID, false,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"clear oper mode failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"clear oper: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
// Tests for Tier 3 utility IRC commands: USERHOST,
|
|
||||||
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest
|
|
||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- USERHOST ---
|
|
||||||
|
|
||||||
func TestUserhostSingleNick(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("alice")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"alice"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 302 RPL_USERHOST.
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body should contain "alice" with the
|
|
||||||
// nick=+user@host format.
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "alice") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected body to contain 'alice', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// '+' means not away.
|
|
||||||
if !strings.Contains(body, "=+") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected not-away prefix '=+', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostMultipleNicks(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token1 := tserver.createSession("bob")
|
|
||||||
token2 := tserver.createSession("carol")
|
|
||||||
|
|
||||||
_ = token2
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token1, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token1, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"bob", "carol"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token1, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "bob") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected body to contain 'bob', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(body, "carol") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected body to contain 'carol', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostNonexistentNick(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("dave")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"nobody"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Should still get 302 but with empty body.
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostNoParams(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("eve")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostShowsOper(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("opernick")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Authenticate as oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// USERHOST should show '*' for oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"opernick"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "opernick*=") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected oper '*' in reply, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostShowsAway(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("awaynick")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Set away.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "AWAY",
|
|
||||||
bodyKey: []string{"gone fishing"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// USERHOST should show '-' for away.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"awaynick"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "=-") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected away prefix '=-' in reply, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- VERSION ---
|
|
||||||
|
|
||||||
func TestVersion(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("frank")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "VERSION",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 351 RPL_VERSION.
|
|
||||||
msg := findNumericWithParams(msgs, "351")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_VERSION (351), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
params := getNumericParams(msg)
|
|
||||||
if len(params) == 0 {
|
|
||||||
t.Fatal("expected VERSION params, got none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// First param should contain version string.
|
|
||||||
if !strings.Contains(params[0], "test") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected version to contain 'test', got %q",
|
|
||||||
params[0],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ADMIN ---
|
|
||||||
|
|
||||||
func TestAdmin(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("grace")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "ADMIN",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 256 RPL_ADMINME.
|
|
||||||
if !findNumeric(msgs, "256") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINME (256), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 257 RPL_ADMINLOC1.
|
|
||||||
if !findNumeric(msgs, "257") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINLOC1 (257), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 258 RPL_ADMINLOC2.
|
|
||||||
if !findNumeric(msgs, "258") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINLOC2 (258), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 259 RPL_ADMINEMAIL.
|
|
||||||
if !findNumeric(msgs, "259") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINEMAIL (259), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- INFO ---
|
|
||||||
|
|
||||||
func TestInfo(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("hank")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "INFO",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 371 RPL_INFO (at least one).
|
|
||||||
if !findNumeric(msgs, "371") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_INFO (371), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 374 RPL_ENDOFINFO.
|
|
||||||
if !findNumeric(msgs, "374") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ENDOFINFO (374), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TIME ---
|
|
||||||
|
|
||||||
func TestTime(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("iris")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "TIME",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 391 RPL_TIME.
|
|
||||||
msg := findNumericWithParams(msgs, "391")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_TIME (391), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- KILL ---
|
|
||||||
|
|
||||||
func TestKillSuccess(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create the victim first.
|
|
||||||
victimToken := tserver.createSession("victim")
|
|
||||||
_ = victimToken
|
|
||||||
|
|
||||||
// Create oper user.
|
|
||||||
operToken := tserver.createSession("killer")
|
|
||||||
_, lastID := tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Authenticate as oper.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(operToken, lastID)
|
|
||||||
|
|
||||||
// Kill the victim.
|
|
||||||
status, result := tserver.sendCommand(
|
|
||||||
operToken, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{"victim", "go away"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if status != 200 {
|
|
||||||
t.Fatalf("expected 200, got %d: %v", status, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
resultStatus, _ := result[statusKey].(string)
|
|
||||||
if resultStatus != "ok" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected status ok, got %v",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the victim's session is gone by trying
|
|
||||||
// to WHOIS them.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "WHOIS",
|
|
||||||
toKey: "victim",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(operToken, lastID)
|
|
||||||
|
|
||||||
// Should get 401 ERR_NOSUCHNICK.
|
|
||||||
if !findNumeric(msgs, "401") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected victim to be gone (401), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillNotOper(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
_ = tserver.createSession("target")
|
|
||||||
|
|
||||||
token := tserver.createSession("notoper")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{"target", "no reason"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 481 ERR_NOPRIVILEGES.
|
|
||||||
if !findNumeric(msgs, "481") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NOPRIVILEGES (481), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillNoParams(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("opertest")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendOperKillCommand is a helper that creates an oper
|
|
||||||
// session, authenticates, then sends KILL with the given
|
|
||||||
// target nick, and returns the resulting messages.
|
|
||||||
func sendOperKillCommand(
|
|
||||||
t *testing.T,
|
|
||||||
tserver *testServer,
|
|
||||||
operNick, targetNick string,
|
|
||||||
) []map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
token := tserver.createSession(operNick)
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{targetNick},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillNonexistentUser(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
msgs := sendOperKillCommand(
|
|
||||||
t, tserver, "opertest2", "ghost",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Expect 401 ERR_NOSUCHNICK.
|
|
||||||
if !findNumeric(msgs, "401") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NOSUCHNICK (401), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillSelf(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
msgs := sendOperKillCommand(
|
|
||||||
t, tserver, "selfkiller", "selfkiller",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Expect 483 ERR_CANTKILLSERVER.
|
|
||||||
if !findNumeric(msgs, "483") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_CANTKILLSERVER (483), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillBroadcastsQuit(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create victim and join a channel.
|
|
||||||
victimToken := tserver.createSession("vuser")
|
|
||||||
|
|
||||||
tserver.sendCommand(victimToken, map[string]any{
|
|
||||||
commandKey: joinCmd,
|
|
||||||
toKey: "#killtest",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create observer and join same channel.
|
|
||||||
observerToken := tserver.createSession("observer")
|
|
||||||
|
|
||||||
tserver.sendCommand(observerToken, map[string]any{
|
|
||||||
commandKey: joinCmd,
|
|
||||||
toKey: "#killtest",
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastObs := tserver.pollMessages(observerToken, 0)
|
|
||||||
|
|
||||||
// Create oper.
|
|
||||||
operToken := tserver.createSession("theoper2")
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Kill the victim.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{"vuser", "testing kill"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Observer should see a QUIT message.
|
|
||||||
msgs, _ := tserver.pollMessages(observerToken, lastObs)
|
|
||||||
|
|
||||||
foundQuit := false
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
cmd, _ := msg["command"].(string)
|
|
||||||
if cmd == "QUIT" {
|
|
||||||
from, _ := msg["from"].(string)
|
|
||||||
if from == "vuser" {
|
|
||||||
foundQuit = true
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundQuit {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected QUIT from vuser, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- WALLOPS ---
|
|
||||||
|
|
||||||
func TestWallopsSuccess(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create receiver with +w.
|
|
||||||
receiverToken := tserver.createSession("receiver")
|
|
||||||
|
|
||||||
tserver.sendCommand(receiverToken, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "receiver",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
|
||||||
|
|
||||||
// Create oper.
|
|
||||||
operToken := tserver.createSession("walloper")
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Also set +w on oper so they receive it too.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "walloper",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Send WALLOPS.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
bodyKey: []string{"server going down"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Receiver should get the WALLOPS message.
|
|
||||||
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
|
||||||
|
|
||||||
foundWallops := false
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
cmd, _ := msg["command"].(string)
|
|
||||||
if cmd == "WALLOPS" {
|
|
||||||
foundWallops = true
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundWallops {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected WALLOPS message, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWallopsNotOper(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("notoper2")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
bodyKey: []string{"hello"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 481 ERR_NOPRIVILEGES.
|
|
||||||
if !findNumeric(msgs, "481") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NOPRIVILEGES (481), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWallopsNoParams(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("operempty")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWallopsNotReceivedWithoutW(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create receiver WITHOUT +w.
|
|
||||||
receiverToken := tserver.createSession("nowallops")
|
|
||||||
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
|
||||||
|
|
||||||
// Create oper.
|
|
||||||
operToken := tserver.createSession("walloper2")
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Send WALLOPS.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
bodyKey: []string{"secret message"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Receiver should NOT get the WALLOPS message.
|
|
||||||
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
cmd, _ := msg["command"].(string)
|
|
||||||
if cmd == "WALLOPS" {
|
|
||||||
t.Fatalf(
|
|
||||||
"did not expect WALLOPS for user "+
|
|
||||||
"without +w, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- User Mode +w ---
|
|
||||||
|
|
||||||
func TestUserModeSetW(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("wmoder")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Set +w.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wmoder",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, lastID := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 221 RPL_UMODEIS with "+w".
|
|
||||||
msg := findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "w") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected mode string to contain 'w', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now query mode.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wmoder",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg = findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221) on query, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body = getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "w") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected mode '+w' in query, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeUnsetW(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("wunsetter")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Set +w first.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wunsetter",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Unset -w.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wunsetter",
|
|
||||||
bodyKey: []string{"-w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if strings.Contains(body, "w") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 'w' to be removed, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeUnknownFlag(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("badmode")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "badmode",
|
|
||||||
bodyKey: []string{"+z"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
|
||||||
if !findNumeric(msgs, "501") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeCannotSetO(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("tryoper")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Try to set +o via MODE (should fail).
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "tryoper",
|
|
||||||
bodyKey: []string{"+o"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
|
||||||
if !findNumeric(msgs, "501") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeDeoper(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("deoper")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Authenticate as oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Use MODE -o to de-oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "deoper",
|
|
||||||
bodyKey: []string{"-o"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if strings.Contains(body, "o") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 'o' to be removed, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeCannotChangeOtherUser(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
_ = tserver.createSession("other")
|
|
||||||
|
|
||||||
token := tserver.createSession("changer")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Try to change another user's mode.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "other",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 502 ERR_USERSDONTMATCH.
|
|
||||||
if !findNumeric(msgs, "502") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_USERSDONTMATCH (502), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNumericBody extracts the body text from a numeric
|
|
||||||
// message. The body is stored as a JSON array; this
|
|
||||||
// returns the first element.
|
|
||||||
func getNumericBody(msg map[string]any) string {
|
|
||||||
raw, exists := msg["body"]
|
|
||||||
if !exists || raw == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
arr, isArr := raw.([]any)
|
|
||||||
if !isArr || len(arr) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
str, isStr := arr[0].(string)
|
|
||||||
if !isStr {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
1178
internal/ircserver/commands.go
Normal file
1178
internal/ircserver/commands.go
Normal file
File diff suppressed because it is too large
Load Diff
501
internal/ircserver/conn.go
Normal file
501
internal/ircserver/conn.go
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxLineLen = 512
|
||||||
|
readTimeout = 5 * time.Minute
|
||||||
|
writeTimeout = 30 * time.Second
|
||||||
|
dnsTimeout = 3 * time.Second
|
||||||
|
pollInterval = 100 * time.Millisecond
|
||||||
|
pingInterval = 90 * time.Second
|
||||||
|
pongDeadline = 30 * time.Second
|
||||||
|
maxNickLen = 32
|
||||||
|
minPasswordLen = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdHandler is the signature for registered IRC command
|
||||||
|
// handlers.
|
||||||
|
type cmdHandler func(ctx context.Context, msg *Message)
|
||||||
|
|
||||||
|
// Conn represents a single IRC client TCP connection.
|
||||||
|
type Conn struct {
|
||||||
|
conn net.Conn
|
||||||
|
log *slog.Logger
|
||||||
|
database *db.Database
|
||||||
|
brk *broker.Broker
|
||||||
|
cfg *config.Config
|
||||||
|
svc *service.Service
|
||||||
|
serverSfx string
|
||||||
|
commands map[string]cmdHandler
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
nick string
|
||||||
|
username string
|
||||||
|
realname string
|
||||||
|
hostname string
|
||||||
|
remoteIP string
|
||||||
|
sessionID int64
|
||||||
|
clientID int64
|
||||||
|
|
||||||
|
registered bool
|
||||||
|
gotNick bool
|
||||||
|
gotUser bool
|
||||||
|
passWord string
|
||||||
|
|
||||||
|
lastQueueID int64
|
||||||
|
closed bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConn(
|
||||||
|
ctx context.Context,
|
||||||
|
tcpConn net.Conn,
|
||||||
|
log *slog.Logger,
|
||||||
|
database *db.Database,
|
||||||
|
brk *broker.Broker,
|
||||||
|
cfg *config.Config,
|
||||||
|
svc *service.Service,
|
||||||
|
) *Conn {
|
||||||
|
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
|
||||||
|
|
||||||
|
srvName := cfg.ServerName
|
||||||
|
if srvName == "" {
|
||||||
|
srvName = "neoirc"
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := &Conn{ //nolint:exhaustruct // zero-value defaults
|
||||||
|
conn: tcpConn,
|
||||||
|
log: log,
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
cfg: cfg,
|
||||||
|
svc: svc,
|
||||||
|
serverSfx: srvName,
|
||||||
|
remoteIP: host,
|
||||||
|
hostname: resolveHost(ctx, host),
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.commands = conn.buildCommandMap()
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCommandMap returns a map from IRC command strings
|
||||||
|
// to handler functions.
|
||||||
|
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||||
|
return map[string]cmdHandler{
|
||||||
|
irc.CmdPing: func(_ context.Context, msg *Message) {
|
||||||
|
c.handlePing(msg)
|
||||||
|
},
|
||||||
|
"PONG": func(context.Context, *Message) {},
|
||||||
|
irc.CmdNick: c.handleNick,
|
||||||
|
irc.CmdPrivmsg: c.handlePrivmsg,
|
||||||
|
irc.CmdNotice: c.handlePrivmsg,
|
||||||
|
irc.CmdJoin: c.handleJoin,
|
||||||
|
irc.CmdPart: c.handlePart,
|
||||||
|
irc.CmdQuit: func(_ context.Context, msg *Message) {
|
||||||
|
c.handleQuit(msg)
|
||||||
|
},
|
||||||
|
irc.CmdTopic: c.handleTopic,
|
||||||
|
irc.CmdMode: c.handleMode,
|
||||||
|
irc.CmdNames: c.handleNames,
|
||||||
|
irc.CmdList: func(ctx context.Context, _ *Message) { c.handleList(ctx) },
|
||||||
|
irc.CmdWhois: c.handleWhois,
|
||||||
|
irc.CmdWho: c.handleWho,
|
||||||
|
irc.CmdLusers: func(ctx context.Context, _ *Message) { c.handleLusers(ctx) },
|
||||||
|
irc.CmdMotd: func(context.Context, *Message) { c.deliverMOTD() },
|
||||||
|
irc.CmdOper: c.handleOper,
|
||||||
|
irc.CmdAway: c.handleAway,
|
||||||
|
irc.CmdKick: c.handleKick,
|
||||||
|
irc.CmdPass: c.handlePassPostReg,
|
||||||
|
"INVITE": c.handleInvite,
|
||||||
|
"CAP": func(_ context.Context, msg *Message) {
|
||||||
|
c.handleCAP(msg)
|
||||||
|
},
|
||||||
|
"USERHOST": c.handleUserhost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHost does a reverse DNS lookup, returning the IP
|
||||||
|
// on failure.
|
||||||
|
func resolveHost(ctx context.Context, addr string) string {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, dnsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resolver := &net.Resolver{} //nolint:exhaustruct
|
||||||
|
|
||||||
|
names, err := resolver.LookupAddr(ctx, addr)
|
||||||
|
if err != nil || len(names) == 0 {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(names[0], ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve is the main loop for a single IRC client connection.
|
||||||
|
func (c *Conn) serve(ctx context.Context) {
|
||||||
|
ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
defer c.cleanup(ctx)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(c.conn)
|
||||||
|
scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_ = c.conn.SetReadDeadline(
|
||||||
|
time.Now().Add(readTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := ParseMessage(line)
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.handleMessage(ctx, msg)
|
||||||
|
|
||||||
|
if c.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Conn) cleanup(ctx context.Context) {
|
||||||
|
c.mu.Lock()
|
||||||
|
wasRegistered := c.registered
|
||||||
|
sessID := c.sessionID
|
||||||
|
nick := c.nick
|
||||||
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if wasRegistered && sessID > 0 {
|
||||||
|
c.svc.BroadcastQuit(
|
||||||
|
ctx, sessID, nick, "Connection closed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
// send writes a formatted IRC line to the connection.
|
||||||
|
func (c *Conn) send(line string) {
|
||||||
|
_ = c.conn.SetWriteDeadline(
|
||||||
|
time.Now().Add(writeTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendNumeric sends a numeric reply from the server.
|
||||||
|
func (c *Conn) sendNumeric(
|
||||||
|
code irc.IRCMessageType,
|
||||||
|
params ...string,
|
||||||
|
) {
|
||||||
|
nick := c.nick
|
||||||
|
if nick == "" {
|
||||||
|
nick = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
allParams := make([]string, 0, 1+len(params))
|
||||||
|
allParams = append(allParams, nick)
|
||||||
|
allParams = append(allParams, params...)
|
||||||
|
|
||||||
|
c.send(FormatMessage(
|
||||||
|
c.serverSfx, code.Code(), allParams...,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendFromServer sends a message from the server.
|
||||||
|
func (c *Conn) sendFromServer(
|
||||||
|
command string, params ...string,
|
||||||
|
) {
|
||||||
|
c.send(FormatMessage(c.serverSfx, command, params...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostmask returns the client's full hostmask
|
||||||
|
// (nick!user@host).
|
||||||
|
func (c *Conn) hostmask() string {
|
||||||
|
user := c.username
|
||||||
|
if user == "" {
|
||||||
|
user = c.nick
|
||||||
|
}
|
||||||
|
|
||||||
|
host := c.hostname
|
||||||
|
if host == "" {
|
||||||
|
host = c.remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.nick + "!" + user + "@" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessage dispatches a parsed IRC message using
|
||||||
|
// the command handler map.
|
||||||
|
func (c *Conn) handleMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
// Before registration, only NICK, USER, PASS, PING,
|
||||||
|
// QUIT, and CAP are accepted.
|
||||||
|
if !c.registered {
|
||||||
|
c.handlePreRegistration(ctx, msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, ok := c.commands[msg.Command]
|
||||||
|
if !ok {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUnknownCommand,
|
||||||
|
msg.Command, "Unknown command",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePreRegistration handles messages before the
|
||||||
|
// connection is registered (NICK+USER received).
|
||||||
|
func (c *Conn) handlePreRegistration(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
switch msg.Command {
|
||||||
|
case irc.CmdPass:
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"PASS", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.passWord = msg.Params[0]
|
||||||
|
case irc.CmdNick:
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoNicknameGiven,
|
||||||
|
"No nickname given",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.nick = msg.Params[0]
|
||||||
|
if len(c.nick) > maxNickLen {
|
||||||
|
c.nick = c.nick[:maxNickLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.gotNick = true
|
||||||
|
case irc.CmdUser:
|
||||||
|
if len(msg.Params) < 4 { //nolint:mnd
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"USER", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.username = msg.Params[0]
|
||||||
|
c.realname = msg.Params[3]
|
||||||
|
c.gotUser = true
|
||||||
|
case irc.CmdPing:
|
||||||
|
c.handlePing(msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
case irc.CmdQuit:
|
||||||
|
c.handleQuit(msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
case "CAP":
|
||||||
|
c.handleCAP(msg)
|
||||||
|
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNotRegistered,
|
||||||
|
"You have not registered",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to complete registration once we have both
|
||||||
|
// NICK and USER.
|
||||||
|
if c.gotNick && c.gotUser {
|
||||||
|
c.completeRegistration(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeRegistration creates a session and sends the
|
||||||
|
// welcome burst.
|
||||||
|
func (c *Conn) completeRegistration(ctx context.Context) {
|
||||||
|
// Check if nick is valid.
|
||||||
|
if c.nick == "" {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoNicknameGiven, "No nickname given",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session in DB.
|
||||||
|
sessionID, clientID, _, err := c.database.CreateSession(
|
||||||
|
ctx, c.nick, c.username, c.hostname, c.remoteIP,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint") ||
|
||||||
|
strings.Contains(err.Error(), "nick") {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNicknameInUse,
|
||||||
|
c.nick, "Nickname is already in use",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Error(
|
||||||
|
"failed to create session", "error", err,
|
||||||
|
)
|
||||||
|
c.send("ERROR :Internal server error")
|
||||||
|
c.closed = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.sessionID = sessionID
|
||||||
|
c.clientID = clientID
|
||||||
|
c.registered = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// If PASS was provided before registration, set the
|
||||||
|
// session password.
|
||||||
|
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||||
|
c.setPassword(ctx, c.passWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send welcome burst.
|
||||||
|
c.deliverWelcome()
|
||||||
|
c.deliverLusers(ctx)
|
||||||
|
c.deliverMOTD()
|
||||||
|
|
||||||
|
// Start the message relay goroutine.
|
||||||
|
go c.relayMessages(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverWelcome sends 001-005 welcome numerics.
|
||||||
|
func (c *Conn) deliverWelcome() {
|
||||||
|
c.sendNumeric(irc.RplWelcome, fmt.Sprintf(
|
||||||
|
"Welcome to the %s Network, %s",
|
||||||
|
c.serverSfx, c.hostmask(),
|
||||||
|
))
|
||||||
|
c.sendNumeric(irc.RplYourHost, fmt.Sprintf(
|
||||||
|
"Your host is %s, running version neoirc",
|
||||||
|
c.serverSfx,
|
||||||
|
))
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplCreated,
|
||||||
|
"This server was created recently",
|
||||||
|
)
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplMyInfo,
|
||||||
|
c.serverSfx, "neoirc", "", "mnst",
|
||||||
|
)
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplIsupport,
|
||||||
|
"CHANTYPES=#",
|
||||||
|
"NICKLEN=32",
|
||||||
|
"PREFIX=(ov)@+",
|
||||||
|
"CHANMODES=,,H,mnst",
|
||||||
|
"NETWORK="+c.serverSfx,
|
||||||
|
"are supported by this server",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverLusers sends 251/252/254/255 server statistics.
|
||||||
|
func (c *Conn) deliverLusers(ctx context.Context) {
|
||||||
|
users, _ := c.database.GetUserCount(ctx)
|
||||||
|
opers, _ := c.database.GetOperCount(ctx)
|
||||||
|
channels, _ := c.database.GetChannelCount(ctx)
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplLuserClient, fmt.Sprintf(
|
||||||
|
"There are %d users and 0 invisible on 1 servers",
|
||||||
|
users,
|
||||||
|
))
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplLuserOp,
|
||||||
|
strconv.FormatInt(opers, 10),
|
||||||
|
"operator(s) online",
|
||||||
|
)
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplLuserChannels,
|
||||||
|
strconv.FormatInt(channels, 10),
|
||||||
|
"channels formed",
|
||||||
|
)
|
||||||
|
c.sendNumeric(irc.RplLuserMe, fmt.Sprintf(
|
||||||
|
"I have %d clients and 1 servers", users,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverMOTD sends 375/372/376 MOTD lines.
|
||||||
|
func (c *Conn) deliverMOTD() {
|
||||||
|
motd := c.cfg.MOTD
|
||||||
|
if motd == "" {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoMotd, "MOTD File is missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(irc.RplMotdStart, fmt.Sprintf(
|
||||||
|
"- %s Message of the Day -", c.serverSfx,
|
||||||
|
))
|
||||||
|
|
||||||
|
for _, line := range strings.Split(motd, "\n") {
|
||||||
|
c.sendNumeric(irc.RplMotd, "- "+line)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplEndOfMotd, "End of /MOTD command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPassword sets a bcrypt password on the session.
|
||||||
|
func (c *Conn) setPassword(ctx context.Context, pw string) {
|
||||||
|
// Use the database's auth module to hash and store.
|
||||||
|
err := c.database.SetPassword(ctx, c.sessionID, pw)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Error(
|
||||||
|
"failed to set password", "error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/ircserver/export_test.go
Normal file
52
internal/ircserver/export_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewTestServer creates a Server suitable for testing.
|
||||||
|
// The caller must call Stop() when finished.
|
||||||
|
func NewTestServer(
|
||||||
|
log *slog.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
database *db.Database,
|
||||||
|
brk *broker.Broker,
|
||||||
|
) *Server {
|
||||||
|
svc := &service.Service{
|
||||||
|
DB: database,
|
||||||
|
Broker: brk,
|
||||||
|
Config: cfg,
|
||||||
|
Log: log,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{ //nolint:exhaustruct
|
||||||
|
log: log,
|
||||||
|
cfg: cfg,
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
svc: svc,
|
||||||
|
conns: make(map[*Conn]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start exposes the unexported start method for tests.
|
||||||
|
func (s *Server) Start(addr string) error {
|
||||||
|
return s.start(context.Background(), addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop exposes the unexported stop method for tests.
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
s.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener returns the server's net.Listener for tests.
|
||||||
|
func (s *Server) Listener() net.Listener {
|
||||||
|
return s.listener
|
||||||
|
}
|
||||||
123
internal/ircserver/parser.go
Normal file
123
internal/ircserver/parser.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Package ircserver implements a traditional IRC wire protocol
|
||||||
|
// listener (RFC 1459/2812) that bridges to the neoirc HTTP/JSON
|
||||||
|
// server internals.
|
||||||
|
package ircserver
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Message represents a parsed IRC wire protocol message.
|
||||||
|
type Message struct {
|
||||||
|
// Prefix is the optional :prefix at the start (may be
|
||||||
|
// empty for client-to-server messages).
|
||||||
|
Prefix string
|
||||||
|
// Command is the IRC command (e.g., "PRIVMSG", "NICK").
|
||||||
|
Command string
|
||||||
|
// Params holds the positional parameters, including the
|
||||||
|
// trailing parameter (which was preceded by ':' on the
|
||||||
|
// wire).
|
||||||
|
Params []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMessage parses a single IRC wire protocol line
|
||||||
|
// (without the trailing CR-LF) into a Message.
|
||||||
|
// Returns nil if the line is empty.
|
||||||
|
//
|
||||||
|
// IRC message format (RFC 1459 §2.3.1):
|
||||||
|
//
|
||||||
|
// [":" prefix SPACE] command { SPACE param } [SPACE ":" trailing]
|
||||||
|
func ParseMessage(line string) *Message {
|
||||||
|
if line == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &Message{} //nolint:exhaustruct // fields set below
|
||||||
|
|
||||||
|
// Extract prefix if present.
|
||||||
|
if line[0] == ':' {
|
||||||
|
idx := strings.IndexByte(line, ' ')
|
||||||
|
if idx < 0 {
|
||||||
|
// Only a prefix, no command — invalid.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Prefix = line[1:idx]
|
||||||
|
line = line[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip leading spaces.
|
||||||
|
line = strings.TrimLeft(line, " ")
|
||||||
|
if line == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract command.
|
||||||
|
idx := strings.IndexByte(line, ' ')
|
||||||
|
if idx < 0 {
|
||||||
|
msg.Command = strings.ToUpper(line)
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Command = strings.ToUpper(line[:idx])
|
||||||
|
line = line[idx+1:]
|
||||||
|
|
||||||
|
// Extract parameters.
|
||||||
|
for line != "" {
|
||||||
|
line = strings.TrimLeft(line, " ")
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trailing parameter (everything after ':').
|
||||||
|
if line[0] == ':' {
|
||||||
|
msg.Params = append(msg.Params, line[1:])
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = strings.IndexByte(line, ' ')
|
||||||
|
if idx < 0 {
|
||||||
|
msg.Params = append(msg.Params, line)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Params = append(msg.Params, line[:idx])
|
||||||
|
line = line[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatMessage formats an IRC message into wire protocol
|
||||||
|
// format (without the trailing CR-LF).
|
||||||
|
func FormatMessage(
|
||||||
|
prefix, command string,
|
||||||
|
params ...string,
|
||||||
|
) string {
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
if prefix != "" {
|
||||||
|
buf.WriteByte(':')
|
||||||
|
buf.WriteString(prefix)
|
||||||
|
buf.WriteByte(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(command)
|
||||||
|
|
||||||
|
for i, param := range params {
|
||||||
|
buf.WriteByte(' ')
|
||||||
|
|
||||||
|
isLast := i == len(params)-1
|
||||||
|
needsColon := strings.Contains(param, " ") ||
|
||||||
|
param == "" || param[0] == ':'
|
||||||
|
|
||||||
|
if isLast && needsColon {
|
||||||
|
buf.WriteByte(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(param)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
328
internal/ircserver/parser_test.go
Normal file
328
internal/ircserver/parser_test.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package ircserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:funlen // table-driven test
|
||||||
|
func TestParseMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want *ircserver.Message
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple command",
|
||||||
|
input: "PING",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PING",
|
||||||
|
Params: nil,
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command with one param",
|
||||||
|
input: "NICK alice",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "NICK",
|
||||||
|
Params: []string{"alice"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command case insensitive",
|
||||||
|
input: "nick Alice",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "NICK",
|
||||||
|
Params: []string{"Alice"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "privmsg with trailing",
|
||||||
|
input: "PRIVMSG #general :hello world",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
Params: []string{"#general", "hello world"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with prefix",
|
||||||
|
input: ":server.example.com 001 alice :Welcome to IRC",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "server.example.com",
|
||||||
|
Command: "001",
|
||||||
|
Params: []string{"alice", "Welcome to IRC"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user command",
|
||||||
|
input: "USER alice 0 * :Alice Smith",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "USER",
|
||||||
|
Params: []string{
|
||||||
|
"alice", "0", "*", "Alice Smith",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "join channel",
|
||||||
|
input: "JOIN #general",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "JOIN",
|
||||||
|
Params: []string{"#general"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quit with trailing",
|
||||||
|
input: "QUIT :leaving now",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "QUIT",
|
||||||
|
Params: []string{"leaving now"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quit without reason",
|
||||||
|
input: "QUIT",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "QUIT",
|
||||||
|
Params: nil,
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mode query",
|
||||||
|
input: "MODE #general",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "MODE",
|
||||||
|
Params: []string{"#general"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kick with reason",
|
||||||
|
input: "KICK #general bob :misbehaving",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "KICK",
|
||||||
|
Params: []string{
|
||||||
|
"#general", "bob", "misbehaving",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty trailing",
|
||||||
|
input: "PRIVMSG #general :",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
Params: []string{"#general", ""},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pass command",
|
||||||
|
input: "PASS mysecret",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PASS",
|
||||||
|
Params: []string{"mysecret"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ping with server",
|
||||||
|
input: "PING :irc.example.com",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "PING",
|
||||||
|
Params: []string{"irc.example.com"},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "topic with trailing spaces",
|
||||||
|
input: "TOPIC #general :Welcome to the channel!",
|
||||||
|
want: &ircserver.Message{
|
||||||
|
Prefix: "",
|
||||||
|
Command: "TOPIC",
|
||||||
|
Params: []string{
|
||||||
|
"#general",
|
||||||
|
"Welcome to the channel!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range tests {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ircserver.ParseMessage(testCase.input)
|
||||||
|
if testCase.wantNil {
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("expected nil, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil message")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Prefix != testCase.want.Prefix {
|
||||||
|
t.Errorf(
|
||||||
|
"prefix: got %q, want %q",
|
||||||
|
got.Prefix, testCase.want.Prefix,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Command != testCase.want.Command {
|
||||||
|
t.Errorf(
|
||||||
|
"command: got %q, want %q",
|
||||||
|
got.Command, testCase.want.Command,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Params) != len(testCase.want.Params) {
|
||||||
|
t.Fatalf(
|
||||||
|
"params length: got %d, want %d (%v vs %v)",
|
||||||
|
len(got.Params),
|
||||||
|
len(testCase.want.Params),
|
||||||
|
got.Params,
|
||||||
|
testCase.want.Params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, p := range got.Params {
|
||||||
|
if p != testCase.want.Params[i] {
|
||||||
|
t.Errorf(
|
||||||
|
"param[%d]: got %q, want %q",
|
||||||
|
i, p, testCase.want.Params[i],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
command string
|
||||||
|
params []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple command",
|
||||||
|
prefix: "",
|
||||||
|
command: "PING",
|
||||||
|
params: nil,
|
||||||
|
want: "PING",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with prefix",
|
||||||
|
prefix: "server",
|
||||||
|
command: "PONG",
|
||||||
|
params: []string{"server"},
|
||||||
|
want: ":server PONG server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "privmsg with trailing",
|
||||||
|
prefix: "alice!alice@host",
|
||||||
|
command: "PRIVMSG",
|
||||||
|
params: []string{"#general", "hello world"},
|
||||||
|
want: ":alice!alice@host PRIVMSG #general :hello world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric reply",
|
||||||
|
prefix: "server",
|
||||||
|
command: "001",
|
||||||
|
params: []string{"alice", "Welcome to IRC"},
|
||||||
|
want: ":server 001 alice :Welcome to IRC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty trailing",
|
||||||
|
prefix: "server",
|
||||||
|
command: "PRIVMSG",
|
||||||
|
params: []string{"#chan", ""},
|
||||||
|
want: ":server PRIVMSG #chan :",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range tests {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ircserver.FormatMessage(
|
||||||
|
testCase.prefix, testCase.command, testCase.params...,
|
||||||
|
)
|
||||||
|
if got != testCase.want {
|
||||||
|
t.Errorf("got %q, want %q", got, testCase.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFormatRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Round-trip only works for lines where the last
|
||||||
|
// parameter either contains a space (gets ':' prefix
|
||||||
|
// on format) or is a non-trailing single token.
|
||||||
|
lines := []string{
|
||||||
|
"PING",
|
||||||
|
"NICK alice",
|
||||||
|
"PRIVMSG #general :hello world",
|
||||||
|
"JOIN #general",
|
||||||
|
"MODE #general",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
msg := ircserver.ParseMessage(line)
|
||||||
|
if msg == nil {
|
||||||
|
t.Fatalf("failed to parse: %q", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := ircserver.FormatMessage(
|
||||||
|
msg.Prefix, msg.Command, msg.Params...,
|
||||||
|
)
|
||||||
|
if formatted != line {
|
||||||
|
t.Errorf(
|
||||||
|
"round-trip failed: input %q, got %q",
|
||||||
|
line, formatted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
319
internal/ircserver/relay.go
Normal file
319
internal/ircserver/relay.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relayMessages polls the client output queue and delivers
|
||||||
|
// IRC-formatted messages to the TCP connection. It runs
|
||||||
|
// in a goroutine for the lifetime of the connection.
|
||||||
|
func (c *Conn) relayMessages(ctx context.Context) {
|
||||||
|
// Use a ticker as a fallback; primary wakeup is via
|
||||||
|
// broker notification.
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain any available messages.
|
||||||
|
delivered := c.drainQueue(ctx)
|
||||||
|
|
||||||
|
if delivered {
|
||||||
|
// Tight loop while there are messages.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for notification or timeout.
|
||||||
|
waitCh := c.brk.Wait(c.sessionID)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
// New message notification — loop back.
|
||||||
|
case <-ticker.C:
|
||||||
|
// Periodic check.
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.brk.Remove(c.sessionID, waitCh)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayPollLimit = 100
|
||||||
|
|
||||||
|
// drainQueue polls the output queue and delivers all
|
||||||
|
// pending messages. Returns true if at least one message
|
||||||
|
// was delivered.
|
||||||
|
func (c *Conn) drainQueue(ctx context.Context) bool {
|
||||||
|
msgs, lastID, err := c.database.PollMessages(
|
||||||
|
ctx, c.clientID, c.lastQueueID, relayPollLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range msgs {
|
||||||
|
c.deliverIRCMessage(ctx, &msgs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastID > c.lastQueueID {
|
||||||
|
c.lastQueueID = lastID
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverIRCMessage converts a db.IRCMessage to wire
|
||||||
|
// protocol and sends it.
|
||||||
|
//
|
||||||
|
//nolint:cyclop // dispatch table
|
||||||
|
func (c *Conn) deliverIRCMessage(
|
||||||
|
_ context.Context,
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
) {
|
||||||
|
command := msg.Command
|
||||||
|
|
||||||
|
// Decode body as []string for the trailing text.
|
||||||
|
var bodyLines []string
|
||||||
|
|
||||||
|
if msg.Body != nil {
|
||||||
|
_ = json.Unmarshal(msg.Body, &bodyLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := ""
|
||||||
|
if len(bodyLines) > 0 {
|
||||||
|
text = bodyLines[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route by command type.
|
||||||
|
switch {
|
||||||
|
case isNumeric(command):
|
||||||
|
c.deliverNumeric(msg, text)
|
||||||
|
case command == irc.CmdPrivmsg || command == irc.CmdNotice:
|
||||||
|
c.deliverTextMessage(msg, command, text)
|
||||||
|
case command == irc.CmdJoin:
|
||||||
|
c.deliverJoin(msg)
|
||||||
|
case command == irc.CmdPart:
|
||||||
|
c.deliverPart(msg, text)
|
||||||
|
case command == irc.CmdNick:
|
||||||
|
c.deliverNickChange(msg, text)
|
||||||
|
case command == irc.CmdQuit:
|
||||||
|
c.deliverQuitMsg(msg, text)
|
||||||
|
case command == irc.CmdTopic:
|
||||||
|
c.deliverTopicChange(msg, text)
|
||||||
|
case command == irc.CmdKick:
|
||||||
|
c.deliverKickMsg(msg, text)
|
||||||
|
case command == "INVITE":
|
||||||
|
c.deliverInviteMsg(msg, text)
|
||||||
|
case command == irc.CmdMode:
|
||||||
|
c.deliverMode(msg, text)
|
||||||
|
case command == irc.CmdPing:
|
||||||
|
// Server-originated PING — reply with PONG.
|
||||||
|
c.sendFromServer("PING", c.serverSfx)
|
||||||
|
default:
|
||||||
|
// Unknown command — deliver as server notice.
|
||||||
|
if text != "" {
|
||||||
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumeric returns true if the command is a 3-digit
|
||||||
|
// numeric code.
|
||||||
|
func isNumeric(cmd string) bool {
|
||||||
|
return len(cmd) == 3 &&
|
||||||
|
cmd[0] >= '0' && cmd[0] <= '9' &&
|
||||||
|
cmd[1] >= '0' && cmd[1] <= '9' &&
|
||||||
|
cmd[2] >= '0' && cmd[2] <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverNumeric sends a numeric reply.
|
||||||
|
func (c *Conn) deliverNumeric(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
from := msg.From
|
||||||
|
if from == "" {
|
||||||
|
from = c.serverSfx
|
||||||
|
}
|
||||||
|
|
||||||
|
var params []string
|
||||||
|
|
||||||
|
if msg.Params != nil {
|
||||||
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
allParams := make([]string, 0, 1+len(params)+1)
|
||||||
|
allParams = append(allParams, c.nick)
|
||||||
|
allParams = append(allParams, params...)
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
allParams = append(allParams, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.send(FormatMessage(from, msg.Command, allParams...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverTextMessage sends PRIVMSG or NOTICE.
|
||||||
|
func (c *Conn) deliverTextMessage(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
command, text string,
|
||||||
|
) {
|
||||||
|
from := msg.From
|
||||||
|
target := msg.To
|
||||||
|
|
||||||
|
// Don't echo our own messages back.
|
||||||
|
if strings.EqualFold(from, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := from
|
||||||
|
if !strings.Contains(prefix, "!") {
|
||||||
|
prefix = from + "!" + from + "@*"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, command, target, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverJoin sends a JOIN notification.
|
||||||
|
func (c *Conn) deliverJoin(msg *db.IRCMessage) {
|
||||||
|
// Don't echo our own JOINs (we already sent them
|
||||||
|
// during joinChannel).
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, "JOIN", channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverPart sends a PART notification.
|
||||||
|
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "PART", channel, text,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
c.send(FormatMessage(prefix, "PART", channel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverNickChange sends a NICK change notification.
|
||||||
|
func (c *Conn) deliverNickChange(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
newNick string,
|
||||||
|
) {
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, "NICK", newNick))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverQuitMsg sends a QUIT notification.
|
||||||
|
func (c *Conn) deliverQuitMsg(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
if strings.EqualFold(msg.From, c.nick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "QUIT", "Quit: "+text,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
c.send(FormatMessage(prefix, "QUIT", "Quit"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverTopicChange sends a TOPIC change notification.
|
||||||
|
func (c *Conn) deliverTopicChange(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, "TOPIC", channel, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverKickMsg sends a KICK notification.
|
||||||
|
func (c *Conn) deliverKickMsg(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
channel := msg.To
|
||||||
|
|
||||||
|
var params []string
|
||||||
|
|
||||||
|
if msg.Params != nil {
|
||||||
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
kickTarget := ""
|
||||||
|
if len(params) > 0 {
|
||||||
|
kickTarget = params[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if kickTarget != "" {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "KICK", channel, kickTarget, text,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, "KICK", channel, "?", text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverInviteMsg sends an INVITE notification.
|
||||||
|
func (c *Conn) deliverInviteMsg(
|
||||||
|
_ *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverMode sends a MODE change notification.
|
||||||
|
func (c *Conn) deliverMode(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
target := msg.To
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
c.send(FormatMessage(prefix, "MODE", target, text))
|
||||||
|
}
|
||||||
|
}
|
||||||
157
internal/ircserver/server.go
Normal file
157
internal/ircserver/server.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package ircserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params defines the dependencies for creating an IRC
|
||||||
|
// Server.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Logger *logger.Logger
|
||||||
|
Config *config.Config
|
||||||
|
Database *db.Database
|
||||||
|
Broker *broker.Broker
|
||||||
|
Service *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the TCP IRC protocol server.
|
||||||
|
type Server struct {
|
||||||
|
log *slog.Logger
|
||||||
|
cfg *config.Config
|
||||||
|
database *db.Database
|
||||||
|
brk *broker.Broker
|
||||||
|
svc *service.Service
|
||||||
|
listener net.Listener
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[*Conn]struct{}
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new IRC Server and registers its lifecycle
|
||||||
|
// hooks. The listener is only started if IRC_LISTEN_ADDR
|
||||||
|
// is configured; otherwise the server is inert.
|
||||||
|
func New(
|
||||||
|
lifecycle fx.Lifecycle,
|
||||||
|
params Params,
|
||||||
|
) *Server {
|
||||||
|
srv := &Server{
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
cfg: params.Config,
|
||||||
|
database: params.Database,
|
||||||
|
brk: params.Broker,
|
||||||
|
svc: params.Service,
|
||||||
|
conns: make(map[*Conn]struct{}),
|
||||||
|
listener: nil,
|
||||||
|
cancel: nil,
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
}
|
||||||
|
|
||||||
|
listenAddr := params.Config.IRCListenAddr
|
||||||
|
if listenAddr == "" {
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
return srv.start(ctx, listenAddr)
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// start begins listening for TCP connections.
|
||||||
|
//
|
||||||
|
//nolint:contextcheck // long-lived server ctx, not the short Fx one
|
||||||
|
func (s *Server) start(_ context.Context, addr string) error {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("irc listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.listener = ln
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = cancel
|
||||||
|
|
||||||
|
s.log.Info(
|
||||||
|
"irc server listening", "addr", addr,
|
||||||
|
)
|
||||||
|
|
||||||
|
go s.acceptLoop(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop shuts down the listener and all connections.
|
||||||
|
func (s *Server) stop() {
|
||||||
|
if s.cancel != nil {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.listener != nil {
|
||||||
|
s.listener.Close() //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
for c := range s.conns {
|
||||||
|
c.conn.Close() //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptLoop accepts new connections.
|
||||||
|
func (s *Server) acceptLoop(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
tcpConn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
s.log.Error(
|
||||||
|
"irc accept error", "error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := newConn(
|
||||||
|
ctx, tcpConn, s.log,
|
||||||
|
s.database, s.brk, s.cfg, s.svc,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.conns[client] = struct{}{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.conns, client)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client.serve(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
625
internal/ircserver/server_test.go
Normal file
625
internal/ircserver/server_test.go
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
package ircserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
db.SetBcryptCost(4)
|
||||||
|
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// testEnv holds the shared test infrastructure.
|
||||||
|
type testEnv struct {
|
||||||
|
database *db.Database
|
||||||
|
brk *broker.Broker
|
||||||
|
cfg *config.Config
|
||||||
|
srv *ircserver.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestEnv(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||||
|
t.Name(),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
t.Context(), "PRAGMA foreign_keys = ON",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database := db.NewTestDatabaseFromConn(conn)
|
||||||
|
|
||||||
|
err = database.RunMigrations(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brk := broker.New()
|
||||||
|
|
||||||
|
cfg := &config.Config{ //nolint:exhaustruct
|
||||||
|
ServerName: "test.irc",
|
||||||
|
MOTD: "Welcome to test IRC",
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
|
||||||
|
err = listener.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||||
|
))
|
||||||
|
|
||||||
|
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||||
|
|
||||||
|
err = srv.Start(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start irc server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
srv.Stop()
|
||||||
|
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("close db: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
cfg: cfg,
|
||||||
|
srv: srv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial connects to the test server.
|
||||||
|
func (env *testEnv) dial(t *testing.T) *testClient {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout(
|
||||||
|
"tcp",
|
||||||
|
env.srv.Listener().Addr().String(),
|
||||||
|
testTimeout,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("close conn: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testClient{
|
||||||
|
t: t,
|
||||||
|
conn: conn,
|
||||||
|
scanner: bufio.NewScanner(conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testClient wraps a raw TCP connection with helpers.
|
||||||
|
type testClient struct {
|
||||||
|
t *testing.T
|
||||||
|
conn net.Conn
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *testClient) send(line string) {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
_ = tc.conn.SetWriteDeadline(
|
||||||
|
time.Now().Add(testTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := fmt.Fprintf(tc.conn, "%s\r\n", line)
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *testClient) readLine() string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
_ = tc.conn.SetReadDeadline(
|
||||||
|
time.Now().Add(testTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
if !tc.scanner.Scan() {
|
||||||
|
err := tc.scanner.Err()
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.t.Fatal("connection closed unexpectedly")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tc.scanner.Text()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUntil reads lines until one matches the predicate.
|
||||||
|
func (tc *testClient) readUntil(
|
||||||
|
pred func(string) bool,
|
||||||
|
) []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for {
|
||||||
|
line := tc.readLine()
|
||||||
|
lines = append(lines, line)
|
||||||
|
|
||||||
|
if pred(line) {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// register sends NICK + USER and reads through the welcome
|
||||||
|
// burst.
|
||||||
|
func (tc *testClient) register(nick string) []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
tc.send("NICK " + nick)
|
||||||
|
tc.send("USER " + nick + " 0 * :Test User")
|
||||||
|
|
||||||
|
return tc.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 376 ") ||
|
||||||
|
strings.Contains(line, " 422 ")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertContains checks that at least one line matches the
|
||||||
|
// given substring.
|
||||||
|
func assertContains(
|
||||||
|
t *testing.T,
|
||||||
|
lines []string,
|
||||||
|
substr, description string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, substr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("did not find %q in output: %s", substr, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinAndDrain joins a channel and reads until
|
||||||
|
// RPL_ENDOFNAMES.
|
||||||
|
func (tc *testClient) joinAndDrain(channel string) {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
tc.send("JOIN " + channel)
|
||||||
|
|
||||||
|
tc.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 366 ")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAndExpect sends a command and reads until a line
|
||||||
|
// containing the expected substring is found.
|
||||||
|
func (tc *testClient) sendAndExpect(
|
||||||
|
cmd, expect string,
|
||||||
|
) []string {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
tc.send(cmd)
|
||||||
|
|
||||||
|
return tc.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, expect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
lines := client.register("alice")
|
||||||
|
assertContains(t, lines, " 001 ", "RPL_WELCOME")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWelcomeContainsNick(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
lines := client.register("bob")
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, " 001 ") &&
|
||||||
|
!strings.Contains(line, "bob") {
|
||||||
|
t.Errorf("001 should contain nick: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingPong(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("pingtest")
|
||||||
|
lines := client.sendAndExpect("PING :hello", "PONG")
|
||||||
|
assertContains(t, lines, "PONG", "PONG response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("joiner")
|
||||||
|
client.send("JOIN #test")
|
||||||
|
|
||||||
|
lines := client.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 366 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
assertContains(t, lines, "JOIN", "JOIN echo")
|
||||||
|
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivmsgBetweenClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice_pm")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob_pm")
|
||||||
|
|
||||||
|
alice.joinAndDrain("#chat")
|
||||||
|
bob.joinAndDrain("#chat")
|
||||||
|
|
||||||
|
alice.send("PRIVMSG #chat :hello bob!")
|
||||||
|
lines := bob.sendAndExpect("PING :sync", "hello bob!")
|
||||||
|
assertContains(t, lines, "hello bob!", "channel PRIVMSG")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNickChange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("oldnick")
|
||||||
|
lines := client.sendAndExpect("NICK newnick", "newnick")
|
||||||
|
assertContains(t, lines, "NICK", "NICK change echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateNick(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
first := env.dial(t)
|
||||||
|
first.register("taken")
|
||||||
|
|
||||||
|
second := env.dial(t)
|
||||||
|
second.send("NICK taken")
|
||||||
|
second.send("USER taken 0 * :Test")
|
||||||
|
|
||||||
|
lines := second.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, " 433 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListChannels(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("lister")
|
||||||
|
client.joinAndDrain("#listtest")
|
||||||
|
lines := client.sendAndExpect("LIST", " 323 ")
|
||||||
|
assertContains(t, lines, " 323 ", "RPL_LISTEND") //nolint:misspell // IRC term
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhois(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("whoistest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"WHOIS whoistest", " 318 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(t, lines, " 311 ", "RPL_WHOISUSER")
|
||||||
|
assertContains(t, lines, " 318 ", "RPL_ENDOFWHOIS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("quitter")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"QUIT :goodbye", "ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(t, lines, "goodbye", "QUIT reason")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopicSetAndGet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("topicuser")
|
||||||
|
client.joinAndDrain("#topictest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"TOPIC #topictest :New topic here",
|
||||||
|
"New topic here",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, "New topic here", "TOPIC echo",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("unknowncmd")
|
||||||
|
lines := client.sendAndExpect("FOOBAR", " 421 ")
|
||||||
|
assertContains(t, lines, " 421 ", "ERR_UNKNOWNCOMMAND")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
sender := env.dial(t)
|
||||||
|
sender.register("dmsender")
|
||||||
|
|
||||||
|
receiver := env.dial(t)
|
||||||
|
receiver.register("dmreceiver")
|
||||||
|
|
||||||
|
// Give relay goroutines time to start.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
sender.send("PRIVMSG dmreceiver :hello privately")
|
||||||
|
|
||||||
|
lines := receiver.readUntil(func(line string) bool {
|
||||||
|
return strings.Contains(line, "hello privately")
|
||||||
|
})
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, "hello privately", "direct PRIVMSG",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCAPNegotiation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.send("CAP LS 302")
|
||||||
|
|
||||||
|
line := client.readLine()
|
||||||
|
if !strings.Contains(line, "CAP") {
|
||||||
|
t.Errorf("expected CAP response, got: %s", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.send("CAP END")
|
||||||
|
lines := client.register("capuser")
|
||||||
|
assertContains(t, lines, " 001 ", "registration after CAP")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("parter")
|
||||||
|
client.joinAndDrain("#parttest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"PART #parttest :leaving", "PART",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(t, lines, "#parttest", "PART echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("modeuser")
|
||||||
|
client.joinAndDrain("#modetest")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"MODE #modetest", " 324 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, " 324 ", "RPL_CHANNELMODEIS",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhoChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("whouser")
|
||||||
|
client.joinAndDrain("#whotest")
|
||||||
|
lines := client.sendAndExpect("WHO #whotest", " 315 ")
|
||||||
|
assertContains(t, lines, " 352 ", "RPL_WHOREPLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLusers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("luseruser")
|
||||||
|
lines := client.sendAndExpect("LUSERS", " 255 ")
|
||||||
|
assertContains(t, lines, " 251 ", "RPL_LUSERCLIENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMotd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("motduser")
|
||||||
|
lines := client.sendAndExpect("MOTD", " 376 ")
|
||||||
|
assertContains(t, lines, " 376 ", "RPL_ENDOFMOTD")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwaySetAndClear(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("awayuser")
|
||||||
|
|
||||||
|
setLines := client.sendAndExpect(
|
||||||
|
"AWAY :brb lunch", " 306 ",
|
||||||
|
)
|
||||||
|
assertContains(t, setLines, " 306 ", "RPL_NOWAWAY")
|
||||||
|
|
||||||
|
clearLines := client.sendAndExpect("AWAY", " 305 ")
|
||||||
|
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePassPostRegistration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("passuser")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"PASS :mypassword123", "Password set",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, "Password set", "password confirmation",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreRegistrationNotRegistered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.send("PRIVMSG #test :hello")
|
||||||
|
|
||||||
|
line := client.readLine()
|
||||||
|
if !strings.Contains(line, " 451 ") {
|
||||||
|
t.Errorf(
|
||||||
|
"expected ERR_NOTREGISTERED (451), got: %s",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamesNonExistentChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("namesuser")
|
||||||
|
lines := client.sendAndExpect(
|
||||||
|
"NAMES #doesnotexist", " 366 ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertContains(
|
||||||
|
t, lines, " 366 ",
|
||||||
|
"RPL_ENDOFNAMES for non-existent channel",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseMessage(b *testing.B) {
|
||||||
|
line := ":nick!user@host PRIVMSG #channel :Hello, world!"
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
for range b.N {
|
||||||
|
_ = ircserver.ParseMessage(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkFormatMessage(b *testing.B) {
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
for range b.N {
|
||||||
|
_ = ircserver.FormatMessage(
|
||||||
|
"nick!user@host", "PRIVMSG",
|
||||||
|
"#channel", "Hello, world!",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
739
internal/service/service.go
Normal file
739
internal/service/service.go
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
// Package service provides shared business logic for both
|
||||||
|
// the IRC wire protocol and HTTP/JSON transports.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params defines the dependencies for creating a Service.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Logger *logger.Logger
|
||||||
|
Config *config.Config
|
||||||
|
Database *db.Database
|
||||||
|
Broker *broker.Broker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service provides shared business logic for IRC commands.
|
||||||
|
type Service struct {
|
||||||
|
DB *db.Database
|
||||||
|
Broker *broker.Broker
|
||||||
|
Config *config.Config
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Service.
|
||||||
|
func New(params Params) *Service {
|
||||||
|
return &Service{
|
||||||
|
DB: params.Database,
|
||||||
|
Broker: params.Broker,
|
||||||
|
Config: params.Config,
|
||||||
|
Log: params.Logger.Get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IRCError represents an IRC protocol-level error with a
|
||||||
|
// numeric code that both transports can map to responses.
|
||||||
|
type IRCError struct {
|
||||||
|
Code irc.IRCMessageType
|
||||||
|
Params []string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *IRCError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
// JoinResult contains the outcome of a channel join.
|
||||||
|
type JoinResult struct {
|
||||||
|
ChannelID int64
|
||||||
|
IsCreator bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectMsgResult contains the outcome of a direct message.
|
||||||
|
type DirectMsgResult struct {
|
||||||
|
UUID string
|
||||||
|
AwayMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FanOut inserts a message and enqueues it to all given
|
||||||
|
// session IDs, notifying each via the broker.
|
||||||
|
func (s *Service) FanOut(
|
||||||
|
ctx context.Context,
|
||||||
|
command, from, to string,
|
||||||
|
params, body, meta json.RawMessage,
|
||||||
|
sessionIDs []int64,
|
||||||
|
) (int64, string, error) {
|
||||||
|
dbID, msgUUID, err := s.DB.InsertMessage(
|
||||||
|
ctx, command, from, to, params, body, meta,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", fmt.Errorf("insert message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sid := range sessionIDs {
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||||
|
s.Broker.Notify(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbID, msgUUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// excludeSession returns a copy of ids without the given
|
||||||
|
// session.
|
||||||
|
func excludeSession(
|
||||||
|
ids []int64,
|
||||||
|
exclude int64,
|
||||||
|
) []int64 {
|
||||||
|
out := make([]int64, 0, len(ids))
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
if id != exclude {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChannelMessage validates membership and moderation,
|
||||||
|
// then fans out a message to all channel members except
|
||||||
|
// the sender.
|
||||||
|
func (s *Service) SendChannelMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, command, channel string,
|
||||||
|
body, meta json.RawMessage,
|
||||||
|
) (string, error) {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return "", &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return "", &IRCError{
|
||||||
|
irc.ErrCannotSendToChan,
|
||||||
|
[]string{channel},
|
||||||
|
"Cannot send to channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||||
|
if moderated {
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
isVoiced, _ := s.DB.IsChannelVoiced(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !isOp && !isVoiced {
|
||||||
|
return "", &IRCError{
|
||||||
|
irc.ErrCannotSendToChan,
|
||||||
|
[]string{channel},
|
||||||
|
"Cannot send to channel (+m)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
|
|
||||||
|
_, uuid, fanErr := s.FanOut(
|
||||||
|
ctx, command, nick, channel,
|
||||||
|
nil, body, meta, recipients,
|
||||||
|
)
|
||||||
|
if fanErr != nil {
|
||||||
|
return "", fanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDirectMessage validates the target and sends a
|
||||||
|
// direct message, returning the message UUID and any away
|
||||||
|
// message set on the target.
|
||||||
|
func (s *Service) SendDirectMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, command, target string,
|
||||||
|
body, meta json.RawMessage,
|
||||||
|
) (*DirectMsgResult, error) {
|
||||||
|
targetSID, err := s.DB.GetSessionByNick(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{target},
|
||||||
|
"No such nick",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
away, _ := s.DB.GetAway(ctx, targetSID)
|
||||||
|
|
||||||
|
recipients := []int64{targetSID}
|
||||||
|
if targetSID != sessionID {
|
||||||
|
recipients = append(recipients, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, uuid, fanErr := s.FanOut(
|
||||||
|
ctx, command, nick, target,
|
||||||
|
nil, body, meta, recipients,
|
||||||
|
)
|
||||||
|
if fanErr != nil {
|
||||||
|
return nil, fanErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DirectMsgResult{UUID: uuid, AwayMsg: away}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinChannel creates or joins a channel, making the
|
||||||
|
// first joiner the operator. Fans out the JOIN to all
|
||||||
|
// channel members.
|
||||||
|
func (s *Service) JoinChannel(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel string,
|
||||||
|
) (*JoinResult, error) {
|
||||||
|
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberCount, countErr := s.DB.CountChannelMembers(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
isCreator := countErr == nil && memberCount == 0
|
||||||
|
|
||||||
|
if isCreator {
|
||||||
|
err = s.DB.JoinChannelAsOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
err = s.DB.JoinChannel(ctx, chID, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("join channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdJoin, nick, channel,
|
||||||
|
nil, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &JoinResult{
|
||||||
|
ChannelID: chID,
|
||||||
|
IsCreator: isCreator,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartChannel validates membership, broadcasts PART to
|
||||||
|
// remaining members, removes the user, and cleans up empty
|
||||||
|
// channels.
|
||||||
|
func (s *Service) PartChannel(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel, reason string,
|
||||||
|
) error {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNotOnChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdPart, nick, channel,
|
||||||
|
nil, body, nil, recipients,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||||
|
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTopic validates membership and topic-lock, sets the
|
||||||
|
// topic, and broadcasts the change.
|
||||||
|
func (s *Service) SetTopic(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel, topic string,
|
||||||
|
) error {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNotOnChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||||
|
if topicLocked {
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isOp {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrChanOpPrivsNeeded,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not channel operator",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if setErr := s.DB.SetTopic(
|
||||||
|
ctx, channel, topic,
|
||||||
|
); setErr != nil {
|
||||||
|
return fmt.Errorf("set topic: %w", setErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdTopic, nick, channel,
|
||||||
|
nil, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickUser validates operator status and target
|
||||||
|
// membership, broadcasts the KICK, removes the target,
|
||||||
|
// and cleans up empty channels.
|
||||||
|
func (s *Service) KickUser(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, channel, targetNick, reason string,
|
||||||
|
) error {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isOp {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrChanOpPrivsNeeded,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not channel operator",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, err := s.DB.GetSessionByNick(
|
||||||
|
ctx, targetNick,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{targetNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, targetSID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrUserNotInChannel,
|
||||||
|
[]string{targetNick, channel},
|
||||||
|
"They aren't on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
params, _ := json.Marshal( //nolint:errchkjson
|
||||||
|
[]string{targetNick},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdKick, nick, channel,
|
||||||
|
params, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||||
|
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeNick changes a user's nickname and broadcasts the
|
||||||
|
// change to all users sharing channels.
|
||||||
|
func (s *Service) ChangeNick(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
oldNick, newNick string,
|
||||||
|
) error {
|
||||||
|
err := s.DB.ChangeNick(ctx, sessionID, newNick)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE") ||
|
||||||
|
db.IsUniqueConstraintError(err) {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNicknameInUse,
|
||||||
|
[]string{newNick},
|
||||||
|
"Nickname is already in use",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrErroneusNickname,
|
||||||
|
[]string{newNick},
|
||||||
|
"Erroneous nickname",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
||||||
|
// parts all channels, and deletes the session.
|
||||||
|
func (s *Service) BroadcastQuit(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
nick, reason string,
|
||||||
|
) {
|
||||||
|
channels, err := s.DB.GetSessionChannels(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||||
|
ctx, ch.ID,
|
||||||
|
)
|
||||||
|
if memErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mid := range memberIDs {
|
||||||
|
if mid == sessionID || notified[mid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
notified[mid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
|
||||||
|
for sid := range notified {
|
||||||
|
dbID, _, insErr := s.DB.InsertMessage(
|
||||||
|
ctx, irc.CmdQuit, nick, "",
|
||||||
|
nil, body, nil,
|
||||||
|
)
|
||||||
|
if insErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||||
|
s.Broker.Notify(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
||||||
|
s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAway sets or clears the away message. Returns true
|
||||||
|
// if the message was cleared (empty string).
|
||||||
|
func (s *Service) SetAway(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
message string,
|
||||||
|
) (bool, error) {
|
||||||
|
err := s.DB.SetAway(ctx, sessionID, message)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("set away: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return message == "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oper validates operator credentials and grants oper
|
||||||
|
// status to the session.
|
||||||
|
func (s *Service) Oper(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
name, password string,
|
||||||
|
) error {
|
||||||
|
cfgName := s.Config.OperName
|
||||||
|
cfgPassword := s.Config.OperPassword
|
||||||
|
|
||||||
|
if cfgName == "" || cfgPassword == "" {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoOperHost,
|
||||||
|
nil,
|
||||||
|
"No O-lines for your host",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name != cfgName || password != cfgPassword {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrPasswdMismatch,
|
||||||
|
nil,
|
||||||
|
"Password incorrect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateChannelOp checks that the session is a channel
|
||||||
|
// operator. Returns the channel ID.
|
||||||
|
func (s *Service) ValidateChannelOp(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
channel string,
|
||||||
|
) (int64, error) {
|
||||||
|
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||||
|
if err != nil {
|
||||||
|
return 0, &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
"No such channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOp, _ := s.DB.IsChannelOperator(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if !isOp {
|
||||||
|
return 0, &IRCError{
|
||||||
|
irc.ErrChanOpPrivsNeeded,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not channel operator",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMemberMode applies +o/-o or +v/-v on a channel
|
||||||
|
// member after validating the target.
|
||||||
|
func (s *Service) ApplyMemberMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
channel, targetNick string,
|
||||||
|
mode rune,
|
||||||
|
adding bool,
|
||||||
|
) error {
|
||||||
|
targetSID, err := s.DB.GetSessionByNick(
|
||||||
|
ctx, targetNick,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
[]string{targetNick},
|
||||||
|
"No such nick/channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember, _ := s.DB.IsChannelMember(
|
||||||
|
ctx, chID, targetSID,
|
||||||
|
)
|
||||||
|
if !isMember {
|
||||||
|
return &IRCError{
|
||||||
|
irc.ErrUserNotInChannel,
|
||||||
|
[]string{targetNick, channel},
|
||||||
|
"They aren't on that channel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case 'o':
|
||||||
|
_ = s.DB.SetChannelMemberOperator(
|
||||||
|
ctx, chID, targetSID, adding,
|
||||||
|
)
|
||||||
|
case 'v':
|
||||||
|
_ = s.DB.SetChannelMemberVoiced(
|
||||||
|
ctx, chID, targetSID, adding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelFlag applies +m/-m or +t/-t on a channel.
|
||||||
|
func (s *Service) SetChannelFlag(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
flag rune,
|
||||||
|
setting bool,
|
||||||
|
) error {
|
||||||
|
switch flag {
|
||||||
|
case 'm':
|
||||||
|
if err := s.DB.SetChannelModerated(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set moderated: %w", err)
|
||||||
|
}
|
||||||
|
case 't':
|
||||||
|
if err := s.DB.SetChannelTopicLocked(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set topic locked: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastMode fans out a MODE change to all channel
|
||||||
|
// members.
|
||||||
|
func (s *Service) BroadcastMode(
|
||||||
|
ctx context.Context,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
modeText string,
|
||||||
|
) {
|
||||||
|
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||||
|
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
||||||
|
|
||||||
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
|
ctx, irc.CmdMode, nick, channel,
|
||||||
|
nil, body, nil, memberIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryChannelMode returns the channel mode string.
|
||||||
|
func (s *Service) QueryChannelMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
) string {
|
||||||
|
modes := "+"
|
||||||
|
|
||||||
|
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||||
|
if moderated {
|
||||||
|
modes += "m"
|
||||||
|
}
|
||||||
|
|
||||||
|
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||||
|
if topicLocked {
|
||||||
|
modes += "t"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcastNickChange notifies channel peers of a nick
|
||||||
|
// change.
|
||||||
|
func (s *Service) broadcastNickChange(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
oldNick, newNick string,
|
||||||
|
) {
|
||||||
|
channels, err := s.DB.GetSessionChannels(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
||||||
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
|
dbID, _, insErr := s.DB.InsertMessage(
|
||||||
|
ctx, irc.CmdNick, oldNick, "",
|
||||||
|
nil, body, nil,
|
||||||
|
)
|
||||||
|
if insErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the user themselves (for multi-client sync).
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
|
||||||
|
s.Broker.Notify(sessionID)
|
||||||
|
notified[sessionID] = true
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||||
|
ctx, ch.ID,
|
||||||
|
)
|
||||||
|
if memErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mid := range memberIDs {
|
||||||
|
if notified[mid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
notified[mid] = true
|
||||||
|
|
||||||
|
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
|
||||||
|
s.Broker.Notify(mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,32 +2,26 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
CmdAdmin = "ADMIN"
|
CmdAway = "AWAY"
|
||||||
CmdAway = "AWAY"
|
CmdInvite = "INVITE"
|
||||||
CmdInfo = "INFO"
|
CmdJoin = "JOIN"
|
||||||
CmdInvite = "INVITE"
|
CmdKick = "KICK"
|
||||||
CmdJoin = "JOIN"
|
CmdList = "LIST"
|
||||||
CmdKick = "KICK"
|
CmdLusers = "LUSERS"
|
||||||
CmdKill = "KILL"
|
CmdMode = "MODE"
|
||||||
CmdList = "LIST"
|
CmdMotd = "MOTD"
|
||||||
CmdLusers = "LUSERS"
|
CmdNames = "NAMES"
|
||||||
CmdMode = "MODE"
|
CmdNick = "NICK"
|
||||||
CmdMotd = "MOTD"
|
CmdNotice = "NOTICE"
|
||||||
CmdNames = "NAMES"
|
CmdOper = "OPER"
|
||||||
CmdNick = "NICK"
|
CmdPass = "PASS"
|
||||||
CmdNotice = "NOTICE"
|
CmdPart = "PART"
|
||||||
CmdOper = "OPER"
|
CmdPing = "PING"
|
||||||
CmdPass = "PASS"
|
CmdPong = "PONG"
|
||||||
CmdPart = "PART"
|
CmdPrivmsg = "PRIVMSG"
|
||||||
CmdPing = "PING"
|
CmdQuit = "QUIT"
|
||||||
CmdPong = "PONG"
|
CmdTopic = "TOPIC"
|
||||||
CmdPrivmsg = "PRIVMSG"
|
CmdUser = "USER"
|
||||||
CmdQuit = "QUIT"
|
CmdWho = "WHO"
|
||||||
CmdTime = "TIME"
|
CmdWhois = "WHOIS"
|
||||||
CmdTopic = "TOPIC"
|
|
||||||
CmdUserhost = "USERHOST"
|
|
||||||
CmdVersion = "VERSION"
|
|
||||||
CmdWallops = "WALLOPS"
|
|
||||||
CmdWho = "WHO"
|
|
||||||
CmdWhois = "WHOIS"
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user