7 Commits

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

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

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

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

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

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

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

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

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

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

closes #98

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

## Tests

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

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

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

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

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

closes sneak/chat#97

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

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

## Changes

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

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

### Modified files

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

### Tests

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

## Key Design Decisions

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

## Supported Commands

Connection: NICK, USER, PASS, QUIT, PING/PONG, CAP
Channels: JOIN, PART, MODE, TOPIC, NAMES, LIST, KICK, INVITE
Messaging: PRIVMSG, NOTICE
Info: WHO, WHOIS, LUSERS, MOTD, AWAY, USERHOST
Operator: OPER

closes #89

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

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

Follows the sneak/pixa pattern.

closes #91

## Test plan

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

Generated with Claude Code

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: sneak/chat#95
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-03-30 21:35:53 +02:00
72 changed files with 4137 additions and 465 deletions

View File

@@ -2307,8 +2307,8 @@ IRC_LISTEN_ADDR=
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | | Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | | Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
| Messaging | `PRIVMSG`, `NOTICE` | | Messaging | `PRIVMSG`, `NOTICE` |
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | | Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | | Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
### Protocol Details ### Protocol Details
@@ -2820,6 +2820,10 @@ guess is borne by the server (bcrypt), not the client.
login from additional devices via `POST /api/v1/login` login from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for - [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication all API authentication
- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN
(256259), INFO (371/374), TIME (391), KILL (oper-only forced
disconnect), WALLOPS (oper-only broadcast to +w users)
- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w`
### Future (1.0+) ### Future (1.0+)

View File

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

View File

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

2
go.mod
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,8 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid" "github.com/google/uuid"
"sneak.berlin/go/neoirc/pkg/irc"
) )
const ( const (
@@ -2165,6 +2165,52 @@ func (database *Database) SetChannelSecret(
return nil return nil
} }
// --- No External Messages (+n) ---
// IsChannelNoExternal checks if a channel has +n mode.
func (database *Database) IsChannelNoExternal(
ctx context.Context,
channelID int64,
) (bool, error) {
var isNoExternal int
err := database.conn.QueryRowContext(ctx,
`SELECT is_no_external FROM channels
WHERE id = ?`,
channelID,
).Scan(&isNoExternal)
if err != nil {
return false, fmt.Errorf(
"check no external: %w", err,
)
}
return isNoExternal != 0, nil
}
// SetChannelNoExternal sets or unsets +n mode.
func (database *Database) SetChannelNoExternal(
ctx context.Context,
channelID int64,
noExternal bool,
) error {
val := 0
if noExternal {
val = 1
}
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET is_no_external = ?, updated_at = ?
WHERE id = ?`,
val, time.Now(), channelID)
if err != nil {
return fmt.Errorf("set no external: %w", err)
}
return nil
}
// ListAllChannelsWithCountsFiltered returns all channels // ListAllChannelsWithCountsFiltered returns all channels
// with member counts, excluding secret channels that // with member counts, excluding secret channels that
// the given session is not a member of. // the given session is not a member of.
@@ -2367,3 +2413,132 @@ func (database *Database) SetChannelUserLimit(
return nil return nil
} }
// SetSessionWallops sets the wallops (+w) flag on a
// session.
func (database *Database) SetSessionWallops(
ctx context.Context,
sessionID int64,
enabled bool,
) error {
val := 0
if enabled {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session wallops: %w", err)
}
return nil
}
// IsSessionWallops returns whether the session has the
// wallops (+w) usermode set.
func (database *Database) IsSessionWallops(
ctx context.Context,
sessionID int64,
) (bool, error) {
var isWallops int
err := database.conn.QueryRowContext(
ctx,
`SELECT is_wallops FROM sessions WHERE id = ?`,
sessionID,
).Scan(&isWallops)
if err != nil {
return false, fmt.Errorf(
"check session wallops: %w", err,
)
}
return isWallops != 0, nil
}
// GetWallopsSessionIDs returns all session IDs that have
// the wallops (+w) usermode set.
func (database *Database) GetWallopsSessionIDs(
ctx context.Context,
) ([]int64, error) {
rows, err := database.conn.QueryContext(
ctx,
`SELECT id FROM sessions WHERE is_wallops = 1`,
)
if err != nil {
return nil, fmt.Errorf(
"get wallops sessions: %w", err,
)
}
defer func() { _ = rows.Close() }()
var ids []int64
for rows.Next() {
var sessionID int64
if scanErr := rows.Scan(&sessionID); scanErr != nil {
return nil, fmt.Errorf(
"scan wallops session: %w", scanErr,
)
}
ids = append(ids, sessionID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterate wallops sessions: %w", err,
)
}
return ids, nil
}
// UserhostInfo holds the data needed for RPL_USERHOST.
type UserhostInfo struct {
Nick string
Username string
Hostname string
IsOper bool
AwayMessage string
}
// GetUserhostInfo returns USERHOST info for the given
// nicks. Only nicks that exist are returned.
func (database *Database) GetUserhostInfo(
ctx context.Context,
nicks []string,
) ([]UserhostInfo, error) {
if len(nicks) == 0 {
return nil, nil
}
results := make([]UserhostInfo, 0, len(nicks))
for _, nick := range nicks {
var info UserhostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT nick, username, hostname,
is_oper, away_message
FROM sessions WHERE nick = ?`,
nick,
).Scan(
&info.Nick, &info.Username, &info.Hostname,
&info.IsOper, &info.AwayMessage,
)
if err != nil {
continue // nick not found, skip
}
results = append(results, info)
}
return results, nil
}

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
hostname TEXT NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0, is_oper INTEGER NOT NULL DEFAULT 0,
is_wallops INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '',
@@ -44,6 +45,7 @@ CREATE TABLE IF NOT EXISTS channels (
is_topic_locked INTEGER NOT NULL DEFAULT 1, is_topic_locked INTEGER NOT NULL DEFAULT 1,
is_invite_only INTEGER NOT NULL DEFAULT 0, is_invite_only INTEGER NOT NULL DEFAULT 0,
is_secret INTEGER NOT NULL DEFAULT 0, is_secret INTEGER NOT NULL DEFAULT 0,
is_no_external INTEGER NOT NULL DEFAULT 1,
channel_key TEXT NOT NULL DEFAULT '', channel_key TEXT NOT NULL DEFAULT '',
user_limit INTEGER NOT NULL DEFAULT 0, user_limit INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

View File

@@ -12,11 +12,11 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
) )
var ( var (
@@ -969,10 +969,12 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string, bodyLines func() []string,
) { ) {
switch command { switch command {
case irc.CmdAway: case irc.CmdAway, irc.CmdNick,
hdlr.handleAway( irc.CmdPass, irc.CmdInvite:
hdlr.dispatchBodyOnlyCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick,
command, bodyLines,
) )
case irc.CmdPrivmsg, irc.CmdNotice: case irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
@@ -991,27 +993,12 @@ func (hdlr *Handlers) dispatchCommand(
writer, request, writer, request,
sessionID, clientID, nick, target, body, sessionID, clientID, nick, target, body,
) )
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic: case irc.CmdTopic:
hdlr.handleTopic( hdlr.handleTopic(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
target, body, bodyLines, target, body, bodyLines,
) )
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKick: case irc.CmdKick:
hdlr.handleKick( hdlr.handleKick(
writer, request, writer, request,
@@ -1022,12 +1009,15 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit( hdlr.handleQuit(
writer, request, sessionID, nick, body, writer, request, sessionID, nick, body,
) )
case irc.CmdOper: case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
hdlr.handleOper( hdlr.dispatchOperCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick,
command, bodyLines,
) )
case irc.CmdMotd, irc.CmdPing: case irc.CmdMotd, irc.CmdPing,
irc.CmdVersion, irc.CmdAdmin,
irc.CmdInfo, irc.CmdTime:
hdlr.dispatchInfoCommand( hdlr.dispatchInfoCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
@@ -1082,6 +1072,11 @@ func (hdlr *Handlers) dispatchQueryCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
) )
case irc.CmdUserhost:
hdlr.handleUserhost(
writer, request,
sessionID, clientID, nick, bodyLines,
)
default: default:
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1528,87 +1523,6 @@ func (hdlr *Handlers) executeJoin(
http.StatusOK) http.StatusOK)
} }
// checkJoinAllowed runs Tier 2 restrictions for an
// existing channel. Returns true if join is allowed.
func (hdlr *Handlers) checkJoinAllowed(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
suppliedKey string,
) bool {
ctx := request.Context()
// 1. Ban check — prevents banned users from joining.
isBanned, banErr := hdlr.params.Database.
IsSessionBanned(ctx, chID, sessionID)
if banErr == nil && isBanned {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrBannedFromChan, nick,
[]string{channel},
"Cannot join channel (+b)",
)
return false
}
// 2. Invite-only check (+i).
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
hasInvite, invErr := hdlr.params.Database.
HasChannelInvite(ctx, chID, sessionID)
if invErr != nil || !hasInvite {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrInviteOnlyChan, nick,
[]string{channel},
"Cannot join channel (+i)",
)
return false
}
}
// 3. Channel key check (+k).
key, keyErr := hdlr.params.Database.
GetChannelKey(ctx, chID)
if keyErr == nil && key != "" {
if suppliedKey != key {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrBadChannelKey, nick,
[]string{channel},
"Cannot join channel (+k)",
)
return false
}
}
// 4. User limit check (+l).
limit, limErr := hdlr.params.Database.
GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 {
count, cntErr := hdlr.params.Database.
CountChannelMembers(ctx, chID)
if cntErr == nil && count >= int64(limit) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrChannelIsFull, nick,
[]string{channel},
"Cannot join channel (+l)",
)
return false
}
}
return true
}
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC, // deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client. // RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
func (hdlr *Handlers) deliverJoinNumerics( func (hdlr *Handlers) deliverJoinNumerics(
@@ -1955,7 +1869,8 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
} }
// dispatchInfoCommand handles informational IRC commands // dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, PING). // that produce server-side numerics (MOTD, PING,
// VERSION, ADMIN, INFO, TIME).
func (hdlr *Handlers) dispatchInfoCommand( func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1981,6 +1896,34 @@ func (hdlr *Handlers) dispatchInfoCommand(
}, },
http.StatusOK) http.StatusOK)
return
case irc.CmdVersion:
hdlr.handleVersion(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdInfo:
hdlr.handleInfo(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdTime:
hdlr.handleTime(
writer, request,
sessionID, clientID, nick,
)
return return
} }
@@ -2037,15 +1980,11 @@ func (hdlr *Handlers) handleMode(
channel := target channel := target
if !strings.HasPrefix(channel, "#") { if !strings.HasPrefix(channel, "#") {
// User mode query — return empty modes. hdlr.handleUserMode(
hdlr.enqueueNumeric( writer, request,
request.Context(), clientID, sessionID, clientID, nick, target,
irc.RplUmodeIs, nick, nil, "+", bodyLines,
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return return
} }
@@ -2097,62 +2036,14 @@ func (hdlr *Handlers) handleChannelMode(
} }
// buildChannelModeString constructs the current mode // buildChannelModeString constructs the current mode
// string for a channel, including +n (always on), +t, +m, // string for a channel by delegating to the service
// +i, +s, +k, +l, and +H with their parameters. // layer's QueryChannelMode, which returns the complete
// mode string including all flags and parameters.
func (hdlr *Handlers) buildChannelModeString( func (hdlr *Handlers) buildChannelModeString(
ctx context.Context, ctx context.Context,
chID int64, chID int64,
) string { ) string {
modes := "+n" return hdlr.svc.QueryChannelMode(ctx, chID)
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
modes += "i"
}
isModerated, modErr := hdlr.params.Database.
IsChannelModerated(ctx, chID)
if modErr == nil && isModerated {
modes += "m"
}
isSecret, secErr := hdlr.params.Database.
IsChannelSecret(ctx, chID)
if secErr == nil && isSecret {
modes += "s"
}
isTopicLocked, tlErr := hdlr.params.Database.
IsChannelTopicLocked(ctx, chID)
if tlErr == nil && isTopicLocked {
modes += "t"
}
var modeParams string
key, keyErr := hdlr.params.Database.
GetChannelKey(ctx, chID)
if keyErr == nil && key != "" {
modes += "k"
modeParams += " " + key
}
limit, limErr := hdlr.params.Database.
GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 {
modes += "l"
modeParams += " " + strconv.Itoa(limit)
}
bits, bitsErr := hdlr.params.Database.
GetChannelHashcashBits(ctx, chID)
if bitsErr == nil && bits > 0 {
modes += "H"
modeParams += " " + strconv.Itoa(bits)
}
return modes + modeParams
} }
// queryChannelMode sends RPL_CHANNELMODEIS and // queryChannelMode sends RPL_CHANNELMODEIS and
@@ -4091,3 +3982,76 @@ func (hdlr *Handlers) deliverWhoisIdle(
"seconds idle, signon time", "seconds idle, signon time",
) )
} }
// fanOut inserts a message and enqueues it to all given
// sessions.
func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target,
nil, body, meta,
)
if err != nil {
return "", fmt.Errorf("insert message: %w", err)
}
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
"error", enqErr, "session_id", sid)
}
hdlr.broker.Notify(sid)
}
return msgUUID, nil
}
// fanOutSilent calls fanOut and discards the UUID.
func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
body json.RawMessage,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target,
body, nil, sessionIDs,
)
return err
}
// requireChannelOp checks if the session is a channel
// operator and sends ERR_CHANOPRIVSNEEDED if not.
func (hdlr *Handlers) requireChannelOp(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
) bool {
isOp, err := hdlr.params.Database.IsChannelOperator(
request.Context(), chID, sessionID,
)
if err != nil || !isOp {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrChanOpPrivsNeeded, nick,
[]string{channel},
"You're not channel operator",
)
return false
}
return true
}

View File

@@ -18,21 +18,21 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {

View File

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

View File

@@ -9,17 +9,17 @@ import (
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/ratelimit"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
) )
var errUnauthorized = errors.New("unauthorized") var errUnauthorized = errors.New("unauthorized")

View File

@@ -0,0 +1,659 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/pkg/irc"
)
// maxUserhostNicks is the maximum number of nicks allowed
// in a single USERHOST query (RFC 2812).
const maxUserhostNicks = 5
// dispatchBodyOnlyCommand routes commands that take
// (writer, request, sessionID, clientID, nick, bodyLines).
func (hdlr *Handlers) dispatchBodyOnlyCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// dispatchOperCommand routes oper-related commands (OPER,
// KILL, WALLOPS) to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKill:
hdlr.handleKill(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdWallops:
hdlr.handleWallops(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// handleUserhost handles the USERHOST command.
// Returns user@host info for up to 5 nicks.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
// Limit to 5 nicks per RFC 2812.
nicks := lines
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
)
if err != nil {
hdlr.log.Error(
"userhost query failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUserHost, nick, nil,
replyStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserhostReply builds the RPL_USERHOST reply
// string per RFC 2812.
func (hdlr *Handlers) buildUserhostReply(
infos []db.UserhostInfo,
) string {
replies := make([]string, 0, len(infos))
for idx := range infos {
info := &infos[idx]
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = hdlr.serverName()
}
operStar := ""
if info.IsOper {
operStar = "*"
}
awayPrefix := "+"
if info.AwayMessage != "" {
awayPrefix = "-"
}
replies = append(replies,
info.Nick+operStar+"="+
awayPrefix+username+"@"+hostname,
)
}
return strings.Join(replies, " ")
}
// handleVersion handles the VERSION command.
// Returns the server version string.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
ctx, clientID, irc.RplVersion, nick,
[]string{version + ".", srvName},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAdmin handles the ADMIN command.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 256 RPL_ADMINME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName},
"Administrative info",
)
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
)
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
)
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleInfo handles the INFO command.
// Returns server software information.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
version := hdlr.serverVersion()
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + version,
"Written in Go",
"Started: " +
hdlr.params.Globals.StartTime.
Format(time.RFC1123),
}
for _, line := range infoLines {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
line,
)
}
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleTime handles the TIME command.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplTime, nick,
[]string{srvName},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleKill handles the KILL command.
// Forcibly disconnects a user (oper only).
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
var targetNick string
if len(lines) > 0 {
targetNick = strings.TrimSpace(lines[0])
}
if targetNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
reason := "KILLed"
if len(lines) > 1 {
reason = lines[1]
}
targetSID, lookupErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if lookupErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return
}
// Do not allow killing yourself.
if targetSID == sessionID {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCantKillServer, nick, nil,
"You cannot KILL yourself",
)
return
}
quitReason := "Killed (" + nick + " (" + reason + "))"
hdlr.svc.BroadcastQuit(
request.Context(), targetSID,
targetNick, quitReason,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleWallops handles the WALLOPS command.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
message := strings.Join(lines, " ")
wallopsSIDs, err := hdlr.params.Database.
GetWallopsSessionIDs(ctx)
if err != nil {
hdlr.log.Error(
"get wallops sessions failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
hdlr.log.Error(
"marshal wallops body", "error", mErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.fanOutSilent(
request, irc.CmdWallops, nick, "*",
json.RawMessage(body), wallopsSIDs,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleUserMode handles user mode queries and changes
// (e.g., MODE nick, MODE nick +w).
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
// Users can only change their own modes.
if target != nick && target != "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
hdlr.applyUserModeChange(
writer, request,
sessionID, clientID, nick, lines[0],
)
return
}
// Mode query — build the current mode string.
modeStr := hdlr.buildUserModeString(ctx, sessionID)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserModeString constructs the mode string for a
// user (e.g., "+ow" for oper+wallops).
func (hdlr *Handlers) buildUserModeString(
ctx context.Context,
sessionID int64,
) string {
modes := "+"
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err == nil && isOper {
modes += "o"
}
isWallops, err := hdlr.params.Database.IsSessionWallops(
ctx, sessionID,
)
if err == nil && isWallops {
modes += "w"
}
return modes
}
// applyUserModeChange applies a user mode change string
// (e.g., "+w", "-w").
func (hdlr *Handlers) applyUserModeChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context()
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return
}
adding := modeStr[0] == '+'
modeChar := modeStr[1:]
applied, err := hdlr.applyModeChar(
ctx, writer, request,
sessionID, clientID, nick,
modeChar, adding,
)
if err != nil || !applied {
return
}
newModes := hdlr.buildUserModeString(ctx, sessionID)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// applyModeChar applies a single user mode character.
// Returns (applied, error).
func (hdlr *Handlers) applyModeChar(
ctx context.Context,
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeChar string,
adding bool,
) (bool, error) {
switch modeChar {
case "w":
err := hdlr.params.Database.SetSessionWallops(
ctx, sessionID, adding,
)
if err != nil {
hdlr.log.Error(
"set wallops mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"set wallops: %w", err,
)
}
case "o":
// +o cannot be set via MODE, only via OPER.
if adding {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
err := hdlr.params.Database.SetSessionOper(
ctx, sessionID, false,
)
if err != nil {
hdlr.log.Error(
"clear oper mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"clear oper: %w", err,
)
}
default:
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
return true, nil
}

View File

@@ -0,0 +1,982 @@
// Tests for Tier 3 utility IRC commands: USERHOST,
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
//
//nolint:paralleltest
package handlers_test
import (
"strings"
"testing"
)
// --- USERHOST ---
func TestUserhostSingleNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"alice"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 302 RPL_USERHOST.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
// Body should contain "alice" with the
// nick=+user@host format.
body := getNumericBody(msg)
if !strings.Contains(body, "alice") {
t.Fatalf(
"expected body to contain 'alice', got %q",
body,
)
}
// '+' means not away.
if !strings.Contains(body, "=+") {
t.Fatalf(
"expected not-away prefix '=+', got %q",
body,
)
}
}
func TestUserhostMultipleNicks(t *testing.T) {
tserver := newTestServer(t)
token1 := tserver.createSession("bob")
token2 := tserver.createSession("carol")
_ = token2
_, lastID := tserver.pollMessages(token1, 0)
tserver.sendCommand(token1, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"bob", "carol"},
})
msgs, _ := tserver.pollMessages(token1, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "bob") {
t.Fatalf(
"expected body to contain 'bob', got %q",
body,
)
}
if !strings.Contains(body, "carol") {
t.Fatalf(
"expected body to contain 'carol', got %q",
body,
)
}
}
func TestUserhostNonexistentNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dave")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"nobody"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should still get 302 but with empty body.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
}
func TestUserhostNoParams(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("eve")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestUserhostShowsOper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opernick")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '*' for oper.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"opernick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "opernick*=") {
t.Fatalf(
"expected oper '*' in reply, got %q",
body,
)
}
}
func TestUserhostShowsAway(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("awaynick")
_, lastID := tserver.pollMessages(token, 0)
// Set away.
tserver.sendCommand(token, map[string]any{
commandKey: "AWAY",
bodyKey: []string{"gone fishing"},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '-' for away.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"awaynick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "=-") {
t.Fatalf(
"expected away prefix '=-' in reply, got %q",
body,
)
}
}
// --- VERSION ---
func TestVersion(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("frank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "VERSION",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 351 RPL_VERSION.
msg := findNumericWithParams(msgs, "351")
if msg == nil {
t.Fatalf(
"expected RPL_VERSION (351), got %v",
msgs,
)
}
params := getNumericParams(msg)
if len(params) == 0 {
t.Fatal("expected VERSION params, got none")
}
// First param should contain version string.
if !strings.Contains(params[0], "test") {
t.Fatalf(
"expected version to contain 'test', got %q",
params[0],
)
}
}
// --- ADMIN ---
func TestAdmin(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("grace")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "ADMIN",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 256 RPL_ADMINME.
if !findNumeric(msgs, "256") {
t.Fatalf(
"expected RPL_ADMINME (256), got %v",
msgs,
)
}
// Expect 257 RPL_ADMINLOC1.
if !findNumeric(msgs, "257") {
t.Fatalf(
"expected RPL_ADMINLOC1 (257), got %v",
msgs,
)
}
// Expect 258 RPL_ADMINLOC2.
if !findNumeric(msgs, "258") {
t.Fatalf(
"expected RPL_ADMINLOC2 (258), got %v",
msgs,
)
}
// Expect 259 RPL_ADMINEMAIL.
if !findNumeric(msgs, "259") {
t.Fatalf(
"expected RPL_ADMINEMAIL (259), got %v",
msgs,
)
}
}
// --- INFO ---
func TestInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "INFO",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 371 RPL_INFO (at least one).
if !findNumeric(msgs, "371") {
t.Fatalf(
"expected RPL_INFO (371), got %v",
msgs,
)
}
// Expect 374 RPL_ENDOFINFO.
if !findNumeric(msgs, "374") {
t.Fatalf(
"expected RPL_ENDOFINFO (374), got %v",
msgs,
)
}
}
// --- TIME ---
func TestTime(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("iris")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "TIME",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 391 RPL_TIME.
msg := findNumericWithParams(msgs, "391")
if msg == nil {
t.Fatalf(
"expected RPL_TIME (391), got %v",
msgs,
)
}
}
// --- KILL ---
func TestKillSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create the victim first.
victimToken := tserver.createSession("victim")
_ = victimToken
// Create oper user.
operToken := tserver.createSession("killer")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(operToken, lastID)
// Kill the victim.
status, result := tserver.sendCommand(
operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"victim", "go away"},
},
)
if status != 200 {
t.Fatalf("expected 200, got %d: %v", status, result)
}
resultStatus, _ := result[statusKey].(string)
if resultStatus != "ok" {
t.Fatalf(
"expected status ok, got %v",
result,
)
}
// Verify the victim's session is gone by trying
// to WHOIS them.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WHOIS",
toKey: "victim",
})
msgs, _ := tserver.pollMessages(operToken, lastID)
// Should get 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected victim to be gone (401), got %v",
msgs,
)
}
}
func TestKillNotOper(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("target")
token := tserver.createSession("notoper")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{"target", "no reason"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestKillNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opertest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
// sendOperKillCommand is a helper that creates an oper
// session, authenticates, then sends KILL with the given
// target nick, and returns the resulting messages.
func sendOperKillCommand(
t *testing.T,
tserver *testServer,
operNick, targetNick string,
) []map[string]any {
t.Helper()
token := tserver.createSession(operNick)
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{targetNick},
})
msgs, _ := tserver.pollMessages(token, lastID)
return msgs
}
func TestKillNonexistentUser(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "opertest2", "ghost",
)
// Expect 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
}
}
func TestKillSelf(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "selfkiller", "selfkiller",
)
// Expect 483 ERR_CANTKILLSERVER.
if !findNumeric(msgs, "483") {
t.Fatalf(
"expected ERR_CANTKILLSERVER (483), got %v",
msgs,
)
}
}
func TestKillBroadcastsQuit(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create victim and join a channel.
victimToken := tserver.createSession("vuser")
tserver.sendCommand(victimToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
// Create observer and join same channel.
observerToken := tserver.createSession("observer")
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
_, lastObs := tserver.pollMessages(observerToken, 0)
// Create oper.
operToken := tserver.createSession("theoper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Kill the victim.
tserver.sendCommand(operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"vuser", "testing kill"},
})
// Observer should see a QUIT message.
msgs, _ := tserver.pollMessages(observerToken, lastObs)
foundQuit := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "QUIT" {
from, _ := msg["from"].(string)
if from == "vuser" {
foundQuit = true
break
}
}
}
if !foundQuit {
t.Fatalf(
"expected QUIT from vuser, got %v",
msgs,
)
}
}
// --- WALLOPS ---
func TestWallopsSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver with +w.
receiverToken := tserver.createSession("receiver")
tserver.sendCommand(receiverToken, map[string]any{
commandKey: "MODE",
toKey: "receiver",
bodyKey: []string{"+w"},
})
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Also set +w on oper so they receive it too.
tserver.sendCommand(operToken, map[string]any{
commandKey: "MODE",
toKey: "walloper",
bodyKey: []string{"+w"},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"server going down"},
})
// Receiver should get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
foundWallops := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
foundWallops = true
break
}
}
if !foundWallops {
t.Fatalf(
"expected WALLOPS message, got %v",
msgs,
)
}
}
func TestWallopsNotOper(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("notoper2")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"hello"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestWallopsNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operempty")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestWallopsNotReceivedWithoutW(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver WITHOUT +w.
receiverToken := tserver.createSession("nowallops")
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"secret message"},
})
// Receiver should NOT get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
t.Fatalf(
"did not expect WALLOPS for user "+
"without +w, got %v",
msgs,
)
}
}
}
// --- User Mode +w ---
func TestUserModeSetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wmoder")
_, lastID := tserver.pollMessages(token, 0)
// Set +w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
bodyKey: []string{"+w"},
})
msgs, lastID := tserver.pollMessages(token, lastID)
// Expect 221 RPL_UMODEIS with "+w".
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode string to contain 'w', got %q",
body,
)
}
// Now query mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
})
msgs, _ = tserver.pollMessages(token, lastID)
msg = findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221) on query, got %v",
msgs,
)
}
body = getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode '+w' in query, got %q",
body,
)
}
}
func TestUserModeUnsetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wunsetter")
_, lastID := tserver.pollMessages(token, 0)
// Set +w first.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"+w"},
})
_, lastID = tserver.pollMessages(token, lastID)
// Unset -w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"-w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "w") {
t.Fatalf(
"expected 'w' to be removed, got %q",
body,
)
}
}
func TestUserModeUnknownFlag(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("badmode")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "badmode",
bodyKey: []string{"+z"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeCannotSetO(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("tryoper")
_, lastID := tserver.pollMessages(token, 0)
// Try to set +o via MODE (should fail).
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "tryoper",
bodyKey: []string{"+o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeDeoper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("deoper")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// Use MODE -o to de-oper.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "deoper",
bodyKey: []string{"-o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "o") {
t.Fatalf(
"expected 'o' to be removed, got %q",
body,
)
}
}
func TestUserModeCannotChangeOtherUser(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("other")
token := tserver.createSession("changer")
_, lastID := tserver.pollMessages(token, 0)
// Try to change another user's mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "other",
bodyKey: []string{"+w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 502 ERR_USERSDONTMATCH.
if !findNumeric(msgs, "502") {
t.Fatalf(
"expected ERR_USERSDONTMATCH (502), got %v",
msgs,
)
}
}
// getNumericBody extracts the body text from a numeric
// message. The body is stored as a JSON array; this
// returns the first element.
func getNumericBody(msg map[string]any) string {
raw, exists := msg["body"]
if !exists || raw == nil {
return ""
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
return ""
}
str, isStr := arr[0].(string)
if !isStr {
return ""
}
return str
}

View File

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

View File

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

View File

@@ -6,12 +6,12 @@ import (
"log/slog" "log/slog"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/stats"
) )
// Params defines the dependencies for creating a Healthcheck. // Params defines the dependencies for creating a Healthcheck.

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -11,10 +11,10 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -112,6 +112,87 @@ func newTestEnv(t *testing.T) *testEnv {
} }
} }
// newTestEnvWithOper creates a test environment with oper
// credentials configured.
func newTestEnvWithOper(t *testing.T) *testEnv {
t.Helper()
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
}
database := db.NewTestDatabaseFromConn(conn)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
srv := ircserver.NewTestServer(log, cfg, database, brk)
err = srv.Start(addr)
if err != nil {
t.Fatalf("start irc server: %v", err)
}
t.Cleanup(func() {
srv.Stop()
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{
database: database,
brk: brk,
cfg: cfg,
srv: srv,
}
}
// dial connects to the test server. // dial connects to the test server.
func (env *testEnv) dial(t *testing.T) *testClient { func (env *testEnv) dial(t *testing.T) *testClient {
t.Helper() t.Helper()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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