Compare commits
1 Commits
feature/sc
...
feature/ir
| Author | SHA1 | Date | |
|---|---|---|---|
| 92d5145ac6 |
@@ -53,7 +53,7 @@ RUN apk add --no-cache ca-certificates \
|
||||
COPY --from=builder /neoircd /usr/local/bin/neoircd
|
||||
|
||||
USER neoirc
|
||||
EXPOSE 8080
|
||||
EXPOSE 8080 6667
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8080/.well-known/healthcheck.json || exit 1
|
||||
ENTRYPOINT ["neoircd"]
|
||||
|
||||
65
README.md
65
README.md
@@ -27,6 +27,7 @@ web client as a convenience/reference implementation, but the API comes first.
|
||||
- [Federation](#federation-server-to-server)
|
||||
- [Storage](#storage)
|
||||
- [Configuration](#configuration)
|
||||
- [IRC Protocol Listener](#irc-protocol-listener)
|
||||
- [Deployment](#deployment)
|
||||
- [Client Development Guide](#client-development-guide)
|
||||
- [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention)
|
||||
@@ -2266,6 +2267,7 @@ directory is also loaded automatically via
|
||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||||
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||||
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||||
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. |
|
||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||
|
||||
### Example `.env` file
|
||||
@@ -2282,6 +2284,69 @@ NEOIRC_HASHCASH_BITS=20
|
||||
|
||||
---
|
||||
|
||||
## IRC Protocol Listener
|
||||
|
||||
neoirc includes an optional traditional IRC wire protocol listener (RFC
|
||||
1459/2812) that allows standard IRC clients to connect directly. This enables
|
||||
backward compatibility with existing IRC clients like irssi, weechat, hexchat,
|
||||
and others.
|
||||
|
||||
### Configuration
|
||||
|
||||
The IRC listener is **enabled by default** on `:6667`. To disable it, set
|
||||
`IRC_LISTEN_ADDR` to an empty string:
|
||||
|
||||
```bash
|
||||
IRC_LISTEN_ADDR=
|
||||
```
|
||||
|
||||
### Supported Commands
|
||||
|
||||
| Category | Commands |
|
||||
|------------|------------------------------------------------------|
|
||||
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
||||
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
||||
| Messaging | `PRIVMSG`, `NOTICE` |
|
||||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` |
|
||||
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||
|
||||
### Protocol Details
|
||||
|
||||
- **Wire format**: CR-LF delimited lines, max 512 bytes per line
|
||||
- **Connection registration**: Clients must send `NICK` and `USER` to register.
|
||||
An optional `PASS` before registration sets the session password (minimum 8
|
||||
characters).
|
||||
- **CAP negotiation**: `CAP LS` and `CAP END` are silently handled for
|
||||
compatibility with modern clients. No capabilities are advertised.
|
||||
- **Channel prefixes**: Channels must start with `#`. If omitted, it's
|
||||
automatically prepended.
|
||||
- **First joiner**: The first user to join a channel is automatically granted
|
||||
operator status (`@`).
|
||||
- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator),
|
||||
`+v` (voice)
|
||||
|
||||
### Bridge to HTTP API
|
||||
|
||||
Messages sent by IRC clients appear in channels visible to HTTP/JSON API
|
||||
clients and vice versa. The IRC listener and HTTP API share the same database,
|
||||
broker, and session infrastructure. A user connected via IRC and a user
|
||||
connected via the HTTP API can communicate in the same channels seamlessly.
|
||||
|
||||
### Docker Usage
|
||||
|
||||
To expose the IRC port in Docker (the listener is enabled by default on
|
||||
`:6667`):
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 8080:8080 \
|
||||
-p 6667:6667 \
|
||||
-v neoirc-data:/var/lib/neoirc \
|
||||
neoirc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
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"
|
||||
)
|
||||
@@ -28,16 +31,23 @@ func main() {
|
||||
|
||||
fx.New(
|
||||
fx.Provide(
|
||||
broker.New,
|
||||
config.New,
|
||||
db.New,
|
||||
globals.New,
|
||||
handlers.New,
|
||||
ircserver.New,
|
||||
logger.New,
|
||||
server.New,
|
||||
middleware.New,
|
||||
healthcheck.New,
|
||||
service.New,
|
||||
stats.New,
|
||||
),
|
||||
fx.Invoke(func(*server.Server) {}),
|
||||
fx.Invoke(func(
|
||||
_ *server.Server,
|
||||
_ *ircserver.Server,
|
||||
) {
|
||||
}),
|
||||
).Run()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ type Config struct {
|
||||
OperPassword string
|
||||
LoginRateLimit float64
|
||||
LoginRateBurst int
|
||||
IRCListenAddr string
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -86,6 +87,7 @@ func New(
|
||||
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
||||
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
||||
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
||||
viper.SetDefault("IRC_LISTEN_ADDR", ":6667")
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
@@ -116,6 +118,7 @@ func New(
|
||||
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
||||
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
||||
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
||||
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
|
||||
log: log,
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
@@ -135,21 +135,13 @@ type migration struct {
|
||||
func (database *Database) runMigrations(
|
||||
ctx context.Context,
|
||||
) error {
|
||||
bootstrap, err := SchemaFiles.ReadFile(
|
||||
"schema/000.sql",
|
||||
)
|
||||
_, err := database.conn.ExecContext(ctx,
|
||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"read bootstrap migration: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
_, err = database.conn.ExecContext(
|
||||
ctx, string(bootstrap),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"execute bootstrap migration: %w", err,
|
||||
"create schema_migrations: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,11 +270,6 @@ func (database *Database) loadMigrations() (
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip bootstrap migration; it is executed separately.
|
||||
if version == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
content, readErr := SchemaFiles.ReadFile(
|
||||
"schema/" + entry.Name(),
|
||||
)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
-- 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);
|
||||
25
internal/db/testing.go
Normal file
25
internal/db/testing.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// NewTestDatabaseFromConn creates a Database wrapping an
|
||||
// existing *sql.DB connection. Intended for integration
|
||||
// tests in other packages.
|
||||
func NewTestDatabaseFromConn(conn *sql.DB) *Database {
|
||||
return &Database{ //nolint:exhaustruct
|
||||
conn: conn,
|
||||
log: slog.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// RunMigrations applies all schema migrations. Exposed
|
||||
// for integration tests in other packages.
|
||||
func (database *Database) RunMigrations(
|
||||
ctx context.Context,
|
||||
) error {
|
||||
return database.runMigrations(ctx)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
"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"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
@@ -206,6 +208,14 @@ func newTestHandlers(
|
||||
hcheck *healthcheck.Healthcheck,
|
||||
tracker *stats.Tracker,
|
||||
) (*handlers.Handlers, error) {
|
||||
brk := broker.New()
|
||||
svc := service.New(service.Params{ //nolint:exhaustruct
|
||||
Logger: log,
|
||||
Config: cfg,
|
||||
Database: database,
|
||||
Broker: brk,
|
||||
})
|
||||
|
||||
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
|
||||
Logger: log,
|
||||
Globals: globs,
|
||||
@@ -213,6 +223,8 @@ func newTestHandlers(
|
||||
Database: database,
|
||||
Healthcheck: hcheck,
|
||||
Stats: tracker,
|
||||
Broker: brk,
|
||||
Service: svc,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("test handlers: %w", err)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
@@ -33,6 +34,8 @@ type Params struct {
|
||||
Database *db.Database
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Stats *stats.Tracker
|
||||
Broker *broker.Broker
|
||||
Service *service.Service
|
||||
}
|
||||
|
||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||
@@ -48,6 +51,7 @@ type Handlers struct {
|
||||
log *slog.Logger
|
||||
hc *healthcheck.Healthcheck
|
||||
broker *broker.Broker
|
||||
svc *service.Service
|
||||
hashcashVal *hashcash.Validator
|
||||
channelHashcash *hashcash.ChannelValidator
|
||||
loginLimiter *ratelimit.Limiter
|
||||
@@ -79,7 +83,8 @@ func New(
|
||||
params: ¶ms,
|
||||
log: params.Logger.Get(),
|
||||
hc: params.Healthcheck,
|
||||
broker: broker.New(),
|
||||
broker: params.Broker,
|
||||
svc: params.Service,
|
||||
hashcashVal: hashcash.NewValidator(resource),
|
||||
channelHashcash: hashcash.NewChannelValidator(),
|
||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||
|
||||
1178
internal/ircserver/commands.go
Normal file
1178
internal/ircserver/commands.go
Normal file
File diff suppressed because it is too large
Load Diff
501
internal/ircserver/conn.go
Normal file
501
internal/ircserver/conn.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package ircserver
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLineLen = 512
|
||||
readTimeout = 5 * time.Minute
|
||||
writeTimeout = 30 * time.Second
|
||||
dnsTimeout = 3 * time.Second
|
||||
pollInterval = 100 * time.Millisecond
|
||||
pingInterval = 90 * time.Second
|
||||
pongDeadline = 30 * time.Second
|
||||
maxNickLen = 32
|
||||
minPasswordLen = 8
|
||||
)
|
||||
|
||||
// cmdHandler is the signature for registered IRC command
|
||||
// handlers.
|
||||
type cmdHandler func(ctx context.Context, msg *Message)
|
||||
|
||||
// Conn represents a single IRC client TCP connection.
|
||||
type Conn struct {
|
||||
conn net.Conn
|
||||
log *slog.Logger
|
||||
database *db.Database
|
||||
brk *broker.Broker
|
||||
cfg *config.Config
|
||||
svc *service.Service
|
||||
serverSfx string
|
||||
commands map[string]cmdHandler
|
||||
|
||||
mu sync.Mutex
|
||||
nick string
|
||||
username string
|
||||
realname string
|
||||
hostname string
|
||||
remoteIP string
|
||||
sessionID int64
|
||||
clientID int64
|
||||
|
||||
registered bool
|
||||
gotNick bool
|
||||
gotUser bool
|
||||
passWord string
|
||||
|
||||
lastQueueID int64
|
||||
closed bool
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newConn(
|
||||
ctx context.Context,
|
||||
tcpConn net.Conn,
|
||||
log *slog.Logger,
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
cfg *config.Config,
|
||||
svc *service.Service,
|
||||
) *Conn {
|
||||
host, _, _ := net.SplitHostPort(tcpConn.RemoteAddr().String())
|
||||
|
||||
srvName := cfg.ServerName
|
||||
if srvName == "" {
|
||||
srvName = "neoirc"
|
||||
}
|
||||
|
||||
conn := &Conn{ //nolint:exhaustruct // zero-value defaults
|
||||
conn: tcpConn,
|
||||
log: log,
|
||||
database: database,
|
||||
brk: brk,
|
||||
cfg: cfg,
|
||||
svc: svc,
|
||||
serverSfx: srvName,
|
||||
remoteIP: host,
|
||||
hostname: resolveHost(ctx, host),
|
||||
}
|
||||
|
||||
conn.commands = conn.buildCommandMap()
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// buildCommandMap returns a map from IRC command strings
|
||||
// to handler functions.
|
||||
func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
||||
return map[string]cmdHandler{
|
||||
irc.CmdPing: func(_ context.Context, msg *Message) {
|
||||
c.handlePing(msg)
|
||||
},
|
||||
"PONG": func(context.Context, *Message) {},
|
||||
irc.CmdNick: c.handleNick,
|
||||
irc.CmdPrivmsg: c.handlePrivmsg,
|
||||
irc.CmdNotice: c.handlePrivmsg,
|
||||
irc.CmdJoin: c.handleJoin,
|
||||
irc.CmdPart: c.handlePart,
|
||||
irc.CmdQuit: func(_ context.Context, msg *Message) {
|
||||
c.handleQuit(msg)
|
||||
},
|
||||
irc.CmdTopic: c.handleTopic,
|
||||
irc.CmdMode: c.handleMode,
|
||||
irc.CmdNames: c.handleNames,
|
||||
irc.CmdList: func(ctx context.Context, _ *Message) { c.handleList(ctx) },
|
||||
irc.CmdWhois: c.handleWhois,
|
||||
irc.CmdWho: c.handleWho,
|
||||
irc.CmdLusers: func(ctx context.Context, _ *Message) { c.handleLusers(ctx) },
|
||||
irc.CmdMotd: func(context.Context, *Message) { c.deliverMOTD() },
|
||||
irc.CmdOper: c.handleOper,
|
||||
irc.CmdAway: c.handleAway,
|
||||
irc.CmdKick: c.handleKick,
|
||||
irc.CmdPass: c.handlePassPostReg,
|
||||
"INVITE": c.handleInvite,
|
||||
"CAP": func(_ context.Context, msg *Message) {
|
||||
c.handleCAP(msg)
|
||||
},
|
||||
"USERHOST": c.handleUserhost,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveHost does a reverse DNS lookup, returning the IP
|
||||
// on failure.
|
||||
func resolveHost(ctx context.Context, addr string) string {
|
||||
ctx, cancel := context.WithTimeout(ctx, dnsTimeout)
|
||||
defer cancel()
|
||||
|
||||
resolver := &net.Resolver{} //nolint:exhaustruct
|
||||
|
||||
names, err := resolver.LookupAddr(ctx, addr)
|
||||
if err != nil || len(names) == 0 {
|
||||
return addr
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(names[0], ".")
|
||||
}
|
||||
|
||||
// serve is the main loop for a single IRC client connection.
|
||||
func (c *Conn) serve(ctx context.Context) {
|
||||
ctx, c.cancel = context.WithCancel(ctx)
|
||||
defer c.cleanup(ctx)
|
||||
|
||||
scanner := bufio.NewScanner(c.conn)
|
||||
scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
|
||||
|
||||
for {
|
||||
_ = c.conn.SetReadDeadline(
|
||||
time.Now().Add(readTimeout),
|
||||
)
|
||||
|
||||
if !scanner.Scan() {
|
||||
return
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := ParseMessage(line)
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
c.handleMessage(ctx, msg)
|
||||
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) cleanup(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
wasRegistered := c.registered
|
||||
sessID := c.sessionID
|
||||
nick := c.nick
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
if wasRegistered && sessID > 0 {
|
||||
c.svc.BroadcastQuit(
|
||||
ctx, sessID, nick, "Connection closed",
|
||||
)
|
||||
}
|
||||
|
||||
c.conn.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
// send writes a formatted IRC line to the connection.
|
||||
func (c *Conn) send(line string) {
|
||||
_ = c.conn.SetWriteDeadline(
|
||||
time.Now().Add(writeTimeout),
|
||||
)
|
||||
|
||||
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
|
||||
}
|
||||
|
||||
// sendNumeric sends a numeric reply from the server.
|
||||
func (c *Conn) sendNumeric(
|
||||
code irc.IRCMessageType,
|
||||
params ...string,
|
||||
) {
|
||||
nick := c.nick
|
||||
if nick == "" {
|
||||
nick = "*"
|
||||
}
|
||||
|
||||
allParams := make([]string, 0, 1+len(params))
|
||||
allParams = append(allParams, nick)
|
||||
allParams = append(allParams, params...)
|
||||
|
||||
c.send(FormatMessage(
|
||||
c.serverSfx, code.Code(), allParams...,
|
||||
))
|
||||
}
|
||||
|
||||
// sendFromServer sends a message from the server.
|
||||
func (c *Conn) sendFromServer(
|
||||
command string, params ...string,
|
||||
) {
|
||||
c.send(FormatMessage(c.serverSfx, command, params...))
|
||||
}
|
||||
|
||||
// hostmask returns the client's full hostmask
|
||||
// (nick!user@host).
|
||||
func (c *Conn) hostmask() string {
|
||||
user := c.username
|
||||
if user == "" {
|
||||
user = c.nick
|
||||
}
|
||||
|
||||
host := c.hostname
|
||||
if host == "" {
|
||||
host = c.remoteIP
|
||||
}
|
||||
|
||||
return c.nick + "!" + user + "@" + host
|
||||
}
|
||||
|
||||
// handleMessage dispatches a parsed IRC message using
|
||||
// the command handler map.
|
||||
func (c *Conn) handleMessage(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
// Before registration, only NICK, USER, PASS, PING,
|
||||
// QUIT, and CAP are accepted.
|
||||
if !c.registered {
|
||||
c.handlePreRegistration(ctx, msg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
handler, ok := c.commands[msg.Command]
|
||||
if !ok {
|
||||
c.sendNumeric(
|
||||
irc.ErrUnknownCommand,
|
||||
msg.Command, "Unknown command",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
handler(ctx, msg)
|
||||
}
|
||||
|
||||
// handlePreRegistration handles messages before the
|
||||
// connection is registered (NICK+USER received).
|
||||
func (c *Conn) handlePreRegistration(
|
||||
ctx context.Context,
|
||||
msg *Message,
|
||||
) {
|
||||
switch msg.Command {
|
||||
case irc.CmdPass:
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
"PASS", "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.passWord = msg.Params[0]
|
||||
case irc.CmdNick:
|
||||
if len(msg.Params) < 1 {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoNicknameGiven,
|
||||
"No nickname given",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.nick = msg.Params[0]
|
||||
if len(c.nick) > maxNickLen {
|
||||
c.nick = c.nick[:maxNickLen]
|
||||
}
|
||||
|
||||
c.gotNick = true
|
||||
case irc.CmdUser:
|
||||
if len(msg.Params) < 4 { //nolint:mnd
|
||||
c.sendNumeric(
|
||||
irc.ErrNeedMoreParams,
|
||||
"USER", "Not enough parameters",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.username = msg.Params[0]
|
||||
c.realname = msg.Params[3]
|
||||
c.gotUser = true
|
||||
case irc.CmdPing:
|
||||
c.handlePing(msg)
|
||||
|
||||
return
|
||||
case irc.CmdQuit:
|
||||
c.handleQuit(msg)
|
||||
|
||||
return
|
||||
case "CAP":
|
||||
c.handleCAP(msg)
|
||||
|
||||
return
|
||||
default:
|
||||
c.sendNumeric(
|
||||
irc.ErrNotRegistered,
|
||||
"You have not registered",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Try to complete registration once we have both
|
||||
// NICK and USER.
|
||||
if c.gotNick && c.gotUser {
|
||||
c.completeRegistration(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// completeRegistration creates a session and sends the
|
||||
// welcome burst.
|
||||
func (c *Conn) completeRegistration(ctx context.Context) {
|
||||
// Check if nick is valid.
|
||||
if c.nick == "" {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoNicknameGiven, "No nickname given",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create session in DB.
|
||||
sessionID, clientID, _, err := c.database.CreateSession(
|
||||
ctx, c.nick, c.username, c.hostname, c.remoteIP,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint") ||
|
||||
strings.Contains(err.Error(), "nick") {
|
||||
c.sendNumeric(
|
||||
irc.ErrNicknameInUse,
|
||||
c.nick, "Nickname is already in use",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Error(
|
||||
"failed to create session", "error", err,
|
||||
)
|
||||
c.send("ERROR :Internal server error")
|
||||
c.closed = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.sessionID = sessionID
|
||||
c.clientID = clientID
|
||||
c.registered = true
|
||||
c.mu.Unlock()
|
||||
|
||||
// If PASS was provided before registration, set the
|
||||
// session password.
|
||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
||||
c.setPassword(ctx, c.passWord)
|
||||
}
|
||||
|
||||
// Send welcome burst.
|
||||
c.deliverWelcome()
|
||||
c.deliverLusers(ctx)
|
||||
c.deliverMOTD()
|
||||
|
||||
// Start the message relay goroutine.
|
||||
go c.relayMessages(ctx)
|
||||
}
|
||||
|
||||
// deliverWelcome sends 001-005 welcome numerics.
|
||||
func (c *Conn) deliverWelcome() {
|
||||
c.sendNumeric(irc.RplWelcome, fmt.Sprintf(
|
||||
"Welcome to the %s Network, %s",
|
||||
c.serverSfx, c.hostmask(),
|
||||
))
|
||||
c.sendNumeric(irc.RplYourHost, fmt.Sprintf(
|
||||
"Your host is %s, running version neoirc",
|
||||
c.serverSfx,
|
||||
))
|
||||
c.sendNumeric(
|
||||
irc.RplCreated,
|
||||
"This server was created recently",
|
||||
)
|
||||
c.sendNumeric(
|
||||
irc.RplMyInfo,
|
||||
c.serverSfx, "neoirc", "", "mnst",
|
||||
)
|
||||
c.sendNumeric(
|
||||
irc.RplIsupport,
|
||||
"CHANTYPES=#",
|
||||
"NICKLEN=32",
|
||||
"PREFIX=(ov)@+",
|
||||
"CHANMODES=,,H,mnst",
|
||||
"NETWORK="+c.serverSfx,
|
||||
"are supported by this server",
|
||||
)
|
||||
}
|
||||
|
||||
// deliverLusers sends 251/252/254/255 server statistics.
|
||||
func (c *Conn) deliverLusers(ctx context.Context) {
|
||||
users, _ := c.database.GetUserCount(ctx)
|
||||
opers, _ := c.database.GetOperCount(ctx)
|
||||
channels, _ := c.database.GetChannelCount(ctx)
|
||||
|
||||
c.sendNumeric(irc.RplLuserClient, fmt.Sprintf(
|
||||
"There are %d users and 0 invisible on 1 servers",
|
||||
users,
|
||||
))
|
||||
c.sendNumeric(
|
||||
irc.RplLuserOp,
|
||||
strconv.FormatInt(opers, 10),
|
||||
"operator(s) online",
|
||||
)
|
||||
c.sendNumeric(
|
||||
irc.RplLuserChannels,
|
||||
strconv.FormatInt(channels, 10),
|
||||
"channels formed",
|
||||
)
|
||||
c.sendNumeric(irc.RplLuserMe, fmt.Sprintf(
|
||||
"I have %d clients and 1 servers", users,
|
||||
))
|
||||
}
|
||||
|
||||
// deliverMOTD sends 375/372/376 MOTD lines.
|
||||
func (c *Conn) deliverMOTD() {
|
||||
motd := c.cfg.MOTD
|
||||
if motd == "" {
|
||||
c.sendNumeric(
|
||||
irc.ErrNoMotd, "MOTD File is missing",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.sendNumeric(irc.RplMotdStart, fmt.Sprintf(
|
||||
"- %s Message of the Day -", c.serverSfx,
|
||||
))
|
||||
|
||||
for _, line := range strings.Split(motd, "\n") {
|
||||
c.sendNumeric(irc.RplMotd, "- "+line)
|
||||
}
|
||||
|
||||
c.sendNumeric(
|
||||
irc.RplEndOfMotd, "End of /MOTD command",
|
||||
)
|
||||
}
|
||||
|
||||
// setPassword sets a bcrypt password on the session.
|
||||
func (c *Conn) setPassword(ctx context.Context, pw string) {
|
||||
// Use the database's auth module to hash and store.
|
||||
err := c.database.SetPassword(ctx, c.sessionID, pw)
|
||||
if err != nil {
|
||||
c.log.Error(
|
||||
"failed to set password", "error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
52
internal/ircserver/export_test.go
Normal file
52
internal/ircserver/export_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package ircserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
)
|
||||
|
||||
// NewTestServer creates a Server suitable for testing.
|
||||
// The caller must call Stop() when finished.
|
||||
func NewTestServer(
|
||||
log *slog.Logger,
|
||||
cfg *config.Config,
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
) *Server {
|
||||
svc := &service.Service{
|
||||
DB: database,
|
||||
Broker: brk,
|
||||
Config: cfg,
|
||||
Log: log,
|
||||
}
|
||||
|
||||
return &Server{ //nolint:exhaustruct
|
||||
log: log,
|
||||
cfg: cfg,
|
||||
database: database,
|
||||
brk: brk,
|
||||
svc: svc,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start exposes the unexported start method for tests.
|
||||
func (s *Server) Start(addr string) error {
|
||||
return s.start(context.Background(), addr)
|
||||
}
|
||||
|
||||
// Stop exposes the unexported stop method for tests.
|
||||
func (s *Server) Stop() {
|
||||
s.stop()
|
||||
}
|
||||
|
||||
// Listener returns the server's net.Listener for tests.
|
||||
func (s *Server) Listener() net.Listener {
|
||||
return s.listener
|
||||
}
|
||||
123
internal/ircserver/parser.go
Normal file
123
internal/ircserver/parser.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Package ircserver implements a traditional IRC wire protocol
|
||||
// listener (RFC 1459/2812) that bridges to the neoirc HTTP/JSON
|
||||
// server internals.
|
||||
package ircserver
|
||||
|
||||
import "strings"
|
||||
|
||||
// Message represents a parsed IRC wire protocol message.
|
||||
type Message struct {
|
||||
// Prefix is the optional :prefix at the start (may be
|
||||
// empty for client-to-server messages).
|
||||
Prefix string
|
||||
// Command is the IRC command (e.g., "PRIVMSG", "NICK").
|
||||
Command string
|
||||
// Params holds the positional parameters, including the
|
||||
// trailing parameter (which was preceded by ':' on the
|
||||
// wire).
|
||||
Params []string
|
||||
}
|
||||
|
||||
// ParseMessage parses a single IRC wire protocol line
|
||||
// (without the trailing CR-LF) into a Message.
|
||||
// Returns nil if the line is empty.
|
||||
//
|
||||
// IRC message format (RFC 1459 §2.3.1):
|
||||
//
|
||||
// [":" prefix SPACE] command { SPACE param } [SPACE ":" trailing]
|
||||
func ParseMessage(line string) *Message {
|
||||
if line == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := &Message{} //nolint:exhaustruct // fields set below
|
||||
|
||||
// Extract prefix if present.
|
||||
if line[0] == ':' {
|
||||
idx := strings.IndexByte(line, ' ')
|
||||
if idx < 0 {
|
||||
// Only a prefix, no command — invalid.
|
||||
return nil
|
||||
}
|
||||
|
||||
msg.Prefix = line[1:idx]
|
||||
line = line[idx+1:]
|
||||
}
|
||||
|
||||
// Skip leading spaces.
|
||||
line = strings.TrimLeft(line, " ")
|
||||
if line == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract command.
|
||||
idx := strings.IndexByte(line, ' ')
|
||||
if idx < 0 {
|
||||
msg.Command = strings.ToUpper(line)
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
msg.Command = strings.ToUpper(line[:idx])
|
||||
line = line[idx+1:]
|
||||
|
||||
// Extract parameters.
|
||||
for line != "" {
|
||||
line = strings.TrimLeft(line, " ")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
|
||||
// Trailing parameter (everything after ':').
|
||||
if line[0] == ':' {
|
||||
msg.Params = append(msg.Params, line[1:])
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
idx = strings.IndexByte(line, ' ')
|
||||
if idx < 0 {
|
||||
msg.Params = append(msg.Params, line)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
msg.Params = append(msg.Params, line[:idx])
|
||||
line = line[idx+1:]
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// FormatMessage formats an IRC message into wire protocol
|
||||
// format (without the trailing CR-LF).
|
||||
func FormatMessage(
|
||||
prefix, command string,
|
||||
params ...string,
|
||||
) string {
|
||||
var buf strings.Builder
|
||||
|
||||
if prefix != "" {
|
||||
buf.WriteByte(':')
|
||||
buf.WriteString(prefix)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
|
||||
buf.WriteString(command)
|
||||
|
||||
for i, param := range params {
|
||||
buf.WriteByte(' ')
|
||||
|
||||
isLast := i == len(params)-1
|
||||
needsColon := strings.Contains(param, " ") ||
|
||||
param == "" || param[0] == ':'
|
||||
|
||||
if isLast && needsColon {
|
||||
buf.WriteByte(':')
|
||||
}
|
||||
|
||||
buf.WriteString(param)
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
328
internal/ircserver/parser_test.go
Normal file
328
internal/ircserver/parser_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package ircserver_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||
)
|
||||
|
||||
//nolint:funlen // table-driven test
|
||||
func TestParseMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *ircserver.Message
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
want: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "simple command",
|
||||
input: "PING",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "PING",
|
||||
Params: nil,
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "command with one param",
|
||||
input: "NICK alice",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "NICK",
|
||||
Params: []string{"alice"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "command case insensitive",
|
||||
input: "nick Alice",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "NICK",
|
||||
Params: []string{"Alice"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "privmsg with trailing",
|
||||
input: "PRIVMSG #general :hello world",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "PRIVMSG",
|
||||
Params: []string{"#general", "hello world"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "with prefix",
|
||||
input: ":server.example.com 001 alice :Welcome to IRC",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "server.example.com",
|
||||
Command: "001",
|
||||
Params: []string{"alice", "Welcome to IRC"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "user command",
|
||||
input: "USER alice 0 * :Alice Smith",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "USER",
|
||||
Params: []string{
|
||||
"alice", "0", "*", "Alice Smith",
|
||||
},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "join channel",
|
||||
input: "JOIN #general",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "JOIN",
|
||||
Params: []string{"#general"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "quit with trailing",
|
||||
input: "QUIT :leaving now",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "QUIT",
|
||||
Params: []string{"leaving now"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "quit without reason",
|
||||
input: "QUIT",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "QUIT",
|
||||
Params: nil,
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "mode query",
|
||||
input: "MODE #general",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "MODE",
|
||||
Params: []string{"#general"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "kick with reason",
|
||||
input: "KICK #general bob :misbehaving",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "KICK",
|
||||
Params: []string{
|
||||
"#general", "bob", "misbehaving",
|
||||
},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "empty trailing",
|
||||
input: "PRIVMSG #general :",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "PRIVMSG",
|
||||
Params: []string{"#general", ""},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "pass command",
|
||||
input: "PASS mysecret",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "PASS",
|
||||
Params: []string{"mysecret"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "ping with server",
|
||||
input: "PING :irc.example.com",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "PING",
|
||||
Params: []string{"irc.example.com"},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "topic with trailing spaces",
|
||||
input: "TOPIC #general :Welcome to the channel!",
|
||||
want: &ircserver.Message{
|
||||
Prefix: "",
|
||||
Command: "TOPIC",
|
||||
Params: []string{
|
||||
"#general",
|
||||
"Welcome to the channel!",
|
||||
},
|
||||
},
|
||||
wantNil: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := ircserver.ParseMessage(testCase.input)
|
||||
if testCase.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil, got %+v", got)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil message")
|
||||
}
|
||||
|
||||
if got.Prefix != testCase.want.Prefix {
|
||||
t.Errorf(
|
||||
"prefix: got %q, want %q",
|
||||
got.Prefix, testCase.want.Prefix,
|
||||
)
|
||||
}
|
||||
|
||||
if got.Command != testCase.want.Command {
|
||||
t.Errorf(
|
||||
"command: got %q, want %q",
|
||||
got.Command, testCase.want.Command,
|
||||
)
|
||||
}
|
||||
|
||||
if len(got.Params) != len(testCase.want.Params) {
|
||||
t.Fatalf(
|
||||
"params length: got %d, want %d (%v vs %v)",
|
||||
len(got.Params),
|
||||
len(testCase.want.Params),
|
||||
got.Params,
|
||||
testCase.want.Params,
|
||||
)
|
||||
}
|
||||
|
||||
for i, p := range got.Params {
|
||||
if p != testCase.want.Params[i] {
|
||||
t.Errorf(
|
||||
"param[%d]: got %q, want %q",
|
||||
i, p, testCase.want.Params[i],
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
command string
|
||||
params []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "simple command",
|
||||
prefix: "",
|
||||
command: "PING",
|
||||
params: nil,
|
||||
want: "PING",
|
||||
},
|
||||
{
|
||||
name: "with prefix",
|
||||
prefix: "server",
|
||||
command: "PONG",
|
||||
params: []string{"server"},
|
||||
want: ":server PONG server",
|
||||
},
|
||||
{
|
||||
name: "privmsg with trailing",
|
||||
prefix: "alice!alice@host",
|
||||
command: "PRIVMSG",
|
||||
params: []string{"#general", "hello world"},
|
||||
want: ":alice!alice@host PRIVMSG #general :hello world",
|
||||
},
|
||||
{
|
||||
name: "numeric reply",
|
||||
prefix: "server",
|
||||
command: "001",
|
||||
params: []string{"alice", "Welcome to IRC"},
|
||||
want: ":server 001 alice :Welcome to IRC",
|
||||
},
|
||||
{
|
||||
name: "empty trailing",
|
||||
prefix: "server",
|
||||
command: "PRIVMSG",
|
||||
params: []string{"#chan", ""},
|
||||
want: ":server PRIVMSG #chan :",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := ircserver.FormatMessage(
|
||||
testCase.prefix, testCase.command, testCase.params...,
|
||||
)
|
||||
if got != testCase.want {
|
||||
t.Errorf("got %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormatRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Round-trip only works for lines where the last
|
||||
// parameter either contains a space (gets ':' prefix
|
||||
// on format) or is a non-trailing single token.
|
||||
lines := []string{
|
||||
"PING",
|
||||
"NICK alice",
|
||||
"PRIVMSG #general :hello world",
|
||||
"JOIN #general",
|
||||
"MODE #general",
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
msg := ircserver.ParseMessage(line)
|
||||
if msg == nil {
|
||||
t.Fatalf("failed to parse: %q", line)
|
||||
}
|
||||
|
||||
formatted := ircserver.FormatMessage(
|
||||
msg.Prefix, msg.Command, msg.Params...,
|
||||
)
|
||||
if formatted != line {
|
||||
t.Errorf(
|
||||
"round-trip failed: input %q, got %q",
|
||||
line, formatted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
319
internal/ircserver/relay.go
Normal file
319
internal/ircserver/relay.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package ircserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
)
|
||||
|
||||
// relayMessages polls the client output queue and delivers
|
||||
// IRC-formatted messages to the TCP connection. It runs
|
||||
// in a goroutine for the lifetime of the connection.
|
||||
func (c *Conn) relayMessages(ctx context.Context) {
|
||||
// Use a ticker as a fallback; primary wakeup is via
|
||||
// broker notification.
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Drain any available messages.
|
||||
delivered := c.drainQueue(ctx)
|
||||
|
||||
if delivered {
|
||||
// Tight loop while there are messages.
|
||||
continue
|
||||
}
|
||||
|
||||
// Wait for notification or timeout.
|
||||
waitCh := c.brk.Wait(c.sessionID)
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
// New message notification — loop back.
|
||||
case <-ticker.C:
|
||||
// Periodic check.
|
||||
case <-ctx.Done():
|
||||
c.brk.Remove(c.sessionID, waitCh)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relayPollLimit = 100
|
||||
|
||||
// drainQueue polls the output queue and delivers all
|
||||
// pending messages. Returns true if at least one message
|
||||
// was delivered.
|
||||
func (c *Conn) drainQueue(ctx context.Context) bool {
|
||||
msgs, lastID, err := c.database.PollMessages(
|
||||
ctx, c.clientID, c.lastQueueID, relayPollLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(msgs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range msgs {
|
||||
c.deliverIRCMessage(ctx, &msgs[i])
|
||||
}
|
||||
|
||||
if lastID > c.lastQueueID {
|
||||
c.lastQueueID = lastID
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// deliverIRCMessage converts a db.IRCMessage to wire
|
||||
// protocol and sends it.
|
||||
//
|
||||
//nolint:cyclop // dispatch table
|
||||
func (c *Conn) deliverIRCMessage(
|
||||
_ context.Context,
|
||||
msg *db.IRCMessage,
|
||||
) {
|
||||
command := msg.Command
|
||||
|
||||
// Decode body as []string for the trailing text.
|
||||
var bodyLines []string
|
||||
|
||||
if msg.Body != nil {
|
||||
_ = json.Unmarshal(msg.Body, &bodyLines)
|
||||
}
|
||||
|
||||
text := ""
|
||||
if len(bodyLines) > 0 {
|
||||
text = bodyLines[0]
|
||||
}
|
||||
|
||||
// Route by command type.
|
||||
switch {
|
||||
case isNumeric(command):
|
||||
c.deliverNumeric(msg, text)
|
||||
case command == irc.CmdPrivmsg || command == irc.CmdNotice:
|
||||
c.deliverTextMessage(msg, command, text)
|
||||
case command == irc.CmdJoin:
|
||||
c.deliverJoin(msg)
|
||||
case command == irc.CmdPart:
|
||||
c.deliverPart(msg, text)
|
||||
case command == irc.CmdNick:
|
||||
c.deliverNickChange(msg, text)
|
||||
case command == irc.CmdQuit:
|
||||
c.deliverQuitMsg(msg, text)
|
||||
case command == irc.CmdTopic:
|
||||
c.deliverTopicChange(msg, text)
|
||||
case command == irc.CmdKick:
|
||||
c.deliverKickMsg(msg, text)
|
||||
case command == "INVITE":
|
||||
c.deliverInviteMsg(msg, text)
|
||||
case command == irc.CmdMode:
|
||||
c.deliverMode(msg, text)
|
||||
case command == irc.CmdPing:
|
||||
// Server-originated PING — reply with PONG.
|
||||
c.sendFromServer("PING", c.serverSfx)
|
||||
default:
|
||||
// Unknown command — deliver as server notice.
|
||||
if text != "" {
|
||||
c.sendFromServer("NOTICE", c.nick, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isNumeric returns true if the command is a 3-digit
|
||||
// numeric code.
|
||||
func isNumeric(cmd string) bool {
|
||||
return len(cmd) == 3 &&
|
||||
cmd[0] >= '0' && cmd[0] <= '9' &&
|
||||
cmd[1] >= '0' && cmd[1] <= '9' &&
|
||||
cmd[2] >= '0' && cmd[2] <= '9'
|
||||
}
|
||||
|
||||
// deliverNumeric sends a numeric reply.
|
||||
func (c *Conn) deliverNumeric(
|
||||
msg *db.IRCMessage,
|
||||
text string,
|
||||
) {
|
||||
from := msg.From
|
||||
if from == "" {
|
||||
from = c.serverSfx
|
||||
}
|
||||
|
||||
var params []string
|
||||
|
||||
if msg.Params != nil {
|
||||
_ = json.Unmarshal(msg.Params, ¶ms)
|
||||
}
|
||||
|
||||
allParams := make([]string, 0, 1+len(params)+1)
|
||||
allParams = append(allParams, c.nick)
|
||||
allParams = append(allParams, params...)
|
||||
|
||||
if text != "" {
|
||||
allParams = append(allParams, text)
|
||||
}
|
||||
|
||||
c.send(FormatMessage(from, msg.Command, allParams...))
|
||||
}
|
||||
|
||||
// deliverTextMessage sends PRIVMSG or NOTICE.
|
||||
func (c *Conn) deliverTextMessage(
|
||||
msg *db.IRCMessage,
|
||||
command, text string,
|
||||
) {
|
||||
from := msg.From
|
||||
target := msg.To
|
||||
|
||||
// Don't echo our own messages back.
|
||||
if strings.EqualFold(from, c.nick) {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := from
|
||||
if !strings.Contains(prefix, "!") {
|
||||
prefix = from + "!" + from + "@*"
|
||||
}
|
||||
|
||||
c.send(FormatMessage(prefix, command, target, text))
|
||||
}
|
||||
|
||||
// deliverJoin sends a JOIN notification.
|
||||
func (c *Conn) deliverJoin(msg *db.IRCMessage) {
|
||||
// Don't echo our own JOINs (we already sent them
|
||||
// during joinChannel).
|
||||
if strings.EqualFold(msg.From, c.nick) {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
channel := msg.To
|
||||
|
||||
c.send(FormatMessage(prefix, "JOIN", channel))
|
||||
}
|
||||
|
||||
// deliverPart sends a PART notification.
|
||||
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
|
||||
if strings.EqualFold(msg.From, c.nick) {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
channel := msg.To
|
||||
|
||||
if text != "" {
|
||||
c.send(FormatMessage(
|
||||
prefix, "PART", channel, text,
|
||||
))
|
||||
} else {
|
||||
c.send(FormatMessage(prefix, "PART", channel))
|
||||
}
|
||||
}
|
||||
|
||||
// deliverNickChange sends a NICK change notification.
|
||||
func (c *Conn) deliverNickChange(
|
||||
msg *db.IRCMessage,
|
||||
newNick string,
|
||||
) {
|
||||
if strings.EqualFold(msg.From, c.nick) {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
|
||||
c.send(FormatMessage(prefix, "NICK", newNick))
|
||||
}
|
||||
|
||||
// deliverQuitMsg sends a QUIT notification.
|
||||
func (c *Conn) deliverQuitMsg(
|
||||
msg *db.IRCMessage,
|
||||
text string,
|
||||
) {
|
||||
if strings.EqualFold(msg.From, c.nick) {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
|
||||
if text != "" {
|
||||
c.send(FormatMessage(
|
||||
prefix, "QUIT", "Quit: "+text,
|
||||
))
|
||||
} else {
|
||||
c.send(FormatMessage(prefix, "QUIT", "Quit"))
|
||||
}
|
||||
}
|
||||
|
||||
// deliverTopicChange sends a TOPIC change notification.
|
||||
func (c *Conn) deliverTopicChange(
|
||||
msg *db.IRCMessage,
|
||||
text string,
|
||||
) {
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
channel := msg.To
|
||||
|
||||
c.send(FormatMessage(prefix, "TOPIC", channel, text))
|
||||
}
|
||||
|
||||
// deliverKickMsg sends a KICK notification.
|
||||
func (c *Conn) deliverKickMsg(
|
||||
msg *db.IRCMessage,
|
||||
text string,
|
||||
) {
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
channel := msg.To
|
||||
|
||||
var params []string
|
||||
|
||||
if msg.Params != nil {
|
||||
_ = json.Unmarshal(msg.Params, ¶ms)
|
||||
}
|
||||
|
||||
kickTarget := ""
|
||||
if len(params) > 0 {
|
||||
kickTarget = params[0]
|
||||
}
|
||||
|
||||
if kickTarget != "" {
|
||||
c.send(FormatMessage(
|
||||
prefix, "KICK", channel, kickTarget, text,
|
||||
))
|
||||
} else {
|
||||
c.send(FormatMessage(
|
||||
prefix, "KICK", channel, "?", text,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// deliverInviteMsg sends an INVITE notification.
|
||||
func (c *Conn) deliverInviteMsg(
|
||||
_ *db.IRCMessage,
|
||||
text string,
|
||||
) {
|
||||
c.sendFromServer("NOTICE", c.nick, text)
|
||||
}
|
||||
|
||||
// deliverMode sends a MODE change notification.
|
||||
func (c *Conn) deliverMode(
|
||||
msg *db.IRCMessage,
|
||||
text string,
|
||||
) {
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
target := msg.To
|
||||
|
||||
if text != "" {
|
||||
c.send(FormatMessage(prefix, "MODE", target, text))
|
||||
}
|
||||
}
|
||||
157
internal/ircserver/server.go
Normal file
157
internal/ircserver/server.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package ircserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating an IRC
|
||||
// Server.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Broker *broker.Broker
|
||||
Service *service.Service
|
||||
}
|
||||
|
||||
// Server is the TCP IRC protocol server.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
cfg *config.Config
|
||||
database *db.Database
|
||||
brk *broker.Broker
|
||||
svc *service.Service
|
||||
listener net.Listener
|
||||
mu sync.Mutex
|
||||
conns map[*Conn]struct{}
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new IRC Server and registers its lifecycle
|
||||
// hooks. The listener is only started if IRC_LISTEN_ADDR
|
||||
// is configured; otherwise the server is inert.
|
||||
func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) *Server {
|
||||
srv := &Server{
|
||||
log: params.Logger.Get(),
|
||||
cfg: params.Config,
|
||||
database: params.Database,
|
||||
brk: params.Broker,
|
||||
svc: params.Service,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
listener: nil,
|
||||
cancel: nil,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
|
||||
listenAddr := params.Config.IRCListenAddr
|
||||
if listenAddr == "" {
|
||||
return srv
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
return srv.start(ctx, listenAddr)
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
srv.stop()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// start begins listening for TCP connections.
|
||||
//
|
||||
//nolint:contextcheck // long-lived server ctx, not the short Fx one
|
||||
func (s *Server) start(_ context.Context, addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("irc listen: %w", err)
|
||||
}
|
||||
|
||||
s.listener = ln
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
|
||||
s.log.Info(
|
||||
"irc server listening", "addr", addr,
|
||||
)
|
||||
|
||||
go s.acceptLoop(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stop shuts down the listener and all connections.
|
||||
func (s *Server) stop() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
if s.listener != nil {
|
||||
s.listener.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for c := range s.conns {
|
||||
c.conn.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// acceptLoop accepts new connections.
|
||||
func (s *Server) acceptLoop(ctx context.Context) {
|
||||
for {
|
||||
tcpConn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
s.log.Error(
|
||||
"irc accept error", "error", err,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
client := newConn(
|
||||
ctx, tcpConn, s.log,
|
||||
s.database, s.brk, s.cfg, s.svc,
|
||||
)
|
||||
|
||||
s.mu.Lock()
|
||||
s.conns[client] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, client)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
client.serve(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
625
internal/ircserver/server_test.go
Normal file
625
internal/ircserver/server_test.go
Normal file
@@ -0,0 +1,625 @@
|
||||
package ircserver_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const testTimeout = 5 * time.Second
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
db.SetBcryptCost(4)
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// testEnv holds the shared test infrastructure.
|
||||
type testEnv struct {
|
||||
database *db.Database
|
||||
brk *broker.Broker
|
||||
cfg *config.Config
|
||||
srv *ircserver.Server
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||
t.Name(),
|
||||
)
|
||||
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
_, err = conn.ExecContext(
|
||||
t.Context(), "PRAGMA foreign_keys = ON",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pragma: %v", err)
|
||||
}
|
||||
|
||||
database := db.NewTestDatabaseFromConn(conn)
|
||||
|
||||
err = database.RunMigrations(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
brk := broker.New()
|
||||
|
||||
cfg := &config.Config{ //nolint:exhaustruct
|
||||
ServerName: "test.irc",
|
||||
MOTD: "Welcome to test IRC",
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
addr := listener.Addr().String()
|
||||
|
||||
err = listener.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||
))
|
||||
|
||||
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||
|
||||
err = srv.Start(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("start irc server: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
srv.Stop()
|
||||
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
t.Logf("close db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return &testEnv{
|
||||
database: database,
|
||||
brk: brk,
|
||||
cfg: cfg,
|
||||
srv: srv,
|
||||
}
|
||||
}
|
||||
|
||||
// dial connects to the test server.
|
||||
func (env *testEnv) dial(t *testing.T) *testClient {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.DialTimeout(
|
||||
"tcp",
|
||||
env.srv.Listener().Addr().String(),
|
||||
testTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
t.Logf("close conn: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return &testClient{
|
||||
t: t,
|
||||
conn: conn,
|
||||
scanner: bufio.NewScanner(conn),
|
||||
}
|
||||
}
|
||||
|
||||
// testClient wraps a raw TCP connection with helpers.
|
||||
type testClient struct {
|
||||
t *testing.T
|
||||
conn net.Conn
|
||||
scanner *bufio.Scanner
|
||||
}
|
||||
|
||||
func (tc *testClient) send(line string) {
|
||||
tc.t.Helper()
|
||||
|
||||
_ = tc.conn.SetWriteDeadline(
|
||||
time.Now().Add(testTimeout),
|
||||
)
|
||||
|
||||
_, err := fmt.Fprintf(tc.conn, "%s\r\n", line)
|
||||
if err != nil {
|
||||
tc.t.Fatalf("send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *testClient) readLine() string {
|
||||
tc.t.Helper()
|
||||
|
||||
_ = tc.conn.SetReadDeadline(
|
||||
time.Now().Add(testTimeout),
|
||||
)
|
||||
|
||||
if !tc.scanner.Scan() {
|
||||
err := tc.scanner.Err()
|
||||
if err != nil {
|
||||
tc.t.Fatalf("read: %v", err)
|
||||
}
|
||||
|
||||
tc.t.Fatal("connection closed unexpectedly")
|
||||
}
|
||||
|
||||
return tc.scanner.Text()
|
||||
}
|
||||
|
||||
// readUntil reads lines until one matches the predicate.
|
||||
func (tc *testClient) readUntil(
|
||||
pred func(string) bool,
|
||||
) []string {
|
||||
tc.t.Helper()
|
||||
|
||||
var lines []string
|
||||
|
||||
for {
|
||||
line := tc.readLine()
|
||||
lines = append(lines, line)
|
||||
|
||||
if pred(line) {
|
||||
return lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// register sends NICK + USER and reads through the welcome
|
||||
// burst.
|
||||
func (tc *testClient) register(nick string) []string {
|
||||
tc.t.Helper()
|
||||
|
||||
tc.send("NICK " + nick)
|
||||
tc.send("USER " + nick + " 0 * :Test User")
|
||||
|
||||
return tc.readUntil(func(line string) bool {
|
||||
return strings.Contains(line, " 376 ") ||
|
||||
strings.Contains(line, " 422 ")
|
||||
})
|
||||
}
|
||||
|
||||
// assertContains checks that at least one line matches the
|
||||
// given substring.
|
||||
func assertContains(
|
||||
t *testing.T,
|
||||
lines []string,
|
||||
substr, description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, substr) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Errorf("did not find %q in output: %s", substr, description)
|
||||
}
|
||||
|
||||
// joinAndDrain joins a channel and reads until
|
||||
// RPL_ENDOFNAMES.
|
||||
func (tc *testClient) joinAndDrain(channel string) {
|
||||
tc.t.Helper()
|
||||
|
||||
tc.send("JOIN " + channel)
|
||||
|
||||
tc.readUntil(func(line string) bool {
|
||||
return strings.Contains(line, " 366 ")
|
||||
})
|
||||
}
|
||||
|
||||
// sendAndExpect sends a command and reads until a line
|
||||
// containing the expected substring is found.
|
||||
func (tc *testClient) sendAndExpect(
|
||||
cmd, expect string,
|
||||
) []string {
|
||||
tc.t.Helper()
|
||||
|
||||
tc.send(cmd)
|
||||
|
||||
return tc.readUntil(func(line string) bool {
|
||||
return strings.Contains(line, expect)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
lines := client.register("alice")
|
||||
assertContains(t, lines, " 001 ", "RPL_WELCOME")
|
||||
}
|
||||
|
||||
func TestWelcomeContainsNick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
lines := client.register("bob")
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, " 001 ") &&
|
||||
!strings.Contains(line, "bob") {
|
||||
t.Errorf("001 should contain nick: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingPong(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("pingtest")
|
||||
lines := client.sendAndExpect("PING :hello", "PONG")
|
||||
assertContains(t, lines, "PONG", "PONG response")
|
||||
}
|
||||
|
||||
func TestJoinChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("joiner")
|
||||
client.send("JOIN #test")
|
||||
|
||||
lines := client.readUntil(func(line string) bool {
|
||||
return strings.Contains(line, " 366 ")
|
||||
})
|
||||
|
||||
assertContains(t, lines, "JOIN", "JOIN echo")
|
||||
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
|
||||
}
|
||||
|
||||
func TestPrivmsgBetweenClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
alice := env.dial(t)
|
||||
alice.register("alice_pm")
|
||||
|
||||
bob := env.dial(t)
|
||||
bob.register("bob_pm")
|
||||
|
||||
alice.joinAndDrain("#chat")
|
||||
bob.joinAndDrain("#chat")
|
||||
|
||||
alice.send("PRIVMSG #chat :hello bob!")
|
||||
lines := bob.sendAndExpect("PING :sync", "hello bob!")
|
||||
assertContains(t, lines, "hello bob!", "channel PRIVMSG")
|
||||
}
|
||||
|
||||
func TestNickChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("oldnick")
|
||||
lines := client.sendAndExpect("NICK newnick", "newnick")
|
||||
assertContains(t, lines, "NICK", "NICK change echo")
|
||||
}
|
||||
|
||||
func TestDuplicateNick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
first := env.dial(t)
|
||||
first.register("taken")
|
||||
|
||||
second := env.dial(t)
|
||||
second.send("NICK taken")
|
||||
second.send("USER taken 0 * :Test")
|
||||
|
||||
lines := second.readUntil(func(line string) bool {
|
||||
return strings.Contains(line, " 433 ")
|
||||
})
|
||||
|
||||
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE")
|
||||
}
|
||||
|
||||
func TestListChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("lister")
|
||||
client.joinAndDrain("#listtest")
|
||||
lines := client.sendAndExpect("LIST", " 323 ")
|
||||
assertContains(t, lines, " 323 ", "RPL_LISTEND") //nolint:misspell // IRC term
|
||||
}
|
||||
|
||||
func TestWhois(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("whoistest")
|
||||
lines := client.sendAndExpect(
|
||||
"WHOIS whoistest", " 318 ",
|
||||
)
|
||||
|
||||
assertContains(t, lines, " 311 ", "RPL_WHOISUSER")
|
||||
assertContains(t, lines, " 318 ", "RPL_ENDOFWHOIS")
|
||||
}
|
||||
|
||||
func TestQuit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("quitter")
|
||||
lines := client.sendAndExpect(
|
||||
"QUIT :goodbye", "ERROR",
|
||||
)
|
||||
|
||||
assertContains(t, lines, "goodbye", "QUIT reason")
|
||||
}
|
||||
|
||||
func TestTopicSetAndGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("topicuser")
|
||||
client.joinAndDrain("#topictest")
|
||||
lines := client.sendAndExpect(
|
||||
"TOPIC #topictest :New topic here",
|
||||
"New topic here",
|
||||
)
|
||||
|
||||
assertContains(
|
||||
t, lines, "New topic here", "TOPIC echo",
|
||||
)
|
||||
}
|
||||
|
||||
func TestUnknownCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("unknowncmd")
|
||||
lines := client.sendAndExpect("FOOBAR", " 421 ")
|
||||
assertContains(t, lines, " 421 ", "ERR_UNKNOWNCOMMAND")
|
||||
}
|
||||
|
||||
func TestDirectMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
sender := env.dial(t)
|
||||
sender.register("dmsender")
|
||||
|
||||
receiver := env.dial(t)
|
||||
receiver.register("dmreceiver")
|
||||
|
||||
// Give relay goroutines time to start.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
sender.send("PRIVMSG dmreceiver :hello privately")
|
||||
|
||||
lines := receiver.readUntil(func(line string) bool {
|
||||
return strings.Contains(line, "hello privately")
|
||||
})
|
||||
|
||||
assertContains(
|
||||
t, lines, "hello privately", "direct PRIVMSG",
|
||||
)
|
||||
}
|
||||
|
||||
func TestCAPNegotiation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.send("CAP LS 302")
|
||||
|
||||
line := client.readLine()
|
||||
if !strings.Contains(line, "CAP") {
|
||||
t.Errorf("expected CAP response, got: %s", line)
|
||||
}
|
||||
|
||||
client.send("CAP END")
|
||||
lines := client.register("capuser")
|
||||
assertContains(t, lines, " 001 ", "registration after CAP")
|
||||
}
|
||||
|
||||
func TestPartChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("parter")
|
||||
client.joinAndDrain("#parttest")
|
||||
lines := client.sendAndExpect(
|
||||
"PART #parttest :leaving", "PART",
|
||||
)
|
||||
|
||||
assertContains(t, lines, "#parttest", "PART echo")
|
||||
}
|
||||
|
||||
func TestModeQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("modeuser")
|
||||
client.joinAndDrain("#modetest")
|
||||
lines := client.sendAndExpect(
|
||||
"MODE #modetest", " 324 ",
|
||||
)
|
||||
|
||||
assertContains(
|
||||
t, lines, " 324 ", "RPL_CHANNELMODEIS",
|
||||
)
|
||||
}
|
||||
|
||||
func TestWhoChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("whouser")
|
||||
client.joinAndDrain("#whotest")
|
||||
lines := client.sendAndExpect("WHO #whotest", " 315 ")
|
||||
assertContains(t, lines, " 352 ", "RPL_WHOREPLY")
|
||||
}
|
||||
|
||||
func TestLusers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("luseruser")
|
||||
lines := client.sendAndExpect("LUSERS", " 255 ")
|
||||
assertContains(t, lines, " 251 ", "RPL_LUSERCLIENT")
|
||||
}
|
||||
|
||||
func TestMotd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("motduser")
|
||||
lines := client.sendAndExpect("MOTD", " 376 ")
|
||||
assertContains(t, lines, " 376 ", "RPL_ENDOFMOTD")
|
||||
}
|
||||
|
||||
func TestAwaySetAndClear(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("awayuser")
|
||||
|
||||
setLines := client.sendAndExpect(
|
||||
"AWAY :brb lunch", " 306 ",
|
||||
)
|
||||
assertContains(t, setLines, " 306 ", "RPL_NOWAWAY")
|
||||
|
||||
clearLines := client.sendAndExpect("AWAY", " 305 ")
|
||||
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY")
|
||||
}
|
||||
|
||||
func TestHandlePassPostRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("passuser")
|
||||
lines := client.sendAndExpect(
|
||||
"PASS :mypassword123", "Password set",
|
||||
)
|
||||
|
||||
assertContains(
|
||||
t, lines, "Password set", "password confirmation",
|
||||
)
|
||||
}
|
||||
|
||||
func TestPreRegistrationNotRegistered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.send("PRIVMSG #test :hello")
|
||||
|
||||
line := client.readLine()
|
||||
if !strings.Contains(line, " 451 ") {
|
||||
t.Errorf(
|
||||
"expected ERR_NOTREGISTERED (451), got: %s",
|
||||
line,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesNonExistentChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("namesuser")
|
||||
lines := client.sendAndExpect(
|
||||
"NAMES #doesnotexist", " 366 ",
|
||||
)
|
||||
|
||||
assertContains(
|
||||
t, lines, " 366 ",
|
||||
"RPL_ENDOFNAMES for non-existent channel",
|
||||
)
|
||||
}
|
||||
|
||||
func BenchmarkParseMessage(b *testing.B) {
|
||||
line := ":nick!user@host PRIVMSG #channel :Hello, world!"
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for range b.N {
|
||||
_ = ircserver.ParseMessage(line)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFormatMessage(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
|
||||
for range b.N {
|
||||
_ = ircserver.FormatMessage(
|
||||
"nick!user@host", "PRIVMSG",
|
||||
"#channel", "Hello, world!",
|
||||
)
|
||||
}
|
||||
}
|
||||
745
internal/service/service.go
Normal file
745
internal/service/service.go
Normal file
@@ -0,0 +1,745 @@
|
||||
// Package service provides shared business logic for both
|
||||
// the IRC wire protocol and HTTP/JSON transports.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating a Service.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Broker *broker.Broker
|
||||
}
|
||||
|
||||
// Service provides shared business logic for IRC commands.
|
||||
type Service struct {
|
||||
DB *db.Database
|
||||
Broker *broker.Broker
|
||||
Config *config.Config
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Service.
|
||||
func New(params Params) *Service {
|
||||
return &Service{
|
||||
DB: params.Database,
|
||||
Broker: params.Broker,
|
||||
Config: params.Config,
|
||||
Log: params.Logger.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
// IRCError represents an IRC protocol-level error with a
|
||||
// numeric code that both transports can map to responses.
|
||||
type IRCError struct {
|
||||
Code irc.IRCMessageType
|
||||
Params []string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *IRCError) Error() string { return e.Message }
|
||||
|
||||
// JoinResult contains the outcome of a channel join.
|
||||
type JoinResult struct {
|
||||
ChannelID int64
|
||||
IsCreator bool
|
||||
}
|
||||
|
||||
// DirectMsgResult contains the outcome of a direct message.
|
||||
type DirectMsgResult struct {
|
||||
UUID string
|
||||
AwayMsg string
|
||||
}
|
||||
|
||||
// FanOut inserts a message and enqueues it to all given
|
||||
// session IDs, notifying each via the broker.
|
||||
func (s *Service) FanOut(
|
||||
ctx context.Context,
|
||||
command, from, to string,
|
||||
params, body, meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) (int64, string, error) {
|
||||
dbID, msgUUID, err := s.DB.InsertMessage(
|
||||
ctx, command, from, to, params, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
|
||||
for _, sid := range sessionIDs {
|
||||
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||
s.Broker.Notify(sid)
|
||||
}
|
||||
|
||||
return dbID, msgUUID, nil
|
||||
}
|
||||
|
||||
// excludeSession returns a copy of ids without the given
|
||||
// session.
|
||||
func excludeSession(
|
||||
ids []int64,
|
||||
exclude int64,
|
||||
) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
|
||||
for _, id := range ids {
|
||||
if id != exclude {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// SendChannelMessage validates membership and moderation,
|
||||
// then fans out a message to all channel members except
|
||||
// the sender. Returns the database row ID, message UUID,
|
||||
// and any error. The dbID lets callers enqueue the same
|
||||
// message to the sender when echo is needed (HTTP
|
||||
// transport).
|
||||
func (s *Service) SendChannelMessage(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, command, channel string,
|
||||
body, meta json.RawMessage,
|
||||
) (int64, string, error) {
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel",
|
||||
}
|
||||
}
|
||||
|
||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||
if moderated {
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
isVoiced, _ := s.DB.IsChannelVoiced(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
|
||||
if !isOp && !isVoiced {
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel (+m)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
|
||||
dbID, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, channel,
|
||||
nil, body, meta, recipients,
|
||||
)
|
||||
if fanErr != nil {
|
||||
return 0, "", fanErr
|
||||
}
|
||||
|
||||
return dbID, uuid, nil
|
||||
}
|
||||
|
||||
// SendDirectMessage validates the target and sends a
|
||||
// direct message, returning the message UUID and any away
|
||||
// message set on the target.
|
||||
func (s *Service) SendDirectMessage(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, command, target string,
|
||||
body, meta json.RawMessage,
|
||||
) (*DirectMsgResult, error) {
|
||||
targetSID, err := s.DB.GetSessionByNick(ctx, target)
|
||||
if err != nil {
|
||||
return nil, &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
[]string{target},
|
||||
"No such nick",
|
||||
}
|
||||
}
|
||||
|
||||
away, _ := s.DB.GetAway(ctx, targetSID)
|
||||
|
||||
recipients := []int64{targetSID}
|
||||
if targetSID != sessionID {
|
||||
recipients = append(recipients, sessionID)
|
||||
}
|
||||
|
||||
_, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, target,
|
||||
nil, body, meta, recipients,
|
||||
)
|
||||
if fanErr != nil {
|
||||
return nil, fanErr
|
||||
}
|
||||
|
||||
return &DirectMsgResult{UUID: uuid, AwayMsg: away}, nil
|
||||
}
|
||||
|
||||
// JoinChannel creates or joins a channel, making the
|
||||
// first joiner the operator. Fans out the JOIN to all
|
||||
// channel members.
|
||||
func (s *Service) JoinChannel(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, channel string,
|
||||
) (*JoinResult, error) {
|
||||
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||
}
|
||||
|
||||
memberCount, countErr := s.DB.CountChannelMembers(
|
||||
ctx, chID,
|
||||
)
|
||||
isCreator := countErr == nil && memberCount == 0
|
||||
|
||||
if isCreator {
|
||||
err = s.DB.JoinChannelAsOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
} else {
|
||||
err = s.DB.JoinChannel(ctx, chID, sessionID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("join channel: %w", err)
|
||||
}
|
||||
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdJoin, nick, channel,
|
||||
nil, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
return &JoinResult{
|
||||
ChannelID: chID,
|
||||
IsCreator: isCreator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PartChannel validates membership, broadcasts PART to
|
||||
// remaining members, removes the user, and cleans up empty
|
||||
// channels.
|
||||
func (s *Service) PartChannel(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, channel, reason string,
|
||||
) error {
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
return &IRCError{
|
||||
irc.ErrNotOnChannel,
|
||||
[]string{channel},
|
||||
"You're not on that channel",
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdPart, nick, channel,
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
|
||||
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTopic validates membership and topic-lock, sets the
|
||||
// topic, and broadcasts the change.
|
||||
func (s *Service) SetTopic(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, channel, topic string,
|
||||
) error {
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
return &IRCError{
|
||||
irc.ErrNotOnChannel,
|
||||
[]string{channel},
|
||||
"You're not on that channel",
|
||||
}
|
||||
}
|
||||
|
||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||
if topicLocked {
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isOp {
|
||||
return &IRCError{
|
||||
irc.ErrChanOpPrivsNeeded,
|
||||
[]string{channel},
|
||||
"You're not channel operator",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if setErr := s.DB.SetTopic(
|
||||
ctx, channel, topic,
|
||||
); setErr != nil {
|
||||
return fmt.Errorf("set topic: %w", setErr)
|
||||
}
|
||||
|
||||
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
|
||||
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdTopic, nick, channel,
|
||||
nil, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// KickUser validates operator status and target
|
||||
// membership, broadcasts the KICK, removes the target,
|
||||
// and cleans up empty channels.
|
||||
func (s *Service) KickUser(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, channel, targetNick, reason string,
|
||||
) error {
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
}
|
||||
}
|
||||
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isOp {
|
||||
return &IRCError{
|
||||
irc.ErrChanOpPrivsNeeded,
|
||||
[]string{channel},
|
||||
"You're not channel operator",
|
||||
}
|
||||
}
|
||||
|
||||
targetSID, err := s.DB.GetSessionByNick(
|
||||
ctx, targetNick,
|
||||
)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
[]string{targetNick},
|
||||
"No such nick/channel",
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, targetSID,
|
||||
)
|
||||
if !isMember {
|
||||
return &IRCError{
|
||||
irc.ErrUserNotInChannel,
|
||||
[]string{targetNick, channel},
|
||||
"They aren't on that channel",
|
||||
}
|
||||
}
|
||||
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
params, _ := json.Marshal( //nolint:errchkjson
|
||||
[]string{targetNick},
|
||||
)
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdKick, nick, channel,
|
||||
params, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeNick changes a user's nickname and broadcasts the
|
||||
// change to all users sharing channels.
|
||||
func (s *Service) ChangeNick(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
oldNick, newNick string,
|
||||
) error {
|
||||
err := s.DB.ChangeNick(ctx, sessionID, newNick)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") ||
|
||||
db.IsUniqueConstraintError(err) {
|
||||
return &IRCError{
|
||||
irc.ErrNicknameInUse,
|
||||
[]string{newNick},
|
||||
"Nickname is already in use",
|
||||
}
|
||||
}
|
||||
|
||||
return &IRCError{
|
||||
irc.ErrErroneusNickname,
|
||||
[]string{newNick},
|
||||
"Erroneous nickname",
|
||||
}
|
||||
}
|
||||
|
||||
s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
||||
// parts all channels, and deletes the session. Uses the
|
||||
// FanOut pattern: one message row fanned out to all unique
|
||||
// peer sessions.
|
||||
func (s *Service) BroadcastQuit(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, reason string,
|
||||
) {
|
||||
channels, err := s.DB.GetSessionChannels(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
notified := make(map[int64]bool)
|
||||
|
||||
for _, ch := range channels {
|
||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||
ctx, ch.ID,
|
||||
)
|
||||
if memErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, mid := range memberIDs {
|
||||
if mid == sessionID || notified[mid] {
|
||||
continue
|
||||
}
|
||||
|
||||
notified[mid] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(notified) > 0 {
|
||||
recipients := make([]int64, 0, len(notified))
|
||||
for sid := range notified {
|
||||
recipients = append(recipients, sid)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut(
|
||||
ctx, irc.CmdQuit, nick, "",
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
||||
s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
// SetAway sets or clears the away message. Returns true
|
||||
// if the message was cleared (empty string).
|
||||
func (s *Service) SetAway(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
message string,
|
||||
) (bool, error) {
|
||||
err := s.DB.SetAway(ctx, sessionID, message)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("set away: %w", err)
|
||||
}
|
||||
|
||||
return message == "", nil
|
||||
}
|
||||
|
||||
// Oper validates operator credentials and grants oper
|
||||
// status to the session.
|
||||
func (s *Service) Oper(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
name, password string,
|
||||
) error {
|
||||
cfgName := s.Config.OperName
|
||||
cfgPassword := s.Config.OperPassword
|
||||
|
||||
// Use constant-time comparison and return the same
|
||||
// error for all failures to prevent information
|
||||
// leakage about valid operator names.
|
||||
if cfgName == "" || cfgPassword == "" ||
|
||||
subtle.ConstantTimeCompare(
|
||||
[]byte(name), []byte(cfgName),
|
||||
) != 1 ||
|
||||
subtle.ConstantTimeCompare(
|
||||
[]byte(password), []byte(cfgPassword),
|
||||
) != 1 {
|
||||
return &IRCError{
|
||||
irc.ErrNoOperHost,
|
||||
nil,
|
||||
"No O-lines for your host",
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateChannelOp checks that the session is a channel
|
||||
// operator. Returns the channel ID.
|
||||
func (s *Service) ValidateChannelOp(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
channel string,
|
||||
) (int64, error) {
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return 0, &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
}
|
||||
}
|
||||
|
||||
isOp, _ := s.DB.IsChannelOperator(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isOp {
|
||||
return 0, &IRCError{
|
||||
irc.ErrChanOpPrivsNeeded,
|
||||
[]string{channel},
|
||||
"You're not channel operator",
|
||||
}
|
||||
}
|
||||
|
||||
return chID, nil
|
||||
}
|
||||
|
||||
// ApplyMemberMode applies +o/-o or +v/-v on a channel
|
||||
// member after validating the target.
|
||||
func (s *Service) ApplyMemberMode(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
channel, targetNick string,
|
||||
mode rune,
|
||||
adding bool,
|
||||
) error {
|
||||
targetSID, err := s.DB.GetSessionByNick(
|
||||
ctx, targetNick,
|
||||
)
|
||||
if err != nil {
|
||||
return &IRCError{
|
||||
irc.ErrNoSuchNick,
|
||||
[]string{targetNick},
|
||||
"No such nick/channel",
|
||||
}
|
||||
}
|
||||
|
||||
isMember, _ := s.DB.IsChannelMember(
|
||||
ctx, chID, targetSID,
|
||||
)
|
||||
if !isMember {
|
||||
return &IRCError{
|
||||
irc.ErrUserNotInChannel,
|
||||
[]string{targetNick, channel},
|
||||
"They aren't on that channel",
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case 'o':
|
||||
_ = s.DB.SetChannelMemberOperator(
|
||||
ctx, chID, targetSID, adding,
|
||||
)
|
||||
case 'v':
|
||||
_ = s.DB.SetChannelMemberVoiced(
|
||||
ctx, chID, targetSID, adding,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetChannelFlag applies +m/-m or +t/-t on a channel.
|
||||
func (s *Service) SetChannelFlag(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
flag rune,
|
||||
setting bool,
|
||||
) error {
|
||||
switch flag {
|
||||
case 'm':
|
||||
if err := s.DB.SetChannelModerated(
|
||||
ctx, chID, setting,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set moderated: %w", err)
|
||||
}
|
||||
case 't':
|
||||
if err := s.DB.SetChannelTopicLocked(
|
||||
ctx, chID, setting,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set topic locked: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BroadcastMode fans out a MODE change to all channel
|
||||
// members.
|
||||
func (s *Service) BroadcastMode(
|
||||
ctx context.Context,
|
||||
nick, channel string,
|
||||
chID int64,
|
||||
modeText string,
|
||||
) {
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdMode, nick, channel,
|
||||
nil, body, nil, memberIDs,
|
||||
)
|
||||
}
|
||||
|
||||
// QueryChannelMode returns the channel mode string.
|
||||
func (s *Service) QueryChannelMode(
|
||||
ctx context.Context,
|
||||
chID int64,
|
||||
) string {
|
||||
modes := "+"
|
||||
|
||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
||||
if moderated {
|
||||
modes += "m"
|
||||
}
|
||||
|
||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
||||
if topicLocked {
|
||||
modes += "t"
|
||||
}
|
||||
|
||||
return modes
|
||||
}
|
||||
|
||||
// broadcastNickChange notifies channel peers of a nick
|
||||
// change.
|
||||
func (s *Service) broadcastNickChange(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
oldNick, newNick string,
|
||||
) {
|
||||
channels, err := s.DB.GetSessionChannels(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
||||
notified := make(map[int64]bool)
|
||||
|
||||
dbID, _, insErr := s.DB.InsertMessage(
|
||||
ctx, irc.CmdNick, oldNick, "",
|
||||
nil, body, nil,
|
||||
)
|
||||
if insErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Notify the user themselves (for multi-client sync).
|
||||
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
|
||||
s.Broker.Notify(sessionID)
|
||||
notified[sessionID] = true
|
||||
|
||||
for _, ch := range channels {
|
||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
||||
ctx, ch.ID,
|
||||
)
|
||||
if memErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, mid := range memberIDs {
|
||||
if notified[mid] {
|
||||
continue
|
||||
}
|
||||
|
||||
notified[mid] = true
|
||||
|
||||
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
|
||||
s.Broker.Notify(mid)
|
||||
}
|
||||
}
|
||||
}
|
||||
365
internal/service/service_test.go
Normal file
365
internal/service/service_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// Tests use a global viper instance for configuration,
|
||||
// making parallel execution unsafe.
|
||||
//
|
||||
//nolint:paralleltest
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
db.SetBcryptCost(bcrypt.MinCost)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// testEnv holds all dependencies for a service test.
|
||||
type testEnv struct {
|
||||
svc *service.Service
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
app *fxtest.App
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
dbURL := fmt.Sprintf(
|
||||
"file:svc_test_%p?mode=memory&cache=shared",
|
||||
t,
|
||||
)
|
||||
|
||||
var (
|
||||
database *db.Database
|
||||
svc *service.Service
|
||||
)
|
||||
|
||||
brk := broker.New()
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
func() *globals.Globals {
|
||||
return &globals.Globals{ //nolint:exhaustruct
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
}
|
||||
},
|
||||
logger.New,
|
||||
func(
|
||||
lifecycle fx.Lifecycle,
|
||||
globs *globals.Globals,
|
||||
log *logger.Logger,
|
||||
) (*config.Config, error) {
|
||||
cfg, err := config.New(
|
||||
lifecycle, config.Params{ //nolint:exhaustruct
|
||||
Globals: globs, Logger: log,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"test config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
cfg.DBURL = dbURL
|
||||
cfg.Port = 0
|
||||
cfg.OperName = "admin"
|
||||
cfg.OperPassword = "secret"
|
||||
|
||||
return cfg, nil
|
||||
},
|
||||
func(
|
||||
lifecycle fx.Lifecycle,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
) (*db.Database, error) {
|
||||
return db.New(lifecycle, db.Params{ //nolint:exhaustruct
|
||||
Logger: log, Config: cfg,
|
||||
})
|
||||
},
|
||||
func() *broker.Broker { return brk },
|
||||
service.New,
|
||||
),
|
||||
fx.Populate(&database, &svc),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(func() {
|
||||
app.RequireStop()
|
||||
})
|
||||
|
||||
return &testEnv{
|
||||
svc: svc,
|
||||
db: database,
|
||||
broker: brk,
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
// createSession is a test helper that creates a session
|
||||
// and returns the session ID.
|
||||
func createSession(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
nick string,
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
sessionID, _, _, err := database.CreateSession(
|
||||
ctx, nick, nick, "localhost", "127.0.0.1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create session %s: %v", nick, err)
|
||||
}
|
||||
|
||||
return sessionID
|
||||
}
|
||||
|
||||
func TestFanOut(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
||||
|
||||
dbID, uuid, err := env.svc.FanOut(
|
||||
ctx, irc.CmdPrivmsg, "alice", "#test",
|
||||
nil, body, nil,
|
||||
[]int64{sid1, sid2},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("FanOut: %v", err)
|
||||
}
|
||||
|
||||
if dbID == 0 {
|
||||
t.Error("expected non-zero dbID")
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
t.Error("expected non-empty UUID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinChannel(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid := createSession(ctx, t, env.db, "alice")
|
||||
|
||||
result, err := env.svc.JoinChannel(
|
||||
ctx, sid, "alice", "#general",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinChannel: %v", err)
|
||||
}
|
||||
|
||||
if result.ChannelID == 0 {
|
||||
t.Error("expected non-zero channel ID")
|
||||
}
|
||||
|
||||
if !result.IsCreator {
|
||||
t.Error("first joiner should be creator")
|
||||
}
|
||||
|
||||
// Second user joins — not creator.
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
result2, err := env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#general",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinChannel bob: %v", err)
|
||||
}
|
||||
|
||||
if result2.IsCreator {
|
||||
t.Error("second joiner should not be creator")
|
||||
}
|
||||
|
||||
if result2.ChannelID != result.ChannelID {
|
||||
t.Error("both should join the same channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartChannel(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid := createSession(ctx, t, env.db, "alice")
|
||||
|
||||
_, err := env.svc.JoinChannel(
|
||||
ctx, sid, "alice", "#general",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("JoinChannel: %v", err)
|
||||
}
|
||||
|
||||
err = env.svc.PartChannel(
|
||||
ctx, sid, "alice", "#general", "bye",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("PartChannel: %v", err)
|
||||
}
|
||||
|
||||
// Parting a non-existent channel returns error.
|
||||
err = env.svc.PartChannel(
|
||||
ctx, sid, "alice", "#nonexistent", "",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent channel")
|
||||
}
|
||||
|
||||
var ircErr *service.IRCError
|
||||
if !errors.As(err, &ircErr) {
|
||||
t.Errorf("expected IRCError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendChannelMessage(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
_, err := env.svc.JoinChannel(
|
||||
ctx, sid1, "alice", "#chat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#chat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
||||
|
||||
dbID, uuid, err := env.svc.SendChannelMessage(
|
||||
ctx, sid1, "alice",
|
||||
irc.CmdPrivmsg, "#chat", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SendChannelMessage: %v", err)
|
||||
}
|
||||
|
||||
if dbID == 0 {
|
||||
t.Error("expected non-zero dbID")
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
t.Error("expected non-empty UUID")
|
||||
}
|
||||
|
||||
// Non-member cannot send.
|
||||
sid3 := createSession(ctx, t, env.db, "charlie")
|
||||
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, sid3, "charlie",
|
||||
irc.CmdPrivmsg, "#chat", body, nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-member send")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastQuit(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
_, err := env.svc.JoinChannel(
|
||||
ctx, sid1, "alice", "#room",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#room",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
|
||||
// BroadcastQuit should not panic and should clean up.
|
||||
env.svc.BroadcastQuit(
|
||||
ctx, sid1, "alice", "Goodbye",
|
||||
)
|
||||
|
||||
// Session should be deleted.
|
||||
_, lookupErr := env.db.GetSessionByNick(ctx, "alice")
|
||||
if lookupErr == nil {
|
||||
t.Error("expected session to be deleted after quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sid1 := createSession(ctx, t, env.db, "alice")
|
||||
sid2 := createSession(ctx, t, env.db, "bob")
|
||||
|
||||
result, err := env.svc.JoinChannel(
|
||||
ctx, sid1, "alice", "#modchat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.JoinChannel(
|
||||
ctx, sid2, "bob", "#modchat",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
|
||||
// Set channel to moderated.
|
||||
chID := result.ChannelID
|
||||
_ = env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
||||
|
||||
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
||||
|
||||
// Bob (non-op, non-voiced) should fail to send.
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, sid2, "bob",
|
||||
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-voiced user in moderated channel")
|
||||
}
|
||||
|
||||
// Alice (operator) should succeed.
|
||||
_, _, err = env.svc.SendChannelMessage(
|
||||
ctx, sid1, "alice",
|
||||
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
CmdPrivmsg = "PRIVMSG"
|
||||
CmdQuit = "QUIT"
|
||||
CmdTopic = "TOPIC"
|
||||
CmdUser = "USER"
|
||||
CmdWho = "WHO"
|
||||
CmdWhois = "WHOIS"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user