Compare commits
3 Commits
f2af55e4a5
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9fd0c5c5 | ||
| bf4d63bc4d | |||
| efbd8fe9ff |
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
|
||||||
|
|
||||||
BINARY := neoircd
|
BINARY := neoircd
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
@@ -7,10 +7,21 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|||||||
|
|
||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
build:
|
# ensure-web-dist creates placeholder files so //go:embed dist/* in
|
||||||
|
# web/embed.go resolves without a full Node.js build. The real SPA is
|
||||||
|
# built by the web-builder Docker stage; these placeholders let
|
||||||
|
# "make test" and "make build" work outside Docker.
|
||||||
|
ensure-web-dist:
|
||||||
|
@if [ ! -d web/dist ]; then \
|
||||||
|
mkdir -p web/dist && \
|
||||||
|
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
|
||||||
|
echo "==> Created placeholder web/dist/ for go:embed"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
build: ensure-web-dist
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||||
|
|
||||||
lint:
|
lint: ensure-web-dist
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@@ -20,7 +31,7 @@ fmt:
|
|||||||
fmt-check:
|
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:
|
test: ensure-web-dist
|
||||||
go test -timeout 30s -v -race -cover ./...
|
go test -timeout 30s -v -race -cover ./...
|
||||||
|
|
||||||
# check runs all validation without making changes
|
# check runs all validation without making changes
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,16 +29,19 @@ 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.
|
// NewClient creates a new API client with a cookie jar
|
||||||
|
// for automatic auth cookie management.
|
||||||
func NewClient(baseURL string) *Client {
|
func NewClient(baseURL string) *Client {
|
||||||
return &Client{ //nolint:exhaustruct // Token set after CreateSession
|
jar, _ := cookiejar.New(nil)
|
||||||
|
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,8 +83,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +123,7 @@ 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{}
|
||||||
@@ -145,10 +148,6 @@ 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)
|
||||||
@@ -304,12 +303,6 @@ 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)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -37,6 +39,23 @@ func MintHashcash(bits int, resource string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MintChannelHashcash computes a hashcash stamp bound to
|
||||||
|
// a specific channel and message body. The stamp format
|
||||||
|
// is 1:bits:YYMMDD:channel:bodyhash:counter where
|
||||||
|
// bodyhash is the hex-encoded SHA-256 of the message
|
||||||
|
// body bytes. Delegates to the internal/hashcash package.
|
||||||
|
func MintChannelHashcash(
|
||||||
|
bits int,
|
||||||
|
channel string,
|
||||||
|
body []byte,
|
||||||
|
) string {
|
||||||
|
bodyHash := hashcash.BodyHash(body)
|
||||||
|
|
||||||
|
return hashcash.MintChannelStamp(
|
||||||
|
bits, channel, bodyHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// hasLeadingZeroBits checks if hash has at least numBits
|
// hasLeadingZeroBits checks if hash has at least numBits
|
||||||
// leading zero bits.
|
// leading zero bits.
|
||||||
func hasLeadingZeroBits(
|
func hasLeadingZeroBits(
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ type SessionRequest struct {
|
|||||||
|
|
||||||
// SessionResponse is the response from session creation.
|
// SessionResponse is the response from session creation.
|
||||||
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.
|
||||||
|
|||||||
@@ -16,80 +16,28 @@ var errNoPassword = errors.New(
|
|||||||
"account has no password set",
|
"account has no password set",
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterUser creates a session with a hashed password
|
// SetPassword sets a bcrypt-hashed password on a session,
|
||||||
// and returns session ID, client ID, and token.
|
// enabling multi-client login via POST /api/v1/login.
|
||||||
func (database *Database) RegisterUser(
|
func (database *Database) SetPassword(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nick, password string,
|
sessionID int64,
|
||||||
) (int64, int64, string, error) {
|
password string,
|
||||||
|
) error {
|
||||||
hash, err := bcrypt.GenerateFromPassword(
|
hash, err := bcrypt.GenerateFromPassword(
|
||||||
[]byte(password), bcryptCost,
|
[]byte(password), bcryptCost,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf(
|
return fmt.Errorf("hash password: %w", err)
|
||||||
"hash password: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionUUID := uuid.New().String()
|
_, err = database.conn.ExecContext(ctx,
|
||||||
clientUUID := uuid.New().String()
|
"UPDATE sessions SET password_hash = ? WHERE id = ?",
|
||||||
|
string(hash), sessionID)
|
||||||
token, err := generateToken()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", err
|
return fmt.Errorf("set password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
return nil
|
||||||
|
|
||||||
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, password_hash,
|
|
||||||
created_at, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
sessionUUID, nick, 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,
|
|
||||||
created_at, last_seen)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
clientUUID, sessionID, tokenHash, 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,63 +6,65 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegisterUser(t *testing.T) {
|
func TestSetPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
sessionID, _, _, err :=
|
||||||
database.RegisterUser(ctx, "reguser", "password123")
|
database.CreateSession(ctx, "passuser")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionID == 0 || clientID == 0 || token == "" {
|
err = database.SetPassword(
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sid != sessionID || cid != clientID {
|
|
||||||
t.Fatal("session/client id mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
if nick != "reguser" {
|
|
||||||
t.Fatalf("expected reguser, got %s", nick)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterUserDuplicateNick(t *testing.T) {
|
func TestSetPasswordThenWrongLogin(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
sessionID, _, _, err :=
|
||||||
database.RegisterUser(ctx, "dupnick", "password123")
|
database.CreateSession(ctx, "wrongpw")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = regSID
|
err = database.SetPassword(
|
||||||
_ = regCID
|
ctx, sessionID, "correctpass",
|
||||||
_ = regToken
|
)
|
||||||
|
if err != nil {
|
||||||
dupSID, dupCID, dupToken, dupErr :=
|
t.Fatal(err)
|
||||||
database.RegisterUser(ctx, "dupnick", "other12345")
|
|
||||||
if dupErr == nil {
|
|
||||||
t.Fatal("expected error for duplicate nick")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = dupSID
|
loginSID, loginCID, loginToken, loginErr :=
|
||||||
_ = dupCID
|
database.LoginUser(ctx, "wrongpw", "wrongpass12")
|
||||||
_ = dupToken
|
if loginErr == nil {
|
||||||
|
t.Fatal("expected error for wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = loginSID
|
||||||
|
_ = loginCID
|
||||||
|
_ = loginToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginUser(t *testing.T) {
|
func TestLoginUser(t *testing.T) {
|
||||||
@@ -71,23 +73,26 @@ func TestLoginUser(t *testing.T) {
|
|||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
regSID, regCID, regToken, err :=
|
sessionID, _, _, err :=
|
||||||
database.RegisterUser(ctx, "loginuser", "mypassword")
|
database.CreateSession(ctx, "loginuser")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = regSID
|
err = database.SetPassword(
|
||||||
_ = regCID
|
ctx, sessionID, "mypassword",
|
||||||
_ = regToken
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
loginSID, loginCID, 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 sessionID == 0 || clientID == 0 || token == "" {
|
if loginSID == 0 || loginCID == 0 || token == "" {
|
||||||
t.Fatal("expected valid ids and token")
|
t.Fatal("expected valid ids and token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,33 +108,6 @@ func TestLoginUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -1305,3 +1305,110 @@ func (database *Database) GetQueueEntryCount(
|
|||||||
|
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetChannelHashcashBits returns the hashcash difficulty
|
||||||
|
// requirement for a channel. Returns 0 if not set.
|
||||||
|
func (database *Database) GetChannelHashcashBits(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) (int, error) {
|
||||||
|
var bits int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
"SELECT hashcash_bits FROM channels WHERE id = ?",
|
||||||
|
channelID,
|
||||||
|
).Scan(&bits)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"get channel hashcash bits: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelHashcashBits sets the hashcash difficulty
|
||||||
|
// requirement for a channel. A value of 0 disables the
|
||||||
|
// requirement.
|
||||||
|
func (database *Database) SetChannelHashcashBits(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
bits int,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`UPDATE channels
|
||||||
|
SET hashcash_bits = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
bits, time.Now(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"set channel hashcash bits: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSpentHashcash stores a spent hashcash stamp hash
|
||||||
|
// for replay prevention.
|
||||||
|
func (database *Database) RecordSpentHashcash(
|
||||||
|
ctx context.Context,
|
||||||
|
stampHash string,
|
||||||
|
) error {
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`INSERT OR IGNORE INTO spent_hashcash
|
||||||
|
(stamp_hash, created_at)
|
||||||
|
VALUES (?, ?)`,
|
||||||
|
stampHash, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"record spent hashcash: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHashcashSpent checks whether a hashcash stamp hash
|
||||||
|
// has already been used.
|
||||||
|
func (database *Database) IsHashcashSpent(
|
||||||
|
ctx context.Context,
|
||||||
|
stampHash string,
|
||||||
|
) (bool, error) {
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM spent_hashcash
|
||||||
|
WHERE stamp_hash = ?`,
|
||||||
|
stampHash,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check spent hashcash: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneSpentHashcash deletes spent hashcash tokens older
|
||||||
|
// than the cutoff and returns the number of rows removed.
|
||||||
|
func (database *Database) PruneSpentHashcash(
|
||||||
|
ctx context.Context,
|
||||||
|
cutoff time.Time,
|
||||||
|
) (int64, error) {
|
||||||
|
res, err := database.conn.ExecContext(ctx,
|
||||||
|
"DELETE FROM spent_hashcash WHERE created_at < ?",
|
||||||
|
cutoff,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"prune spent hashcash: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, _ := res.RowsAffected()
|
||||||
|
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
topic TEXT NOT NULL DEFAULT '',
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
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,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -61,6 +62,14 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
|
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
||||||
|
|
||||||
|
-- Spent hashcash tokens for replay prevention (1-year TTL)
|
||||||
|
CREATE TABLE IF NOT EXISTS spent_hashcash (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
stamp_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
|
||||||
|
|
||||||
-- Per-client message queues for fan-out delivery
|
-- Per-client message queues for fan-out delivery
|
||||||
CREATE TABLE IF NOT EXISTS client_queues (
|
CREATE TABLE IF NOT EXISTS client_queues (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -11,10 +12,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errHashcashRequired = errors.New("hashcash required")
|
||||||
|
errHashcashReused = errors.New("hashcash reused")
|
||||||
|
)
|
||||||
|
|
||||||
var validNickRe = regexp.MustCompile(
|
var validNickRe = regexp.MustCompile(
|
||||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
||||||
)
|
)
|
||||||
@@ -29,6 +36,7 @@ const (
|
|||||||
defaultMaxBodySize = 4096
|
defaultMaxBodySize = 4096
|
||||||
defaultHistLimit = 50
|
defaultHistLimit = 50
|
||||||
maxHistLimit = 500
|
maxHistLimit = 500
|
||||||
|
authCookieName = "neoirc_auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (hdlr *Handlers) maxBodySize() int64 {
|
func (hdlr *Handlers) maxBodySize() int64 {
|
||||||
@@ -39,23 +47,18 @@ func (hdlr *Handlers) maxBodySize() int64 {
|
|||||||
return defaultMaxBodySize
|
return defaultMaxBodySize
|
||||||
}
|
}
|
||||||
|
|
||||||
// authSession extracts the session from the client token.
|
// authSession extracts the session from the auth cookie.
|
||||||
func (hdlr *Handlers) authSession(
|
func (hdlr *Handlers) authSession(
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) (int64, int64, string, error) {
|
) (int64, int64, string, error) {
|
||||||
auth := request.Header.Get("Authorization")
|
cookie, err := request.Cookie(authCookieName)
|
||||||
if !strings.HasPrefix(auth, "Bearer ") {
|
if err != nil || cookie.Value == "" {
|
||||||
return 0, 0, "", errUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(auth, "Bearer ")
|
|
||||||
if token == "" {
|
|
||||||
return 0, 0, "", errUnauthorized
|
return 0, 0, "", errUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, clientID, nick, err :=
|
sessionID, clientID, nick, err :=
|
||||||
hdlr.params.Database.GetSessionByToken(
|
hdlr.params.Database.GetSessionByToken(
|
||||||
request.Context(), token,
|
request.Context(), cookie.Value,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, "", fmt.Errorf("auth: %w", err)
|
return 0, 0, "", fmt.Errorf("auth: %w", err)
|
||||||
@@ -64,6 +67,46 @@ func (hdlr *Handlers) authSession(
|
|||||||
return sessionID, clientID, nick, nil
|
return sessionID, clientID, nick, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setAuthCookie sets the authentication cookie on the
|
||||||
|
// response.
|
||||||
|
func (hdlr *Handlers) setAuthCookie(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
token string,
|
||||||
|
) {
|
||||||
|
secure := request.TLS != nil ||
|
||||||
|
request.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
|
|
||||||
|
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
||||||
|
Name: authCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAuthCookie removes the authentication cookie from
|
||||||
|
// the client.
|
||||||
|
func (hdlr *Handlers) clearAuthCookie(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
secure := request.TLS != nil ||
|
||||||
|
request.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
|
|
||||||
|
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
|
||||||
|
Name: authCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) requireAuth(
|
func (hdlr *Handlers) requireAuth(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -88,10 +131,11 @@ func (hdlr *Handlers) fanOut(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
command, from, target string,
|
command, from, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
||||||
request.Context(), command, from, target, nil, body, nil,
|
request.Context(), command, from, target, nil, body, meta,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert message: %w", err)
|
return "", fmt.Errorf("insert message: %w", err)
|
||||||
@@ -117,10 +161,11 @@ func (hdlr *Handlers) fanOutSilent(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
command, from, target string,
|
command, from, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := hdlr.fanOut(
|
_, err := hdlr.fanOut(
|
||||||
request, command, from, target, body, sessionIDs,
|
request, command, from, target, body, meta, sessionIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -217,10 +262,11 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
hdlr.deliverMOTD(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": payload.Nick,
|
"nick": payload.Nick,
|
||||||
"token": token,
|
|
||||||
}, http.StatusCreated)
|
}, http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +340,7 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
[]string{
|
[]string{
|
||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
"CHANMODES=,,," + "imnst",
|
"CHANMODES=,,H," + "imnst",
|
||||||
"NETWORK=neoirc",
|
"NETWORK=neoirc",
|
||||||
"CASEMAPPING=ascii",
|
"CASEMAPPING=ascii",
|
||||||
},
|
},
|
||||||
@@ -825,7 +871,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
payload.Command, payload.To,
|
payload.Command, payload.To,
|
||||||
payload.Body, bodyLines,
|
payload.Body, payload.Meta, bodyLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,6 +882,7 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
@@ -848,7 +895,7 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, bodyLines,
|
command, target, body, meta, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdJoin:
|
case irc.CmdJoin:
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
@@ -865,6 +912,11 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick, bodyLines,
|
||||||
)
|
)
|
||||||
|
case irc.CmdPass:
|
||||||
|
hdlr.handlePass(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
case irc.CmdTopic:
|
case irc.CmdTopic:
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -949,6 +1001,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
if target == "" {
|
if target == "" {
|
||||||
@@ -986,7 +1039,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
hdlr.handleChannelMsg(
|
hdlr.handleChannelMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body,
|
command, target, body, meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -995,7 +1048,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
hdlr.handleDirectMsg(
|
hdlr.handleDirectMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body,
|
command, target, body, meta,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1026,6 +1079,7 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
) {
|
) {
|
||||||
chID, err := hdlr.params.Database.GetChannelByName(
|
chID, err := hdlr.params.Database.GetChannelByName(
|
||||||
request.Context(), target,
|
request.Context(), target,
|
||||||
@@ -1066,9 +1120,172 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.sendChannelMsg(
|
hashcashErr := hdlr.validateChannelHashcash(
|
||||||
writer, request, command, nick, target, body, chID,
|
request, clientID, sessionID,
|
||||||
|
writer, nick, target, body, meta, chID,
|
||||||
)
|
)
|
||||||
|
if hashcashErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.sendChannelMsg(
|
||||||
|
writer, request, command, nick, target,
|
||||||
|
body, meta, chID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateChannelHashcash checks whether the channel
|
||||||
|
// requires hashcash proof-of-work for messages and
|
||||||
|
// validates the stamp from the message meta field.
|
||||||
|
// Returns nil on success or if the channel has no
|
||||||
|
// hashcash requirement. On failure, it sends the
|
||||||
|
// appropriate IRC error and returns a non-nil error.
|
||||||
|
func (hdlr *Handlers) validateChannelHashcash(
|
||||||
|
request *http.Request,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
nick, target string,
|
||||||
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
|
chID int64,
|
||||||
|
) error {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
if bitsErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"get channel hashcash bits", "error", bitsErr,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bits <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := hdlr.extractHashcashFromMeta(meta)
|
||||||
|
if stamp == "" {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrCannotSendToChan, nick, []string{target},
|
||||||
|
"Channel requires hashcash proof-of-work",
|
||||||
|
)
|
||||||
|
|
||||||
|
return errHashcashRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
return hdlr.verifyChannelStamp(
|
||||||
|
request, writer,
|
||||||
|
clientID, sessionID,
|
||||||
|
nick, target, body, stamp, bits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyChannelStamp validates a channel hashcash stamp
|
||||||
|
// and checks for replay attacks.
|
||||||
|
func (hdlr *Handlers) verifyChannelStamp(
|
||||||
|
request *http.Request,
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
clientID, sessionID int64,
|
||||||
|
nick, target string,
|
||||||
|
body json.RawMessage,
|
||||||
|
stamp string,
|
||||||
|
bits int,
|
||||||
|
) error {
|
||||||
|
ctx := request.Context()
|
||||||
|
bodyHashStr := hashcash.BodyHash(body)
|
||||||
|
|
||||||
|
valErr := hdlr.channelHashcash.ValidateStamp(
|
||||||
|
stamp, bits, target, bodyHashStr,
|
||||||
|
)
|
||||||
|
if valErr != nil {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrCannotSendToChan, nick, []string{target},
|
||||||
|
"Invalid hashcash: "+valErr.Error(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return fmt.Errorf("channel hashcash: %w", valErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
stampKey := hashcash.StampHash(stamp)
|
||||||
|
|
||||||
|
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
|
||||||
|
ctx, stampKey,
|
||||||
|
)
|
||||||
|
if spentErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"check spent hashcash", "error", spentErr,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return fmt.Errorf("check spent hashcash: %w", spentErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if spent {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrCannotSendToChan, nick, []string{target},
|
||||||
|
"Hashcash stamp already used",
|
||||||
|
)
|
||||||
|
|
||||||
|
return errHashcashReused
|
||||||
|
}
|
||||||
|
|
||||||
|
recordErr := hdlr.params.Database.RecordSpentHashcash(
|
||||||
|
ctx, stampKey,
|
||||||
|
)
|
||||||
|
if recordErr != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"record spent hashcash", "error", recordErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHashcashFromMeta parses the meta JSON and
|
||||||
|
// returns the hashcash stamp string, or empty string
|
||||||
|
// if not present.
|
||||||
|
func (hdlr *Handlers) extractHashcashFromMeta(
|
||||||
|
meta json.RawMessage,
|
||||||
|
) string {
|
||||||
|
if len(meta) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var metaMap map[string]json.RawMessage
|
||||||
|
|
||||||
|
err := json.Unmarshal(meta, &metaMap)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := metaMap["hashcash"]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var stamp string
|
||||||
|
|
||||||
|
err = json.Unmarshal(raw, &stamp)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return stamp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) sendChannelMsg(
|
func (hdlr *Handlers) sendChannelMsg(
|
||||||
@@ -1076,6 +1293,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
command, nick, target string,
|
command, nick, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
chID int64,
|
chID int64,
|
||||||
) {
|
) {
|
||||||
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
|
||||||
@@ -1095,7 +1313,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgUUID, err := hdlr.fanOut(
|
msgUUID, err := hdlr.fanOut(
|
||||||
request, command, nick, target, body, memberIDs,
|
request, command, nick, target, body, meta, memberIDs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error("send message failed", "error", err)
|
hdlr.log.Error("send message failed", "error", err)
|
||||||
@@ -1119,6 +1337,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
) {
|
) {
|
||||||
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
targetSID, err := hdlr.params.Database.GetSessionByNick(
|
||||||
request.Context(), target,
|
request.Context(), target,
|
||||||
@@ -1143,7 +1362,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgUUID, err := hdlr.fanOut(
|
msgUUID, err := hdlr.fanOut(
|
||||||
request, command, nick, target, body, recipients,
|
request, command, nick, target, body, meta, recipients,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error("send dm failed", "error", err)
|
hdlr.log.Error("send dm failed", "error", err)
|
||||||
@@ -1254,7 +1473,7 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.deliverJoinNumerics(
|
hdlr.deliverJoinNumerics(
|
||||||
@@ -1424,7 +1643,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
err = hdlr.params.Database.PartChannel(
|
err = hdlr.params.Database.PartChannel(
|
||||||
@@ -1704,7 +1923,7 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
@@ -1828,6 +2047,8 @@ func (hdlr *Handlers) handleQuit(
|
|||||||
request.Context(), sessionID,
|
request.Context(), sessionID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hdlr.clearAuthCookie(writer, request)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"status": "quit"},
|
map[string]string{"status": "quit"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1867,11 +2088,10 @@ func (hdlr *Handlers) handleMode(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = bodyLines
|
|
||||||
|
|
||||||
hdlr.handleChannelMode(
|
hdlr.handleChannelMode(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, channel,
|
sessionID, clientID, nick, channel,
|
||||||
|
bodyLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1880,6 +2100,7 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
request *http.Request,
|
request *http.Request,
|
||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
nick, channel string,
|
nick, channel string,
|
||||||
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
@@ -1896,10 +2117,47 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines := bodyLines()
|
||||||
|
if len(lines) > 0 {
|
||||||
|
hdlr.applyChannelMode(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
|
channel, chID, lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.queryChannelMode(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, channel, chID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryChannelMode sends RPL_CHANNELMODEIS and
|
||||||
|
// RPL_CREATIONTIME for a channel. Includes +H if
|
||||||
|
// the channel has a hashcash requirement.
|
||||||
|
func (hdlr *Handlers) queryChannelMode(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
modeStr := "+n"
|
||||||
|
|
||||||
|
bits, bitsErr := hdlr.params.Database.
|
||||||
|
GetChannelHashcashBits(ctx, chID)
|
||||||
|
if bitsErr == nil && bits > 0 {
|
||||||
|
modeStr = fmt.Sprintf("+nH %d", bits)
|
||||||
|
}
|
||||||
|
|
||||||
// 324 RPL_CHANNELMODEIS
|
// 324 RPL_CHANNELMODEIS
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplChannelModeIs, nick,
|
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||||
[]string{channel, "+n"}, "",
|
[]string{channel, modeStr}, "",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 329 RPL_CREATIONTIME
|
// 329 RPL_CREATIONTIME
|
||||||
@@ -1924,6 +2182,156 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyChannelMode handles setting channel modes.
|
||||||
|
// Currently supports +H/-H for hashcash bits.
|
||||||
|
func (hdlr *Handlers) applyChannelMode(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
modeArgs []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
modeStr := modeArgs[0]
|
||||||
|
|
||||||
|
switch modeStr {
|
||||||
|
case "+H":
|
||||||
|
hdlr.setHashcashMode(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
|
channel, chID, modeArgs,
|
||||||
|
)
|
||||||
|
case "-H":
|
||||||
|
hdlr.clearHashcashMode(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick,
|
||||||
|
channel, chID,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
// Unknown or unsupported mode change.
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.ErrUnknownMode, nick,
|
||||||
|
[]string{modeStr},
|
||||||
|
"is unknown mode char to me",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "error"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// minHashcashBits is the minimum allowed hashcash
|
||||||
|
// difficulty for channels.
|
||||||
|
minHashcashBits = 1
|
||||||
|
// maxHashcashBits is the maximum allowed hashcash
|
||||||
|
// difficulty for channels.
|
||||||
|
maxHashcashBits = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
// setHashcashMode handles MODE #channel +H <bits>.
|
||||||
|
func (hdlr *Handlers) setHashcashMode(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
modeArgs []string,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
|
||||||
|
"Not enough parameters (+H requires bits)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bits, err := strconv.Atoi(modeArgs[1])
|
||||||
|
if err != nil || bits < minHashcashBits ||
|
||||||
|
bits > maxHashcashBits {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrUnknownMode, nick, []string{"+H"},
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Invalid hashcash bits (must be %d-%d)",
|
||||||
|
minHashcashBits, maxHashcashBits,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = hdlr.params.Database.SetChannelHashcashBits(
|
||||||
|
ctx, chID, bits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"set channel hashcash bits", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||||
|
[]string{
|
||||||
|
channel,
|
||||||
|
fmt.Sprintf("+H %d", bits),
|
||||||
|
}, "",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearHashcashMode handles MODE #channel -H.
|
||||||
|
func (hdlr *Handlers) clearHashcashMode(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
) {
|
||||||
|
ctx := request.Context()
|
||||||
|
|
||||||
|
err := hdlr.params.Database.SetChannelHashcashBits(
|
||||||
|
ctx, chID, 0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"clear channel hashcash bits", "error", err,
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"internal error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.enqueueNumeric(
|
||||||
|
ctx, clientID, irc.RplChannelModeIs, nick,
|
||||||
|
[]string{channel, "+n"}, "",
|
||||||
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
// handleNames sends NAMES reply for a channel.
|
// handleNames sends NAMES reply for a channel.
|
||||||
func (hdlr *Handlers) handleNames(
|
func (hdlr *Handlers) handleNames(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
@@ -2443,6 +2851,8 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.clearAuthCookie(writer, request)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"status": "ok"},
|
map[string]string{"status": "ok"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
||||||
@@ -32,15 +33,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
commandKey = "command"
|
commandKey = "command"
|
||||||
bodyKey = "body"
|
bodyKey = "body"
|
||||||
toKey = "to"
|
toKey = "to"
|
||||||
statusKey = "status"
|
statusKey = "status"
|
||||||
privmsgCmd = "PRIVMSG"
|
privmsgCmd = "PRIVMSG"
|
||||||
joinCmd = "JOIN"
|
joinCmd = "JOIN"
|
||||||
apiMessages = "/api/v1/messages"
|
apiMessages = "/api/v1/messages"
|
||||||
apiSession = "/api/v1/session"
|
apiSession = "/api/v1/session"
|
||||||
apiState = "/api/v1/state"
|
apiState = "/api/v1/state"
|
||||||
|
authCookieName = "neoirc_auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testServer wraps a test HTTP server with helpers.
|
// testServer wraps a test HTTP server with helpers.
|
||||||
@@ -260,7 +262,7 @@ func doRequest(
|
|||||||
|
|
||||||
func doRequestAuth(
|
func doRequestAuth(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
method, url, token string,
|
method, url, cookie string,
|
||||||
body io.Reader,
|
body io.Reader,
|
||||||
) (*http.Response, error) {
|
) (*http.Response, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -278,10 +280,11 @@ func doRequestAuth(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if token != "" {
|
if cookie != "" {
|
||||||
request.Header.Set(
|
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
|
||||||
"Authorization", "Bearer "+token,
|
Name: authCookieName,
|
||||||
)
|
Value: cookie,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(request)
|
resp, err := http.DefaultClient.Do(request)
|
||||||
@@ -324,17 +327,19 @@ func (tserver *testServer) createSession(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
// Drain the body.
|
||||||
ID int64 `json:"id"`
|
_, _ = io.ReadAll(resp.Body)
|
||||||
Token string `json:"token"`
|
|
||||||
|
// Extract auth cookie from response.
|
||||||
|
for _, cookie := range resp.Cookies() {
|
||||||
|
if cookie.Name == authCookieName {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
decErr := json.NewDecoder(resp.Body).Decode(&result)
|
tserver.t.Fatal("no auth cookie in response")
|
||||||
if decErr != nil {
|
|
||||||
tserver.t.Fatalf("decode session: %v", decErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Token
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tserver *testServer) sendCommand(
|
func (tserver *testServer) sendCommand(
|
||||||
@@ -491,10 +496,10 @@ func findNumeric(
|
|||||||
|
|
||||||
func TestCreateSessionValid(t *testing.T) {
|
func TestCreateSessionValid(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("alice")
|
cookie := tserver.createSession("alice")
|
||||||
|
|
||||||
if token == "" {
|
if cookie == "" {
|
||||||
t.Fatal("expected token")
|
t.Fatal("expected auth cookie")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,7 +621,7 @@ func TestCreateSessionMalformed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthNoHeader(t *testing.T) {
|
func TestAuthNoCookie(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
status, _ := tserver.getState("")
|
status, _ := tserver.getState("")
|
||||||
@@ -625,11 +630,11 @@ func TestAuthNoHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthBadToken(t *testing.T) {
|
func TestAuthBadCookie(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
status, _ := tserver.getState(
|
status, _ := tserver.getState(
|
||||||
"invalid-token-12345",
|
"invalid-cookie-12345",
|
||||||
)
|
)
|
||||||
if status != http.StatusUnauthorized {
|
if status != http.StatusUnauthorized {
|
||||||
t.Fatalf("expected 401, got %d", status)
|
t.Fatalf("expected 401, got %d", status)
|
||||||
@@ -1826,90 +1831,6 @@ func assertFieldGTE(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterValid(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
body, err := json.Marshal(map[string]string{
|
|
||||||
"nick": "reguser", "password": "password123",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := doRequest(
|
|
||||||
t,
|
|
||||||
http.MethodPost,
|
|
||||||
tserver.url("/api/v1/register"),
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 201, got %d: %s",
|
|
||||||
resp.StatusCode, respBody,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
|
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if result["token"] == nil || result["token"] == "" {
|
|
||||||
t.Fatal("expected token in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result["nick"] != "reguser" {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected reguser, got %v", result["nick"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterDuplicate(t *testing.T) {
|
|
||||||
tserver := newTestServer(t)
|
|
||||||
|
|
||||||
body, err := json.Marshal(map[string]string{
|
|
||||||
"nick": "dupuser", "password": "password123",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := doRequest(
|
|
||||||
t,
|
|
||||||
http.MethodPost,
|
|
||||||
tserver.url("/api/v1/register"),
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
resp2, err := doRequest(
|
|
||||||
t,
|
|
||||||
http.MethodPost,
|
|
||||||
tserver.url("/api/v1/register"),
|
|
||||||
bytes.NewReader(body),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp2.Body.Close() }()
|
|
||||||
|
|
||||||
if resp2.StatusCode != http.StatusConflict {
|
|
||||||
t.Fatalf("expected 409, got %d", resp2.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func postJSONExpectStatus(
|
func postJSONExpectStatus(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
tserver *testServer,
|
tserver *testServer,
|
||||||
@@ -1944,36 +1865,102 @@ func postJSONExpectStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterShortPassword(t *testing.T) {
|
func TestPassCommand(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("passuser")
|
||||||
|
|
||||||
postJSONExpectStatus(
|
// Drain initial messages.
|
||||||
t, tserver, "/api/v1/register",
|
_, _ = tserver.pollMessages(token, 0)
|
||||||
map[string]string{
|
|
||||||
"nick": "shortpw", "password": "short",
|
// Set password via PASS command.
|
||||||
|
status, result := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: "PASS",
|
||||||
|
bodyKey: []string{"s3cure_pass"},
|
||||||
},
|
},
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 200, got %d: %v", status, result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[statusKey] != "ok" {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ok, got %v", result[statusKey],
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterInvalidNick(t *testing.T) {
|
func TestPassCommandShortPassword(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("shortpw")
|
||||||
|
|
||||||
postJSONExpectStatus(
|
// Drain initial messages.
|
||||||
t, tserver, "/api/v1/register",
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
map[string]string{
|
|
||||||
"nick": "bad nick!",
|
// Try short password — should fail.
|
||||||
"password": "password123",
|
status, _ := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: "PASS",
|
||||||
|
bodyKey: []string{"short"},
|
||||||
},
|
},
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassCommandEmpty(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("emptypw")
|
||||||
|
|
||||||
|
// Drain initial messages.
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Try empty password — should fail.
|
||||||
|
status, _ := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{commandKey: "PASS"},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginValid(t *testing.T) {
|
func TestLoginValid(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
// Register first.
|
// Create session and set password via PASS command.
|
||||||
regBody, err := json.Marshal(map[string]string{
|
token := tserver.createSession("loginuser")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "PASS",
|
||||||
|
bodyKey: []string{"password123"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Login with nick + password.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
"nick": "loginuser", "password": "password123",
|
"nick": "loginuser", "password": "password123",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1981,26 +1968,6 @@ func TestLoginValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp, err := doRequest(
|
resp, err := doRequest(
|
||||||
t,
|
|
||||||
http.MethodPost,
|
|
||||||
tserver.url("/api/v1/register"),
|
|
||||||
bytes.NewReader(regBody),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
// Login.
|
|
||||||
loginBody, err := json.Marshal(map[string]string{
|
|
||||||
"nick": "loginuser", "password": "password123",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp2, err := doRequest(
|
|
||||||
t,
|
t,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
tserver.url("/api/v1/login"),
|
tserver.url("/api/v1/login"),
|
||||||
@@ -2010,31 +1977,33 @@ func TestLoginValid(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = resp2.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp2.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp2.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"expected 200, got %d: %s",
|
"expected 200, got %d: %s",
|
||||||
resp2.StatusCode, respBody,
|
resp.StatusCode, respBody,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]any
|
// Extract auth cookie from login response.
|
||||||
|
var loginCookie string
|
||||||
|
|
||||||
_ = json.NewDecoder(resp2.Body).Decode(&result)
|
for _, cookie := range resp.Cookies() {
|
||||||
|
if cookie.Name == authCookieName {
|
||||||
|
loginCookie = cookie.Value
|
||||||
|
|
||||||
if result["token"] == nil || result["token"] == "" {
|
break
|
||||||
t.Fatal("expected token in response")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token works.
|
if loginCookie == "" {
|
||||||
token, ok := result["token"].(string)
|
t.Fatal("expected auth cookie from login")
|
||||||
if !ok {
|
|
||||||
t.Fatal("token not a string")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
status, state := tserver.getState(token)
|
// Verify login cookie works for auth.
|
||||||
|
status, state := tserver.getState(loginCookie)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d", status)
|
t.Fatalf("expected 200, got %d", status)
|
||||||
}
|
}
|
||||||
@@ -2050,49 +2019,22 @@ func TestLoginValid(t *testing.T) {
|
|||||||
func TestLoginWrongPassword(t *testing.T) {
|
func TestLoginWrongPassword(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
regBody, err := json.Marshal(map[string]string{
|
// Create session and set password via PASS command.
|
||||||
"nick": "wrongpwuser", "password": "correctpass1",
|
token := tserver.createSession("wrongpwuser")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: "PASS",
|
||||||
|
bodyKey: []string{"correctpass1"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := doRequest(
|
postJSONExpectStatus(
|
||||||
t,
|
t, tserver, "/api/v1/login",
|
||||||
http.MethodPost,
|
map[string]string{
|
||||||
tserver.url("/api/v1/register"),
|
"nick": "wrongpwuser",
|
||||||
bytes.NewReader(regBody),
|
"password": "wrongpass12",
|
||||||
|
},
|
||||||
|
http.StatusUnauthorized,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
|
|
||||||
loginBody, err := json.Marshal(map[string]string{
|
|
||||||
"nick": "wrongpwuser", "password": "wrongpass12",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp2, err := doRequest(
|
|
||||||
t,
|
|
||||||
http.MethodPost,
|
|
||||||
tserver.url("/api/v1/login"),
|
|
||||||
bytes.NewReader(loginBody),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = resp2.Body.Close() }()
|
|
||||||
|
|
||||||
if resp2.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected 401, got %d", resp2.StatusCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginNonexistentUser(t *testing.T) {
|
func TestLoginNonexistentUser(t *testing.T) {
|
||||||
@@ -2108,13 +2050,74 @@ func TestLoginNonexistentUser(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionCookie(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
body, err := json.Marshal(
|
||||||
|
map[string]string{"nick": "cookietest"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url(apiSession),
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 201, got %d", resp.StatusCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Set-Cookie header.
|
||||||
|
var authCookie *http.Cookie
|
||||||
|
|
||||||
|
for _, cookie := range resp.Cookies() {
|
||||||
|
if cookie.Name == authCookieName {
|
||||||
|
authCookie = cookie
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if authCookie == nil {
|
||||||
|
t.Fatal("expected neoirc_auth cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authCookie.HttpOnly {
|
||||||
|
t.Fatal("cookie should be HttpOnly")
|
||||||
|
}
|
||||||
|
|
||||||
|
if authCookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Fatal("cookie should be SameSite=Strict")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON body does NOT contain token.
|
||||||
|
var result map[string]any
|
||||||
|
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
|
||||||
|
if _, hasToken := result["token"]; hasToken {
|
||||||
|
t.Fatal("JSON body should not contain token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionStillWorks(t *testing.T) {
|
func TestSessionStillWorks(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
// Verify anonymous session creation still works.
|
// Verify anonymous session creation still works.
|
||||||
token := tserver.createSession("anon_user")
|
token := tserver.createSession("anon_user")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
t.Fatal("expected token for anonymous session")
|
t.Fatal("expected cookie for anonymous session")
|
||||||
}
|
}
|
||||||
|
|
||||||
status, state := tserver.getState(token)
|
status, state := tserver.getState(token)
|
||||||
@@ -2157,3 +2160,397 @@ func TestNickBroadcastToChannels(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Channel Hashcash Tests ---
|
||||||
|
|
||||||
|
const (
|
||||||
|
metaKey = "meta"
|
||||||
|
modeCmd = "MODE"
|
||||||
|
hashcashKey = "hashcash"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mintTestChannelHashcash(
|
||||||
|
tb testing.TB,
|
||||||
|
bits int,
|
||||||
|
channel string,
|
||||||
|
body json.RawMessage,
|
||||||
|
) string {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
bodyHash := hashcash.BodyHash(body)
|
||||||
|
|
||||||
|
return hashcash.MintChannelStamp(bits, channel, bodyHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashSetMode(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcmode_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hctest",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Set hashcash bits to 2 via MODE +H.
|
||||||
|
status, _ := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hctest",
|
||||||
|
bodyKey: []string{"+H", "2"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Should get RPL_CHANNELMODEIS (324) confirming +H.
|
||||||
|
if !findNumeric(msgs, "324") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashQueryMode(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcquery_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hcquery",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set hashcash bits.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcquery",
|
||||||
|
bodyKey: []string{"+H", "5"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Query mode — should show +nH.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcquery",
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
code, ok := msg["code"].(float64)
|
||||||
|
if ok && int(code) == 324 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected RPL_CHANNELMODEIS (324), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashClearMode(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcclear_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hcclear",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set hashcash bits.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcclear",
|
||||||
|
bodyKey: []string{"+H", "5"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear hashcash bits.
|
||||||
|
status, _ := tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcclear",
|
||||||
|
bodyKey: []string{"-H"},
|
||||||
|
})
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now message should succeed without hashcash.
|
||||||
|
status, result := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: "#hcclear",
|
||||||
|
bodyKey: []string{"test message"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 200, got %d: %v", status, result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashRejectNoStamp(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcreject_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hcreject",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set hashcash requirement.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcreject",
|
||||||
|
bodyKey: []string{"+H", "2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Send message without hashcash — should fail.
|
||||||
|
status, _ := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: "#hcreject",
|
||||||
|
bodyKey: []string{"spam message"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||||
|
if !findNumeric(msgs, "404") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcaccept_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hcaccept",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set hashcash requirement (2 bits = fast to mint).
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcaccept",
|
||||||
|
bodyKey: []string{"+H", "2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Mint a valid hashcash stamp.
|
||||||
|
msgBody, marshalErr := json.Marshal(
|
||||||
|
[]string{"hello world"},
|
||||||
|
)
|
||||||
|
if marshalErr != nil {
|
||||||
|
t.Fatal(marshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := mintTestChannelHashcash(
|
||||||
|
t, 2, "#hcaccept", msgBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send message with valid hashcash.
|
||||||
|
status, result := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: "#hcaccept",
|
||||||
|
bodyKey: []string{"hello world"},
|
||||||
|
metaKey: map[string]any{
|
||||||
|
hashcashKey: stamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 200, got %d: %v", status, result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result["id"] == nil || result["id"] == "" {
|
||||||
|
t.Fatal("expected message id for valid hashcash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the message was delivered.
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
|
||||||
|
t.Fatalf(
|
||||||
|
"message not received: %v", msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcreplay_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hcreplay",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set hashcash requirement.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcreplay",
|
||||||
|
bodyKey: []string{"+H", "2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
_, _ = tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Mint and send once — should succeed.
|
||||||
|
msgBody, marshalErr := json.Marshal(
|
||||||
|
[]string{"unique msg"},
|
||||||
|
)
|
||||||
|
if marshalErr != nil {
|
||||||
|
t.Fatal(marshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := mintTestChannelHashcash(
|
||||||
|
t, 2, "#hcreplay", msgBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
status, _ := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: "#hcreplay",
|
||||||
|
bodyKey: []string{"unique msg"},
|
||||||
|
metaKey: map[string]any{
|
||||||
|
hashcashKey: stamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Replay the same stamp — should fail.
|
||||||
|
status, _ = tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: "#hcreplay",
|
||||||
|
bodyKey: []string{"unique msg"},
|
||||||
|
metaKey: map[string]any{
|
||||||
|
hashcashKey: stamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
// Should get ERR_CANNOTSENDTOCHAN (404).
|
||||||
|
if !findNumeric(msgs, "404") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected replay rejection (404), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcnone_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#nohashcash",
|
||||||
|
})
|
||||||
|
|
||||||
|
// No hashcash set — message should work.
|
||||||
|
status, result := tserver.sendCommand(
|
||||||
|
token,
|
||||||
|
map[string]any{
|
||||||
|
commandKey: privmsgCmd,
|
||||||
|
toKey: "#nohashcash",
|
||||||
|
bodyKey: []string{"free message"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 200, got %d: %v", status, result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result["id"] == nil || result["id"] == "" {
|
||||||
|
t.Fatal("expected message id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcbits_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hcbits",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Try to set bits to 0 — should fail.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcbits",
|
||||||
|
bodyKey: []string{"+H", "0"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "472") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_UNKNOWNMODE (472), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("hcnoarg_user")
|
||||||
|
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#hcnoarg",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
|
|
||||||
|
// Try to set +H without bits argument.
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: modeCmd,
|
||||||
|
toKey: "#hcnoarg",
|
||||||
|
bodyKey: []string{"+H"},
|
||||||
|
})
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,120 +5,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(payload.Password) < minPasswordLength {
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"password must be at least 8 characters",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
|
||||||
hdlr.params.Database.RegisterUser(
|
|
||||||
request.Context(),
|
|
||||||
payload.Nick,
|
|
||||||
payload.Password,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
hdlr.handleRegisterError(
|
|
||||||
writer, request, err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.stats.IncrSessions()
|
|
||||||
hdlr.stats.IncrConnections()
|
|
||||||
|
|
||||||
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
|
||||||
"id": sessionID,
|
|
||||||
"nick": payload.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(
|
||||||
@@ -195,9 +86,66 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
request, clientID, sessionID, payload.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": payload.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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,15 +36,21 @@ type Params struct {
|
|||||||
|
|
||||||
const defaultIdleTimeout = 30 * 24 * time.Hour
|
const defaultIdleTimeout = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// spentHashcashTTL is how long spent hashcash tokens are
|
||||||
|
// retained for replay prevention. Per issue requirements,
|
||||||
|
// this is 1 year.
|
||||||
|
const spentHashcashTTL = 365 * 24 * time.Hour
|
||||||
|
|
||||||
// Handlers manages HTTP request handling.
|
// Handlers manages HTTP request handling.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
stats *stats.Tracker
|
channelHashcash *hashcash.ChannelValidator
|
||||||
cancelCleanup context.CancelFunc
|
stats *stats.Tracker
|
||||||
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -58,12 +64,13 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
stats: params.Stats,
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -285,4 +292,20 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune spent hashcash tokens older than 1 year.
|
||||||
|
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
|
||||||
|
|
||||||
|
pruned, err := hdlr.params.Database.
|
||||||
|
PruneSpentHashcash(ctx, hashcashCutoff)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.log.Error(
|
||||||
|
"spent hashcash pruning failed", "error", err,
|
||||||
|
)
|
||||||
|
} else if pruned > 0 {
|
||||||
|
hdlr.log.Info(
|
||||||
|
"pruned spent hashcash tokens",
|
||||||
|
"deleted", pruned,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
186
internal/hashcash/channel.go
Normal file
186
internal/hashcash/channel.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package hashcash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errBodyHashMismatch = errors.New(
|
||||||
|
"body hash mismatch",
|
||||||
|
)
|
||||||
|
errBodyHashMissing = errors.New(
|
||||||
|
"body hash missing",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelValidator checks hashcash stamps for
|
||||||
|
// per-channel PRIVMSG validation. It verifies that
|
||||||
|
// stamps are bound to a specific channel and message
|
||||||
|
// body. Replay prevention is handled externally via
|
||||||
|
// the database spent_hashcash table for persistence
|
||||||
|
// across server restarts (1-year TTL).
|
||||||
|
type ChannelValidator struct{}
|
||||||
|
|
||||||
|
// NewChannelValidator creates a ChannelValidator.
|
||||||
|
func NewChannelValidator() *ChannelValidator {
|
||||||
|
return &ChannelValidator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyHash computes the hex-encoded SHA-256 hash of a
|
||||||
|
// message body for use in hashcash stamp validation.
|
||||||
|
func BodyHash(body []byte) string {
|
||||||
|
hash := sha256.Sum256(body)
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStamp checks a channel hashcash stamp. It
|
||||||
|
// verifies the stamp format, difficulty, date, channel
|
||||||
|
// binding, body hash binding, and proof-of-work. Replay
|
||||||
|
// detection is NOT performed here — callers must check
|
||||||
|
// the spent_hashcash table separately.
|
||||||
|
//
|
||||||
|
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||||
|
func (cv *ChannelValidator) ValidateStamp(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
channel string,
|
||||||
|
bodyHash string,
|
||||||
|
) error {
|
||||||
|
if requiredBits <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(stamp, ":")
|
||||||
|
if len(parts) != stampFields {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: expected %d, got %d",
|
||||||
|
errInvalidFields, stampFields, len(parts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := parts[0]
|
||||||
|
bitsStr := parts[1]
|
||||||
|
dateStr := parts[2]
|
||||||
|
resource := parts[3]
|
||||||
|
stampBodyHash := parts[4]
|
||||||
|
|
||||||
|
headerErr := validateChannelHeader(
|
||||||
|
version, bitsStr, resource,
|
||||||
|
requiredBits, channel,
|
||||||
|
)
|
||||||
|
if headerErr != nil {
|
||||||
|
return headerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
stampTime, parseErr := parseStampDate(dateStr)
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
timeErr := validateTime(stampTime)
|
||||||
|
if timeErr != nil {
|
||||||
|
return timeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyErr := validateBodyHash(
|
||||||
|
stampBodyHash, bodyHash,
|
||||||
|
)
|
||||||
|
if bodyErr != nil {
|
||||||
|
return bodyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateProof(stamp, requiredBits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StampHash returns a deterministic hash of a stamp
|
||||||
|
// string for use as a spent-token key.
|
||||||
|
func StampHash(stamp string) string {
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateChannelHeader(
|
||||||
|
version, bitsStr, resource string,
|
||||||
|
requiredBits int,
|
||||||
|
channel string,
|
||||||
|
) error {
|
||||||
|
if version != stampVersion {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s", errBadVersion, version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedBits, err := strconv.Atoi(bitsStr)
|
||||||
|
if err != nil || claimedBits < requiredBits {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: need %d bits",
|
||||||
|
errInsufficientBits, requiredBits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource != channel {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
errWrongResource, resource, channel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBodyHash(
|
||||||
|
stampBodyHash, expectedBodyHash string,
|
||||||
|
) error {
|
||||||
|
if stampBodyHash == "" {
|
||||||
|
return errBodyHashMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if stampBodyHash != expectedBodyHash {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: got %q, want %q",
|
||||||
|
errBodyHashMismatch,
|
||||||
|
stampBodyHash, expectedBodyHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MintChannelStamp computes a channel hashcash stamp
|
||||||
|
// with the given difficulty, channel name, and body hash.
|
||||||
|
// This is intended for clients to generate stamps before
|
||||||
|
// sending PRIVMSG to hashcash-protected channels.
|
||||||
|
//
|
||||||
|
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
|
||||||
|
func MintChannelStamp(
|
||||||
|
bits int,
|
||||||
|
channel string,
|
||||||
|
bodyHash string,
|
||||||
|
) string {
|
||||||
|
date := time.Now().UTC().Format(dateFormatShort)
|
||||||
|
prefix := fmt.Sprintf(
|
||||||
|
"1:%d:%s:%s:%s:",
|
||||||
|
bits, date, channel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
counter := uint64(0)
|
||||||
|
|
||||||
|
for {
|
||||||
|
stamp := prefix + strconv.FormatUint(counter, 16)
|
||||||
|
hash := sha256.Sum256([]byte(stamp))
|
||||||
|
|
||||||
|
if hasLeadingZeroBits(hash[:], bits) {
|
||||||
|
return stamp
|
||||||
|
}
|
||||||
|
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
}
|
||||||
244
internal/hashcash/channel_test.go
Normal file
244
internal/hashcash/channel_test.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package hashcash_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testChannel = "#general"
|
||||||
|
testBodyText = `["hello world"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBodyHash() string {
|
||||||
|
hash := sha256.Sum256([]byte(testBodyText))
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateHappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid channel stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateWrongChannel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, "#other", bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected channel mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateWrongBodyHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
wrongHash := sha256.Sum256([]byte("different body"))
|
||||||
|
wrongBodyHash := hex.EncodeToString(wrongHash[:])
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, wrongBodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected body hash mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateInsufficientBits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
// Mint with 2 bits but require 4.
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, 4, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected insufficient bits error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateZeroBitsSkips(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
"garbage", 0, "#ch", "abc",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zero bits should skip: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateBadFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
"not:valid", testBits, testChannel, "abc",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad format error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateBadVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected bad version error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateExpiredStamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
// Mint with a very old date by manually constructing.
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, testChannel, "200101",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected expired stamp error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelValidateMissingBodyHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
|
||||||
|
// Construct a stamp with empty body hash field.
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, testChannel, todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// This uses the session-style stamp which has empty
|
||||||
|
// ext field — body hash is missing.
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing body hash error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := []byte(`["hello world"]`)
|
||||||
|
bodyHash := hashcash.BodyHash(body)
|
||||||
|
|
||||||
|
if len(bodyHash) != 64 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 64-char hex hash, got %d",
|
||||||
|
len(bodyHash),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same input should produce same hash.
|
||||||
|
bodyHash2 := hashcash.BodyHash(body)
|
||||||
|
if bodyHash != bodyHash2 {
|
||||||
|
t.Fatal("body hash not deterministic")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different input should produce different hash.
|
||||||
|
bodyHash3 := hashcash.BodyHash([]byte("different"))
|
||||||
|
if bodyHash == bodyHash3 {
|
||||||
|
t.Fatal("different inputs produced same hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStampHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hash1 := hashcash.StampHash("stamp1")
|
||||||
|
hash2 := hashcash.StampHash("stamp2")
|
||||||
|
|
||||||
|
if hash1 == hash2 {
|
||||||
|
t.Fatal("different stamps produced same hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same input should be deterministic.
|
||||||
|
hash1b := hashcash.StampHash("stamp1")
|
||||||
|
if hash1 != hash1b {
|
||||||
|
t.Fatal("stamp hash not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintChannelStamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
bodyHash := testBodyHash()
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stamp == "" {
|
||||||
|
t.Fatal("expected non-empty stamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the minted stamp.
|
||||||
|
validator := hashcash.NewChannelValidator()
|
||||||
|
|
||||||
|
err := validator.ValidateStamp(
|
||||||
|
stamp, testBits, testChannel, bodyHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minted stamp failed validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,18 +126,23 @@ 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
|
||||||
AllowedOrigins: []string{"*"},
|
AllowOriginFunc: func(
|
||||||
|
_ *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", "Authorization",
|
"Accept", "Content-Type", "X-CSRF-Token",
|
||||||
"Content-Type", "X-CSRF-Token",
|
|
||||||
},
|
},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: true,
|
||||||
MaxAge: corsMaxAge,
|
MaxAge: corsMaxAge,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,6 @@ 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(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const (
|
|||||||
CmdNames = "NAMES"
|
CmdNames = "NAMES"
|
||||||
CmdNick = "NICK"
|
CmdNick = "NICK"
|
||||||
CmdNotice = "NOTICE"
|
CmdNotice = "NOTICE"
|
||||||
|
CmdPass = "PASS"
|
||||||
CmdPart = "PART"
|
CmdPart = "PART"
|
||||||
CmdPing = "PING"
|
CmdPing = "PING"
|
||||||
CmdPong = "PONG"
|
CmdPong = "PONG"
|
||||||
|
|||||||
Reference in New Issue
Block a user