Compare commits
6 Commits
feature/87
...
67460ea6b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67460ea6b2 | ||
|
|
b7999b201f | ||
|
|
d85956ca1a | ||
|
|
58f958c8d3 | ||
|
|
0fef7929ad | ||
|
|
c4652728b8 |
2
Makefile
2
Makefile
@@ -32,7 +32,7 @@ fmt-check:
|
|||||||
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
test: ensure-web-dist
|
test: ensure-web-dist
|
||||||
go test -timeout 30s -race -cover ./... || go test -timeout 30s -race -v ./...
|
go test -timeout 30s -v -race -cover ./...
|
||||||
|
|
||||||
# check runs all validation without making changes
|
# check runs all validation without making changes
|
||||||
# Used by CI and Docker build — fails if anything is wrong
|
# Used by CI and Docker build — fails if anything is wrong
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -16,7 +16,6 @@ require (
|
|||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/time v0.6.0
|
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -151,8 +151,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -29,19 +28,16 @@ var errHTTP = errors.New("HTTP error")
|
|||||||
// Client wraps HTTP calls to the neoirc server API.
|
// Client wraps HTTP calls to the neoirc server API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
Token string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new API client with a cookie jar
|
// NewClient creates a new API client.
|
||||||
// for automatic auth cookie management.
|
|
||||||
func NewClient(baseURL string) *Client {
|
func NewClient(baseURL string) *Client {
|
||||||
jar, _ := cookiejar.New(nil)
|
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
||||||
|
|
||||||
return &Client{
|
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
|
||||||
Timeout: httpTimeout,
|
Timeout: httpTimeout,
|
||||||
Jar: jar,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +79,8 @@ func (client *Client) CreateSession(
|
|||||||
return nil, fmt.Errorf("decode session: %w", err)
|
return nil, fmt.Errorf("decode session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.Token = resp.Token
|
||||||
|
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +121,6 @@ func (client *Client) PollMessages(
|
|||||||
Timeout: time.Duration(
|
Timeout: time.Duration(
|
||||||
timeout+pollExtraTime,
|
timeout+pollExtraTime,
|
||||||
) * time.Second,
|
) * time.Second,
|
||||||
Jar: client.HTTPClient.Jar,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
@@ -148,6 +145,10 @@ func (client *Client) PollMessages(
|
|||||||
return nil, fmt.Errorf("new request: %w", err)
|
return nil, fmt.Errorf("new request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.Header.Set(
|
||||||
|
"Authorization", "Bearer "+client.Token,
|
||||||
|
)
|
||||||
|
|
||||||
resp, err := pollClient.Do(request)
|
resp, err := pollClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("poll request: %w", err)
|
return nil, fmt.Errorf("poll request: %w", err)
|
||||||
@@ -303,6 +304,12 @@ func (client *Client) do(
|
|||||||
"Content-Type", "application/json",
|
"Content-Type", "application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if client.Token != "" {
|
||||||
|
request.Header.Set(
|
||||||
|
"Authorization", "Bearer "+client.Token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := client.HTTPClient.Do(request)
|
resp, err := client.HTTPClient.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type SessionRequest struct {
|
|||||||
type SessionResponse struct {
|
type SessionResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateResponse is the response from GET /api/v1/state.
|
// StateResponse is the response from GET /api/v1/state.
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ type Config struct {
|
|||||||
HashcashBits int
|
HashcashBits int
|
||||||
OperName string
|
OperName string
|
||||||
OperPassword string
|
OperPassword string
|
||||||
LoginRateLimit float64
|
|
||||||
LoginRateBurst int
|
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -84,8 +82,6 @@ func New(
|
|||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
||||||
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
||||||
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
|
||||||
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,8 +110,6 @@ func New(
|
|||||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
||||||
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
||||||
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
|
||||||
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,39 +10,92 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
|
const bcryptCost = bcrypt.DefaultCost
|
||||||
var bcryptCost = bcrypt.DefaultCost
|
|
||||||
|
|
||||||
// SetBcryptCost overrides the bcrypt cost.
|
|
||||||
// Use bcrypt.MinCost in tests to avoid slow hashing.
|
|
||||||
func SetBcryptCost(cost int) { bcryptCost = cost }
|
|
||||||
|
|
||||||
var errNoPassword = errors.New(
|
var errNoPassword = errors.New(
|
||||||
"account has no password set",
|
"account has no password set",
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetPassword sets a bcrypt-hashed password on a session,
|
// RegisterUser creates a session with a hashed password
|
||||||
// enabling multi-client login via POST /api/v1/login.
|
// and returns session ID, client ID, and token.
|
||||||
func (database *Database) SetPassword(
|
func (database *Database) RegisterUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
nick, password, username, hostname, remoteIP string,
|
||||||
password string,
|
) (int64, int64, string, error) {
|
||||||
) error {
|
if username == "" {
|
||||||
|
username = nick
|
||||||
|
}
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword(
|
hash, err := bcrypt.GenerateFromPassword(
|
||||||
[]byte(password), bcryptCost,
|
[]byte(password), bcryptCost,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hash password: %w", err)
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"hash password: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = database.conn.ExecContext(ctx,
|
sessionUUID := uuid.New().String()
|
||||||
"UPDATE sessions SET password_hash = ? WHERE id = ?",
|
clientUUID := uuid.New().String()
|
||||||
string(hash), sessionID)
|
|
||||||
|
token, err := generateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set password: %w", err)
|
return 0, 0, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
now := time.Now()
|
||||||
|
|
||||||
|
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"begin tx: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := transaction.ExecContext(ctx,
|
||||||
|
`INSERT INTO sessions
|
||||||
|
(uuid, nick, username, hostname, ip,
|
||||||
|
password_hash, created_at, last_seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
sessionUUID, nick, username, hostname,
|
||||||
|
remoteIP, string(hash), now, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"create session: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
|
clientRes, err := transaction.ExecContext(ctx,
|
||||||
|
`INSERT INTO clients
|
||||||
|
(uuid, session_id, token, ip, hostname,
|
||||||
|
created_at, last_seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
clientUUID, sessionID, tokenHash,
|
||||||
|
remoteIP, hostname, now, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = transaction.Rollback()
|
||||||
|
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"create client: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, _ := clientRes.LastInsertId()
|
||||||
|
|
||||||
|
err = transaction.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", fmt.Errorf(
|
||||||
|
"commit registration: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionID, clientID, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginUser verifies a nick/password and creates a new
|
// LoginUser verifies a nick/password and creates a new
|
||||||
|
|||||||
@@ -6,65 +6,126 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetPassword(t *testing.T) {
|
func TestRegisterUser(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
sessionID, clientID, token, err :=
|
||||||
database.CreateSession(ctx, "passuser", "", "", "")
|
database.RegisterUser(ctx, "reguser", "password123", "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.SetPassword(
|
if sessionID == 0 || clientID == 0 || token == "" {
|
||||||
ctx, sessionID, "password123",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we can now log in with the password.
|
|
||||||
loginSID, loginCID, loginToken, err :=
|
|
||||||
database.LoginUser(ctx, "passuser", "password123", "", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loginSID == 0 || loginCID == 0 || loginToken == "" {
|
|
||||||
t.Fatal("expected valid ids and token")
|
t.Fatal("expected valid ids and token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify session works via token lookup.
|
||||||
|
sid, cid, nick, err :=
|
||||||
|
database.GetSessionByToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetPasswordThenWrongLogin(t *testing.T) {
|
if sid != sessionID || cid != clientID {
|
||||||
|
t.Fatal("session/client id mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if nick != "reguser" {
|
||||||
|
t.Fatalf("expected reguser, got %s", nick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserWithUserHost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
sessionID, _, _, err := database.RegisterUser(
|
||||||
database.CreateSession(ctx, "wrongpw", "", "", "")
|
ctx, "reguhost", "password123",
|
||||||
if err != nil {
|
"myident", "example.org", "",
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.SetPassword(
|
|
||||||
ctx, sessionID, "correctpass",
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginSID, loginCID, loginToken, loginErr :=
|
info, err := database.GetSessionHostInfo(
|
||||||
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
|
ctx, sessionID,
|
||||||
if loginErr == nil {
|
)
|
||||||
t.Fatal("expected error for wrong password")
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = loginSID
|
if info.Username != "myident" {
|
||||||
_ = loginCID
|
t.Fatalf(
|
||||||
_ = loginToken
|
"expected myident, got %s", info.Username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Hostname != "example.org" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected example.org, got %s",
|
||||||
|
info.Hostname,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserDefaultUsername(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.RegisterUser(
|
||||||
|
ctx, "regdefault", "password123", "", "", "",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := database.GetSessionHostInfo(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Username != "regdefault" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected regdefault, got %s",
|
||||||
|
info.Username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserDuplicateNick(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
regSID, regCID, regToken, err :=
|
||||||
|
database.RegisterUser(ctx, "dupnick", "password123", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = regSID
|
||||||
|
_ = regCID
|
||||||
|
_ = regToken
|
||||||
|
|
||||||
|
dupSID, dupCID, dupToken, dupErr :=
|
||||||
|
database.RegisterUser(ctx, "dupnick", "other12345", "", "", "")
|
||||||
|
if dupErr == nil {
|
||||||
|
t.Fatal("expected error for duplicate nick")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = dupSID
|
||||||
|
_ = dupCID
|
||||||
|
_ = dupToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginUser(t *testing.T) {
|
func TestLoginUser(t *testing.T) {
|
||||||
@@ -73,26 +134,23 @@ func TestLoginUser(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, _, _, err :=
|
regSID, regCID, regToken, err :=
|
||||||
database.CreateSession(ctx, "loginuser", "", "", "")
|
database.RegisterUser(ctx, "loginuser", "mypassword", "", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.SetPassword(
|
_ = regSID
|
||||||
ctx, sessionID, "mypassword",
|
_ = regCID
|
||||||
)
|
_ = regToken
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loginSID, loginCID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
|
database.LoginUser(ctx, "loginuser", "mypassword", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginSID == 0 || loginCID == 0 || token == "" {
|
if sessionID == 0 || clientID == 0 || token == "" {
|
||||||
t.Fatal("expected valid ids and token")
|
t.Fatal("expected valid ids and token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +166,110 @@ func TestLoginUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginUserStoresClientIPHostname(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
regSID, regCID, regToken, err := database.RegisterUser(
|
||||||
|
ctx, "loginipuser", "password123",
|
||||||
|
"", "", "10.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = regSID
|
||||||
|
_ = regCID
|
||||||
|
_ = regToken
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, clientID, _, err := database.LoginUser(
|
||||||
|
ctx, "loginipuser", "password123",
|
||||||
|
"10.0.0.99", "newhost.example.com",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientInfo, err := database.GetClientHostInfo(
|
||||||
|
ctx, clientID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientInfo.IP != "10.0.0.99" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected client IP 10.0.0.99, got %s",
|
||||||
|
clientInfo.IP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientInfo.Hostname != "newhost.example.com" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected hostname newhost.example.com, got %s",
|
||||||
|
clientInfo.Hostname,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterUserStoresSessionIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
sessionID, _, _, err := database.RegisterUser(
|
||||||
|
ctx, "regipuser", "password123",
|
||||||
|
"ident", "host.local", "172.16.0.5",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := database.GetSessionHostInfo(
|
||||||
|
ctx, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IP != "172.16.0.5" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected session IP 172.16.0.5, got %s",
|
||||||
|
info.IP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginUserWrongPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
database := setupTestDB(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
regSID, regCID, regToken, err :=
|
||||||
|
database.RegisterUser(ctx, "wrongpw", "correctpass", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = regSID
|
||||||
|
_ = regCID
|
||||||
|
_ = regToken
|
||||||
|
|
||||||
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
|
database.LoginUser(ctx, "wrongpw", "wrongpass12", "", "")
|
||||||
|
if loginErr == nil {
|
||||||
|
t.Fatal("expected error for wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = loginSID
|
||||||
|
_ = loginCID
|
||||||
|
_ = loginToken
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginUserNoPassword(t *testing.T) {
|
func TestLoginUserNoPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package db_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
db.SetBcryptCost(bcrypt.MinCost)
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
@@ -77,8 +76,6 @@ type MemberInfo struct {
|
|||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
IsOperator bool `json:"isOperator"`
|
|
||||||
IsVoiced bool `json:"isVoiced"`
|
|
||||||
LastSeen time.Time `json:"lastSeen"`
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,237 +436,6 @@ func (database *Database) JoinChannel(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// JoinChannelAsOperator adds a session to a channel with
|
|
||||||
// operator status. Used when a user creates a new channel.
|
|
||||||
func (database *Database) JoinChannelAsOperator(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`INSERT OR IGNORE INTO channel_members
|
|
||||||
(channel_id, session_id, is_operator, joined_at)
|
|
||||||
VALUES (?, ?, 1, ?)`,
|
|
||||||
channelID, sessionID, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"join channel as operator: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountChannelMembers returns the number of members in
|
|
||||||
// a channel.
|
|
||||||
func (database *Database) CountChannelMembers(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (int64, error) {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM channel_members
|
|
||||||
WHERE channel_id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"count channel members: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsChannelOperator checks if a session has operator
|
|
||||||
// status in a channel.
|
|
||||||
func (database *Database) IsChannelOperator(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isOp int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_operator FROM channel_members
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
channelID, sessionID,
|
|
||||||
).Scan(&isOp)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check channel operator: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isOp != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsChannelVoiced checks if a session has voice status
|
|
||||||
// in a channel.
|
|
||||||
func (database *Database) IsChannelVoiced(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isVoiced int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_voiced FROM channel_members
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
channelID, sessionID,
|
|
||||||
).Scan(&isVoiced)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check channel voiced: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isVoiced != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelMemberOperator sets or clears operator status
|
|
||||||
// for a session in a channel.
|
|
||||||
func (database *Database) SetChannelMemberOperator(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
isOp bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if isOp {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channel_members
|
|
||||||
SET is_operator = ?
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
val, channelID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set channel member operator: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelMemberVoiced sets or clears voice status
|
|
||||||
// for a session in a channel.
|
|
||||||
func (database *Database) SetChannelMemberVoiced(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
isVoiced bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if isVoiced {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channel_members
|
|
||||||
SET is_voiced = ?
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
val, channelID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set channel member voiced: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsChannelModerated returns whether a channel has +m set.
|
|
||||||
func (database *Database) IsChannelModerated(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isMod int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_moderated FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&isMod)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check channel moderated: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isMod != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelModerated sets or clears +m on a channel.
|
|
||||||
func (database *Database) SetChannelModerated(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
moderated bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if moderated {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET is_moderated = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
val, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set channel moderated: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsChannelTopicLocked returns whether a channel has
|
|
||||||
// +t set.
|
|
||||||
func (database *Database) IsChannelTopicLocked(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isLocked int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_topic_locked FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&isLocked)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check channel topic locked: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isLocked != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelTopicLocked sets or clears +t on a channel.
|
|
||||||
func (database *Database) SetChannelTopicLocked(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
locked bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if locked {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET is_topic_locked = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
val, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set channel topic locked: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PartChannel removes a session from a channel.
|
// PartChannel removes a session from a channel.
|
||||||
func (database *Database) PartChannel(
|
func (database *Database) PartChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -781,8 +547,7 @@ func (database *Database) ChannelMembers(
|
|||||||
) ([]MemberInfo, error) {
|
) ([]MemberInfo, error) {
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
rows, err := database.conn.QueryContext(ctx,
|
||||||
`SELECT s.id, s.nick, s.username,
|
`SELECT s.id, s.nick, s.username,
|
||||||
s.hostname, cm.is_operator, cm.is_voiced,
|
s.hostname, s.last_seen
|
||||||
s.last_seen
|
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
INNER JOIN channel_members cm
|
INNER JOIN channel_members cm
|
||||||
ON cm.session_id = s.id
|
ON cm.session_id = s.id
|
||||||
@@ -799,16 +564,11 @@ func (database *Database) ChannelMembers(
|
|||||||
var members []MemberInfo
|
var members []MemberInfo
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var member MemberInfo
|
||||||
member MemberInfo
|
|
||||||
isOp int
|
|
||||||
isV int
|
|
||||||
)
|
|
||||||
|
|
||||||
err = rows.Scan(
|
err = rows.Scan(
|
||||||
&member.ID, &member.Nick,
|
&member.ID, &member.Nick,
|
||||||
&member.Username, &member.Hostname,
|
&member.Username, &member.Hostname,
|
||||||
&isOp, &isV,
|
|
||||||
&member.LastSeen,
|
&member.LastSeen,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -817,9 +577,6 @@ func (database *Database) ChannelMembers(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
member.IsOperator = isOp != 0
|
|
||||||
member.IsVoiced = isV != 0
|
|
||||||
|
|
||||||
members = append(members, member)
|
members = append(members, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1836,663 +1593,3 @@ func (database *Database) PruneSpentHashcash(
|
|||||||
|
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tier 2: Ban system (+b) ---
|
|
||||||
|
|
||||||
// BanInfo represents a channel ban entry.
|
|
||||||
type BanInfo struct {
|
|
||||||
Mask string
|
|
||||||
SetBy string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddChannelBan inserts a ban mask for a channel.
|
|
||||||
func (database *Database) AddChannelBan(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
mask, setBy string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`INSERT OR IGNORE INTO channel_bans
|
|
||||||
(channel_id, mask, set_by, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
channelID, mask, setBy, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("add channel ban: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveChannelBan removes a ban mask from a channel.
|
|
||||||
func (database *Database) RemoveChannelBan(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
mask string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`DELETE FROM channel_bans
|
|
||||||
WHERE channel_id = ? AND mask = ?`,
|
|
||||||
channelID, mask)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("remove channel ban: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListChannelBans returns all bans for a channel.
|
|
||||||
//
|
|
||||||
//nolint:dupl // different query+type vs filtered variant
|
|
||||||
func (database *Database) ListChannelBans(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) ([]BanInfo, error) {
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT mask, set_by, created_at
|
|
||||||
FROM channel_bans
|
|
||||||
WHERE channel_id = ?
|
|
||||||
ORDER BY created_at ASC`,
|
|
||||||
channelID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list channel bans: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var bans []BanInfo
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var ban BanInfo
|
|
||||||
|
|
||||||
if scanErr := rows.Scan(
|
|
||||||
&ban.Mask, &ban.SetBy, &ban.CreatedAt,
|
|
||||||
); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel ban: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans = append(bans, ban)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowErr := rows.Err(); rowErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate channel bans: %w", rowErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return bans, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSessionBanned checks if a session's hostmask matches
|
|
||||||
// any ban in the channel. Returns true if banned.
|
|
||||||
func (database *Database) IsSessionBanned(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
// Get the session's hostmask parts.
|
|
||||||
var nick, username, hostname string
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT nick, username, hostname
|
|
||||||
FROM sessions WHERE id = ?`,
|
|
||||||
sessionID,
|
|
||||||
).Scan(&nick, &username, &hostname)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"get session hostmask: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostmask := FormatHostmask(nick, username, hostname)
|
|
||||||
|
|
||||||
// Get all ban masks for the channel.
|
|
||||||
bans, banErr := database.ListChannelBans(ctx, channelID)
|
|
||||||
if banErr != nil {
|
|
||||||
return false, banErr
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ban := range bans {
|
|
||||||
if MatchBanMask(ban.Mask, hostmask) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatchBanMask checks if hostmask matches a ban pattern
|
|
||||||
// using IRC-style wildcard matching (* and ?).
|
|
||||||
func MatchBanMask(pattern, hostmask string) bool {
|
|
||||||
return wildcardMatch(
|
|
||||||
strings.ToLower(pattern),
|
|
||||||
strings.ToLower(hostmask),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wildcardMatch implements simple glob-style matching
|
|
||||||
// with * (any sequence) and ? (any single character).
|
|
||||||
func wildcardMatch(pattern, str string) bool {
|
|
||||||
for len(pattern) > 0 {
|
|
||||||
switch pattern[0] {
|
|
||||||
case '*':
|
|
||||||
// Skip consecutive asterisks.
|
|
||||||
for len(pattern) > 0 && pattern[0] == '*' {
|
|
||||||
pattern = pattern[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pattern) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i <= len(str); i++ {
|
|
||||||
if wildcardMatch(pattern, str[i:]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
case '?':
|
|
||||||
if len(str) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern = pattern[1:]
|
|
||||||
str = str[1:]
|
|
||||||
default:
|
|
||||||
if len(str) == 0 || pattern[0] != str[0] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern = pattern[1:]
|
|
||||||
str = str[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(str) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: Invite-only (+i) ---
|
|
||||||
|
|
||||||
// IsChannelInviteOnly checks if a channel has +i mode.
|
|
||||||
func (database *Database) IsChannelInviteOnly(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isInviteOnly int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_invite_only FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&isInviteOnly)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check invite only: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInviteOnly != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelInviteOnly sets or unsets +i mode.
|
|
||||||
func (database *Database) SetChannelInviteOnly(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
inviteOnly bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if inviteOnly {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET is_invite_only = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
val, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set invite only: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddChannelInvite records that a session has been
|
|
||||||
// invited to a channel.
|
|
||||||
func (database *Database) AddChannelInvite(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
invitedBy string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`INSERT OR IGNORE INTO channel_invites
|
|
||||||
(channel_id, session_id, invited_by, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
channelID, sessionID, invitedBy, time.Now())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("add channel invite: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasChannelInvite checks if a session has been invited
|
|
||||||
// to a channel.
|
|
||||||
func (database *Database) HasChannelInvite(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM channel_invites
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
channelID, sessionID,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check invite: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearChannelInvite removes a session's invite to a
|
|
||||||
// channel (called after successful JOIN).
|
|
||||||
func (database *Database) ClearChannelInvite(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID, sessionID int64,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`DELETE FROM channel_invites
|
|
||||||
WHERE channel_id = ? AND session_id = ?`,
|
|
||||||
channelID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("clear invite: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: Secret (+s) ---
|
|
||||||
|
|
||||||
// IsChannelSecret checks if a channel has +s mode.
|
|
||||||
func (database *Database) IsChannelSecret(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isSecret int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT is_secret FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&isSecret)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check secret: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isSecret != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelSecret sets or unsets +s mode.
|
|
||||||
func (database *Database) SetChannelSecret(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
secret bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if secret {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET is_secret = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
val, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set secret: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAllChannelsWithCountsFiltered returns all channels
|
|
||||||
// with member counts, excluding secret channels that
|
|
||||||
// the given session is not a member of.
|
|
||||||
//
|
|
||||||
//nolint:dupl // different query+type vs ListChannelBans
|
|
||||||
func (database *Database) ListAllChannelsWithCountsFiltered(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) ([]ChannelInfoFull, error) {
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT c.name, COUNT(cm.id) AS member_count,
|
|
||||||
c.topic
|
|
||||||
FROM channels c
|
|
||||||
LEFT JOIN channel_members cm
|
|
||||||
ON cm.channel_id = c.id
|
|
||||||
WHERE c.is_secret = 0
|
|
||||||
OR c.id IN (
|
|
||||||
SELECT channel_id FROM channel_members
|
|
||||||
WHERE session_id = ?
|
|
||||||
)
|
|
||||||
GROUP BY c.id
|
|
||||||
ORDER BY c.name ASC`,
|
|
||||||
sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"list channels filtered: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var channels []ChannelInfoFull
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var chanInfo ChannelInfoFull
|
|
||||||
|
|
||||||
if scanErr := rows.Scan(
|
|
||||||
&chanInfo.Name,
|
|
||||||
&chanInfo.MemberCount,
|
|
||||||
&chanInfo.Topic,
|
|
||||||
); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
channels = append(channels, chanInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowErr := rows.Err(); rowErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate channels: %w", rowErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionChannelsFiltered returns channels a session
|
|
||||||
// belongs to, optionally excluding secret channels for
|
|
||||||
// WHOIS (when the querier is not in the same channel).
|
|
||||||
// If querierID == targetID, returns all channels.
|
|
||||||
func (database *Database) GetSessionChannelsFiltered(
|
|
||||||
ctx context.Context,
|
|
||||||
targetSID, querierSID int64,
|
|
||||||
) ([]ChannelInfo, error) {
|
|
||||||
// If querying yourself, return all channels.
|
|
||||||
if targetSID == querierSID {
|
|
||||||
return database.GetSessionChannels(ctx, targetSID)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := database.conn.QueryContext(ctx,
|
|
||||||
`SELECT c.id, c.name, c.topic
|
|
||||||
FROM channels c
|
|
||||||
JOIN channel_members cm
|
|
||||||
ON cm.channel_id = c.id
|
|
||||||
WHERE cm.session_id = ?
|
|
||||||
AND (c.is_secret = 0
|
|
||||||
OR c.id IN (
|
|
||||||
SELECT channel_id FROM channel_members
|
|
||||||
WHERE session_id = ?
|
|
||||||
))
|
|
||||||
ORDER BY c.name ASC`,
|
|
||||||
targetSID, querierSID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"get session channels filtered: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var channels []ChannelInfo
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var chanInfo ChannelInfo
|
|
||||||
|
|
||||||
if scanErr := rows.Scan(
|
|
||||||
&chanInfo.ID,
|
|
||||||
&chanInfo.Name,
|
|
||||||
&chanInfo.Topic,
|
|
||||||
); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan channel: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
channels = append(channels, chanInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowErr := rows.Err(); rowErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate channels: %w", rowErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: Channel Key (+k) ---
|
|
||||||
|
|
||||||
// GetChannelKey returns the key for a channel (empty
|
|
||||||
// string means no key set).
|
|
||||||
func (database *Database) GetChannelKey(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (string, error) {
|
|
||||||
var key string
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT channel_key FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&key)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("get channel key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelKey sets or clears the key for a channel.
|
|
||||||
func (database *Database) SetChannelKey(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
key string,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET channel_key = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
key, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set channel key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tier 2: User Limit (+l) ---
|
|
||||||
|
|
||||||
// GetChannelUserLimit returns the user limit for a
|
|
||||||
// channel (0 means no limit).
|
|
||||||
func (database *Database) GetChannelUserLimit(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
) (int, error) {
|
|
||||||
var limit int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(ctx,
|
|
||||||
`SELECT user_limit FROM channels
|
|
||||||
WHERE id = ?`,
|
|
||||||
channelID,
|
|
||||||
).Scan(&limit)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"get channel user limit: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return limit, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetChannelUserLimit sets the user limit for a channel.
|
|
||||||
func (database *Database) SetChannelUserLimit(
|
|
||||||
ctx context.Context,
|
|
||||||
channelID int64,
|
|
||||||
limit int,
|
|
||||||
) error {
|
|
||||||
_, err := database.conn.ExecContext(ctx,
|
|
||||||
`UPDATE channels
|
|
||||||
SET user_limit = ?, updated_at = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
limit, time.Now(), channelID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"set channel user limit: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSessionWallops sets the wallops (+w) flag on a
|
|
||||||
// session.
|
|
||||||
func (database *Database) SetSessionWallops(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
enabled bool,
|
|
||||||
) error {
|
|
||||||
val := 0
|
|
||||||
if enabled {
|
|
||||||
val = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := database.conn.ExecContext(
|
|
||||||
ctx,
|
|
||||||
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
|
|
||||||
val, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set session wallops: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSessionWallops returns whether the session has the
|
|
||||||
// wallops (+w) usermode set.
|
|
||||||
func (database *Database) IsSessionWallops(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) (bool, error) {
|
|
||||||
var isWallops int
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT is_wallops FROM sessions WHERE id = ?`,
|
|
||||||
sessionID,
|
|
||||||
).Scan(&isWallops)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"check session wallops: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return isWallops != 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWallopsSessionIDs returns all session IDs that have
|
|
||||||
// the wallops (+w) usermode set.
|
|
||||||
func (database *Database) GetWallopsSessionIDs(
|
|
||||||
ctx context.Context,
|
|
||||||
) ([]int64, error) {
|
|
||||||
rows, err := database.conn.QueryContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT id FROM sessions WHERE is_wallops = 1`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"get wallops sessions: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var ids []int64
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var sessionID int64
|
|
||||||
if scanErr := rows.Scan(&sessionID); scanErr != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"scan wallops session: %w", scanErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ids = append(ids, sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"iterate wallops sessions: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserhostInfo holds the data needed for RPL_USERHOST.
|
|
||||||
type UserhostInfo struct {
|
|
||||||
Nick string
|
|
||||||
Username string
|
|
||||||
Hostname string
|
|
||||||
IsOper bool
|
|
||||||
AwayMessage string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserhostInfo returns USERHOST info for the given
|
|
||||||
// nicks. Only nicks that exist are returned.
|
|
||||||
func (database *Database) GetUserhostInfo(
|
|
||||||
ctx context.Context,
|
|
||||||
nicks []string,
|
|
||||||
) ([]UserhostInfo, error) {
|
|
||||||
if len(nicks) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]UserhostInfo, 0, len(nicks))
|
|
||||||
|
|
||||||
for _, nick := range nicks {
|
|
||||||
var info UserhostInfo
|
|
||||||
|
|
||||||
err := database.conn.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT nick, username, hostname,
|
|
||||||
is_oper, away_message
|
|
||||||
FROM sessions WHERE nick = ?`,
|
|
||||||
nick,
|
|
||||||
).Scan(
|
|
||||||
&info.Nick, &info.Username, &info.Hostname,
|
|
||||||
&info.IsOper, &info.AwayMessage,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue // nick not found, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1017,474 +1017,3 @@ func TestGetOperCount(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 oper, got %d", count)
|
t.Fatalf("expected 1 oper, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tier 2 Tests ---
|
|
||||||
|
|
||||||
func TestWildcardMatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
pattern string
|
|
||||||
input string
|
|
||||||
match bool
|
|
||||||
}{
|
|
||||||
{"*!*@*", "nick!user@host", true},
|
|
||||||
{"*!*@*.example.com", "nick!user@foo.example.com", true},
|
|
||||||
{"*!*@*.example.com", "nick!user@other.net", false},
|
|
||||||
{"badnick!*@*", "badnick!user@host", true},
|
|
||||||
{"badnick!*@*", "goodnick!user@host", false},
|
|
||||||
{"nick!user@host", "nick!user@host", true},
|
|
||||||
{"nick!user@host", "nick!user@other", false},
|
|
||||||
{"*", "anything", true},
|
|
||||||
{"?ick!*@*", "nick!user@host", true},
|
|
||||||
{"?ick!*@*", "nn!user@host", false},
|
|
||||||
// Case-insensitive.
|
|
||||||
{"Nick!*@*", "nick!user@host", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
result := db.MatchBanMask(tc.pattern, tc.input)
|
|
||||||
if result != tc.match {
|
|
||||||
t.Errorf(
|
|
||||||
"MatchBanMask(%q, %q) = %v, want %v",
|
|
||||||
tc.pattern, tc.input, result, tc.match,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelBanCRUD(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No bans initially.
|
|
||||||
bans, err := database.ListChannelBans(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(bans) != 0 {
|
|
||||||
t.Fatalf("expected 0 bans, got %d", len(bans))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a ban.
|
|
||||||
err = database.AddChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com", "op",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans, err = database.ListChannelBans(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(bans) != 1 {
|
|
||||||
t.Fatalf("expected 1 ban, got %d", len(bans))
|
|
||||||
}
|
|
||||||
|
|
||||||
if bans[0].Mask != "*!*@evil.com" {
|
|
||||||
t.Fatalf("wrong mask: %s", bans[0].Mask)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate add is ignored (OR IGNORE).
|
|
||||||
err = database.AddChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com", "op2",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans, _ = database.ListChannelBans(ctx, chID)
|
|
||||||
if len(bans) != 1 {
|
|
||||||
t.Fatalf("expected 1 ban after dup, got %d", len(bans))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove ban.
|
|
||||||
err = database.RemoveChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bans, _ = database.ListChannelBans(ctx, chID)
|
|
||||||
if len(bans) != 0 {
|
|
||||||
t.Fatalf("expected 0 bans after remove, got %d", len(bans))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsSessionBanned(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
ctx, "victim", "victim", "evil.com", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#bantest")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not banned initially.
|
|
||||||
banned, err := database.IsSessionBanned(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if banned {
|
|
||||||
t.Fatal("should not be banned initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ban matching the hostmask.
|
|
||||||
err = database.AddChannelBan(
|
|
||||||
ctx, chID, "*!*@evil.com", "op",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
banned, err = database.IsSessionBanned(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !banned {
|
|
||||||
t.Fatal("should be banned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelInviteOnly(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#invite")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: not invite-only.
|
|
||||||
isIO, err := database.IsChannelInviteOnly(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isIO {
|
|
||||||
t.Fatal("should not be invite-only by default")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set invite-only.
|
|
||||||
err = database.SetChannelInviteOnly(ctx, chID, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
|
|
||||||
if !isIO {
|
|
||||||
t.Fatal("should be invite-only")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unset.
|
|
||||||
err = database.SetChannelInviteOnly(ctx, chID, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isIO, _ = database.IsChannelInviteOnly(ctx, chID)
|
|
||||||
if isIO {
|
|
||||||
t.Fatal("should not be invite-only")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelInviteCRUD(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
ctx, "invited", "", "", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#inv")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No invite initially.
|
|
||||||
has, err := database.HasChannelInvite(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if has {
|
|
||||||
t.Fatal("should not have invite")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add invite.
|
|
||||||
err = database.AddChannelInvite(ctx, chID, sid, "op")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
has, _ = database.HasChannelInvite(ctx, chID, sid)
|
|
||||||
if !has {
|
|
||||||
t.Fatal("should have invite")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear invite.
|
|
||||||
err = database.ClearChannelInvite(ctx, chID, sid)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
has, _ = database.HasChannelInvite(ctx, chID, sid)
|
|
||||||
if has {
|
|
||||||
t.Fatal("invite should be cleared")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChannelSecret(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#secret")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: not secret.
|
|
||||||
isSec, err := database.IsChannelSecret(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSec {
|
|
||||||
t.Fatal("should not be secret by default")
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.SetChannelSecret(ctx, chID, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSec, _ = database.IsChannelSecret(ctx, chID)
|
|
||||||
if !isSec {
|
|
||||||
t.Fatal("should be secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTestSession is a helper to create a session and
|
|
||||||
// return only the session ID.
|
|
||||||
func createTestSession(
|
|
||||||
t *testing.T,
|
|
||||||
database *db.Database,
|
|
||||||
nick string,
|
|
||||||
) int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
sid, _, _, err := database.CreateSession(
|
|
||||||
t.Context(), nick, "", "", "",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create session %s: %v", nick, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sid
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecretChannelFiltering(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
// Create two sessions.
|
|
||||||
sid1 := createTestSession(t, database, "member")
|
|
||||||
sid2 := createTestSession(t, database, "outsider")
|
|
||||||
|
|
||||||
// Create a secret channel.
|
|
||||||
chID, _ := database.GetOrCreateChannel(ctx, "#secret")
|
|
||||||
_ = database.SetChannelSecret(ctx, chID, true)
|
|
||||||
_ = database.JoinChannel(ctx, chID, sid1)
|
|
||||||
|
|
||||||
// Create a non-secret channel.
|
|
||||||
chID2, _ := database.GetOrCreateChannel(ctx, "#public")
|
|
||||||
_ = database.JoinChannel(ctx, chID2, sid1)
|
|
||||||
|
|
||||||
// Member should see both.
|
|
||||||
list, err := database.ListAllChannelsWithCountsFiltered(
|
|
||||||
ctx, sid1,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) != 2 {
|
|
||||||
t.Fatalf("member should see 2 channels, got %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outsider should only see public.
|
|
||||||
list, _ = database.ListAllChannelsWithCountsFiltered(
|
|
||||||
ctx, sid2,
|
|
||||||
)
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Fatalf("outsider should see 1 channel, got %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
if list[0].Name != "#public" {
|
|
||||||
t.Fatalf("outsider should see #public, got %s", list[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhoisChannelFiltering(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid1 := createTestSession(t, database, "target")
|
|
||||||
sid2 := createTestSession(t, database, "querier")
|
|
||||||
|
|
||||||
// Create secret channel, target joins it.
|
|
||||||
chID, _ := database.GetOrCreateChannel(ctx, "#hidden")
|
|
||||||
_ = database.SetChannelSecret(ctx, chID, true)
|
|
||||||
_ = database.JoinChannel(ctx, chID, sid1)
|
|
||||||
|
|
||||||
// Querier (non-member) should not see the channel.
|
|
||||||
channels, err := database.GetSessionChannelsFiltered(
|
|
||||||
ctx, sid1, sid2,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(channels) != 0 {
|
|
||||||
t.Fatalf(
|
|
||||||
"querier should see 0 channels, got %d",
|
|
||||||
len(channels),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Target querying self should see it.
|
|
||||||
channels, _ = database.GetSessionChannelsFiltered(
|
|
||||||
ctx, sid1, sid1,
|
|
||||||
)
|
|
||||||
if len(channels) != 1 {
|
|
||||||
t.Fatalf(
|
|
||||||
"self-query should see 1 channel, got %d",
|
|
||||||
len(channels),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:dupl // structurally similar to TestChannelUserLimit
|
|
||||||
func TestChannelKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#keyed")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: no key.
|
|
||||||
key, err := database.GetChannelKey(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if key != "" {
|
|
||||||
t.Fatalf("expected empty key, got %q", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set key.
|
|
||||||
err = database.SetChannelKey(ctx, chID, "secret123")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, _ = database.GetChannelKey(ctx, chID)
|
|
||||||
if key != "secret123" {
|
|
||||||
t.Fatalf("expected secret123, got %q", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear key.
|
|
||||||
err = database.SetChannelKey(ctx, chID, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, _ = database.GetChannelKey(ctx, chID)
|
|
||||||
if key != "" {
|
|
||||||
t.Fatalf("expected empty key, got %q", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:dupl // structurally similar to TestChannelKey
|
|
||||||
func TestChannelUserLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
database := setupTestDB(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
chID, err := database.GetOrCreateChannel(ctx, "#limited")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: no limit.
|
|
||||||
limit, err := database.GetChannelUserLimit(ctx, chID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if limit != 0 {
|
|
||||||
t.Fatalf("expected 0 limit, got %d", limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set limit.
|
|
||||||
err = database.SetChannelUserLimit(ctx, chID, 50)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, _ = database.GetChannelUserLimit(ctx, chID)
|
|
||||||
if limit != 50 {
|
|
||||||
t.Fatalf("expected 50, got %d", limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear limit.
|
|
||||||
err = database.SetChannelUserLimit(ctx, chID, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, _ = database.GetChannelUserLimit(ctx, chID)
|
|
||||||
if limit != 0 {
|
|
||||||
t.Fatalf("expected 0, got %d", limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
hostname TEXT NOT NULL DEFAULT '',
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||||
is_wallops INTEGER NOT NULL DEFAULT 0,
|
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
away_message TEXT NOT NULL DEFAULT '',
|
||||||
@@ -41,45 +40,15 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
topic_set_by TEXT NOT NULL DEFAULT '',
|
topic_set_by TEXT NOT NULL DEFAULT '',
|
||||||
topic_set_at DATETIME,
|
topic_set_at DATETIME,
|
||||||
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
hashcash_bits INTEGER NOT NULL DEFAULT 0,
|
||||||
is_moderated INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_invite_only INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
|
||||||
channel_key TEXT NOT NULL DEFAULT '',
|
|
||||||
user_limit INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Channel bans
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_bans (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
mask TEXT NOT NULL,
|
|
||||||
set_by TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(channel_id, mask)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_bans_channel ON channel_bans(channel_id);
|
|
||||||
|
|
||||||
-- Channel invites (in-memory would be simpler but DB survives restarts)
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_invites (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
||||||
invited_by TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(channel_id, session_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_invites_channel ON channel_invites(channel_id);
|
|
||||||
|
|
||||||
-- Channel members
|
-- Channel members
|
||||||
CREATE TABLE IF NOT EXISTS channel_members (
|
CREATE TABLE IF NOT EXISTS channel_members (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
is_operator INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_voiced INTEGER NOT NULL DEFAULT 0,
|
|
||||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(channel_id, session_id)
|
UNIQUE(channel_id, session_id)
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,151 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|
||||||
|
// HandleRegister creates a new user with a password.
|
||||||
|
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
request.Body = http.MaxBytesReader(
|
||||||
|
writer, request.Body, hdlr.maxBodySize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.handleRegister(writer, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) handleRegister(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
type registerRequest struct {
|
||||||
|
Nick string `json:"nick"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload registerRequest
|
||||||
|
|
||||||
|
err := json.NewDecoder(request.Body).Decode(&payload)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid request body",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.Nick = strings.TrimSpace(payload.Nick)
|
||||||
|
|
||||||
|
if !validNickRe.MatchString(payload.Nick) {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid nick format",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := resolveUsername(
|
||||||
|
payload.Username, payload.Nick,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !validUsernameRe.MatchString(username) {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid username format",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(payload.Password) < minPasswordLength {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"password must be at least 8 characters",
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.executeRegister(
|
||||||
|
writer, request,
|
||||||
|
payload.Nick, payload.Password, username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) executeRegister(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
nick, password, username string,
|
||||||
|
) {
|
||||||
|
remoteIP := clientIP(request)
|
||||||
|
|
||||||
|
hostname := resolveHostname(
|
||||||
|
request.Context(), remoteIP,
|
||||||
|
)
|
||||||
|
|
||||||
|
sessionID, clientID, token, err :=
|
||||||
|
hdlr.params.Database.RegisterUser(
|
||||||
|
request.Context(),
|
||||||
|
nick, password, username, hostname, remoteIP,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.handleRegisterError(
|
||||||
|
writer, request, err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.stats.IncrSessions()
|
||||||
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
|
hdlr.deliverMOTD(request, clientID, sessionID, nick)
|
||||||
|
|
||||||
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
|
"id": sessionID,
|
||||||
|
"nick": nick,
|
||||||
|
"token": token,
|
||||||
|
}, http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) handleRegisterError(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
if db.IsUniqueConstraintError(err) {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"nick already taken",
|
||||||
|
http.StatusConflict,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.log.Error(
|
||||||
|
"register user failed", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleLogin authenticates a user with nick and password.
|
// HandleLogin authenticates a user with nick and password.
|
||||||
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
||||||
return func(
|
return func(
|
||||||
@@ -28,21 +168,6 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
ip := clientIP(request)
|
|
||||||
|
|
||||||
if !hdlr.loginLimiter.Allow(ip) {
|
|
||||||
writer.Header().Set(
|
|
||||||
"Retry-After", "1",
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"too many login attempts, try again later",
|
|
||||||
http.StatusTooManyRequests,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -73,16 +198,6 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.executeLogin(
|
|
||||||
writer, request, payload.Nick, payload.Password,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hdlr *Handlers) executeLogin(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
nick, password string,
|
|
||||||
) {
|
|
||||||
remoteIP := clientIP(request)
|
remoteIP := clientIP(request)
|
||||||
|
|
||||||
hostname := resolveHostname(
|
hostname := resolveHostname(
|
||||||
@@ -92,7 +207,8 @@ func (hdlr *Handlers) executeLogin(
|
|||||||
sessionID, clientID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
nick, password,
|
payload.Nick,
|
||||||
|
payload.Password,
|
||||||
remoteIP, hostname,
|
remoteIP, hostname,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,75 +224,18 @@ func (hdlr *Handlers) executeLogin(
|
|||||||
hdlr.stats.IncrConnections()
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize channel state so the new client knows
|
// Initialize channel state so the new client knows
|
||||||
// which channels the session already belongs to.
|
// which channels the session already belongs to.
|
||||||
hdlr.initChannelState(
|
hdlr.initChannelState(
|
||||||
request, clientID, sessionID, nick,
|
request, clientID, sessionID, payload.Nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.setAuthCookie(writer, request, token)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": nick,
|
"nick": payload.Nick,
|
||||||
|
"token": token,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePass handles the IRC PASS command to set a
|
|
||||||
// password on the authenticated session, enabling
|
|
||||||
// multi-client login via POST /api/v1/login.
|
|
||||||
func (hdlr *Handlers) handlePass(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 || lines[0] == "" {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdPass},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
password := lines[0]
|
|
||||||
|
|
||||||
if len(password) < minPasswordLength {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdPass},
|
|
||||||
"Password must be at least 8 characters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hdlr.params.Database.SetPassword(
|
|
||||||
request.Context(), sessionID, password,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"set password failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
@@ -50,7 +49,6 @@ type Handlers struct {
|
|||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
channelHashcash *hashcash.ChannelValidator
|
channelHashcash *hashcash.ChannelValidator
|
||||||
loginLimiter *ratelimit.Limiter
|
|
||||||
stats *stats.Tracker
|
stats *stats.Tracker
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
@@ -65,16 +63,6 @@ func New(
|
|||||||
resource = "neoirc"
|
resource = "neoirc"
|
||||||
}
|
}
|
||||||
|
|
||||||
loginRate := params.Config.LoginRateLimit
|
|
||||||
if loginRate <= 0 {
|
|
||||||
loginRate = ratelimit.DefaultRate
|
|
||||||
}
|
|
||||||
|
|
||||||
loginBurst := params.Config.LoginRateBurst
|
|
||||||
if loginBurst <= 0 {
|
|
||||||
loginBurst = ratelimit.DefaultBurst
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
@@ -82,7 +70,6 @@ func New(
|
|||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
channelHashcash: hashcash.NewChannelValidator(),
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
|
||||||
stats: params.Stats,
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +162,6 @@ func (hdlr *Handlers) stopCleanup() {
|
|||||||
if hdlr.cancelCleanup != nil {
|
if hdlr.cancelCleanup != nil {
|
||||||
hdlr.cancelCleanup()
|
hdlr.cancelCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
if hdlr.loginLimiter != nil {
|
|
||||||
hdlr.loginLimiter.Stop()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
||||||
|
|||||||
@@ -1,727 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxUserhostNicks is the maximum number of nicks allowed
|
|
||||||
// in a single USERHOST query (RFC 2812).
|
|
||||||
const maxUserhostNicks = 5
|
|
||||||
|
|
||||||
// dispatchBodyOnlyCommand routes commands that take
|
|
||||||
// (writer, request, sessionID, clientID, nick, bodyLines).
|
|
||||||
func (hdlr *Handlers) dispatchBodyOnlyCommand(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, command string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
switch command {
|
|
||||||
case irc.CmdAway:
|
|
||||||
hdlr.handleAway(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdNick:
|
|
||||||
hdlr.handleNick(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdPass:
|
|
||||||
hdlr.handlePass(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdInvite:
|
|
||||||
hdlr.handleInvite(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dispatchOperCommand routes oper-related commands (OPER,
|
|
||||||
// KILL, WALLOPS) to their handlers.
|
|
||||||
func (hdlr *Handlers) dispatchOperCommand(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, command string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
switch command {
|
|
||||||
case irc.CmdOper:
|
|
||||||
hdlr.handleOper(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdKill:
|
|
||||||
hdlr.handleKill(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdWallops:
|
|
||||||
hdlr.handleWallops(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUserhost handles the USERHOST command.
|
|
||||||
// Returns user@host info for up to 5 nicks.
|
|
||||||
func (hdlr *Handlers) handleUserhost(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdUserhost},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit to 5 nicks per RFC 2812.
|
|
||||||
nicks := lines
|
|
||||||
if len(nicks) > maxUserhostNicks {
|
|
||||||
nicks = nicks[:maxUserhostNicks]
|
|
||||||
}
|
|
||||||
|
|
||||||
infos, err := hdlr.params.Database.GetUserhostInfo(
|
|
||||||
ctx, nicks,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"userhost query failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
replyStr := hdlr.buildUserhostReply(infos)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUserHost, nick, nil,
|
|
||||||
replyStr,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUserhostReply builds the RPL_USERHOST reply
|
|
||||||
// string per RFC 2812.
|
|
||||||
func (hdlr *Handlers) buildUserhostReply(
|
|
||||||
infos []db.UserhostInfo,
|
|
||||||
) string {
|
|
||||||
replies := make([]string, 0, len(infos))
|
|
||||||
|
|
||||||
for idx := range infos {
|
|
||||||
info := &infos[idx]
|
|
||||||
|
|
||||||
username := info.Username
|
|
||||||
if username == "" {
|
|
||||||
username = info.Nick
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname := info.Hostname
|
|
||||||
if hostname == "" {
|
|
||||||
hostname = hdlr.serverName()
|
|
||||||
}
|
|
||||||
|
|
||||||
operStar := ""
|
|
||||||
if info.IsOper {
|
|
||||||
operStar = "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
awayPrefix := "+"
|
|
||||||
if info.AwayMessage != "" {
|
|
||||||
awayPrefix = "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
replies = append(replies,
|
|
||||||
info.Nick+operStar+"="+
|
|
||||||
awayPrefix+username+"@"+hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(replies, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleVersion handles the VERSION command.
|
|
||||||
// Returns the server version string.
|
|
||||||
func (hdlr *Handlers) handleVersion(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
srvName := hdlr.serverName()
|
|
||||||
version := hdlr.serverVersion()
|
|
||||||
|
|
||||||
// 351 RPL_VERSION
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplVersion, nick,
|
|
||||||
[]string{version + ".", srvName},
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdmin handles the ADMIN command.
|
|
||||||
// Returns server admin contact info.
|
|
||||||
func (hdlr *Handlers) handleAdmin(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
srvName := hdlr.serverName()
|
|
||||||
|
|
||||||
// 256 RPL_ADMINME
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminMe, nick,
|
|
||||||
[]string{srvName},
|
|
||||||
"Administrative info",
|
|
||||||
)
|
|
||||||
|
|
||||||
// 257 RPL_ADMINLOC1
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminLoc1, nick, nil,
|
|
||||||
"neoirc server",
|
|
||||||
)
|
|
||||||
|
|
||||||
// 258 RPL_ADMINLOC2
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminLoc2, nick, nil,
|
|
||||||
"IRC over HTTP",
|
|
||||||
)
|
|
||||||
|
|
||||||
// 259 RPL_ADMINEMAIL
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplAdminEmail, nick, nil,
|
|
||||||
"admin@"+srvName,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInfo handles the INFO command.
|
|
||||||
// Returns server software information.
|
|
||||||
func (hdlr *Handlers) handleInfo(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
version := hdlr.serverVersion()
|
|
||||||
|
|
||||||
infoLines := []string{
|
|
||||||
"neoirc — IRC semantics over HTTP",
|
|
||||||
"Version: " + version,
|
|
||||||
"Written in Go",
|
|
||||||
"Started: " +
|
|
||||||
hdlr.params.Globals.StartTime.
|
|
||||||
Format(time.RFC1123),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range infoLines {
|
|
||||||
// 371 RPL_INFO
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplInfo, nick, nil,
|
|
||||||
line,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 374 RPL_ENDOFINFO
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplEndOfInfo, nick, nil,
|
|
||||||
"End of /INFO list",
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTime handles the TIME command.
|
|
||||||
// Returns the server's local time in RFC format.
|
|
||||||
func (hdlr *Handlers) handleTime(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
srvName := hdlr.serverName()
|
|
||||||
|
|
||||||
// 391 RPL_TIME
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplTime, nick,
|
|
||||||
[]string{srvName},
|
|
||||||
time.Now().Format(time.RFC1123),
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleKill handles the KILL command.
|
|
||||||
// Forcibly disconnects a user (oper only).
|
|
||||||
func (hdlr *Handlers) handleKill(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
// Check oper status.
|
|
||||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil || !isOper {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNoPrivileges, nick, nil,
|
|
||||||
"Permission Denied- You're not an IRC operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdKill},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetNick := strings.TrimSpace(lines[0])
|
|
||||||
if targetNick == "" {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdKill},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := "KILLed"
|
|
||||||
if len(lines) > 1 {
|
|
||||||
reason = lines[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
targetSID, lookupErr := hdlr.params.Database.
|
|
||||||
GetSessionByNick(ctx, targetNick)
|
|
||||||
if lookupErr != nil {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNoSuchNick, nick,
|
|
||||||
[]string{targetNick},
|
|
||||||
"No such nick/channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not allow killing yourself.
|
|
||||||
if targetSID == sessionID {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrCantKillServer, nick, nil,
|
|
||||||
"You cannot KILL yourself",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.executeKillUser(
|
|
||||||
request, targetSID, targetNick, nick, reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeKillUser forcibly disconnects a user: broadcasts
|
|
||||||
// QUIT to their channels, parts all channels, and deletes
|
|
||||||
// the session.
|
|
||||||
func (hdlr *Handlers) executeKillUser(
|
|
||||||
request *http.Request,
|
|
||||||
targetSID int64,
|
|
||||||
targetNick, killerNick, reason string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
quitMsg := "Killed (" + killerNick + " (" + reason + "))"
|
|
||||||
|
|
||||||
quitBody, err := json.Marshal([]string{quitMsg})
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"marshal kill quit body", "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
channels, _ := hdlr.params.Database.
|
|
||||||
GetSessionChannels(ctx, targetSID)
|
|
||||||
|
|
||||||
notified := map[int64]bool{}
|
|
||||||
|
|
||||||
var dbID int64
|
|
||||||
|
|
||||||
if len(channels) > 0 {
|
|
||||||
dbID, _, _ = hdlr.params.Database.InsertMessage(
|
|
||||||
ctx, irc.CmdQuit, targetNick, "",
|
|
||||||
nil, json.RawMessage(quitBody), nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, chanInfo := range channels {
|
|
||||||
memberIDs, _ := hdlr.params.Database.
|
|
||||||
GetChannelMemberIDs(ctx, chanInfo.ID)
|
|
||||||
|
|
||||||
for _, mid := range memberIDs {
|
|
||||||
if mid != targetSID && !notified[mid] {
|
|
||||||
notified[mid] = true
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.EnqueueToSession(
|
|
||||||
ctx, mid, dbID,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(mid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.PartChannel(
|
|
||||||
ctx, chanInfo.ID, targetSID,
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.DeleteChannelIfEmpty(
|
|
||||||
ctx, chanInfo.ID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.params.Database.DeleteSession(
|
|
||||||
ctx, targetSID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWallops handles the WALLOPS command.
|
|
||||||
// Broadcasts a message to all users with +w usermode
|
|
||||||
// (oper only).
|
|
||||||
func (hdlr *Handlers) handleWallops(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
// Check oper status.
|
|
||||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err != nil || !isOper {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNoPrivileges, nick, nil,
|
|
||||||
"Permission Denied- You're not an IRC operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
if len(lines) == 0 {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdWallops},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
message := strings.Join(lines, " ")
|
|
||||||
|
|
||||||
wallopsSIDs, err := hdlr.params.Database.
|
|
||||||
GetWallopsSessionIDs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"get wallops sessions failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(wallopsSIDs) > 0 {
|
|
||||||
body, mErr := json.Marshal([]string{message})
|
|
||||||
if mErr != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"marshal wallops body", "error", mErr,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
|
||||||
request, irc.CmdWallops, nick, "*",
|
|
||||||
json.RawMessage(body), wallopsSIDs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUserMode handles user mode queries and changes
|
|
||||||
// (e.g., MODE nick, MODE nick +w).
|
|
||||||
func (hdlr *Handlers) handleUserMode(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, target string,
|
|
||||||
bodyLines func() []string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
lines := bodyLines()
|
|
||||||
|
|
||||||
// Mode change requested.
|
|
||||||
if len(lines) > 0 {
|
|
||||||
// Users can only change their own modes.
|
|
||||||
if target != nick && target != "" {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUsersDoNotMatch, nick, nil,
|
|
||||||
"Can't change mode for other users",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.applyUserModeChange(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, lines[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode query — build the current mode string.
|
|
||||||
modeStr := hdlr.buildUserModeString(ctx, sessionID)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
||||||
modeStr,
|
|
||||||
)
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUserModeString constructs the mode string for a
|
|
||||||
// user (e.g., "+ow" for oper+wallops).
|
|
||||||
func (hdlr *Handlers) buildUserModeString(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) string {
|
|
||||||
modes := "+"
|
|
||||||
|
|
||||||
isOper, err := hdlr.params.Database.IsSessionOper(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err == nil && isOper {
|
|
||||||
modes += "o"
|
|
||||||
}
|
|
||||||
|
|
||||||
isWallops, err := hdlr.params.Database.IsSessionWallops(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err == nil && isWallops {
|
|
||||||
modes += "w"
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyUserModeChange applies a user mode change string
|
|
||||||
// (e.g., "+w", "-w").
|
|
||||||
func (hdlr *Handlers) applyUserModeChange(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, modeStr string,
|
|
||||||
) {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
adding := modeStr[0] == '+'
|
|
||||||
modeChar := modeStr[1:]
|
|
||||||
|
|
||||||
applied, err := hdlr.applyModeChar(
|
|
||||||
ctx, writer, request,
|
|
||||||
sessionID, clientID, nick,
|
|
||||||
modeChar, adding,
|
|
||||||
)
|
|
||||||
if err != nil || !applied {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newModes := hdlr.buildUserModeString(ctx, sessionID)
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
||||||
newModes,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyModeChar applies a single user mode character.
|
|
||||||
// Returns (applied, error).
|
|
||||||
func (hdlr *Handlers) applyModeChar(
|
|
||||||
ctx context.Context,
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, modeChar string,
|
|
||||||
adding bool,
|
|
||||||
) (bool, error) {
|
|
||||||
switch modeChar {
|
|
||||||
case "w":
|
|
||||||
err := hdlr.params.Database.SetSessionWallops(
|
|
||||||
ctx, sessionID, adding,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"set wallops mode failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"set wallops: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case "o":
|
|
||||||
// +o cannot be set via MODE, only via OPER.
|
|
||||||
if adding {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err := hdlr.params.Database.SetSessionOper(
|
|
||||||
ctx, sessionID, false,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.log.Error(
|
|
||||||
"clear oper mode failed", "error", err,
|
|
||||||
)
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, fmt.Errorf(
|
|
||||||
"clear oper: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrUmodeUnknownFlag, nick, nil,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
// Tests for Tier 3 utility IRC commands: USERHOST,
|
|
||||||
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest
|
|
||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- USERHOST ---
|
|
||||||
|
|
||||||
func TestUserhostSingleNick(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("alice")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"alice"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 302 RPL_USERHOST.
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body should contain "alice" with the
|
|
||||||
// nick=+user@host format.
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "alice") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected body to contain 'alice', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// '+' means not away.
|
|
||||||
if !strings.Contains(body, "=+") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected not-away prefix '=+', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostMultipleNicks(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token1 := tserver.createSession("bob")
|
|
||||||
token2 := tserver.createSession("carol")
|
|
||||||
|
|
||||||
_ = token2
|
|
||||||
|
|
||||||
_, lastID := tserver.pollMessages(token1, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token1, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"bob", "carol"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token1, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "bob") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected body to contain 'bob', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(body, "carol") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected body to contain 'carol', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostNonexistentNick(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("dave")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"nobody"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Should still get 302 but with empty body.
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostNoParams(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("eve")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostShowsOper(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("opernick")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Authenticate as oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// USERHOST should show '*' for oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"opernick"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "opernick*=") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected oper '*' in reply, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserhostShowsAway(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("awaynick")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Set away.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "AWAY",
|
|
||||||
bodyKey: []string{"gone fishing"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// USERHOST should show '-' for away.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "USERHOST",
|
|
||||||
bodyKey: []string{"awaynick"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "302")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_USERHOST (302), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "=-") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected away prefix '=-' in reply, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- VERSION ---
|
|
||||||
|
|
||||||
func TestVersion(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("frank")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "VERSION",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 351 RPL_VERSION.
|
|
||||||
msg := findNumericWithParams(msgs, "351")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_VERSION (351), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
params := getNumericParams(msg)
|
|
||||||
if len(params) == 0 {
|
|
||||||
t.Fatal("expected VERSION params, got none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// First param should contain version string.
|
|
||||||
if !strings.Contains(params[0], "test") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected version to contain 'test', got %q",
|
|
||||||
params[0],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ADMIN ---
|
|
||||||
|
|
||||||
func TestAdmin(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("grace")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "ADMIN",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 256 RPL_ADMINME.
|
|
||||||
if !findNumeric(msgs, "256") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINME (256), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 257 RPL_ADMINLOC1.
|
|
||||||
if !findNumeric(msgs, "257") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINLOC1 (257), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 258 RPL_ADMINLOC2.
|
|
||||||
if !findNumeric(msgs, "258") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINLOC2 (258), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 259 RPL_ADMINEMAIL.
|
|
||||||
if !findNumeric(msgs, "259") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ADMINEMAIL (259), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- INFO ---
|
|
||||||
|
|
||||||
func TestInfo(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("hank")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "INFO",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 371 RPL_INFO (at least one).
|
|
||||||
if !findNumeric(msgs, "371") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_INFO (371), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect 374 RPL_ENDOFINFO.
|
|
||||||
if !findNumeric(msgs, "374") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_ENDOFINFO (374), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TIME ---
|
|
||||||
|
|
||||||
func TestTime(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("iris")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "TIME",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 391 RPL_TIME.
|
|
||||||
msg := findNumericWithParams(msgs, "391")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_TIME (391), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- KILL ---
|
|
||||||
|
|
||||||
func TestKillSuccess(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create the victim first.
|
|
||||||
victimToken := tserver.createSession("victim")
|
|
||||||
_ = victimToken
|
|
||||||
|
|
||||||
// Create oper user.
|
|
||||||
operToken := tserver.createSession("killer")
|
|
||||||
_, lastID := tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Authenticate as oper.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(operToken, lastID)
|
|
||||||
|
|
||||||
// Kill the victim.
|
|
||||||
status, result := tserver.sendCommand(
|
|
||||||
operToken, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{"victim", "go away"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if status != 200 {
|
|
||||||
t.Fatalf("expected 200, got %d: %v", status, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
resultStatus, _ := result[statusKey].(string)
|
|
||||||
if resultStatus != "ok" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected status ok, got %v",
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the victim's session is gone by trying
|
|
||||||
// to WHOIS them.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "WHOIS",
|
|
||||||
toKey: "victim",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(operToken, lastID)
|
|
||||||
|
|
||||||
// Should get 401 ERR_NOSUCHNICK.
|
|
||||||
if !findNumeric(msgs, "401") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected victim to be gone (401), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillNotOper(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
_ = tserver.createSession("target")
|
|
||||||
|
|
||||||
token := tserver.createSession("notoper")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{"target", "no reason"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 481 ERR_NOPRIVILEGES.
|
|
||||||
if !findNumeric(msgs, "481") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NOPRIVILEGES (481), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillNoParams(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("opertest")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendOperKillCommand is a helper that creates an oper
|
|
||||||
// session, authenticates, then sends KILL with the given
|
|
||||||
// target nick, and returns the resulting messages.
|
|
||||||
func sendOperKillCommand(
|
|
||||||
t *testing.T,
|
|
||||||
tserver *testServer,
|
|
||||||
operNick, targetNick string,
|
|
||||||
) []map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
token := tserver.createSession(operNick)
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{targetNick},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillNonexistentUser(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
msgs := sendOperKillCommand(
|
|
||||||
t, tserver, "opertest2", "ghost",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Expect 401 ERR_NOSUCHNICK.
|
|
||||||
if !findNumeric(msgs, "401") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NOSUCHNICK (401), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillSelf(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
msgs := sendOperKillCommand(
|
|
||||||
t, tserver, "selfkiller", "selfkiller",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Expect 483 ERR_CANTKILLSERVER.
|
|
||||||
if !findNumeric(msgs, "483") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_CANTKILLSERVER (483), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKillBroadcastsQuit(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create victim and join a channel.
|
|
||||||
victimToken := tserver.createSession("vuser")
|
|
||||||
|
|
||||||
tserver.sendCommand(victimToken, map[string]any{
|
|
||||||
commandKey: joinCmd,
|
|
||||||
toKey: "#killtest",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create observer and join same channel.
|
|
||||||
observerToken := tserver.createSession("observer")
|
|
||||||
|
|
||||||
tserver.sendCommand(observerToken, map[string]any{
|
|
||||||
commandKey: joinCmd,
|
|
||||||
toKey: "#killtest",
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastObs := tserver.pollMessages(observerToken, 0)
|
|
||||||
|
|
||||||
// Create oper.
|
|
||||||
operToken := tserver.createSession("theoper2")
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Kill the victim.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "KILL",
|
|
||||||
bodyKey: []string{"vuser", "testing kill"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Observer should see a QUIT message.
|
|
||||||
msgs, _ := tserver.pollMessages(observerToken, lastObs)
|
|
||||||
|
|
||||||
foundQuit := false
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
cmd, _ := msg["command"].(string)
|
|
||||||
if cmd == "QUIT" {
|
|
||||||
from, _ := msg["from"].(string)
|
|
||||||
if from == "vuser" {
|
|
||||||
foundQuit = true
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundQuit {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected QUIT from vuser, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- WALLOPS ---
|
|
||||||
|
|
||||||
func TestWallopsSuccess(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create receiver with +w.
|
|
||||||
receiverToken := tserver.createSession("receiver")
|
|
||||||
|
|
||||||
tserver.sendCommand(receiverToken, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "receiver",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
|
||||||
|
|
||||||
// Create oper.
|
|
||||||
operToken := tserver.createSession("walloper")
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Also set +w on oper so they receive it too.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "walloper",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Send WALLOPS.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
bodyKey: []string{"server going down"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Receiver should get the WALLOPS message.
|
|
||||||
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
|
||||||
|
|
||||||
foundWallops := false
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
cmd, _ := msg["command"].(string)
|
|
||||||
if cmd == "WALLOPS" {
|
|
||||||
foundWallops = true
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundWallops {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected WALLOPS message, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWallopsNotOper(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("notoper2")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
bodyKey: []string{"hello"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 481 ERR_NOPRIVILEGES.
|
|
||||||
if !findNumeric(msgs, "481") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NOPRIVILEGES (481), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWallopsNoParams(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("operempty")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
||||||
if !findNumeric(msgs, "461") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWallopsNotReceivedWithoutW(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
// Create receiver WITHOUT +w.
|
|
||||||
receiverToken := tserver.createSession("nowallops")
|
|
||||||
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
|
||||||
|
|
||||||
// Create oper.
|
|
||||||
operToken := tserver.createSession("walloper2")
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
tserver.pollMessages(operToken, 0)
|
|
||||||
|
|
||||||
// Send WALLOPS.
|
|
||||||
tserver.sendCommand(operToken, map[string]any{
|
|
||||||
commandKey: "WALLOPS",
|
|
||||||
bodyKey: []string{"secret message"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Receiver should NOT get the WALLOPS message.
|
|
||||||
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
|
||||||
|
|
||||||
for _, msg := range msgs {
|
|
||||||
cmd, _ := msg["command"].(string)
|
|
||||||
if cmd == "WALLOPS" {
|
|
||||||
t.Fatalf(
|
|
||||||
"did not expect WALLOPS for user "+
|
|
||||||
"without +w, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- User Mode +w ---
|
|
||||||
|
|
||||||
func TestUserModeSetW(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("wmoder")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Set +w.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wmoder",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, lastID := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 221 RPL_UMODEIS with "+w".
|
|
||||||
msg := findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "w") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected mode string to contain 'w', got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now query mode.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wmoder",
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg = findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221) on query, got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body = getNumericBody(msg)
|
|
||||||
if !strings.Contains(body, "w") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected mode '+w' in query, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeUnsetW(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("wunsetter")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Set +w first.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wunsetter",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Unset -w.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "wunsetter",
|
|
||||||
bodyKey: []string{"-w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if strings.Contains(body, "w") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 'w' to be removed, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeUnknownFlag(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("badmode")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "badmode",
|
|
||||||
bodyKey: []string{"+z"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
|
||||||
if !findNumeric(msgs, "501") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeCannotSetO(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("tryoper")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Try to set +o via MODE (should fail).
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "tryoper",
|
|
||||||
bodyKey: []string{"+o"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
|
||||||
if !findNumeric(msgs, "501") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeDeoper(t *testing.T) {
|
|
||||||
tserver := newTestServerWithOper(t)
|
|
||||||
|
|
||||||
token := tserver.createSession("deoper")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Authenticate as oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "OPER",
|
|
||||||
bodyKey: []string{testOperName, testOperPassword},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, lastID = tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Use MODE -o to de-oper.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "deoper",
|
|
||||||
bodyKey: []string{"-o"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
msg := findNumericWithParams(msgs, "221")
|
|
||||||
if msg == nil {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected RPL_UMODEIS (221), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := getNumericBody(msg)
|
|
||||||
if strings.Contains(body, "o") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 'o' to be removed, got %q",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserModeCannotChangeOtherUser(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
_ = tserver.createSession("other")
|
|
||||||
|
|
||||||
token := tserver.createSession("changer")
|
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
|
||||||
|
|
||||||
// Try to change another user's mode.
|
|
||||||
tserver.sendCommand(token, map[string]any{
|
|
||||||
commandKey: "MODE",
|
|
||||||
toKey: "other",
|
|
||||||
bodyKey: []string{"+w"},
|
|
||||||
})
|
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
|
||||||
|
|
||||||
// Expect 502 ERR_USERSDONTMATCH.
|
|
||||||
if !findNumeric(msgs, "502") {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_USERSDONTMATCH (502), got %v",
|
|
||||||
msgs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNumericBody extracts the body text from a numeric
|
|
||||||
// message. The body is stored as a JSON array; this
|
|
||||||
// returns the first element.
|
|
||||||
func getNumericBody(msg map[string]any) string {
|
|
||||||
raw, exists := msg["body"]
|
|
||||||
if !exists || raw == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
arr, isArr := raw.([]any)
|
|
||||||
if !isArr || len(arr) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
str, isStr := arr[0].(string)
|
|
||||||
if !isStr {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
@@ -126,23 +126,18 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
// CORS returns middleware that handles Cross-Origin Resource Sharing.
|
||||||
// AllowCredentials is true so browsers include cookies in
|
|
||||||
// cross-origin API requests.
|
|
||||||
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
|
||||||
AllowOriginFunc: func(
|
AllowedOrigins: []string{"*"},
|
||||||
_ *http.Request, _ string,
|
|
||||||
) bool {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
AllowedMethods: []string{
|
AllowedMethods: []string{
|
||||||
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
||||||
},
|
},
|
||||||
AllowedHeaders: []string{
|
AllowedHeaders: []string{
|
||||||
"Accept", "Content-Type", "X-CSRF-Token",
|
"Accept", "Authorization",
|
||||||
|
"Content-Type", "X-CSRF-Token",
|
||||||
},
|
},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: false,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
|
|
||||||
package ratelimit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// DefaultRate is the default number of allowed requests per second.
|
|
||||||
DefaultRate = 1.0
|
|
||||||
|
|
||||||
// DefaultBurst is the default maximum burst size.
|
|
||||||
DefaultBurst = 5
|
|
||||||
|
|
||||||
// DefaultSweepInterval controls how often stale entries are pruned.
|
|
||||||
DefaultSweepInterval = 10 * time.Minute
|
|
||||||
|
|
||||||
// DefaultEntryTTL is how long an unused entry lives before eviction.
|
|
||||||
DefaultEntryTTL = 15 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// entry tracks a per-IP rate limiter and when it was last used.
|
|
||||||
type entry struct {
|
|
||||||
limiter *rate.Limiter
|
|
||||||
lastSeen time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limiter manages per-key rate limiters with automatic cleanup
|
|
||||||
// of stale entries.
|
|
||||||
type Limiter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
entries map[string]*entry
|
|
||||||
rate rate.Limit
|
|
||||||
burst int
|
|
||||||
entryTTL time.Duration
|
|
||||||
stopCh chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new per-key rate Limiter.
|
|
||||||
// The ratePerSec parameter sets how many requests per second are
|
|
||||||
// allowed per key. The burst parameter sets the maximum number of
|
|
||||||
// requests that can be made in a single burst.
|
|
||||||
func New(ratePerSec float64, burst int) *Limiter {
|
|
||||||
limiter := &Limiter{
|
|
||||||
mu: sync.Mutex{},
|
|
||||||
entries: make(map[string]*entry),
|
|
||||||
rate: rate.Limit(ratePerSec),
|
|
||||||
burst: burst,
|
|
||||||
entryTTL: DefaultEntryTTL,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
go limiter.sweepLoop()
|
|
||||||
|
|
||||||
return limiter
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow reports whether a request from the given key should be
|
|
||||||
// allowed. It consumes one token from the key's rate limiter.
|
|
||||||
func (l *Limiter) Allow(key string) bool {
|
|
||||||
l.mu.Lock()
|
|
||||||
ent, exists := l.entries[key]
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
ent = &entry{
|
|
||||||
limiter: rate.NewLimiter(l.rate, l.burst),
|
|
||||||
lastSeen: time.Now(),
|
|
||||||
}
|
|
||||||
l.entries[key] = ent
|
|
||||||
} else {
|
|
||||||
ent.lastSeen = time.Now()
|
|
||||||
}
|
|
||||||
l.mu.Unlock()
|
|
||||||
|
|
||||||
return ent.limiter.Allow()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop terminates the background sweep goroutine.
|
|
||||||
func (l *Limiter) Stop() {
|
|
||||||
close(l.stopCh)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Len returns the number of tracked keys (for testing).
|
|
||||||
func (l *Limiter) Len() int {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
return len(l.entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sweepLoop periodically removes entries that haven't been seen
|
|
||||||
// within the TTL.
|
|
||||||
func (l *Limiter) sweepLoop() {
|
|
||||||
ticker := time.NewTicker(DefaultSweepInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
l.sweep()
|
|
||||||
case <-l.stopCh:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sweep removes stale entries.
|
|
||||||
func (l *Limiter) sweep() {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
cutoff := time.Now().Add(-l.entryTTL)
|
|
||||||
|
|
||||||
for key, ent := range l.entries {
|
|
||||||
if ent.lastSeen.Before(cutoff) {
|
|
||||||
delete(l.entries, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package ratelimit_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewCreatesLimiter(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 5)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
if limiter == nil {
|
|
||||||
t.Fatal("expected non-nil limiter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowWithinBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 3)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
if !limiter.Allow("192.168.1.1") {
|
|
||||||
t.Fatalf(
|
|
||||||
"request %d should be allowed within burst",
|
|
||||||
i+1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowExceedsBurst(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Rate of 0 means no token replenishment, only burst.
|
|
||||||
limiter := ratelimit.New(0, 3)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
for range 3 {
|
|
||||||
limiter.Allow("10.0.0.1")
|
|
||||||
}
|
|
||||||
|
|
||||||
if limiter.Allow("10.0.0.1") {
|
|
||||||
t.Fatal("fourth request should be denied after burst exhausted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllowSeparateKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Rate of 0, burst of 1 — only one request per key.
|
|
||||||
limiter := ratelimit.New(0, 1)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
if !limiter.Allow("10.0.0.1") {
|
|
||||||
t.Fatal("first request for key A should be allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !limiter.Allow("10.0.0.2") {
|
|
||||||
t.Fatal("first request for key B should be allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if limiter.Allow("10.0.0.1") {
|
|
||||||
t.Fatal("second request for key A should be denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
if limiter.Allow("10.0.0.2") {
|
|
||||||
t.Fatal("second request for key B should be denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLenTracksKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 5)
|
|
||||||
defer limiter.Stop()
|
|
||||||
|
|
||||||
if limiter.Len() != 0 {
|
|
||||||
t.Fatalf("expected 0 entries, got %d", limiter.Len())
|
|
||||||
}
|
|
||||||
|
|
||||||
limiter.Allow("10.0.0.1")
|
|
||||||
limiter.Allow("10.0.0.2")
|
|
||||||
|
|
||||||
if limiter.Len() != 2 {
|
|
||||||
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same key again should not increase count.
|
|
||||||
limiter.Allow("10.0.0.1")
|
|
||||||
|
|
||||||
if limiter.Len() != 2 {
|
|
||||||
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStopDoesNotPanic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
limiter := ratelimit.New(1.0, 5)
|
|
||||||
limiter.Stop()
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,10 @@ func (srv *Server) setupAPIv1(router chi.Router) {
|
|||||||
"/session",
|
"/session",
|
||||||
srv.handlers.HandleCreateSession(),
|
srv.handlers.HandleCreateSession(),
|
||||||
)
|
)
|
||||||
|
router.Post(
|
||||||
|
"/register",
|
||||||
|
srv.handlers.HandleRegister(),
|
||||||
|
)
|
||||||
router.Post(
|
router.Post(
|
||||||
"/login",
|
"/login",
|
||||||
srv.handlers.HandleLogin(),
|
srv.handlers.HandleLogin(),
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ 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"
|
|
||||||
CmdJoin = "JOIN"
|
CmdJoin = "JOIN"
|
||||||
CmdKick = "KICK"
|
|
||||||
CmdKill = "KILL"
|
|
||||||
CmdList = "LIST"
|
CmdList = "LIST"
|
||||||
CmdLusers = "LUSERS"
|
CmdLusers = "LUSERS"
|
||||||
CmdMode = "MODE"
|
CmdMode = "MODE"
|
||||||
@@ -17,17 +12,12 @@ const (
|
|||||||
CmdNick = "NICK"
|
CmdNick = "NICK"
|
||||||
CmdNotice = "NOTICE"
|
CmdNotice = "NOTICE"
|
||||||
CmdOper = "OPER"
|
CmdOper = "OPER"
|
||||||
CmdPass = "PASS"
|
|
||||||
CmdPart = "PART"
|
CmdPart = "PART"
|
||||||
CmdPing = "PING"
|
CmdPing = "PING"
|
||||||
CmdPong = "PONG"
|
CmdPong = "PONG"
|
||||||
CmdPrivmsg = "PRIVMSG"
|
CmdPrivmsg = "PRIVMSG"
|
||||||
CmdQuit = "QUIT"
|
CmdQuit = "QUIT"
|
||||||
CmdTime = "TIME"
|
|
||||||
CmdTopic = "TOPIC"
|
CmdTopic = "TOPIC"
|
||||||
CmdUserhost = "USERHOST"
|
|
||||||
CmdVersion = "VERSION"
|
|
||||||
CmdWallops = "WALLOPS"
|
|
||||||
CmdWho = "WHO"
|
CmdWho = "WHO"
|
||||||
CmdWhois = "WHOIS"
|
CmdWhois = "WHOIS"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user