2 Commits

Author SHA1 Message Date
260f798af4 feat: add IRC wire protocol listener with shared service layer
All checks were successful
check / check (push) Successful in 1m0s
Adds a backward-compatible IRC wire protocol listener (RFC 1459/2812)
with a shared service layer used by both IRC and HTTP transports.

- TCP listener on configurable port (default :6667)
- Full IRC protocol: NICK, USER, JOIN, PART, PRIVMSG, MODE, TOPIC, etc.
- Shared service layer (internal/service/) for consistent code paths
- Tier 2 join restrictions (ban, invite-only, key, limit) in service layer
- Ban check on PRIVMSG in service layer
- SetChannelFlag handles +i and +s modes
- Command dispatch via map[string]cmdHandler pattern
- EXPOSE 6667 in Dockerfile
- Service layer unit tests

closes #89
2026-03-26 17:48:08 -07:00
9a79d92c0d feat: implement Tier 2 channel modes (+b/+i/+s/+k/+l) (#92)
Some checks failed
check / check (push) Failing after 1m31s
## Summary

Implements the second tier of IRC channel features as described in [#86](#86).

## Features

### 1. Ban System (+b)
- `channel_bans` table with mask, set_by, created_at
- Add/remove/list bans via MODE +b/-b
- Wildcard matching (`*!*@*.example.com`, `badnick!*@*`, etc.)
- Ban enforcement on both JOIN and PRIVMSG
- RPL_BANLIST (367) / RPL_ENDOFBANLIST (368) for ban listing

### 2. Invite-Only (+i)
- `is_invite_only` column on channels table
- INVITE command: operators can invite users
- `channel_invites` table tracks pending invites
- Invites consumed on successful JOIN
- ERR_INVITEONLYCHAN (473) for uninvited JOIN attempts

### 3. Secret (+s)
- `is_secret` column on channels table
- Secret channels hidden from LIST for non-members
- Secret channels hidden from WHOIS channel list for non-members

### 4. Channel Key (+k)
- `channel_key` column on channels table
- MODE +k sets key, MODE -k clears it
- Key required on JOIN (`JOIN #channel key`)
- ERR_BADCHANNELKEY (475) for wrong/missing key

### 5. User Limit (+l)
- `user_limit` column on channels table (0 = no limit)
- MODE +l sets limit, MODE -l removes it
- ERR_CHANNELISFULL (471) when limit reached

## ISUPPORT Changes
- CHANMODES updated to `b,k,Hl,imnst`
- RPL_MYINFO modes updated to `ikmnostl`

## Tests

### Database-level tests:
- Wildcard matching (10 patterns)
- Ban CRUD operations
- Session ban checking
- Invite-only flag toggle
- Invite CRUD + clearing
- Secret channel filtering (LIST and WHOIS)
- Channel key set/get/clear
- User limit set/get/clear

### Handler-level tests:
- Ban add/remove/list via MODE
- Ban blocks JOIN
- Ban blocks PRIVMSG
- Invite-only JOIN rejection + INVITE acceptance
- Secret channel hidden from LIST
- Channel key required on JOIN
- User limit enforcement
- Mode string includes new modes
- ISUPPORT updated CHANMODES
- Non-operators cannot set any Tier 2 modes

## Schema Changes
- Added `is_invite_only`, `is_secret`, `channel_key`, `user_limit` to `channels` table
- Added `channel_bans` table
- Added `channel_invites` table
- All changes in `001_initial.sql` (pre-1.0.0 repo)

closes #86

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #92
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 22:38:46 +01:00
15 changed files with 4230 additions and 1815 deletions

View File

@@ -53,7 +53,7 @@ RUN apk add --no-cache ca-certificates \
COPY --from=builder /neoircd /usr/local/bin/neoircd
USER neoirc
EXPOSE 8080
EXPOSE 8080 6667
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
ENTRYPOINT ["neoircd"]

View File

@@ -1080,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"]}` |
| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","mnst"]}` |
| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=,,H,mnst","NETWORK=neoirc"],"body":["are supported by this server"]}` |
| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` |
| `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"]}` |
| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` |
| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` |
@@ -1129,12 +1129,15 @@ Inspired by IRC, simplified:
| 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** |
| `+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** |
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** |
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **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):**
@@ -1146,6 +1149,42 @@ Inspired by IRC, simplified:
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
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
[:reason]`. The kicked user and all channel members receive the KICK message.
@@ -1154,7 +1193,7 @@ RPL_AWAY), and skips hashcash validation on +H channels (servers and services
use NOTICE).
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and
`CHANMODES=,,H,mnst` in RPL_ISUPPORT (005).
`CHANMODES=b,k,Hl,imnst` in RPL_ISUPPORT (005).
### Per-Channel Hashcash (Anti-Spam)
@@ -2228,7 +2267,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. |
| `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. |
| `IRC_LISTEN_ADDR` | string | `":6667"` | TCP address for the traditional IRC protocol listener. Set empty to disable. |
| `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) |
### Example `.env` file
@@ -2252,17 +2291,15 @@ neoirc includes an optional traditional IRC wire protocol listener (RFC
backward compatibility with existing IRC clients like irssi, weechat, hexchat,
and others.
### Enabling
### Configuration
Set the `IRC_LISTEN_ADDR` environment variable to a TCP address:
The IRC listener is **enabled by default** on `:6667`. To disable it, set
`IRC_LISTEN_ADDR` to an empty string:
```bash
IRC_LISTEN_ADDR=:6667
IRC_LISTEN_ADDR=
```
When unset or empty, the IRC listener is disabled and only the HTTP/JSON API is
available.
### Supported Commands
| Category | Commands |
@@ -2297,13 +2334,13 @@ connected via the HTTP API can communicate in the same channels seamlessly.
### Docker Usage
To expose the IRC port in Docker:
To expose the IRC port in Docker (the listener is enabled by default on
`:6667`):
```bash
docker run -d \
-p 8080:8080 \
-p 6667:6667 \
-e IRC_LISTEN_ADDR=:6667 \
-v neoirc-data:/var/lib/neoirc \
neoirc
```
@@ -2762,7 +2799,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] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE`
- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no external)
- [ ] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit)
- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes
- [x] **KICK command** — operator-only channel kick with broadcast
- [x] **MODE command** — query and set channel/user modes

View File

@@ -12,6 +12,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -40,6 +41,7 @@ func main() {
server.New,
middleware.New,
healthcheck.New,
service.New,
stats.New,
),
fx.Invoke(func(

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
@@ -1835,3 +1836,534 @@ func (database *Database) PruneSpentHashcash(
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
}

View File

@@ -1017,3 +1017,474 @@ func TestGetOperCount(t *testing.T) {
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)
}
}

View File

@@ -42,10 +42,36 @@ CREATE TABLE IF NOT EXISTS channels (
hashcash_bits INTEGER NOT NULL DEFAULT 0,
is_moderated INTEGER NOT NULL DEFAULT 0,
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,
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
CREATE TABLE IF NOT EXISTS channel_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
@@ -207,6 +208,14 @@ func newTestHandlers(
hcheck *healthcheck.Healthcheck,
tracker *stats.Tracker,
) (*handlers.Handlers, error) {
brk := broker.New()
svc := service.New(service.Params{ //nolint:exhaustruct
Logger: log,
Config: cfg,
Database: database,
Broker: brk,
})
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
Logger: log,
Globals: globs,
@@ -214,7 +223,8 @@ func newTestHandlers(
Database: database,
Healthcheck: hcheck,
Stats: tracker,
Broker: broker.New(),
Broker: brk,
Service: svc,
})
if err != nil {
return nil, fmt.Errorf("test handlers: %w", err)
@@ -4380,3 +4390,486 @@ 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,
)
}
}
}

View File

@@ -17,6 +17,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
)
@@ -34,6 +35,7 @@ type Params struct {
Healthcheck *healthcheck.Healthcheck
Stats *stats.Tracker
Broker *broker.Broker
Service *service.Service
}
const defaultIdleTimeout = 30 * 24 * time.Hour
@@ -49,6 +51,7 @@ type Handlers struct {
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
svc *service.Service
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
loginLimiter *ratelimit.Limiter
@@ -81,6 +84,7 @@ func New(
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: params.Broker,
svc: params.Service,
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
loginLimiter: ratelimit.New(loginRate, loginBurst),

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ package ircserver
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
@@ -15,6 +14,7 @@ import (
"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"
)
@@ -30,6 +30,10 @@ const (
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
@@ -37,7 +41,9 @@ type Conn struct {
database *db.Database
brk *broker.Broker
cfg *config.Config
svc *service.Service
serverSfx string
commands map[string]cmdHandler
mu sync.Mutex
nick string
@@ -65,6 +71,7 @@ func newConn(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
svc *service.Service,
) *Conn {
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
@@ -73,16 +80,57 @@ func newConn(
srvName = "neoirc"
}
return &Conn{ //nolint:exhaustruct // zero-value defaults
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
@@ -145,71 +193,14 @@ func (c *Conn) cleanup(ctx context.Context) {
c.mu.Unlock()
if wasRegistered && sessID > 0 {
c.broadcastQuit(ctx, nick, "Connection closed")
c.database.DeleteSession(ctx, sessID) //nolint:errcheck,gosec
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
c.conn.Close() //nolint:errcheck,gosec
}
func (c *Conn) broadcastQuit(
ctx context.Context,
nick, reason string,
) {
channels, err := c.database.GetSessionChannels(
ctx, c.sessionID,
)
if err != nil {
return
}
notified := make(map[int64]bool)
for _, ch := range channels {
chID, getErr := c.database.GetChannelByName(
ctx, ch.Name,
)
if getErr != nil {
continue
}
memberIDs, memErr := c.database.GetChannelMemberIDs(
ctx, chID,
)
if memErr != nil {
continue
}
for _, mid := range memberIDs {
if mid == c.sessionID || notified[mid] {
continue
}
notified[mid] = true
}
}
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
for sid := range notified {
dbID, _, insErr := c.database.InsertMessage(
ctx, irc.CmdQuit, nick, "", nil, body, nil,
)
if insErr != nil {
continue
}
_ = c.database.EnqueueToSession(ctx, sid, dbID)
c.brk.Notify(sid)
}
// Part from all channels so they get cleaned up.
for _, ch := range channels {
c.database.PartChannel(ctx, ch.ID, c.sessionID) //nolint:errcheck,gosec
c.database.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
}
}
// send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) {
_ = c.conn.SetWriteDeadline(
@@ -261,9 +252,8 @@ func (c *Conn) hostmask() string {
return c.nick + "!" + user + "@" + host
}
// handleMessage dispatches a parsed IRC message.
//
//nolint:cyclop // dispatch table is inherently branchy
// handleMessage dispatches a parsed IRC message using
// the command handler map.
func (c *Conn) handleMessage(
ctx context.Context,
msg *Message,
@@ -276,57 +266,17 @@ func (c *Conn) handleMessage(
return
}
switch msg.Command {
case irc.CmdPing:
c.handlePing(msg)
case "PONG":
// Silently accept.
case irc.CmdNick:
c.handleNick(ctx, msg)
case irc.CmdPrivmsg, irc.CmdNotice:
c.handlePrivmsg(ctx, msg)
case irc.CmdJoin:
c.handleJoin(ctx, msg)
case irc.CmdPart:
c.handlePart(ctx, msg)
case irc.CmdQuit:
c.handleQuit(msg)
case irc.CmdTopic:
c.handleTopic(ctx, msg)
case irc.CmdMode:
c.handleMode(ctx, msg)
case irc.CmdNames:
c.handleNames(ctx, msg)
case irc.CmdList:
c.handleList(ctx)
case irc.CmdWhois:
c.handleWhois(ctx, msg)
case irc.CmdWho:
c.handleWho(ctx, msg)
case irc.CmdLusers:
c.handleLusers(ctx)
case irc.CmdMotd:
c.deliverMOTD()
case irc.CmdOper:
c.handleOper(ctx, msg)
case irc.CmdAway:
c.handleAway(ctx, msg)
case irc.CmdKick:
c.handleKick(ctx, msg)
case irc.CmdPass:
c.handlePassPostReg(ctx, msg)
case "INVITE":
c.handleInvite(ctx, msg)
case "CAP":
c.handleCAP(msg)
case "USERHOST":
c.handleUserhost(ctx, msg)
default:
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

View File

@@ -8,6 +8,7 @@ import (
"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.
@@ -18,11 +19,19 @@ func NewTestServer(
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{}),
}
}

View File

@@ -11,6 +11,7 @@ import (
"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"
)
@@ -23,6 +24,7 @@ type Params struct {
Config *config.Config
Database *db.Database
Broker *broker.Broker
Service *service.Service
}
// Server is the TCP IRC protocol server.
@@ -31,6 +33,7 @@ type Server struct {
cfg *config.Config
database *db.Database
brk *broker.Broker
svc *service.Service
listener net.Listener
mu sync.Mutex
conns map[*Conn]struct{}
@@ -49,6 +52,7 @@ func New(
cfg: params.Config,
database: params.Database,
brk: params.Broker,
svc: params.Service,
conns: make(map[*Conn]struct{}),
listener: nil,
cancel: nil,
@@ -133,7 +137,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
client := newConn(
ctx, tcpConn, s.log,
s.database, s.brk, s.cfg,
s.database, s.brk, s.cfg, s.svc,
)
s.mu.Lock()

839
internal/service/service.go Normal file
View File

@@ -0,0 +1,839 @@
// Package service provides shared business logic for both
// the IRC wire protocol and HTTP/JSON transports.
package service
import (
"context"
"crypto/subtle"
"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. Returns the database row ID, message UUID,
// and any error. The dbID lets callers enqueue the same
// message to the sender when echo is needed (HTTP
// transport).
func (s *Service) SendChannelMessage(
ctx context.Context,
sessionID int64,
nick, command, channel string,
body, meta json.RawMessage,
) (int64, string, error) {
chID, err := s.DB.GetChannelByName(ctx, channel)
if err != nil {
return 0, "", &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
"No such channel",
}
}
isMember, _ := s.DB.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
return 0, "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel",
}
}
// Ban check — banned users cannot send messages.
isBanned, banErr := s.DB.IsSessionBanned(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
return 0, "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel (+b)",
}
}
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 0, "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel (+m)",
}
}
}
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
dbID, uuid, fanErr := s.FanOut(
ctx, command, nick, channel,
nil, body, meta, recipients,
)
if fanErr != nil {
return 0, "", fanErr
}
return dbID, 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, suppliedKey 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 {
if joinErr := checkJoinRestrictions(
ctx, s.DB, chID, sessionID,
channel, suppliedKey, memberCount,
); joinErr != nil {
return nil, joinErr
}
}
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)
}
// Clear invite after successful join.
_ = s.DB.ClearChannelInvite(ctx, chID, sessionID)
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. Uses the
// FanOut pattern: one message row fanned out to all unique
// peer sessions.
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
}
}
if len(notified) > 0 {
recipients := make([]int64, 0, len(notified))
for sid := range notified {
recipients = append(recipients, sid)
}
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut(
ctx, irc.CmdQuit, nick, "",
nil, body, nil, recipients,
)
}
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
// Use constant-time comparison and return the same
// error for all failures to prevent information
// leakage about valid operator names.
if cfgName == "" || cfgPassword == "" ||
subtle.ConstantTimeCompare(
[]byte(name), []byte(cfgName),
) != 1 ||
subtle.ConstantTimeCompare(
[]byte(password), []byte(cfgPassword),
) != 1 {
return &IRCError{
irc.ErrNoOperHost,
nil,
"No O-lines for your host",
}
}
_ = 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)
}
case 'i':
if err := s.DB.SetChannelInviteOnly(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set invite only: %w", err)
}
case 's':
if err := s.DB.SetChannelSecret(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set secret: %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)
}
}
}
// checkJoinRestrictions validates Tier 2 join conditions:
// bans, invite-only, channel key, and user limit.
func checkJoinRestrictions(
ctx context.Context,
database *db.Database,
chID, sessionID int64,
channel, suppliedKey string,
memberCount int64,
) error {
isBanned, banErr := database.IsSessionBanned(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
return &IRCError{
Code: irc.ErrBannedFromChan,
Params: []string{channel},
Message: "Cannot join channel (+b)",
}
}
isInviteOnly, ioErr := database.IsChannelInviteOnly(
ctx, chID,
)
if ioErr == nil && isInviteOnly {
hasInvite, invErr := database.HasChannelInvite(
ctx, chID, sessionID,
)
if invErr != nil || !hasInvite {
return &IRCError{
Code: irc.ErrInviteOnlyChan,
Params: []string{channel},
Message: "Cannot join channel (+i)",
}
}
}
key, keyErr := database.GetChannelKey(ctx, chID)
if keyErr == nil && key != "" && suppliedKey != key {
return &IRCError{
Code: irc.ErrBadChannelKey,
Params: []string{channel},
Message: "Cannot join channel (+k)",
}
}
limit, limErr := database.GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 &&
memberCount >= int64(limit) {
return &IRCError{
Code: irc.ErrChannelIsFull,
Params: []string{channel},
Message: "Cannot join channel (+l)",
}
}
return nil
}

View File

@@ -0,0 +1,365 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package service_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"testing"
"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/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
)
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
// testEnv holds all dependencies for a service test.
type testEnv struct {
svc *service.Service
db *db.Database
broker *broker.Broker
app *fxtest.App
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
dbURL := fmt.Sprintf(
"file:svc_test_%p?mode=memory&cache=shared",
t,
)
var (
database *db.Database
svc *service.Service
)
brk := broker.New()
app := fxtest.New(t,
fx.Provide(
func() *globals.Globals {
return &globals.Globals{ //nolint:exhaustruct
Appname: "neoirc-test",
Version: "test",
}
},
logger.New,
func(
lifecycle fx.Lifecycle,
globs *globals.Globals,
log *logger.Logger,
) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
}
cfg.DBURL = dbURL
cfg.Port = 0
cfg.OperName = "admin"
cfg.OperPassword = "secret"
return cfg, nil
},
func(
lifecycle fx.Lifecycle,
log *logger.Logger,
cfg *config.Config,
) (*db.Database, error) {
return db.New(lifecycle, db.Params{ //nolint:exhaustruct
Logger: log, Config: cfg,
})
},
func() *broker.Broker { return brk },
service.New,
),
fx.Populate(&database, &svc),
)
app.RequireStart()
t.Cleanup(func() {
app.RequireStop()
})
return &testEnv{
svc: svc,
db: database,
broker: brk,
app: app,
}
}
// createSession is a test helper that creates a session
// and returns the session ID.
func createSession(
ctx context.Context,
t *testing.T,
database *db.Database,
nick string,
) int64 {
t.Helper()
sessionID, _, _, err := database.CreateSession(
ctx, nick, nick, "localhost", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session %s: %v", nick, err)
}
return sessionID
}
func TestFanOut(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid1 := createSession(ctx, t, env.db, "alice")
sid2 := createSession(ctx, t, env.db, "bob")
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
dbID, uuid, err := env.svc.FanOut(
ctx, irc.CmdPrivmsg, "alice", "#test",
nil, body, nil,
[]int64{sid1, sid2},
)
if err != nil {
t.Fatalf("FanOut: %v", err)
}
if dbID == 0 {
t.Error("expected non-zero dbID")
}
if uuid == "" {
t.Error("expected non-empty UUID")
}
}
func TestJoinChannel(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
}
if result.ChannelID == 0 {
t.Error("expected non-zero channel ID")
}
if !result.IsCreator {
t.Error("first joiner should be creator")
}
// Second user joins — not creator.
sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel(
ctx, sid2, "bob", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel bob: %v", err)
}
if result2.IsCreator {
t.Error("second joiner should not be creator")
}
if result2.ChannelID != result.ChannelID {
t.Error("both should join the same channel")
}
}
func TestPartChannel(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
}
err = env.svc.PartChannel(
ctx, sid, "alice", "#general", "bye",
)
if err != nil {
t.Fatalf("PartChannel: %v", err)
}
// Parting a non-existent channel returns error.
err = env.svc.PartChannel(
ctx, sid, "alice", "#nonexistent", "",
)
if err == nil {
t.Error("expected error for non-existent channel")
}
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Errorf("expected IRCError, got %T", err)
}
}
func TestSendChannelMessage(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid1 := createSession(ctx, t, env.db, "alice")
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#chat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#chat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
}
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
dbID, uuid, err := env.svc.SendChannelMessage(
ctx, sid1, "alice",
irc.CmdPrivmsg, "#chat", body, nil,
)
if err != nil {
t.Fatalf("SendChannelMessage: %v", err)
}
if dbID == 0 {
t.Error("expected non-zero dbID")
}
if uuid == "" {
t.Error("expected non-empty UUID")
}
// Non-member cannot send.
sid3 := createSession(ctx, t, env.db, "charlie")
_, _, err = env.svc.SendChannelMessage(
ctx, sid3, "charlie",
irc.CmdPrivmsg, "#chat", body, nil,
)
if err == nil {
t.Error("expected error for non-member send")
}
}
func TestBroadcastQuit(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid1 := createSession(ctx, t, env.db, "alice")
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#room", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#room", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
}
// BroadcastQuit should not panic and should clean up.
env.svc.BroadcastQuit(
ctx, sid1, "alice", "Goodbye",
)
// Session should be deleted.
_, lookupErr := env.db.GetSessionByNick(ctx, "alice")
if lookupErr == nil {
t.Error("expected session to be deleted after quit")
}
}
func TestSendChannelMessage_Moderated(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid1 := createSession(ctx, t, env.db, "alice")
sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#modchat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#modchat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
}
// Set channel to moderated.
chID := result.ChannelID
_ = env.svc.SetChannelFlag(ctx, chID, 'm', true)
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
// Bob (non-op, non-voiced) should fail to send.
_, _, err = env.svc.SendChannelMessage(
ctx, sid2, "bob",
irc.CmdPrivmsg, "#modchat", body, nil,
)
if err == nil {
t.Error("expected error for non-voiced user in moderated channel")
}
// Alice (operator) should succeed.
_, _, err = env.svc.SendChannelMessage(
ctx, sid1, "alice",
irc.CmdPrivmsg, "#modchat", body, nil,
)
if err != nil {
t.Errorf("operator should be able to send in moderated channel: %v", err)
}
}