8 Commits

Author SHA1 Message Date
clawbot
327ff37059 fix: address review findings — dynamic version, deduplicate KILL, update README
All checks were successful
check / check (push) Successful in 1m3s
2026-04-01 14:44:27 -07:00
clawbot
17479c4f44 fix: rebase onto main, add IRC wire handlers and integration tests for Tier 3 commands
Some checks failed
check / check (push) Failing after 2m10s
Rebase onto main to resolve conflicts from module path rename
(sneak.berlin/go/neoirc) and integration test addition.

- Update import paths in utility.go to new module path
- Add IRC wire protocol handlers for VERSION, ADMIN, INFO,
  TIME, KILL, and WALLOPS to ircserver/commands.go
- Register all 6 new commands in the IRC command dispatch map
- Implement proper user MODE +w/-w support for WALLOPS
- Add WALLOPS relay delivery in relay.go
- Add integration tests for all 7 Tier 3 commands:
  USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS
- Add newTestEnvWithOper helper for oper-dependent tests
2026-04-01 14:16:09 -07:00
user
9c4ec966fb feat: implement Tier 3 utility IRC commands
Implement all 7 utility IRC commands from issue #87:

User commands:
- USERHOST: quick lookup of user@host for up to 5 nicks (RPL 302)
- VERSION: server version string using globals.Version (RPL 351)
- ADMIN: server admin contact info (RPL 256-259)
- INFO: server software info text (RPL 371/374)
- TIME: server local time in RFC format (RPL 391)

Oper commands:
- KILL: forcibly disconnect a user (requires is_oper), broadcasts
  QUIT to all shared channels, cleans up sessions
- WALLOPS: broadcast message to all users with +w usermode
  (requires is_oper)

Supporting changes:
- Add is_wallops column to sessions table in 001_initial.sql
- Add user mode +w tracking via MODE nick +w/-w
- User mode queries now return actual modes (+o, +w)
- MODE -o allows de-opering yourself; MODE +o rejected
- MODE for other users returns ERR_USERSDONTMATCH (502)
- Extract dispatch helpers to reduce dispatchCommand complexity

Tests cover all commands including error cases, oper checks,
user mode set/unset, KILL broadcast, WALLOPS delivery, and
edge cases (self-kill, nonexistent users, missing params).

closes #87
2026-04-01 14:07:22 -07:00
f829f9e3da Change module path to sneak.berlin/go/neoirc (#99)
Some checks failed
check / check (push) Failing after 1m21s
Changes the Go module path from `git.eeqj.de/sneak/neoirc` to `sneak.berlin/go/neoirc`.

All occurrences updated:
- `go.mod` module directive
- All Go import paths across 35 `.go` files (107 import statements)
- All JSON schema `$id` URIs across 30 `.json` files in `schema/`

No functional changes — this is a pure rename of the module path.

`docker build .` passes clean (formatting, linting, all tests, binary build).

closes #98

Co-authored-by: clawbot <clawbot@users.noreply.git.eeqj.de>
Reviewed-on: #99
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-04-01 23:05:41 +02:00
02279b2654 test: add comprehensive IRC integration test with two clients (#100)
All checks were successful
check / check (push) Successful in 6s
Adds `integration_test.go` with four test functions that exercise all major IRC features using real TCP connections.

## Tests

**TestIntegrationTwoClients** — sequential two-client test covering:
- NICK/USER registration (001-004 welcome burst)
- JOIN with cross-client visibility
- PRIVMSG channel (both directions)
- PRIVMSG DM (both directions)
- NOTICE channel and DM
- TOPIC set/get/lock/unlock
- MODE query, +m (moderated), +v (voice), -t/+t (topic lock)
- NAMES (with both nicks listed)
- LIST
- WHO
- WHOIS (with channels)
- LUSERS
- NICK change (with relay to other client)
- Duplicate NICK (ERR_NICKNAMEINUSE)
- KICK (with relay + reason)
- KICK non-op error (ERR_CHANOPRIVSNEEDED)
- PING/PONG
- Unknown command (ERR_UNKNOWNCOMMAND)
- MOTD
- AWAY set/clear/RPL_AWAY on DM
- PASS post-registration
- PART with reason + relay
- PART non-existent channel error
- User MODE query
- Multi-channel messaging
- QUIT with relay

**TestIntegrationModeSecret** — verifies +s mode set and query.

**TestIntegrationModeModerated** — verifies +m blocks non-voiced users and +v enables sending.

**TestIntegrationThirdClientObserver** — verifies three-client channel message fanout.

`docker build --no-cache .` passes clean (formatting, linting, all tests, build).

closes sneak/chat#97

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #100
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-04-01 12:47:49 +02:00
0250f14fea feat: add traditional IRC wire protocol listener (closes #89) (#94)
All checks were successful
check / check (push) Successful in 5s
## Summary

Adds a backward-compatible IRC wire protocol listener (RFC 1459/2812) that allows standard IRC clients (irssi, weechat, hexchat, etc.) to connect directly via TCP.

## Changes

### New package: `internal/ircserver/`

- **`parser.go`** — IRC wire protocol message parser and formatter
- **`server.go`** — TCP listener with Fx lifecycle integration
- **`conn.go`** — Per-connection handler with registration flow, PING/PONG, welcome burst
- **`commands.go`** — All IRC command handlers (JOIN, PART, PRIVMSG, MODE, TOPIC, KICK, WHOIS, etc.)
- **`relay.go`** — Message relay goroutine that delivers queued messages to IRC clients in wire format

### Modified files

- **`internal/config/config.go`** — Added `IRC_LISTEN_ADDR` environment variable
- **`internal/handlers/handlers.go`** — Broker is now injected via Fx (shared with IRC server)
- **`cmd/neoircd/main.go`** — Registered `broker.New`, `ircserver.New` as Fx providers
- **`pkg/irc/commands.go`** — Added `CmdUser` and `CmdInvite` constants
- **`README.md`** — Added IRC Protocol Listener documentation section

### Tests

- Parser unit tests (table-driven, round-trip verification)
- Integration tests: registration, PING/PONG, JOIN, PART, PRIVMSG (channel + DM), NICK change, duplicate nick rejection, LIST, WHOIS, QUIT, TOPIC, MODE, WHO, LUSERS, MOTD, AWAY, PASS, CAP negotiation, unknown commands, pre-registration errors
- Benchmarks for parser and formatter

## Key Design Decisions

- **Optional**: Listener is only started when `IRC_LISTEN_ADDR` is set
- **Shared infrastructure**: Same DB, broker, and session system as HTTP API
- **Full bridge**: IRC ↔ HTTP messages are interoperable
- **No schema changes**: Reuses existing tables
- **Broker as Fx dependency**: Extracted from handlers to be shared

## Supported 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, USERHOST
Operator: OPER

closes #89

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: sneak/chat#94
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-04-01 05:00:04 +02:00
24362966e0 feat: move schema_migrations into 000_bootstrap.sql (#95)
All checks were successful
check / check (push) Successful in 5s
## Summary

- Moves schema_migrations table creation from inline Go code into internal/db/schema/000_bootstrap.sql
- Bootstrap SQL is executed directly before the migration loop (which starts from 001+)
- Go code does zero INSERTs for the bootstrap — 000_bootstrap.sql handles the INSERT OR IGNORE for version 0
- loadMigrations() skips 000.sql so it is not processed by the normal migration loop

Follows the sneak/pixa pattern.

closes #91

## Test plan

- [x] All existing tests pass (make test in Docker)
- [x] Linter passes (make lint)
- [x] Docker build succeeds (docker build --no-cache .)
- [x] Existing databases with schema_migrations table work (CREATE TABLE IF NOT EXISTS + INSERT OR IGNORE are idempotent)

Generated with Claude Code

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: sneak/chat#95
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-03-30 21:35:53 +02: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](sneak/chat#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: sneak/chat#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
72 changed files with 6400 additions and 381 deletions

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)
@@ -2268,8 +2307,8 @@ IRC_LISTEN_ADDR=
| 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`) |
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
### Protocol Details
@@ -2760,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
@@ -2781,6 +2820,10 @@ guess is borne by the server (bcrypt), not the client.
login from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication
- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN
(256259), INFO (371/374), TIME (391), KILL (oper-only forced
disconnect), WALLOPS (oper-only broadcast to +w users)
- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w`
### Future (1.0+)

View File

@@ -1,7 +1,7 @@
// Package main is the entry point for the neoirc-cli client.
package main
import "git.eeqj.de/sneak/neoirc/internal/cli"
import "sneak.berlin/go/neoirc/internal/cli"
func main() {
cli.Run()

View File

@@ -2,19 +2,19 @@
package main
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/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"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/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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
var (

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/neoirc
module sneak.berlin/go/neoirc
go 1.24.0

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/broker"
)
func TestNewBroker(t *testing.T) {

View File

@@ -15,7 +15,7 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (

View File

@@ -8,7 +8,7 @@ import (
"math/big"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/hashcash"
)
const (

View File

@@ -8,8 +8,8 @@ import (
"sync"
"time"
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
"git.eeqj.de/sneak/neoirc/pkg/irc"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (

View File

@@ -5,10 +5,10 @@ import (
"errors"
"log/slog"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"github.com/spf13/viper"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
_ "github.com/joho/godotenv/autoload" // loads .env file
)

View File

@@ -12,9 +12,9 @@ import (
"strconv"
"strings"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/logger"
_ "github.com/joho/godotenv/autoload" // .env
_ "modernc.org/sqlite" // driver
@@ -135,13 +135,21 @@ type migration struct {
func (database *Database) runMigrations(
ctx context.Context,
) error {
_, err := database.conn.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
bootstrap, err := SchemaFiles.ReadFile(
"schema/000.sql",
)
if err != nil {
return fmt.Errorf(
"create schema_migrations: %w", err,
"read bootstrap migration: %w", err,
)
}
_, err = database.conn.ExecContext(
ctx, string(bootstrap),
)
if err != nil {
return fmt.Errorf(
"execute bootstrap migration: %w", err,
)
}
@@ -270,6 +278,11 @@ func (database *Database) loadMigrations() (
continue
}
// Skip bootstrap migration; it is executed separately.
if version == 0 {
continue
}
content, readErr := SchemaFiles.ReadFile(
"schema/" + entry.Name(),
)

View File

@@ -4,8 +4,8 @@ import (
"os"
"testing"
"git.eeqj.de/sneak/neoirc/internal/db"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/db"
)
func TestMain(m *testing.M) {

View File

@@ -9,10 +9,11 @@ import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
@@ -1835,3 +1836,709 @@ 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
}
// --- No External Messages (+n) ---
// IsChannelNoExternal checks if a channel has +n mode.
func (database *Database) IsChannelNoExternal(
ctx context.Context,
channelID int64,
) (bool, error) {
var isNoExternal int
err := database.conn.QueryRowContext(ctx,
`SELECT is_no_external FROM channels
WHERE id = ?`,
channelID,
).Scan(&isNoExternal)
if err != nil {
return false, fmt.Errorf(
"check no external: %w", err,
)
}
return isNoExternal != 0, nil
}
// SetChannelNoExternal sets or unsets +n mode.
func (database *Database) SetChannelNoExternal(
ctx context.Context,
channelID int64,
noExternal bool,
) error {
val := 0
if noExternal {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_no_external = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf("set no external: %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
}

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"git.eeqj.de/sneak/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/db"
_ "modernc.org/sqlite"
)
@@ -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

@@ -0,0 +1,6 @@
-- Bootstrap: create the schema_migrations table itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0,
is_wallops INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '',
@@ -42,10 +43,37 @@ 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,
is_no_external INTEGER NOT NULL DEFAULT 1,
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

@@ -18,21 +18,21 @@ import (
"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/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"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"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
func TestMain(m *testing.M) {
@@ -4390,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

@@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/pkg/irc"
)
const minPasswordLength = 8

View File

@@ -9,17 +9,17 @@ import (
"net/http"
"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/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/ratelimit"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
var errUnauthorized = errors.New("unauthorized")

View File

@@ -0,0 +1,659 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/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()
var targetNick string
if len(lines) > 0 {
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
}
quitReason := "Killed (" + nick + " (" + reason + "))"
hdlr.svc.BroadcastQuit(
request.Context(), targetSID,
targetNick, quitReason,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// 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
}

View File

@@ -0,0 +1,982 @@
// 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
}

View File

@@ -5,7 +5,7 @@ import (
"encoding/hex"
"testing"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/hashcash"
)
const (

View File

@@ -9,7 +9,7 @@ import (
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/hashcash"
)
const testBits = 2

View File

@@ -6,12 +6,12 @@ import (
"log/slog"
"time"
"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/stats"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/stats"
)
// Params defines the dependencies for creating a Healthcheck.

View File

@@ -8,10 +8,29 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
// versionString returns the server version for IRC
// responses, falling back to "neoirc-dev" when globals
// are not set (e.g. during tests).
func versionString() string {
name := globals.Appname
ver := globals.Version
if name == "" {
name = "neoirc"
}
if ver == "" {
ver = "dev"
}
return name + "-" + ver
}
// sendIRCError maps a service.IRCError to an IRC numeric
// reply on the wire.
func (c *Conn) sendIRCError(err error) {
@@ -179,7 +198,7 @@ func (c *Conn) joinChannel(
channel string,
) {
result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.nick, channel,
ctx, c.sessionID, c.nick, channel, "",
)
if err != nil {
c.sendIRCError(err)
@@ -431,7 +450,7 @@ func (c *Conn) handleMode(
if strings.HasPrefix(target, "#") {
c.handleChannelMode(ctx, msg)
} else {
c.handleUserMode(msg)
c.handleUserMode(ctx, msg)
}
}
@@ -490,6 +509,124 @@ func (c *Conn) handleChannelMode(
)
}
// modeResult holds the delta strings produced by a
// single mode-char application.
type modeResult struct {
applied string
appliedArgs string
consumed int
skip bool
}
// applyHashcashMode handles +H/-H (hashcash difficulty).
func (c *Conn) applyHashcashMode(
ctx context.Context,
chID int64,
adding bool,
args []string,
argIdx int,
) modeResult {
if !adding {
_ = c.database.SetChannelHashcashBits(
ctx, chID, 0,
)
return modeResult{
applied: "-H",
appliedArgs: "",
consumed: 0,
skip: false,
}
}
if argIdx >= len(args) {
return modeResult{
applied: "",
appliedArgs: "",
consumed: 0,
skip: true,
}
}
bitsStr := args[argIdx]
bits, parseErr := strconv.Atoi(bitsStr)
if parseErr != nil ||
bits < 1 || bits > maxHashcashBits {
c.sendNumeric(
irc.ErrUnknownMode, "H",
"is unknown mode char to me",
)
return modeResult{
applied: "",
appliedArgs: "",
consumed: 1,
skip: true,
}
}
_ = c.database.SetChannelHashcashBits(
ctx, chID, bits,
)
return modeResult{
applied: "+H",
appliedArgs: " " + bitsStr,
consumed: 1,
skip: false,
}
}
// applyMemberMode handles +o/-o and +v/-v.
func (c *Conn) applyMemberMode(
ctx context.Context,
chID int64,
channel string,
modeChar rune,
adding bool,
args []string,
argIdx int,
) modeResult {
if argIdx >= len(args) {
return modeResult{
applied: "",
appliedArgs: "",
consumed: 0,
skip: true,
}
}
targetNick := args[argIdx]
err := c.svc.ApplyMemberMode(
ctx, chID, channel,
targetNick, modeChar, adding,
)
if err != nil {
c.sendIRCError(err)
return modeResult{
applied: "",
appliedArgs: "",
consumed: 1,
skip: true,
}
}
prefix := "+"
if !adding {
prefix = "-"
}
return modeResult{
applied: prefix + string(modeChar),
appliedArgs: " " + targetNick,
consumed: 1,
skip: false,
}
}
// applyChannelModes applies mode changes using the
// service for individual mode operations.
func (c *Conn) applyChannelModes(
@@ -505,52 +642,57 @@ func (c *Conn) applyChannelModes(
appliedArgs := ""
for _, modeChar := range modeStr {
var res modeResult
switch modeChar {
case '+':
adding = true
continue
case '-':
adding = false
case 'm', 't':
continue
case 'i', 'm', 'n', 's', 't':
_ = c.svc.SetChannelFlag(
ctx, chID, modeChar, adding,
)
if adding {
applied += "+" + string(modeChar)
} else {
applied += "-" + string(modeChar)
}
case 'o', 'v':
if argIdx >= len(args) {
break
prefix := "+"
if !adding {
prefix = "-"
}
targetNick := args[argIdx]
argIdx++
err := c.svc.ApplyMemberMode(
ctx, chID, channel,
targetNick, modeChar, adding,
res = modeResult{
applied: prefix + string(modeChar),
appliedArgs: "",
consumed: 0,
skip: false,
}
case 'H':
res = c.applyHashcashMode(
ctx, chID, adding, args, argIdx,
)
case 'o', 'v':
res = c.applyMemberMode(
ctx, chID, channel,
modeChar, adding, args, argIdx,
)
if err != nil {
c.sendIRCError(err)
continue
}
if adding {
applied += "+" + string(modeChar)
} else {
applied += "-" + string(modeChar)
}
appliedArgs += " " + targetNick
default:
c.sendNumeric(
irc.ErrUnknownMode,
string(modeChar),
"is unknown mode char to me",
)
continue
}
argIdx += res.consumed
if !res.skip {
applied += res.applied
appliedArgs += res.appliedArgs
}
}
@@ -571,7 +713,10 @@ func (c *Conn) applyChannelModes(
}
// handleUserMode handles MODE for users.
func (c *Conn) handleUserMode(msg *Message) {
func (c *Conn) handleUserMode(
ctx context.Context,
msg *Message,
) {
target := msg.Params[0]
if !strings.EqualFold(target, c.nick) {
@@ -583,8 +728,85 @@ func (c *Conn) handleUserMode(msg *Message) {
return
}
// We don't support user modes beyond the basics.
c.sendNumeric(irc.RplUmodeIs, "+")
// Mode query (no mode string).
if len(msg.Params) < 2 { //nolint:mnd
c.sendNumeric(
irc.RplUmodeIs,
c.buildUmodeString(ctx),
)
return
}
modeStr := msg.Params[1]
if len(modeStr) < 2 { //nolint:mnd
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
adding := modeStr[0] == '+'
for _, ch := range modeStr[1:] {
switch ch {
case 'w':
_ = c.database.SetSessionWallops(
ctx, c.sessionID, adding,
)
case 'o':
if adding {
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
_ = c.database.SetSessionOper(
ctx, c.sessionID, false,
)
default:
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
}
c.sendNumeric(
irc.RplUmodeIs,
c.buildUmodeString(ctx),
)
}
// buildUmodeString returns the current user mode string.
func (c *Conn) buildUmodeString(
ctx context.Context,
) string {
modes := "+"
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err == nil && isOper {
modes += "o"
}
isWallops, err := c.database.IsSessionWallops(
ctx, c.sessionID,
)
if err == nil && isWallops {
modes += "w"
}
return modes
}
// handleNames replies with channel member list.
@@ -1176,3 +1398,191 @@ func (c *Conn) handleUserhost(
strings.Join(replies, " "),
)
}
// handleVersion replies with the server version string.
func (c *Conn) handleVersion(ctx context.Context) {
_ = ctx
version := versionString()
c.sendNumeric(
irc.RplVersion,
version+".", c.cfg.ServerName,
"",
)
}
// handleAdmin replies with server admin info.
func (c *Conn) handleAdmin(ctx context.Context) {
_ = ctx
srvName := c.cfg.ServerName
c.sendNumeric(
irc.RplAdminMe,
srvName, "Administrative info",
)
c.sendNumeric(
irc.RplAdminLoc1,
"neoirc server",
)
c.sendNumeric(
irc.RplAdminLoc2,
"IRC over HTTP",
)
c.sendNumeric(
irc.RplAdminEmail,
"admin@"+srvName,
)
}
// handleInfo replies with server software info.
func (c *Conn) handleInfo(ctx context.Context) {
_ = ctx
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + versionString(),
"Written in Go",
}
for _, line := range infoLines {
c.sendNumeric(irc.RplInfo, line)
}
c.sendNumeric(
irc.RplEndOfInfo,
"End of /INFO list",
)
}
// handleTime replies with the server's current time.
func (c *Conn) handleTime(ctx context.Context) {
_ = ctx
srvName := c.cfg.ServerName
c.sendNumeric(
irc.RplTime,
srvName, time.Now().Format(time.RFC1123),
)
}
// handleKillCmd forcibly disconnects a target user (oper
// only).
func (c *Conn) handleKillCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"KILL", "Not enough parameters",
)
return
}
targetNick := msg.Params[0]
reason := "KILLed"
if len(msg.Params) > 1 {
reason = msg.Params[1]
}
if targetNick == c.nick {
c.sendNumeric(
irc.ErrCantKillServer,
"You cannot KILL yourself",
)
return
}
targetSID, lookupErr := c.database.GetSessionByNick(
ctx, targetNick,
)
if lookupErr != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
}
quitReason := "Killed (" + c.nick + " (" + reason + "))"
c.svc.BroadcastQuit(
ctx, targetSID, targetNick, quitReason,
)
}
// handleWallopsCmd broadcasts to all +w users (oper only).
func (c *Conn) handleWallopsCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"WALLOPS", "Not enough parameters",
)
return
}
message := msg.Params[0]
wallopsSIDs, wallErr := c.database.
GetWallopsSessionIDs(ctx)
if wallErr != nil {
c.log.Error(
"get wallops sessions failed",
"error", wallErr,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
return
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.nick, "*",
nil, body, nil, wallopsSIDs,
)
}
}

View File

@@ -11,11 +11,11 @@ import (
"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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
@@ -28,6 +28,7 @@ const (
pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8
maxHashcashBits = 40
)
// cmdHandler is the signature for registered IRC command
@@ -130,6 +131,12 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
c.handleCAP(msg)
},
"USERHOST": c.handleUserhost,
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
irc.CmdKill: c.handleKillCmd,
irc.CmdWallops: c.handleWallopsCmd,
}
}
@@ -434,7 +441,7 @@ func (c *Conn) deliverWelcome() {
"CHANTYPES=#",
"NICKLEN=32",
"PREFIX=(ov)@+",
"CHANMODES=,,H,mnst",
"CHANMODES=,,H,imnst",
"NETWORK="+c.serverSfx,
"are supported by this server",
)

View File

@@ -5,10 +5,10 @@ import (
"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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
)
// NewTestServer creates a Server suitable for testing.
@@ -19,12 +19,9 @@ func NewTestServer(
database *db.Database,
brk *broker.Broker,
) *Server {
svc := &service.Service{
DB: database,
Broker: brk,
Config: cfg,
Log: log,
}
svc := service.NewTestService(
database, brk, cfg, log,
)
return &Server{ //nolint:exhaustruct
log: log,

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ package ircserver_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/ircserver"
)
//nolint:funlen // table-driven test

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/pkg/irc"
)
// relayMessages polls the client output queue and delivers
@@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage(
c.deliverKickMsg(msg, text)
case command == "INVITE":
c.deliverInviteMsg(msg, text)
case command == irc.CmdWallops:
c.deliverWallops(msg, text)
case command == irc.CmdMode:
c.deliverMode(msg, text)
case command == irc.CmdPing:
@@ -305,6 +307,18 @@ func (c *Conn) deliverInviteMsg(
c.sendFromServer("NOTICE", c.nick, text)
}
// deliverWallops sends a WALLOPS notification.
func (c *Conn) deliverWallops(
msg *db.IRCMessage,
text string,
) {
prefix := msg.From + "!" + msg.From + "@*"
c.send(FormatMessage(
prefix, irc.CmdWallops, text,
))
}
// deliverMode sends a MODE change notification.
func (c *Conn) deliverMode(
msg *db.IRCMessage,

View File

@@ -7,12 +7,12 @@ import (
"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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service"
)
// Params defines the dependencies for creating an IRC

View File

@@ -11,10 +11,10 @@ import (
"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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/ircserver"
_ "modernc.org/sqlite"
)
@@ -112,6 +112,87 @@ func newTestEnv(t *testing.T) *testEnv {
}
}
// newTestEnvWithOper creates a test environment with oper
// credentials configured.
func newTestEnvWithOper(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",
OperName: "testoper",
OperPassword: "testpass",
}
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()

View File

@@ -5,8 +5,8 @@ import (
"log/slog"
"os"
"git.eeqj.de/sneak/neoirc/internal/globals"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/globals"
)
// Params defines the dependencies for creating a Logger.

View File

@@ -7,9 +7,6 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
@@ -18,6 +15,9 @@ import (
"github.com/slok/go-http-metrics/middleware/std"
"github.com/spf13/viper"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
const corsMaxAge = 300

View File

@@ -3,7 +3,7 @@ package ratelimit_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"sneak.berlin/go/neoirc/internal/ratelimit"
)
func TestNewCreatesLimiter(t *testing.T) {

View File

@@ -5,7 +5,7 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/web"
"sneak.berlin/go/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5"

View File

@@ -12,12 +12,12 @@ import (
"syscall"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"

View File

@@ -8,14 +8,15 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
)
// Params defines the dependencies for creating a Service.
@@ -30,19 +31,35 @@ type Params struct {
// Service provides shared business logic for IRC commands.
type Service struct {
DB *db.Database
Broker *broker.Broker
Config *config.Config
Log *slog.Logger
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(),
db: params.Database,
broker: params.Broker,
config: params.Config,
log: params.Logger.Get(),
}
}
// NewTestService creates a Service for use in tests
// outside the service package.
func NewTestService(
database *db.Database,
brk *broker.Broker,
cfg *config.Config,
log *slog.Logger,
) *Service {
return &Service{
db: database,
broker: brk,
config: cfg,
log: log,
}
}
@@ -76,7 +93,7 @@ func (s *Service) FanOut(
params, body, meta json.RawMessage,
sessionIDs []int64,
) (int64, string, error) {
dbID, msgUUID, err := s.DB.InsertMessage(
dbID, msgUUID, err := s.db.InsertMessage(
ctx, command, from, to, params, body, meta,
)
if err != nil {
@@ -84,8 +101,8 @@ func (s *Service) FanOut(
}
for _, sid := range sessionIDs {
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
s.Broker.Notify(sid)
_ = s.db.EnqueueToSession(ctx, sid, dbID)
s.broker.Notify(sid)
}
return dbID, msgUUID, nil
@@ -120,7 +137,7 @@ func (s *Service) SendChannelMessage(
nick, command, channel string,
body, meta json.RawMessage,
) (int64, string, error) {
chID, err := s.DB.GetChannelByName(ctx, channel)
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return 0, "", &IRCError{
irc.ErrNoSuchChannel,
@@ -129,7 +146,7 @@ func (s *Service) SendChannelMessage(
}
}
isMember, _ := s.DB.IsChannelMember(
isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -140,12 +157,24 @@ func (s *Service) SendChannelMessage(
}
}
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
if moderated {
isOp, _ := s.DB.IsChannelOperator(
// Ban check — banned users cannot send messages.
isBanned, banErr := s.db.IsSessionBanned(
ctx, chID, sessionID,
)
isVoiced, _ := s.DB.IsChannelVoiced(
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,
)
@@ -158,7 +187,7 @@ func (s *Service) SendChannelMessage(
}
}
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
dbID, uuid, fanErr := s.FanOut(
@@ -181,7 +210,7 @@ func (s *Service) SendDirectMessage(
nick, command, target string,
body, meta json.RawMessage,
) (*DirectMsgResult, error) {
targetSID, err := s.DB.GetSessionByNick(ctx, target)
targetSID, err := s.db.GetSessionByNick(ctx, target)
if err != nil {
return nil, &IRCError{
irc.ErrNoSuchNick,
@@ -190,7 +219,7 @@ func (s *Service) SendDirectMessage(
}
}
away, _ := s.DB.GetAway(ctx, targetSID)
away, _ := s.db.GetAway(ctx, targetSID)
recipients := []int64{targetSID}
if targetSID != sessionID {
@@ -214,31 +243,43 @@ func (s *Service) SendDirectMessage(
func (s *Service) JoinChannel(
ctx context.Context,
sessionID int64,
nick, channel string,
nick, channel, suppliedKey string,
) (*JoinResult, error) {
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
chID, err := s.db.GetOrCreateChannel(ctx, channel)
if err != nil {
return nil, fmt.Errorf("get/create channel: %w", err)
}
memberCount, countErr := s.DB.CountChannelMembers(
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(
err = s.db.JoinChannelAsOperator(
ctx, chID, sessionID,
)
} else {
err = s.DB.JoinChannel(ctx, chID, sessionID)
err = s.db.JoinChannel(ctx, chID, sessionID)
}
if err != nil {
return nil, fmt.Errorf("join channel: %w", err)
}
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
// 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
@@ -260,7 +301,7 @@ func (s *Service) PartChannel(
sessionID int64,
nick, channel, reason string,
) error {
chID, err := s.DB.GetChannelByName(ctx, channel)
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -269,7 +310,7 @@ func (s *Service) PartChannel(
}
}
isMember, _ := s.DB.IsChannelMember(
isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -280,7 +321,7 @@ func (s *Service) PartChannel(
}
}
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
@@ -289,8 +330,8 @@ func (s *Service) PartChannel(
nil, body, nil, recipients,
)
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil
}
@@ -302,7 +343,7 @@ func (s *Service) SetTopic(
sessionID int64,
nick, channel, topic string,
) error {
chID, err := s.DB.GetChannelByName(ctx, channel)
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -311,7 +352,7 @@ func (s *Service) SetTopic(
}
}
isMember, _ := s.DB.IsChannelMember(
isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
@@ -322,9 +363,9 @@ func (s *Service) SetTopic(
}
}
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
if topicLocked {
isOp, _ := s.DB.IsChannelOperator(
isOp, _ := s.db.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -336,15 +377,15 @@ func (s *Service) SetTopic(
}
}
if setErr := s.DB.SetTopic(
if setErr := s.db.SetTopic(
ctx, channel, topic,
); setErr != nil {
return fmt.Errorf("set topic: %w", setErr)
}
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
_ = s.db.SetTopicMeta(ctx, channel, topic, nick)
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -363,7 +404,7 @@ func (s *Service) KickUser(
sessionID int64,
nick, channel, targetNick, reason string,
) error {
chID, err := s.DB.GetChannelByName(ctx, channel)
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return &IRCError{
irc.ErrNoSuchChannel,
@@ -372,7 +413,7 @@ func (s *Service) KickUser(
}
}
isOp, _ := s.DB.IsChannelOperator(
isOp, _ := s.db.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -383,7 +424,7 @@ func (s *Service) KickUser(
}
}
targetSID, err := s.DB.GetSessionByNick(
targetSID, err := s.db.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
@@ -394,7 +435,7 @@ func (s *Service) KickUser(
}
}
isMember, _ := s.DB.IsChannelMember(
isMember, _ := s.db.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -405,7 +446,7 @@ func (s *Service) KickUser(
}
}
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
params, _ := json.Marshal( //nolint:errchkjson
[]string{targetNick},
@@ -416,8 +457,8 @@ func (s *Service) KickUser(
params, body, nil, memberIDs,
)
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil
}
@@ -429,7 +470,7 @@ func (s *Service) ChangeNick(
sessionID int64,
oldNick, newNick string,
) error {
err := s.DB.ChangeNick(ctx, sessionID, newNick)
err := s.db.ChangeNick(ctx, sessionID, newNick)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") ||
db.IsUniqueConstraintError(err) {
@@ -461,7 +502,7 @@ func (s *Service) BroadcastQuit(
sessionID int64,
nick, reason string,
) {
channels, err := s.DB.GetSessionChannels(
channels, err := s.db.GetSessionChannels(
ctx, sessionID,
)
if err != nil {
@@ -471,7 +512,7 @@ func (s *Service) BroadcastQuit(
notified := make(map[int64]bool)
for _, ch := range channels {
memberIDs, memErr := s.DB.GetChannelMemberIDs(
memberIDs, memErr := s.db.GetChannelMemberIDs(
ctx, ch.ID,
)
if memErr != nil {
@@ -502,11 +543,11 @@ func (s *Service) BroadcastQuit(
}
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.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
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
}
// SetAway sets or clears the away message. Returns true
@@ -516,7 +557,7 @@ func (s *Service) SetAway(
sessionID int64,
message string,
) (bool, error) {
err := s.DB.SetAway(ctx, sessionID, message)
err := s.db.SetAway(ctx, sessionID, message)
if err != nil {
return false, fmt.Errorf("set away: %w", err)
}
@@ -531,8 +572,8 @@ func (s *Service) Oper(
sessionID int64,
name, password string,
) error {
cfgName := s.Config.OperName
cfgPassword := s.Config.OperPassword
cfgName := s.config.OperName
cfgPassword := s.config.OperPassword
// Use constant-time comparison and return the same
// error for all failures to prevent information
@@ -551,7 +592,7 @@ func (s *Service) Oper(
}
}
_ = s.DB.SetSessionOper(ctx, sessionID, true)
_ = s.db.SetSessionOper(ctx, sessionID, true)
return nil
}
@@ -563,7 +604,7 @@ func (s *Service) ValidateChannelOp(
sessionID int64,
channel string,
) (int64, error) {
chID, err := s.DB.GetChannelByName(ctx, channel)
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return 0, &IRCError{
irc.ErrNoSuchChannel,
@@ -572,7 +613,7 @@ func (s *Service) ValidateChannelOp(
}
}
isOp, _ := s.DB.IsChannelOperator(
isOp, _ := s.db.IsChannelOperator(
ctx, chID, sessionID,
)
if !isOp {
@@ -595,7 +636,7 @@ func (s *Service) ApplyMemberMode(
mode rune,
adding bool,
) error {
targetSID, err := s.DB.GetSessionByNick(
targetSID, err := s.db.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
@@ -606,7 +647,7 @@ func (s *Service) ApplyMemberMode(
}
}
isMember, _ := s.DB.IsChannelMember(
isMember, _ := s.db.IsChannelMember(
ctx, chID, targetSID,
)
if !isMember {
@@ -619,11 +660,11 @@ func (s *Service) ApplyMemberMode(
switch mode {
case 'o':
_ = s.DB.SetChannelMemberOperator(
_ = s.db.SetChannelMemberOperator(
ctx, chID, targetSID, adding,
)
case 'v':
_ = s.DB.SetChannelMemberVoiced(
_ = s.db.SetChannelMemberVoiced(
ctx, chID, targetSID, adding,
)
}
@@ -631,7 +672,8 @@ func (s *Service) ApplyMemberMode(
return nil
}
// SetChannelFlag applies +m/-m or +t/-t on a channel.
// SetChannelFlag applies a simple boolean channel mode
// (+m/-m, +t/-t, +i/-i, +s/-s, +n/-n).
func (s *Service) SetChannelFlag(
ctx context.Context,
chID int64,
@@ -640,17 +682,37 @@ func (s *Service) SetChannelFlag(
) error {
switch flag {
case 'm':
if err := s.DB.SetChannelModerated(
if err := s.db.SetChannelModerated(
ctx, chID, setting,
); err != nil {
return fmt.Errorf("set moderated: %w", err)
}
case 't':
if err := s.DB.SetChannelTopicLocked(
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)
}
case 'n':
if err := s.db.SetChannelNoExternal(
ctx, chID, setting,
); err != nil {
return fmt.Errorf(
"set no external: %w", err,
)
}
}
return nil
@@ -664,7 +726,7 @@ func (s *Service) BroadcastMode(
chID int64,
modeText string,
) {
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
@@ -673,24 +735,60 @@ func (s *Service) BroadcastMode(
)
}
// QueryChannelMode returns the channel mode string.
// QueryChannelMode returns the complete channel mode
// string including all flags and parameterized modes.
func (s *Service) QueryChannelMode(
ctx context.Context,
chID int64,
) string {
modes := "+"
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
noExternal, _ := s.db.IsChannelNoExternal(ctx, chID)
if noExternal {
modes += "n"
}
inviteOnly, _ := s.db.IsChannelInviteOnly(ctx, chID)
if inviteOnly {
modes += "i"
}
moderated, _ := s.db.IsChannelModerated(ctx, chID)
if moderated {
modes += "m"
}
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
secret, _ := s.db.IsChannelSecret(ctx, chID)
if secret {
modes += "s"
}
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
if topicLocked {
modes += "t"
}
return modes
var modeParams string
key, _ := s.db.GetChannelKey(ctx, chID)
if key != "" {
modes += "k"
modeParams += " " + key
}
limit, _ := s.db.GetChannelUserLimit(ctx, chID)
if limit > 0 {
modes += "l"
modeParams += " " + strconv.Itoa(limit)
}
bits, _ := s.db.GetChannelHashcashBits(ctx, chID)
if bits > 0 {
modes += "H"
modeParams += " " + strconv.Itoa(bits)
}
return modes + modeParams
}
// broadcastNickChange notifies channel peers of a nick
@@ -700,7 +798,7 @@ func (s *Service) broadcastNickChange(
sessionID int64,
oldNick, newNick string,
) {
channels, err := s.DB.GetSessionChannels(
channels, err := s.db.GetSessionChannels(
ctx, sessionID,
)
if err != nil {
@@ -710,7 +808,7 @@ func (s *Service) broadcastNickChange(
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
notified := make(map[int64]bool)
dbID, _, insErr := s.DB.InsertMessage(
dbID, _, insErr := s.db.InsertMessage(
ctx, irc.CmdNick, oldNick, "",
nil, body, nil,
)
@@ -719,12 +817,12 @@ func (s *Service) broadcastNickChange(
}
// Notify the user themselves (for multi-client sync).
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
s.Broker.Notify(sessionID)
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
s.broker.Notify(sessionID)
notified[sessionID] = true
for _, ch := range channels {
memberIDs, memErr := s.DB.GetChannelMemberIDs(
memberIDs, memErr := s.db.GetChannelMemberIDs(
ctx, ch.ID,
)
if memErr != nil {
@@ -738,8 +836,66 @@ func (s *Service) broadcastNickChange(
notified[mid] = true
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
s.Broker.Notify(mid)
_ = 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

@@ -12,16 +12,16 @@ import (
"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"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestMain(m *testing.M) {
@@ -167,7 +167,7 @@ func TestJoinChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general",
ctx, sid, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -185,7 +185,7 @@ func TestJoinChannel(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel(
ctx, sid2, "bob", "#general",
ctx, sid2, "bob", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel bob: %v", err)
@@ -207,7 +207,7 @@ func TestPartChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general",
ctx, sid, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -242,14 +242,14 @@ func TestSendChannelMessage(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#chat",
ctx, sid1, "alice", "#chat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#chat",
ctx, sid2, "bob", "#chat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -293,14 +293,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#room",
ctx, sid1, "alice", "#room", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#room",
ctx, sid2, "bob", "#room", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -326,14 +326,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#modchat",
ctx, sid1, "alice", "#modchat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#modchat",
ctx, sid2, "bob", "#modchat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)

View File

@@ -3,7 +3,7 @@ package stats_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/stats"
"sneak.berlin/go/neoirc/internal/stats"
)
func TestNew(t *testing.T) {

View File

@@ -2,10 +2,13 @@ package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdAdmin = "ADMIN"
CmdAway = "AWAY"
CmdInfo = "INFO"
CmdInvite = "INVITE"
CmdJoin = "JOIN"
CmdKick = "KICK"
CmdKill = "KILL"
CmdList = "LIST"
CmdLusers = "LUSERS"
CmdMode = "MODE"
@@ -20,8 +23,12 @@ const (
CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT"
CmdTime = "TIME"
CmdTopic = "TOPIC"
CmdUser = "USER"
CmdUserhost = "USERHOST"
CmdVersion = "VERSION"
CmdWallops = "WALLOPS"
CmdWho = "WHO"
CmdWhois = "WHOIS"
)

View File

@@ -4,7 +4,7 @@ import (
"errors"
"testing"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestName(t *testing.T) {

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
"title": "JOIN",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
"title": "KICK",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
"title": "MODE",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
"title": "NICK",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
"title": "PART",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
"title": "PING",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
"title": "PONG",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
"title": "PRIVMSG",
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
"title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
"title": "QUIT",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC",
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
"title": "IRC Message Envelope",
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
"type": "object",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED",
"description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART",
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json",