1 Commits
Author SHA1 Message Date
clawbot 0e306867d2 Fail startup when the HTTP listener cannot bind (closes #167)
check / check (push) Canceled after 0s
The HTTP server ran ListenAndServe in a goroutine, so a failed bind
was only logged and neoircd kept running without its HTTP API.

The start hook now binds the listen address itself and returns the
bind error, so fx stops startup and neoircd exits 1 with an error
naming the address. The bound listener is then served in a goroutine;
if serving ends with anything other than a normal shutdown, the server
stops the app through fx.Shutdowner with exit code 1.

A new test holds a port and checks that starting the app on it fails
with an error naming the address.

Model: opus-5-5
2026-10-20 21:54:54 +00:00
74 changed files with 4739 additions and 9273 deletions
-4
View File
@@ -61,10 +61,6 @@ linters:
desc: >- desc: >-
Test-support code belongs in test files and in packages whose Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary. directory name ends in test, not in the shipped binary.
- pkg: sneak.berlin/go/neoirc/internal/dbtest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are # Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly. # listed here. Every entry matches the module path exactly.
gomodguard_v2: gomodguard_v2:
+38 -56
View File
@@ -1,73 +1,55 @@
# Rules for coding agents # AGENTS.md — Rules for AI Agents
This file holds the rules for coding agents working in this repository. ## Before Every Commit
`CONVENTIONS.md` defines the architecture. `REPO_POLICIES.md` defines the
repository standards. Both are copies of shared documents. Do not edit them
here.
## Before every commit 1. **Format**: `make fmt`
2. **Check**: `make check` — the tests and the linter, which run in Docker, and
the format check: all passing, zero issues
1. Run `make fmt`. Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make`
2. Run `make check`. It runs the tests and the linter in Docker, then the format targets.
check. All three must pass with no findings.
Never run `go test`, `golangci-lint` or `gofmt` directly. Use the `make` targets No commit lands on main with lint errors, test failures, or formatting issues.
instead, such as `make test` and `make lint`.
## Branches and pull requests ## Code Style
- Never commit to `main` or `next` directly. Every change is a pull request from - Follow CONVENTIONS.md exactly — it defines the architecture
a feature branch into `next`. - All exported types, functions, methods, and vars must have doc comments
- Only the owner merges `next` into `main`. - All packages must have a package doc comment
- A pull request that closes an issue has the title `Description (closes #N)`. - JSON tags use camelCase (not snake_case)
- One logical change per commit. - No magic numbers — use named constants
- Commit messages are in the imperative mood and short. - No inline error handling (`if err := ...; err != nil`) — use plain assignment
- Blank line before every `return` statement
- Blank line separating embedded fields from regular fields in structs
- Blank imports must have a justification comment
## Code style ## Git Workflow
- Follow `CONVENTIONS.md`. - **Never commit directly to main** — all changes go on feature branches
- Every package has a package doc comment. Test files need none. - Merge to main only when ready and passing all checks
- Every exported type, function and variable has a doc comment. Test files need - PR titles: `Description (closes #N)` when closing an issue
none. - One logical change per commit
- JSON tags are camelCase. The HTTP API's `last_id`, `pow_token` and - Commit messages: imperative mood, concise
`hashcash_bits` stay as they are, each with a `//nolint:tagliatelle` comment.
- Prefer named constants to magic numbers. The `mnd` linter checks for them
outside test files.
- No inline error handling such as `if err := f(); err != nil`. Assign the error
on its own line, then test it.
- A `return` that follows another statement in its block has a blank line before
it.
- A blank line between the embedded fields of a struct and its other fields.
- Every blank import has a comment saying why it is there. Test files need none.
## Architecture ## Architecture
- The server uses `go.uber.org/fx` for dependency injection. - Dependency injection via Uber fx — all components are fx providers
`cmd/neoircd/main.go` lists its `fx` providers, such as `db.New` and - `internal/` for all application packages — one package per concern
`handlers.New`. - Handlers return `http.HandlerFunc` using the closure pattern
- Application packages live under `internal/`, one package per concern. - Request/response structs defined inside handler closures
- `pkg/irc` holds the IRC command names and numeric reply codes. - Models embed `models.Base` for database access to relation methods
- The `web` package embeds the built web client. - Database migrations in `internal/db/schema/` — embedded via `go:embed`
- Each exported `Handle…` method of `Handlers` returns an `http.HandlerFunc`. - SQLite by default (pure Go driver: modernc.org/sqlite)
- A request struct is defined inside the method that decodes it.
- The server uses the database only through the methods of `db.Database`.
- The database migrations live in `internal/db/schema/` and are embedded with
`go:embed`.
- The database is SQLite, through the pure Go driver `modernc.org/sqlite`.
## Testing ## Testing
- Prefer table-driven tests. - Table-driven tests preferred
- Use `testing.T` helpers, not assertion libraries. - Use `testing.T` helpers, not assertion libraries
- A test file lives next to the code it tests and ends in `_test.go`. - Test files live next to the code they test
- The linter requires each test file to be in the package's external test - `_test.go` suffix, same package for white-box testing
package, such as `handlers_test`. It allows `export_test.go` and files ending
in `_internal_test.go` in the package itself.
- An `export_test.go` file gives the external tests access to the package's
unexported code.
## Dependencies ## Dependencies
- The required libraries are listed in `CONVENTIONS.md` §1. - Required libraries are listed in CONVENTIONS.md §1
- No new dependency without a reason given in the pull request. - No new dependencies without justification
- Prefer dependencies written in pure Go, which build without cgo. - Pure Go preferred (no CGO)
+4 -8
View File
@@ -33,18 +33,14 @@ RUN golangci-lint run --config .golangci.yml ./...
# golang:1.24.13-bookworm, 2026-10-06 # golang:1.24.13-bookworm, 2026-10-06
FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . . COPY . .
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
# -p 4 because test runs on a shared build host cap their parallelism. # -p 4 because test runs on a shared build host cap their parallelism.
# The modules download in this RUN rather than a layer of their own, because RUN go test -p 4 -timeout 90s -race -cover ./... || \
# exporting a layer that holds them is slow. Nothing reads them or the build
# cache afterwards, so go clean removes both.
RUN set -e; \
go mod download; \
go test -p 4 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -p 4 -timeout 90s -race -v ./...; exit 1; }; \ go test -p 4 -timeout 90s -race -v ./...; exit 1; }
go clean -cache -modcache
# Build stage. Nothing is wanted from either phase above; the copies # Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run # are what make BuildKit build them first, so this stage cannot run
+3122 -1692
View File
File diff suppressed because it is too large Load Diff
+5 -30
View File
@@ -25,10 +25,7 @@ const (
httpErrThreshold = 400 httpErrThreshold = 400
) )
var ( var errHTTP = errors.New("HTTP error")
errHTTP = errors.New("HTTP error")
errJoinRefused = errors.New("the server refused the join")
)
// Client wraps HTTP calls to the neoirc server API. // Client wraps HTTP calls to the neoirc server API.
type Client struct { type Client struct {
@@ -209,35 +206,13 @@ func (client *Client) PollMessages(
}, nil }, nil
} }
// JoinChannel joins a channel. It returns an error unless the // JoinChannel joins a channel.
// server answers that the session joined. When the server
// refuses the join, it sends the reason to the message queue as
// an error numeric.
func (client *Client) JoinChannel(channel string) error { func (client *Client) JoinChannel(channel string) error {
data, err := client.do( return client.SendMessage(
http.MethodPost, "/api/v1/messages",
&Message{ //nolint:exhaustruct // only command+to needed &Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel, Command: irc.CmdJoin, To: channel,
}, },
) )
if err != nil {
return err
}
var resp struct {
Status string `json:"status"`
}
err = json.Unmarshal(data, &resp)
if err != nil {
return fmt.Errorf("decode join: %w", err)
}
if resp.Status != "joined" {
return errJoinRefused
}
return nil
} }
// PartChannel leaves a channel. // PartChannel leaves a channel.
@@ -275,7 +250,7 @@ func (client *Client) ListChannels() (
// GetMembers returns members of a channel. // GetMembers returns members of a channel.
func (client *Client) GetMembers( func (client *Client) GetMembers(
channel string, channel string,
) ([]Member, error) { ) ([]string, error) {
name := strings.TrimPrefix(channel, "#") name := strings.TrimPrefix(channel, "#")
data, err := client.do( data, err := client.do(
@@ -288,7 +263,7 @@ func (client *Client) GetMembers(
return nil, err return nil, err
} }
var members []Member var members []string
err = json.Unmarshal(data, &members) err = json.Unmarshal(data, &members)
if err != nil { if err != nil {
+7 -366
View File
@@ -2,28 +2,18 @@ package neoircapi_test
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"reflect"
"slices"
"strconv"
"strings" "strings"
"testing" "testing"
api "sneak.berlin/go/neoirc/internal/cli/api" api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/internal/hashcash"
) )
const ( const cookieValue = "opaque-value"
cookieValue = "opaque-value"
testNick = "alice"
testChannel = "#general"
)
// newSessionServer starts a plain-HTTP server that, like // newSessionServer starts a plain-HTTP server that, like
// neoircd, sets a Secure auth cookie when a session is // neoircd, sets a Secure auth cookie when a session is
@@ -34,16 +24,10 @@ func newSessionServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
// Without hashcash the server sends no hashcash_bits.
mux.HandleFunc("GET /api/v1/server", func( mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request, writer http.ResponseWriter, _ *http.Request,
) { ) {
_, _ = io.WriteString(writer, `{ _, _ = io.WriteString(writer, `{}`)
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42
}`)
}) })
mux.HandleFunc("POST /api/v1/session", func( mux.HandleFunc("POST /api/v1/session", func(
@@ -72,14 +56,9 @@ func newSessionServer(t *testing.T) *httptest.Server {
return return
} }
_, _ = io.WriteString(writer, `{ _, _ = io.WriteString(
"id": 1, writer, `{"id":1,"nick":"alice","channels":[]}`,
"nick": "alice", )
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}`)
}) })
server := httptest.NewServer(mux) server := httptest.NewServer(mux)
@@ -88,344 +67,6 @@ func newSessionServer(t *testing.T) *httptest.Server {
return server return server
} }
// newClientForPath starts a server that answers GET path with
// body, and returns a client for it.
func newClientForPath(t *testing.T, path, body string) *api.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET "+path, func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, body)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return api.NewClient(server.URL)
}
func TestGetStateDecodesFields(t *testing.T) {
t.Parallel()
client := api.NewClient(newSessionServer(t).URL)
_, err := client.CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
state, err := client.GetState()
if err != nil {
t.Fatalf("state: %v", err)
}
want := api.StateResponse{
ID: 1,
Nick: testNick,
Channels: []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: ""},
},
}
if !reflect.DeepEqual(*state, want) {
t.Fatalf("state: got %+v, want %+v", *state, want)
}
}
func TestGetServerInfoDecodesFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/server", `{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": 20
}`)
info, err := client.GetServerInfo()
if err != nil {
t.Fatalf("server info: %v", err)
}
want := api.ServerInfo{
Name: "My NeoIRC Server",
MOTD: "Welcome! Be nice.",
Version: "0.1.0",
HashcashBits: 20,
}
if *info != want {
t.Fatalf("server info: got %+v, want %+v", *info, want)
}
}
// TestCreateSessionSendsNickAndStamp serves a server that, like
// neoircd with hashcash on, advertises hashcash_bits and creates
// the session only for a pow_token the server's validator in
// internal/hashcash accepts.
func TestCreateSessionSendsNickAndStamp(t *testing.T) {
t.Parallel()
const (
serverName = "My NeoIRC Server"
stampBits = 8
sessionID = 7
)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = fmt.Fprintf(writer, `{
"name": %q,
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": %d
}`, serverName, stampBits)
})
validator := hashcash.NewValidator(serverName)
mux.HandleFunc("POST /api/v1/session", func(
writer http.ResponseWriter, request *http.Request,
) {
// Decoded with the field names handleCreateSession uses.
var payload struct {
Nick string `json:"nick"`
Hashcash string `json:"pow_token"` //nolint:tagliatelle
}
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
err = validator.Claim(payload.Hashcash, stampBits)
if err != nil {
writer.WriteHeader(http.StatusPaymentRequired)
return
}
writer.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(writer).Encode(map[string]any{
"id": sessionID,
"nick": payload.Nick,
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
session, err := api.NewClient(server.URL).CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
want := api.SessionResponse{ID: sessionID, Nick: testNick}
if *session != want {
t.Fatalf("session: got %+v, want %+v", *session, want)
}
}
func TestListChannelsDecodesNamesAndTopics(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels", `[
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": "Development discussion"}
]`)
channels, err := client.ListChannels()
if err != nil {
t.Fatalf("list channels: %v", err)
}
want := []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: "Development discussion"},
}
if !slices.Equal(channels, want) {
t.Fatalf("channels: got %+v, want %+v", channels, want)
}
}
func TestGetMembersDecodesMemberObjects(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels/general/members", `[
{
"id": 1, "nick": "alice", "username": "alice",
"hostname": "host.example.com",
"isOperator": true, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 2, "nick": "bob", "username": "bob",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": true,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 3, "nick": "Carol", "username": "carol",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
}
]`)
members, err := client.GetMembers(testChannel)
if err != nil {
t.Fatalf("get members: %v", err)
}
want := []api.Member{
{Nick: testNick, IsOperator: true, IsVoiced: false},
{Nick: "bob", IsOperator: false, IsVoiced: true},
{Nick: "Carol", IsOperator: false, IsVoiced: false},
}
if !slices.Equal(members, want) {
t.Fatalf("members: got %+v, want %+v", members, want)
}
}
// TestPollMessagesAdvancesCursor serves the queue as the server
// does: entries after the cursor, and last_id as the new cursor.
func TestPollMessagesAdvancesCursor(t *testing.T) {
t.Parallel()
const lastQueueID = 42
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/messages", func(
writer http.ResponseWriter, request *http.Request,
) {
after := request.URL.Query().Get("after")
if after == strconv.Itoa(lastQueueID) {
_, _ = io.WriteString(
writer, `{"messages": [], "last_id": 42}`,
)
return
}
_, _ = io.WriteString(writer, `{
"messages": [{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello world"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {}
}],
"last_id": 42
}`)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := api.NewClient(server.URL)
first, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("first poll: %v", err)
}
if first.LastID != lastQueueID || len(first.Messages) != 1 {
t.Fatalf(
"first poll: got last ID %d and %d messages, "+
"want %d and 1",
first.LastID, len(first.Messages), lastQueueID,
)
}
second, err := client.PollMessages(first.LastID, 0)
if err != nil {
t.Fatalf("second poll: %v", err)
}
if len(second.Messages) != 0 {
t.Fatalf(
"second poll: got %d messages again, want none",
len(second.Messages),
)
}
}
// TestPollMessagesDecodesMessageFields serves a relayed PRIVMSG
// with the sender's meta and a numeric, as the server queues
// them.
func TestPollMessagesDecodesMessageFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/messages", `{
"messages": [
{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["signed message"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {"sig": "base64...", "alg": "ed25519"}
},
{
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
"command": "RPL_LUSEROP",
"code": 252,
"from": "neoirc",
"to": "alice",
"params": ["0"],
"body": ["operator(s) online"],
"ts": "2026-02-10T20:00:02Z",
"meta": {}
}
],
"last_id": 42
}`)
result, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
want := []api.Message{
{
ID: "b7c8210f-849c-4b90-9ee8-d99c8889358e",
Command: "PRIVMSG",
From: testNick,
To: testChannel,
Body: []any{"signed message"},
TS: "2026-02-10T20:00:01.234567891Z",
Meta: map[string]any{"sig": "base64...", "alg": "ed25519"},
},
{
ID: "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
Command: "RPL_LUSEROP",
From: "neoirc",
To: testNick,
Params: []string{"0"},
Body: []any{"operator(s) online"},
TS: "2026-02-10T20:00:02Z",
Meta: map[string]any{},
},
}
if !reflect.DeepEqual(result.Messages, want) {
t.Fatalf("messages: got %+v, want %+v", result.Messages, want)
}
}
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -444,7 +85,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
"http://" + net.JoinHostPort(host, serverURL.Port()), "http://" + net.JoinHostPort(host, serverURL.Port()),
) )
_, err := client.CreateSession(testNick) _, err := client.CreateSession("alice")
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
@@ -477,7 +118,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) {
}, },
} }
_, err := client.CreateSession(testNick) _, err := client.CreateSession("alice")
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
+5 -12
View File
@@ -18,7 +18,7 @@ type SessionResponse struct {
type StateResponse struct { type StateResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Channels []Channel `json:"channels"` Channels []string `json:"channels"`
} }
// Message represents a neoirc message envelope. // Message represents a neoirc message envelope.
@@ -53,19 +53,12 @@ func (m *Message) BodyLines() []string {
} }
} }
// Channel is a channel as GET /api/v1/channels and // Channel represents a channel in the list response.
// GET /api/v1/state describe it.
type Channel struct { type Channel struct {
Name string `json:"name"` Name string `json:"name"`
Topic string `json:"topic"` Topic string `json:"topic"`
} Members int `json:"members"`
CreatedAt string `json:"createdAt"`
// Member is one entry of the response from
// GET /api/v1/channels/{name}/members.
type Member struct {
Nick string `json:"nick"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
} }
// ServerInfo is the response from GET /api/v1/server. // ServerInfo is the response from GET /api/v1/server.
@@ -79,7 +72,7 @@ type ServerInfo struct {
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.
type MessagesResponse struct { type MessagesResponse struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
LastID int64 `json:"last_id"` //nolint:tagliatelle LastID int64 `json:"lastId"`
} }
// PollResult wraps the poll response including the cursor. // PollResult wraps the poll response including the cursor.
+134 -184
View File
@@ -34,30 +34,13 @@ type App struct {
// Run creates and runs the CLI application. // Run creates and runs the CLI application.
func Run() { func Run() {
err := run(NewUI())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// run starts the client on ui and returns when ui stops. Tests
// call it with ui on a simulation screen.
func run(ui *UI) error {
app := &App{ //nolint:exhaustruct app := &App{ //nolint:exhaustruct
ui: ui, ui: NewUI(),
nick: "guest", nick: "guest",
} }
app.ui.OnInput(app.handleInput) app.ui.OnInput(app.handleInput)
app.ui.SetStatus(app.nick, "", "disconnected")
// SetStatus and AddStatus wait for the event loop, which
// app.ui.Run starts below, so they run on a goroutine. The
// nick is read first, as a typed /nick may change app.nick.
nick := app.nick
go func() {
app.ui.SetStatus(nick, "", "disconnected")
app.ui.AddStatus( app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client", "Welcome to neoirc-cli — an IRC-style client",
@@ -67,9 +50,12 @@ func run(ui *UI) error {
"[white] to begin, " + "[white] to begin, " +
"or [yellow]/help[white] for commands", "or [yellow]/help[white] for commands",
) )
}()
return app.ui.Run() err := app.ui.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} }
func (a *App) handleInput(text string) { func (a *App) handleInput(text string) {
@@ -108,9 +94,22 @@ func (a *App) handleInput(text string) {
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(
"[red]Send error: " + escape(err.Error()), "[red]Send error: " + err.Error(),
) )
return
} }
timestamp := time.Now().Format(timeFormat)
a.mu.Lock()
nick := a.nick
a.mu.Unlock()
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
} }
func (a *App) handleCommand(text string) { func (a *App) handleCommand(text string) {
@@ -166,7 +165,7 @@ func (a *App) dispatchInfoCommand(cmd, args string) {
a.cmdWhois(args) a.cmdWhois(args)
default: default:
a.ui.AddStatus( a.ui.AddStatus(
"[red]Unknown command: " + escape(cmd), "[red]Unknown command: " + cmd,
) )
} }
} }
@@ -182,7 +181,7 @@ func (a *App) cmdConnect(serverURL string) {
serverURL = strings.TrimRight(serverURL, "/") serverURL = strings.TrimRight(serverURL, "/")
a.ui.AddStatus("Connecting to " + escape(serverURL) + "...") a.ui.AddStatus("Connecting to " + serverURL + "...")
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
@@ -192,37 +191,29 @@ func (a *App) cmdConnect(serverURL string) {
resp, err := client.CreateSession(nick) resp, err := client.CreateSession(nick)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Connection failed: " + escape(err.Error()), "[red]Connection failed: %v", err,
) ))
return return
} }
// The poll loop of an earlier connection is stopped in the same
// step that replaces the client, so it never polls with the new
// one.
a.mu.Lock() a.mu.Lock()
a.client = client a.client = client
a.nick = resp.Nick a.nick = resp.Nick
a.connected = true a.connected = true
a.lastQID = 0 a.lastQID = 0
if a.stopPoll != nil {
close(a.stopPoll)
}
stop := make(chan struct{})
a.stopPoll = stop
a.mu.Unlock() a.mu.Unlock()
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[green]Connected! Nick: %s, Session: %d", "[green]Connected! Nick: %s, Session: %d",
escape(resp.Nick), resp.ID, resp.Nick, resp.ID,
)) ))
a.ui.SetStatus(resp.Nick, "", "connected") a.ui.SetStatus(resp.Nick, "", "connected")
go a.pollLoop(stop) a.stopPoll = make(chan struct{})
go a.pollLoop()
} }
func (a *App) cmdNick(nick string) { func (a *App) cmdNick(nick string) {
@@ -244,7 +235,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.AddStatus( a.ui.AddStatus(
"Nick set to " + escape(nick) + "Nick set to " + nick +
" (will be used on connect)", " (will be used on connect)",
) )
@@ -256,9 +247,9 @@ func (a *App) cmdNick(nick string) {
Body: []string{nick}, Body: []string{nick},
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Nick change failed: " + escape(err.Error()), "[red]Nick change failed: %v", err,
) ))
return return
} }
@@ -269,7 +260,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus(nick, target, "connected") a.ui.SetStatus(nick, target, "connected")
a.ui.AddStatus("Nick changed to " + escape(nick)) a.ui.AddStatus("Nick changed to " + nick)
} }
func (a *App) cmdJoin(channel string) { func (a *App) cmdJoin(channel string) {
@@ -297,9 +288,9 @@ func (a *App) cmdJoin(channel string) {
err := a.client.JoinChannel(channel) err := a.client.JoinChannel(channel)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Join failed: " + escape(err.Error()), "[red]Join failed: %v", err,
) ))
return return
} }
@@ -310,6 +301,9 @@ func (a *App) cmdJoin(channel string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.SwitchToBuffer(channel) a.ui.SwitchToBuffer(channel)
a.ui.AddLine(channel,
"[yellow]*** Joined "+channel,
)
a.ui.SetStatus(nick, channel, "connected") a.ui.SetStatus(nick, channel, "connected")
} }
@@ -337,13 +331,17 @@ func (a *App) cmdPart(channel string) {
err := a.client.PartChannel(channel) err := a.client.PartChannel(channel)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Part failed: " + escape(err.Error()), "[red]Part failed: %v", err,
) ))
return return
} }
a.ui.AddLine(channel,
"[yellow]*** Left "+channel,
)
a.mu.Lock() a.mu.Lock()
if a.target == channel { if a.target == channel {
a.target = "" a.target = ""
@@ -370,6 +368,7 @@ func (a *App) cmdMsg(args string) {
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
nick := a.nick
a.mu.Unlock() a.mu.Unlock()
if !connected { if !connected {
@@ -384,10 +383,19 @@ func (a *App) cmdMsg(args string) {
Body: []string{text}, Body: []string{text},
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: " + escape(err.Error()), "[red]Send failed: %v", err,
) ))
return
} }
timestamp := time.Now().Format(timeFormat)
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
} }
func (a *App) cmdQuery(nick string) { func (a *App) cmdQuery(nick string) {
@@ -402,16 +410,10 @@ func (a *App) cmdQuery(nick string) {
a.mu.Lock() a.mu.Lock()
a.target = nick a.target = nick
myNick := a.nick myNick := a.nick
connected := a.connected
a.mu.Unlock() a.mu.Unlock()
connStatus := "disconnected"
if connected {
connStatus = "connected"
}
a.ui.SwitchToBuffer(nick) a.ui.SwitchToBuffer(nick)
a.ui.SetStatus(myNick, nick, connStatus) a.ui.SetStatus(myNick, nick, "connected")
} }
func (a *App) cmdTopic(args string) { func (a *App) cmdTopic(args string) {
@@ -432,9 +434,16 @@ func (a *App) cmdTopic(args string) {
return return
} }
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" { if args == "" {
a.ui.AddStatus("[red]Usage: /topic <text>") err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
To: target,
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic query failed: %v", err,
))
}
return return
} }
@@ -445,9 +454,9 @@ func (a *App) cmdTopic(args string) {
Body: []string{args}, Body: []string{args},
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Topic set failed: " + escape(err.Error()), "[red]Topic set failed: %v", err,
) ))
} }
} }
@@ -471,39 +480,19 @@ func (a *App) cmdNames() {
members, err := a.client.GetMembers(target) members, err := a.client.GetMembers(target)
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]Names failed: " + escape(err.Error()), "[red]Names failed: %v", err,
) ))
return return
} }
nicks := make([]string, 0, len(members))
for _, member := range members {
nicks = append(nicks, memberPrefix(member)+member.Nick)
}
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s", "[cyan]*** Members of %s: %s",
escape(target), escape(strings.Join(nicks, " ")), target, strings.Join(members, " "),
)) ))
} }
// memberPrefix returns "@" for an operator, "+" for a voiced
// member, and "" for anyone else.
func memberPrefix(member api.Member) string {
if member.IsOperator {
return "@"
}
if member.IsVoiced {
return "+"
}
return ""
}
func (a *App) cmdList() { func (a *App) cmdList() {
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
@@ -517,9 +506,9 @@ func (a *App) cmdList() {
channels, err := a.client.ListChannels() channels, err := a.client.ListChannels()
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]List failed: " + escape(err.Error()), "[red]List failed: %v", err,
) ))
return return
} }
@@ -528,7 +517,8 @@ func (a *App) cmdList() {
for _, ch := range channels { for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
" %s %s", escape(ch.Name), escape(ch.Topic), " %s (%d members) %s",
ch.Name, ch.Members, ch.Topic,
)) ))
} }
@@ -550,9 +540,9 @@ func (a *App) cmdMotd() {
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
) )
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]MOTD failed: " + escape(err.Error()), "[red]MOTD failed: %v", err,
) ))
} }
} }
@@ -588,9 +578,9 @@ func (a *App) cmdWho(args string) {
}, },
) )
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]WHO failed: " + escape(err.Error()), "[red]WHO failed: %v", err,
) ))
} }
} }
@@ -619,9 +609,9 @@ func (a *App) cmdWhois(args string) {
}, },
) )
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(fmt.Sprintf(
"[red]WHOIS failed: " + escape(err.Error()), "[red]WHOIS failed: %v", err,
) ))
} }
} }
@@ -638,37 +628,24 @@ func (a *App) cmdWindow(args string) {
_, _ = fmt.Sscanf(args, "%d", &bufIndex) _, _ = fmt.Sscanf(args, "%d", &bufIndex)
a.mu.Lock()
nick := a.nick
connected := a.connected
a.mu.Unlock()
connStatus := "disconnected"
if connected {
connStatus = "connected"
}
// Read the name before switching. A message from the server can
// add a buffer at any time, but only at the end, so bufIndex
// still names this buffer when SwitchBuffer runs.
name, found := a.ui.BufferName(bufIndex)
if found {
a.ui.SwitchBuffer(bufIndex) a.ui.SwitchBuffer(bufIndex)
if name != "(status)" {
a.mu.Lock() a.mu.Lock()
a.target = name nick := a.nick
a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
buf := a.ui.buffers[bufIndex]
if buf.Name != "(status)" {
a.mu.Lock()
a.target = buf.Name
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus( a.ui.SetStatus(
nick, name, connStatus, nick, buf.Name, "connected",
) )
} else { } else {
a.mu.Lock() a.ui.SetStatus(nick, "", "connected")
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", connStatus)
} }
} }
} }
@@ -699,7 +676,7 @@ func (a *App) cmdHelp() {
" /part [#chan] — Leave channel", " /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM", " /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window", " /query <nick> — Open DM window",
" /topic <text> — Set topic", " /topic [text] — View/set topic",
" /names — List channel members", " /names — List channel members",
" /list — List channels", " /list — List channels",
" /who [#channel] — List users in channel", " /who [#channel] — List users in channel",
@@ -716,23 +693,16 @@ func (a *App) cmdHelp() {
} }
} }
// pollLoop long-polls for messages in the background until stop is // pollLoop long-polls for messages in the background.
// closed. A later /connect closes stop under a.mu as it replaces the func (a *App) pollLoop() {
// client and the cursor, so the loop checks stop under a.mu too. A
// stopped loop then neither polls with the new client nor moves the
// new cursor.
func (a *App) pollLoop(stop <-chan struct{}) {
for { for {
a.mu.Lock()
select { select {
case <-stop: case <-a.stopPoll:
a.mu.Unlock()
return return
default: default:
} }
a.mu.Lock()
client := a.client client := a.client
lastQID := a.lastQID lastQID := a.lastQID
a.mu.Unlock() a.mu.Unlock()
@@ -750,23 +720,11 @@ func (a *App) pollLoop(stop <-chan struct{}) {
continue continue
} }
// A poll sent before stop was closed answers for the earlier
// connection.
a.mu.Lock()
select {
case <-stop:
a.mu.Unlock()
return
default:
}
if result.LastID > 0 { if result.LastID > 0 {
a.mu.Lock()
a.lastQID = result.LastID a.lastQID = result.LastID
}
a.mu.Unlock() a.mu.Unlock()
}
for i := range result.Messages { for i := range result.Messages {
a.handleServerMessage(&result.Messages[i]) a.handleServerMessage(&result.Messages[i])
@@ -787,7 +745,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin: case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp) a.handleJoinEvent(msg, timestamp)
case irc.CmdPart: case irc.CmdPart:
a.handlePartEvent(msg, timestamp, myNick) a.handlePartEvent(msg, timestamp)
case irc.CmdQuit: case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp) a.handleQuitEvent(msg, timestamp)
case irc.CmdNick: case irc.CmdNick:
@@ -815,16 +773,18 @@ func (a *App) handlePrivmsgEvent(
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
// A direct message shows in the buffer of the other user: if msg.From == myNick {
// the recipient of one the user sent, else the sender. return
}
target := msg.To target := msg.To
if !strings.HasPrefix(target, "#") && msg.From != myNick { if !strings.HasPrefix(target, "#") {
target = msg.From target = msg.From
} }
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s", "[gray]%s [green]<%s>[white] %s",
timestamp, escape(msg.From), escape(text), timestamp, msg.From, text,
)) ))
} }
@@ -837,15 +797,12 @@ func (a *App) handleJoinEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s", "[gray]%s [yellow]*** %s has joined %s",
timestamp, escape(msg.From), escape(msg.To), timestamp, msg.From, msg.To,
)) ))
} }
// handlePartEvent shows a part in the channel's buffer. The
// user's own part does not mark the channel unread: the user
// has just left it.
func (a *App) handlePartEvent( func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string, msg *api.Message, timestamp string,
) { ) {
if msg.To == "" { if msg.To == "" {
return return
@@ -854,22 +811,17 @@ func (a *App) handlePartEvent(
lines := msg.BodyLines() lines := msg.BodyLines()
reason := strings.Join(lines, " ") reason := strings.Join(lines, " ")
line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, escape(msg.From), escape(msg.To),
)
if reason != "" { if reason != "" {
line += " (" + escape(reason) + ")" a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s (%s)",
timestamp, msg.From, msg.To, reason,
))
} else {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
))
} }
if msg.From == myNick {
a.ui.AddLineAsRead(msg.To, line)
return
}
a.ui.AddLine(msg.To, line)
} }
func (a *App) handleQuitEvent( func (a *App) handleQuitEvent(
@@ -881,12 +833,12 @@ func (a *App) handleQuitEvent(
if reason != "" { if reason != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit (%s)", "[gray]%s [yellow]*** %s has quit (%s)",
timestamp, escape(msg.From), escape(reason), timestamp, msg.From, reason,
)) ))
} else { } else {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit", "[gray]%s [yellow]*** %s has quit",
timestamp, escape(msg.From), timestamp, msg.From,
)) ))
} }
} }
@@ -913,7 +865,7 @@ func (a *App) handleNickEvent(
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s is now known as %s", "[gray]%s [yellow]*** %s is now known as %s",
timestamp, escape(msg.From), escape(newNick), timestamp, msg.From, newNick,
)) ))
} }
@@ -925,7 +877,7 @@ func (a *App) handleNoticeEvent(
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [magenta]--%s-- %s", "[gray]%s [magenta]--%s-- %s",
timestamp, escape(msg.From), escape(text), timestamp, msg.From, text,
)) ))
} }
@@ -941,7 +893,7 @@ func (a *App) handleTopicEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [cyan]*** %s set topic: %s", "[gray]%s [cyan]*** %s set topic: %s",
timestamp, escape(msg.From), escape(text), timestamp, msg.From, text,
)) ))
} }
@@ -951,12 +903,10 @@ func (a *App) handleDefaultEvent(
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
// The brackets around the command are escaped with it, as tview
// reads a command such as "[MODE]" as a tag.
if text != "" { if text != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [white]%s %s", "[gray]%s [white][%s] %s",
timestamp, escape("["+msg.Command+"]"), escape(text), timestamp, msg.Command, text,
)) ))
} }
} }
File diff suppressed because it is too large Load Diff
+14 -96
View File
@@ -16,10 +16,7 @@ type Buffer struct {
Unread int Unread int
} }
// UI manages the terminal interface. Most of its methods wait for // UI manages the terminal interface.
// the tview event loop to run their update. Code that runs on that
// loop, such as a key handler, must not call them: it would wait
// for itself.
type UI struct { type UI struct {
app *tview.Application app *tview.Application
messages *tview.TextView messages *tview.TextView
@@ -30,17 +27,7 @@ type UI struct {
buffers []*Buffer buffers []*Buffer
currentBuffer int currentBuffer int
// The nick, target and connection state last passed to
// SetStatus.
nick string
target string
connStatus string
onInput func(string) onInput func(string)
// inputDone is closed once the line last passed to onInput
// has been handled. Only callOnInput, on the event loop, uses
// it.
inputDone chan struct{}
} }
// NewUI creates the tview-based IRC-like UI. // NewUI creates the tview-based IRC-like UI.
@@ -108,19 +95,6 @@ func (ui *UI) AddLine(bufferName, line string) {
}) })
} }
// AddLineAsRead adds a line to the specified buffer, as
// AddLine does, but does not mark the buffer unread.
func (ui *UI) AddLineAsRead(bufferName, line string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(bufferName)
buf.Lines = append(buf.Lines, line)
if ui.buffers[ui.currentBuffer] == buf {
_, _ = fmt.Fprintln(ui.messages, line)
}
})
}
// AddStatus adds a line to the status buffer. // AddStatus adds a line to the status buffer.
func (ui *UI) AddStatus(line string) { func (ui *UI) AddStatus(line string) {
ts := time.Now().Format("15:04") ts := time.Now().Format("15:04")
@@ -185,32 +159,13 @@ func (ui *UI) SetStatus(
nick, target, connStatus string, nick, target, connStatus string,
) { ) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.nick = nick ui.renderStatusBar(nick, target, connStatus)
ui.target = target
ui.connStatus = connStatus
ui.refreshStatusBar()
}) })
} }
// BufferName returns the name of the buffer at index bufIndex, // BufferCount returns the number of buffers.
// and whether there is such a buffer. func (ui *UI) BufferCount() int {
func (ui *UI) BufferName(bufIndex int) (string, bool) { return len(ui.buffers)
var (
name string
found bool
)
ui.app.QueueUpdate(func() {
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
return
}
name = ui.buffers[bufIndex].Name
found = true
})
return name, found
} }
// BufferIndex returns the index of a named buffer. // BufferIndex returns the index of a named buffer.
@@ -235,21 +190,9 @@ func (ui *UI) initMessages() {
ui.messages.SetBorder(false) ui.messages.SetBorder(false)
} }
// escape returns text so that the message window shows it exactly
// as written. It writes each "[" as "[[:]": a "[" followed by
// another "[" never starts a tag, and the tag "[:]" changes
// nothing. tview.Escape is not enough: it does not escape a bracket
// pair holding a symbol such as "|" or "^", and nothing it adds
// stops a tag with a URL, such as "[:::y|z]".
func escape(text string) string {
return strings.ReplaceAll(text, "[", "[[:]")
}
func (ui *UI) initStatusBar() { func (ui *UI) initStatusBar() {
// Colour tags stay off. With them on, tview reads text in ui.statusBar = tview.NewTextView().
// square brackets, such as "[connected]" or part of a nick, SetDynamicColors(true)
// as a tag and drops it.
ui.statusBar = tview.NewTextView()
ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite) ui.statusBar.SetTextColor(tcell.ColorWhite)
} }
@@ -270,7 +213,10 @@ func (ui *UI) initInput() {
} }
ui.input.SetText("") ui.input.SetText("")
ui.callOnInput(text)
if ui.onInput != nil {
ui.onInput(text)
}
}) })
} }
@@ -281,11 +227,10 @@ func (ui *UI) initKeyCapture() {
return event return event
} }
// Alt and a digit runs /window with that digit.
// SwitchBuffer alone would leave the target as it was.
r := event.Rune() r := event.Rune()
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
ui.callOnInput("/window " + string(r)) idx := int(r - '0')
ui.SwitchBuffer(idx)
return nil return nil
} }
@@ -295,35 +240,8 @@ func (ui *UI) initKeyCapture() {
) )
} }
// callOnInput passes text to the input callback on a new
// goroutine. The key handlers call it on the event loop, and the
// callback waits for that loop to update the screen, so they must
// not wait for the callback. The goroutine first waits for the
// line before it to be handled, which keeps lines in the order
// they were typed.
func (ui *UI) callOnInput(text string) {
if ui.onInput == nil {
return
}
previous := ui.inputDone
done := make(chan struct{})
ui.inputDone = done
go func() {
if previous != nil {
<-previous
}
ui.onInput(text)
close(done)
}()
}
// refreshStatusBar redraws the status bar with the nick, target
// and connection state last passed to SetStatus.
func (ui *UI) refreshStatusBar() { func (ui *UI) refreshStatusBar() {
ui.renderStatusBar(ui.nick, ui.target, ui.connStatus) // Placeholder; full refresh needs nick/target context.
} }
func (ui *UI) renderStatusBar( func (ui *UI) renderStatusBar(
+7 -103
View File
@@ -3,12 +3,8 @@ package config
import ( import (
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"math"
"os" "os"
"strconv"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -32,8 +28,6 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" + const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
var errOutOfRange = errors.New("setting out of range")
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -69,8 +63,6 @@ type Config struct {
} }
// New creates a new Config by reading from files and environment variables. // New creates a new Config by reading from files and environment variables.
// It returns an error when a number, true/false or duration setting does
// not parse or is out of range.
func New( func New(
_ fx.Lifecycle, params Params, _ fx.Lifecycle, params Params,
) (*Config, error) { ) (*Config, error) {
@@ -115,27 +107,29 @@ func New(
cfg := &Config{ cfg := &Config{
DBURL: viper.GetString("DBURL"), DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"), OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"), IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
log: log, log: log,
params: &params, params: &params,
} }
err = parseSettings(cfg)
if err != nil {
return nil, err
}
// Viper counts an empty environment variable as unset, but an // Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off. // empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR") ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
@@ -150,93 +144,3 @@ func New(
return cfg, nil return cfg, nil
} }
// parseSettings reads the number and true/false settings into cfg and
// checks the duration settings. Each number and duration must be above
// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the
// system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error {
return errors.Join(
parseInt("PORT", 0, &cfg.Port),
parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits),
parseInt("LOGIN_RATE_BURST", 1, &cfg.LoginRateBurst),
parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit),
parseBool("DEBUG", &cfg.Debug),
parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode),
checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
)
}
// parseInt sets *value to the setting key read as a whole number. It
// returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error {
raw := viper.GetString(key)
parsed, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if parsed < minimum {
return fmt.Errorf("%w: %s is %s, but must be at least %d",
errOutOfRange, key, raw, minimum)
}
*value = parsed
return nil
}
// parseFloat sets *value to the setting key read as a floating-point
// number. It returns an error when the value does not parse or is not a
// finite number above zero.
func parseFloat(key string, value *float64) error {
raw := viper.GetString(key)
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 {
return fmt.Errorf(
"%w: %s is %s, but must be a finite number above zero",
errOutOfRange, key, raw)
}
*value = parsed
return nil
}
// parseBool sets *value to the setting key read as true or false.
func parseBool(key string, value *bool) error {
parsed, err := strconv.ParseBool(viper.GetString(key))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
*value = parsed
return nil
}
// checkDuration returns an error naming the setting key when value is
// not a Go duration above zero.
func checkDuration(key, value string) error {
duration, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if duration <= 0 {
return fmt.Errorf("%w: %s is %s, but must be above zero",
errOutOfRange, key, value)
}
return nil
}
+2 -149
View File
@@ -2,7 +2,6 @@ package config_test
import ( import (
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@@ -11,8 +10,7 @@ import (
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
) )
// loadConfig runs config.New and returns its result. func newConfig(t *testing.T) *config.Config {
func loadConfig(t *testing.T) (*config.Config, error) {
t.Helper() t.Helper()
globs := &globals.Globals{ globs := &globals.Globals{
@@ -26,15 +24,9 @@ func loadConfig(t *testing.T) (*config.Config, error) {
t.Fatal(err) t.Fatal(err)
} }
return config.New( cfg, err := config.New(
nil, config.Params{Globals: globs, Logger: log}, nil, config.Params{Globals: globs, Logger: log},
) )
}
func newConfig(t *testing.T) *config.Config {
t.Helper()
cfg, err := loadConfig(t)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -42,145 +34,6 @@ func newConfig(t *testing.T) *config.Config {
return cfg return cfg
} }
func TestUnsetSettingsKeepDefaults(t *testing.T) {
// Viper counts an empty environment variable as unset.
t.Setenv("PORT", "")
t.Setenv("MAX_MESSAGE_SIZE", "")
t.Setenv("NEOIRC_HASHCASH_BITS", "")
t.Setenv("LOGIN_RATE_BURST", "")
t.Setenv("LOGIN_RATE_LIMIT", "")
t.Setenv("DEBUG", "")
t.Setenv("MAINTENANCE_MODE", "")
t.Setenv("MESSAGE_MAX_AGE", "")
t.Setenv("QUEUE_MAX_AGE", "")
t.Setenv("SESSION_IDLE_TIMEOUT", "")
// Each duration setting defaults to 30 days.
const thirtyDays = "720h"
want := settings{
Port: 8080,
MaxMessageSize: 4096,
HashcashBits: 20,
LoginRateBurst: 5,
LoginRateLimit: 1,
Debug: false,
MaintenanceMode: false,
MessageMaxAge: thirtyDays,
QueueMaxAge: thirtyDays,
SessionIdleTimeout: thirtyDays,
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
func TestValidSettingsAreUsed(t *testing.T) {
// Each whole number is the lowest value its setting allows, and 1ns
// is the shortest duration allowed.
t.Setenv("PORT", "0")
t.Setenv("MAX_MESSAGE_SIZE", "1")
t.Setenv("NEOIRC_HASHCASH_BITS", "0")
t.Setenv("LOGIN_RATE_BURST", "1")
t.Setenv("LOGIN_RATE_LIMIT", "0.5")
t.Setenv("DEBUG", "true")
t.Setenv("MAINTENANCE_MODE", "true")
t.Setenv("MESSAGE_MAX_AGE", "1ns")
t.Setenv("QUEUE_MAX_AGE", "1h30m")
t.Setenv("SESSION_IDLE_TIMEOUT", "45m")
want := settings{
Port: 0,
MaxMessageSize: 1,
HashcashBits: 0,
LoginRateBurst: 1,
LoginRateLimit: 0.5,
Debug: true,
MaintenanceMode: true,
MessageMaxAge: "1ns",
QueueMaxAge: "1h30m",
SessionIdleTimeout: "45m",
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
// settings holds the Config fields whose settings config.New parses.
type settings struct {
Port int
MaxMessageSize int
HashcashBits int
LoginRateBurst int
LoginRateLimit float64
Debug bool
MaintenanceMode bool
MessageMaxAge string
QueueMaxAge string
SessionIdleTimeout string
}
func settingsOf(cfg *config.Config) settings {
return settings{
Port: cfg.Port,
MaxMessageSize: cfg.MaxMessageSize,
HashcashBits: cfg.HashcashBits,
LoginRateBurst: cfg.LoginRateBurst,
LoginRateLimit: cfg.LoginRateLimit,
Debug: cfg.Debug,
MaintenanceMode: cfg.MaintenanceMode,
MessageMaxAge: cfg.MessageMaxAge,
QueueMaxAge: cfg.QueueMaxAge,
SessionIdleTimeout: cfg.SessionIdleTimeout,
}
}
func TestBadSettingStopsStartup(t *testing.T) {
// For each setting, the first value does not parse and the others
// parse but are out of range.
tests := []struct {
key string
values []string
}{
{key: "PORT", values: []string{"80a", "-1"}},
{key: "MAX_MESSAGE_SIZE", values: []string{"4k", "0", "-4096"}},
{key: "NEOIRC_HASHCASH_BITS", values: []string{"twenty", "-1"}},
{key: "LOGIN_RATE_BURST", values: []string{"five", "0", "-5"}},
{key: "LOGIN_RATE_LIMIT", values: []string{
"fast", "0", "-1", "NaN", "Inf", "-Inf",
}},
{key: "DEBUG", values: []string{"yes"}},
{key: "MAINTENANCE_MODE", values: []string{"on"}},
{key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}},
{key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}},
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
}
for _, test := range tests {
for _, value := range test.values {
t.Run(test.key+"="+value, func(t *testing.T) {
t.Setenv(test.key, value)
_, err := loadConfig(t)
if err == nil {
t.Fatalf("%s=%q: got no error", test.key, value)
}
msg := err.Error()
if !strings.Contains(msg, test.key) ||
!strings.Contains(msg, value) {
t.Errorf("error %q does not name %s and %q",
msg, test.key, value)
}
})
}
}
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) { func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test. // t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "") t.Setenv("IRC_LISTEN_ADDR", "")
+8 -1
View File
@@ -10,6 +10,13 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
//nolint:gochecknoglobals // var so tests can override via SetBcryptCost
var bcryptCost = bcrypt.DefaultCost
// SetBcryptCost overrides the bcrypt cost.
// Use bcrypt.MinCost in tests to avoid slow hashing.
func SetBcryptCost(cost int) { bcryptCost = cost }
var errNoPassword = errors.New( var errNoPassword = errors.New(
"account has no password set", "account has no password set",
) )
@@ -22,7 +29,7 @@ func (database *Database) SetPassword(
password string, password string,
) error { ) error {
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.DefaultCost, []byte(password), bcryptCost,
) )
if err != nil { if err != nil {
return fmt.Errorf("hash password: %w", err) return fmt.Errorf("hash password: %w", err)
+12 -33
View File
@@ -5,11 +5,9 @@ import (
"context" "context"
"database/sql" "database/sql"
"embed" "embed"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/url"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -24,10 +22,6 @@ import (
const minMigrationParts = 2 const minMigrationParts = 2
var errForeignKeysInDBURL = errors.New(
"DBURL must not set foreign_keys, which the server always turns on",
)
// SchemaFiles contains embedded SQL migration files. // SchemaFiles contains embedded SQL migration files.
// //
//go:embed schema/*.sql //go:embed schema/*.sql
@@ -85,39 +79,17 @@ func New(
return database, nil return database, nil
} }
// GetDB returns the underlying sql.DB connection.
func (database *Database) GetDB() *sql.DB {
return database.conn
}
func (database *Database) connect(ctx context.Context) error { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
dbURL = config.DefaultDBURL dbURL = config.DefaultDBURL
} }
// SQLite keeps the foreign keys setting per connection. The driver
// runs each _pragma parameter on every connection it opens. So the
// one added below also covers the connection the pool opens after
// a cancelled query breaks the old one. A foreign_keys pragma in
// DBURL could undo it, because the driver sorts the pragmas.
_, query, hasQuery := strings.Cut(dbURL, "?")
urlParams, err := url.ParseQuery(query)
if err != nil {
return fmt.Errorf("parse DBURL: %w", err)
}
for _, pragma := range urlParams["_pragma"] {
if strings.Contains(strings.ToLower(pragma), "foreign_keys") {
return fmt.Errorf(
"%w: _pragma=%s", errForeignKeysInDBURL, pragma,
)
}
}
separator := "?"
if hasQuery {
separator = "&"
}
dbURL += separator + "_pragma=foreign_keys(1)"
database.log.Info( database.log.Info(
"connecting to database", "url", dbURL, "connecting to database", "url", dbURL,
) )
@@ -137,6 +109,13 @@ func (database *Database) connect(ctx context.Context) error {
database.conn = conn database.conn = conn
database.log.Info("database connected") database.log.Info("database connected")
_, err = database.conn.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
)
if err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
return database.runMigrations(ctx) return database.runMigrations(ctx)
} }
-203
View File
@@ -1,34 +1,19 @@
package db_test package db_test
import ( import (
"context"
"errors"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
) )
const wantBusyTimeoutMS = 5000 const wantBusyTimeoutMS = 5000
const (
// endlessQuery counts the rows of a recursive query that never
// stops, so it runs until its context ends.
endlessQuery = `WITH RECURSIVE n(x) AS
(SELECT 1 UNION ALL SELECT x + 1 FROM n)
SELECT COUNT(*) FROM n`
endlessQueryTimeout = 100 * time.Millisecond
)
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database // TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
// with the default URL's parameters the way the server does, // with the default URL's parameters the way the server does,
// and checks the journal mode and busy timeout it ends up with. // and checks the journal mode and busy timeout it ends up with.
@@ -88,191 +73,3 @@ func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
) )
} }
} }
// TestForeignKeysOnAfterCancelledQuery cancels a query while it
// runs, which makes the pool close its only connection and open a
// new one. A TEMP table lasts only as long as its connection, so
// its absence shows the connection was replaced. The test then
// checks that the file database keeps its tables and that the new
// connection has foreign keys on.
func TestForeignKeysOnAfterCancelledQuery(t *testing.T) {
t.Parallel()
_, defaultParams, _ := strings.Cut(config.DefaultDBURL, "?")
urlParams := map[string]string{
"default parameters": "?" + defaultParams,
"no parameters": "",
}
for name, params := range urlParams {
t.Run(name, func(t *testing.T) {
t.Parallel()
dbURL := "file://" +
filepath.Join(t.TempDir(), "state.db") + params
database, err := startDatabase(t, dbURL)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(
t.Context(), "CREATE TEMP TABLE first_connection (x)",
)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(
t.Context(), endlessQueryTimeout,
)
defer cancel()
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("endless query: err = %v", err)
}
tempTables := queryInt(
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
)
if tempTables != 0 {
t.Fatal("the pool kept its first connection")
}
migrations := queryInt(
t, database, "SELECT COUNT(*) FROM schema_migrations",
)
if migrations == 0 {
t.Error("schema_migrations is empty")
}
foreignKeys := queryInt(t, database, "PRAGMA foreign_keys")
if foreignKeys != 1 {
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
}
})
}
}
// TestNewURLKeepsDataAfterCancelledQuery cancels a query on a test
// database while it runs, which makes the pool close its only
// connection and open a new one. A TEMP table lasts only as long as
// its connection, so its absence shows the connection was replaced.
// The database must still have its tables and rows.
func TestNewURLKeepsDataAfterCancelledQuery(t *testing.T) {
t.Parallel()
database, err := startDatabase(t, dbtest.NewURL(t))
if err != nil {
t.Fatal(err)
}
_, err = database.GetOrCreateChannel(t.Context(), "#test")
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(
t.Context(), "CREATE TEMP TABLE first_connection (x)",
)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), endlessQueryTimeout)
defer cancel()
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("endless query: err = %v", err)
}
tempTables := queryInt(
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
)
if tempTables != 0 {
t.Fatal("the pool kept its first connection")
}
channels := queryInt(t, database, "SELECT COUNT(*) FROM channels")
if channels != 1 {
t.Errorf("channels = %d, want 1", channels)
}
}
// TestDBURLSettingForeignKeysFailsStartup checks that the database
// refuses to start when DBURL sets foreign_keys itself, however it
// is spelled.
func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) {
t.Parallel()
pragmas := []string{
"foreign_keys(0)",
"foreign_keys(off)",
"foreign_keys=0",
"FOREIGN_KEYS(FALSE)",
" Foreign_Keys = 1",
}
for _, pragma := range pragmas {
t.Run(pragma, func(t *testing.T) {
t.Parallel()
dbURL := "file://" +
filepath.Join(t.TempDir(), "state.db") +
"?_pragma=" + url.QueryEscape(pragma)
_, err := startDatabase(t, dbURL)
if err == nil {
t.Fatal("the database started")
}
if !strings.Contains(err.Error(), "DBURL") {
t.Errorf("error %q does not name DBURL", err)
}
})
}
}
// startDatabase starts a Database for dbURL the way the server does.
// It returns the error that stopped it from starting, if any.
func startDatabase(t *testing.T, dbURL string) (*db.Database, error) {
t.Helper()
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(lifecycle.RequireStop)
return database, lifecycle.Start(t.Context())
}
// queryInt runs a query that returns a single number.
func queryInt(t *testing.T, database *db.Database, query string) int {
t.Helper()
var result int
err := database.GetDB().QueryRowContext(t.Context(), query).Scan(&result)
if err != nil {
t.Fatal(err)
}
return result
}
+12 -83
View File
@@ -5,17 +5,21 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"testing" "sync/atomic"
"time"
"sneak.berlin/go/neoirc/internal/dbtest"
) )
// NewTestDatabase creates an in-memory database for testing. //nolint:gochecknoglobals // test counter
func NewTestDatabase(tb testing.TB) (*Database, error) { var testDBCounter atomic.Int64
tb.Helper()
dsn := dbtest.NewURL(tb) + "&_pragma=foreign_keys(1)" // NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) {
counter := testDBCounter.Add(1)
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)",
counter,
)
conn, err := sql.Open("sqlite", dsn) conn, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
@@ -68,78 +72,3 @@ func (database *Database) ExecForTest(
return nil return nil
} }
// GetDB returns the underlying sql.DB connection.
func (database *Database) GetDB() *sql.DB {
return database.conn
}
// GetOrCreateChannel returns channel id, creating if needed.
// Uses INSERT OR IGNORE to avoid TOCTOU races.
func (database *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
var channelID int64
err = database.conn.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&channelID)
if err != nil {
return 0, fmt.Errorf("get channel: %w", err)
}
return channelID, nil
}
// JoinChannel adds a session to a channel.
func (database *Database) JoinChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, joined_at)
VALUES (?, ?, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf("join channel: %w", err)
}
return nil
}
// HasChannelInvite checks if a session has been invited
// to a channel.
func (database *Database) HasChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check invite: %w", err,
)
}
return count > 0, nil
}
+14
View File
@@ -0,0 +1,14 @@
package db_test
import (
"os"
"testing"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/db"
)
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
+200 -268
View File
@@ -264,25 +264,6 @@ func (database *Database) GetSessionByNick(
return sessionID, nil return sessionID, nil
} }
// GetSessionNick returns the nick a session holds.
func (database *Database) GetSessionNick(
ctx context.Context,
sessionID int64,
) (string, error) {
var nick string
err := database.conn.QueryRowContext(
ctx,
"SELECT nick FROM sessions WHERE id = ?",
sessionID,
).Scan(&nick)
if err != nil {
return "", fmt.Errorf("get session nick: %w", err)
}
return nick, nil
}
// SessionHostInfo holds the username, hostname, and IP // SessionHostInfo holds the username, hostname, and IP
// for a session. // for a session.
type SessionHostInfo struct { type SessionHostInfo struct {
@@ -320,6 +301,29 @@ type ClientHostInfo struct {
Hostname string Hostname string
} }
// GetClientHostInfo returns the IP and hostname for a
// client.
func (database *Database) GetClientHostInfo(
ctx context.Context,
clientID int64,
) (*ClientHostInfo, error) {
var info ClientHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT ip, hostname
FROM clients WHERE id = ?`,
clientID,
).Scan(&info.IP, &info.Hostname)
if err != nil {
return nil, fmt.Errorf(
"get client host info: %w", err,
)
}
return &info, nil
}
// SetSessionOper sets the is_oper flag on a session. // SetSessionOper sets the is_oper flag on a session.
func (database *Database) SetSessionOper( func (database *Database) SetSessionOper(
ctx context.Context, ctx context.Context,
@@ -410,6 +414,54 @@ func (database *Database) GetChannelByName(
return channelID, nil return channelID, nil
} }
// GetOrCreateChannel returns channel id, creating if needed.
// Uses INSERT OR IGNORE to avoid TOCTOU races.
func (database *Database) GetOrCreateChannel(
ctx context.Context,
name string,
) (int64, error) {
now := time.Now()
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, fmt.Errorf("create channel: %w", err)
}
var channelID int64
err = database.conn.QueryRowContext(
ctx,
"SELECT id FROM channels WHERE name = ?",
name,
).Scan(&channelID)
if err != nil {
return 0, fmt.Errorf("get channel: %w", err)
}
return channelID, nil
}
// JoinChannel adds a session to a channel.
func (database *Database) JoinChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members
(channel_id, session_id, joined_at)
VALUES (?, ?, ?)`,
channelID, sessionID, time.Now())
if err != nil {
return fmt.Errorf("join channel: %w", err)
}
return nil
}
// Errors that CreateOrJoinChannel refuses a join with. // Errors that CreateOrJoinChannel refuses a join with.
var ( var (
ErrBannedFromChannel = errors.New("banned from channel") ErrBannedFromChannel = errors.New("banned from channel")
@@ -802,6 +854,42 @@ func (database *Database) SetChannelTopicLocked(
return nil return nil
} }
// PartChannel removes a session from a channel.
func (database *Database) PartChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("part channel: %w", err)
}
return nil
}
// DeleteChannelIfEmpty removes a channel with no members.
func (database *Database) DeleteChannelIfEmpty(
ctx context.Context,
channelID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channels WHERE id = ?
AND NOT EXISTS
(SELECT 1 FROM channel_members
WHERE channel_id = ?)`,
channelID, channelID)
if err != nil {
return fmt.Errorf(
"delete channel if empty: %w", err,
)
}
return nil
}
// PartChannelAndDeleteIfEmpty removes a session from a // PartChannelAndDeleteIfEmpty removes a session from a
// channel and deletes the channel if that left it with no // channel and deletes the channel if that left it with no
// members, in one transaction, so that no JOIN lands // members, in one transaction, so that no JOIN lands
@@ -817,29 +905,7 @@ func (database *Database) PartChannelAndDeleteIfEmpty(
defer func() { _ = transaction.Rollback() }() defer func() { _ = transaction.Rollback() }()
err = partChannelAndDeleteIfEmptyInTx( _, err = transaction.ExecContext(ctx,
ctx, transaction, channelID, sessionID,
)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit part: %w", err)
}
return nil
}
// partChannelAndDeleteIfEmptyInTx is
// PartChannelAndDeleteIfEmpty, writing through transaction.
func partChannelAndDeleteIfEmptyInTx(
ctx context.Context,
transaction *sql.Tx,
channelID, sessionID int64,
) error {
_, err := transaction.ExecContext(ctx,
`DELETE FROM channel_members `DELETE FROM channel_members
WHERE channel_id = ? AND session_id = ?`, WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID) channelID, sessionID)
@@ -859,6 +925,11 @@ func partChannelAndDeleteIfEmptyInTx(
) )
} }
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit part: %w", err)
}
return nil return nil
} }
@@ -1056,6 +1127,23 @@ func (database *Database) GetChannelMemberIDs(
return scanInt64s(rows) return scanInt64s(rows)
} }
// GetSessionChannelIDs returns channel IDs for a session.
func (database *Database) GetSessionChannelIDs(
ctx context.Context,
sessionID int64,
) ([]int64, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get session channel ids: %w", err,
)
}
return scanInt64s(rows)
}
// InsertMessage stores a message and returns its DB ID. // InsertMessage stores a message and returns its DB ID.
// toSessionID is the session a DM or INVITE is sent to and // toSessionID is the session a DM or INVITE is sent to and
// toChannelID the channel a channel message is sent to; // toChannelID the channel a channel message is sent to;
@@ -1068,40 +1156,6 @@ func (database *Database) InsertMessage(
params json.RawMessage, params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) (int64, string, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, "", fmt.Errorf("begin insert message: %w", err)
}
defer func() { _ = transaction.Rollback() }()
dbID, msgUUID, err := insertMessageInTx(
ctx, transaction, command, from, target,
toSessionID, toChannelID, params, body, meta,
)
if err != nil {
return 0, "", err
}
err = transaction.Commit()
if err != nil {
return 0, "", fmt.Errorf("commit insert message: %w", err)
}
return dbID, msgUUID, nil
}
// insertMessageInTx is InsertMessage, writing through
// transaction.
func insertMessageInTx(
ctx context.Context,
transaction *sql.Tx,
command, from, target string,
toSessionID, toChannelID int64,
params json.RawMessage,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
msgUUID := uuid.New().String() msgUUID := uuid.New().String()
now := time.Now().UTC() now := time.Now().UTC()
@@ -1118,7 +1172,7 @@ func insertMessageInTx(
meta = json.RawMessage("{}") meta = json.RawMessage("{}")
} }
res, err := transaction.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
to_session_id, to_channel_id, to_session_id, to_channel_id,
@@ -1139,49 +1193,19 @@ func insertMessageInTx(
return dbID, msgUUID, nil return dbID, msgUUID, nil
} }
// EnqueueToSession adds a message to the queues of all // EnqueueToSession adds a message to all clients of a
// clients of a session except client skipClientID, or of all // session's queues.
// of them when skipClientID is 0.
func (database *Database) EnqueueToSession( func (database *Database) EnqueueToSession(
ctx context.Context, ctx context.Context,
sessionID, messageID, skipClientID int64, sessionID, messageID int64,
) error { ) error {
transaction, err := database.conn.BeginTx(ctx, nil) _, err := database.conn.ExecContext(ctx,
if err != nil {
return fmt.Errorf("begin enqueue: %w", err)
}
defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx(
ctx, transaction, sessionID, messageID, skipClientID,
)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit enqueue: %w", err)
}
return nil
}
// enqueueToSessionInTx is EnqueueToSession, writing through
// transaction.
func enqueueToSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID, messageID, skipClientID int64,
) error {
_, err := transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues `INSERT OR IGNORE INTO client_queues
(client_id, message_id, created_at) (client_id, message_id, created_at)
SELECT c.id, ?, ? SELECT c.id, ?, ?
FROM clients c FROM clients c
WHERE c.session_id = ? AND c.id != ?`, WHERE c.session_id = ?`,
messageID, time.Now(), sessionID, skipClientID) messageID, time.Now(), sessionID)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"enqueue to session: %w", err, "enqueue to session: %w", err,
@@ -1446,175 +1470,38 @@ func (database *Database) SetTopic(
return nil return nil
} }
// QuitSession ends a session in one transaction: it parts // DeleteSession removes a session and all its data.
// the session from its channels, deleting each channel this func (database *Database) DeleteSession(
// leaves with no members, deletes the session with its
// clients, and stores a QUIT from nick with body and queues
// it to the other sessions that were on those channels. It
// returns those sessions, for the caller to wake once the
// QUIT is committed. Of several calls for one session at
// once, only the first finds the session; the others change
// nothing and return none.
func (database *Database) QuitSession(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick string, ) error {
body json.RawMessage, _, err := database.conn.ExecContext(
) ([]int64, error) { ctx,
transaction, err := database.conn.BeginTx(ctx, nil) "DELETE FROM sessions WHERE id = ?",
if err != nil {
return nil, fmt.Errorf("begin quit: %w", err)
}
defer func() { _ = transaction.Rollback() }()
peerIDs, err := quitSessionInTx(
ctx, transaction, sessionID, nick, body,
)
if err != nil {
return nil, err
}
err = transaction.Commit()
if err != nil {
return nil, fmt.Errorf("commit quit: %w", err)
}
return peerIDs, nil
}
// LogoutClient deletes a client of a session and, if that
// leaves the session with no clients, does what QuitSession
// does, in one transaction. It returns what QuitSession
// returns, or none if the session keeps a client.
func (database *Database) LogoutClient(
ctx context.Context,
sessionID, clientID int64,
nick string,
body json.RawMessage,
) ([]int64, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin logout: %w", err)
}
defer func() { _ = transaction.Rollback() }()
_, err = transaction.ExecContext(ctx,
"DELETE FROM clients WHERE id = ?", clientID)
if err != nil {
return nil, fmt.Errorf("delete client: %w", err)
}
var remaining int64
err = transaction.QueryRowContext(ctx,
"SELECT COUNT(*) FROM clients WHERE session_id = ?",
sessionID, sessionID,
).Scan(&remaining)
if err != nil {
return nil, fmt.Errorf("count clients: %w", err)
}
var peerIDs []int64
if remaining == 0 {
peerIDs, err = quitSessionInTx(
ctx, transaction, sessionID, nick, body,
) )
if err != nil { if err != nil {
return nil, err return fmt.Errorf("delete session: %w", err)
}
} }
err = transaction.Commit() return nil
if err != nil {
return nil, fmt.Errorf("commit logout: %w", err)
}
return peerIDs, nil
} }
// quitSessionInTx is QuitSession, writing through // DeleteClient removes a single client record by ID.
// transaction. func (database *Database) DeleteClient(
func quitSessionInTx(
ctx context.Context, ctx context.Context,
transaction *sql.Tx, clientID int64,
sessionID int64, ) error {
nick string, _, err := database.conn.ExecContext(
body json.RawMessage, ctx,
) ([]int64, error) { "DELETE FROM clients WHERE id = ?",
rows, err := transaction.QueryContext(ctx, clientID,
`SELECT DISTINCT session_id FROM channel_members
WHERE session_id != ? AND channel_id IN
(SELECT channel_id FROM channel_members
WHERE session_id = ?)`,
sessionID, sessionID)
if err != nil {
return nil, fmt.Errorf("get channel peers: %w", err)
}
peerIDs, err := scanInt64s(rows)
if err != nil {
return nil, err
}
rows, err = transaction.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get session channel ids: %w", err,
)
}
channelIDs, err := scanInt64s(rows)
if err != nil {
return nil, err
}
for _, channelID := range channelIDs {
err = partChannelAndDeleteIfEmptyInTx(
ctx, transaction, channelID, sessionID,
) )
if err != nil { if err != nil {
return nil, err return fmt.Errorf("delete client: %w", err)
}
} }
result, err := transaction.ExecContext(ctx, return nil
"DELETE FROM sessions WHERE id = ?", sessionID)
if err != nil {
return nil, fmt.Errorf("delete session: %w", err)
}
deletedRows, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("delete session: %w", err)
}
if deletedRows == 0 || len(peerIDs) == 0 {
return nil, nil
}
messageID, _, err := insertMessageInTx(
ctx, transaction, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil,
)
if err != nil {
return nil, err
}
for _, peerID := range peerIDs {
err = enqueueToSessionInTx(
ctx, transaction, peerID, messageID, 0,
)
if err != nil {
return nil, err
}
}
return peerIDs, nil
} }
// GetUserCount returns the number of active users. // GetUserCount returns the number of active users.
@@ -1656,6 +1543,29 @@ func (database *Database) GetOperCount(
return count, nil return count, nil
} }
// ClientCountForSession returns the number of clients
// belonging to a session.
func (database *Database) ClientCountForSession(
ctx context.Context,
sessionID int64,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
`SELECT COUNT(*) FROM clients
WHERE session_id = ?`,
sessionID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"client count for session: %w", err,
)
}
return count, nil
}
// DeleteStaleUsers removes clients not seen since the // DeleteStaleUsers removes clients not seen since the
// cutoff and cleans up orphaned users (sessions). // cutoff and cleans up orphaned users (sessions).
func (database *Database) DeleteStaleUsers( func (database *Database) DeleteStaleUsers(
@@ -2372,6 +2282,28 @@ func (database *Database) AddChannelInvite(
return nil return nil
} }
// HasChannelInvite checks if a session has been invited
// to a channel.
func (database *Database) HasChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check invite: %w", err,
)
}
return count > 0, nil
}
// --- Tier 2: Secret (+s) --- // --- Tier 2: Secret (+s) ---
// IsChannelSecret checks if a channel has +s mode. // IsChannelSecret checks if a channel has +s mode.
+44 -128
View File
@@ -2,7 +2,6 @@ package db_test
import ( import (
"encoding/json" "encoding/json"
"slices"
"testing" "testing"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -13,7 +12,7 @@ import (
func setupTestDB(t *testing.T) *db.Database { func setupTestDB(t *testing.T) *db.Database {
t.Helper() t.Helper()
database, err := db.NewTestDatabase(t) database, err := db.NewTestDatabase()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -125,7 +124,7 @@ func TestCreateSessionStoresIP(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sessionID, _, _, err := database.CreateSession( sessionID, clientID, _, err := database.CreateSession(
ctx, "ipuser", "ident", "host.example.com", ctx, "ipuser", "ident", "host.example.com",
"192.168.1.42", "192.168.1.42",
) )
@@ -147,8 +146,8 @@ func TestCreateSessionStoresIP(t *testing.T) {
) )
} }
clientInfo, err := database.GetLatestClientForSession( clientInfo, err := database.GetClientHostInfo(
ctx, sessionID, ctx, clientID,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -169,6 +168,19 @@ func TestCreateSessionStoresIP(t *testing.T) {
} }
} }
func TestGetClientHostInfoNotFound(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
_, err := database.GetClientHostInfo(
t.Context(), 99999,
)
if err == nil {
t.Fatal("expected error for nonexistent client")
}
}
func TestGetSessionHostInfoNotFound(t *testing.T) { func TestGetSessionHostInfoNotFound(t *testing.T) {
t.Parallel() t.Parallel()
@@ -399,7 +411,7 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid) err = database.PartChannel(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -410,10 +422,7 @@ func TestJoinAndPart(t *testing.T) {
} }
} }
// TestPartChannelAndDeleteIfEmpty checks that a part keeps func TestDeleteChannelIfEmpty(t *testing.T) {
// a channel another session is still on and deletes the
// channel when its last member parts.
func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
@@ -426,13 +435,9 @@ func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
nicks := []string{"temp1", "temp2"} sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
sids := make([]int64, 0, len(nicks)) if err != nil {
t.Fatal(err)
for _, nick := range nicks {
sid, _, _, createErr := database.CreateSession(ctx, nick, "", "", "")
if createErr != nil {
t.Fatal(createErr)
} }
err = database.JoinChannel(ctx, chID, sid) err = database.JoinChannel(ctx, chID, sid)
@@ -440,20 +445,12 @@ func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
sids = append(sids, sid) err = database.PartChannel(ctx, chID, sid)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[0])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err = database.GetChannelByName(ctx, "#empty") err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil {
t.Fatalf("channel deleted while a member is on it: %v", err)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[1])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -662,7 +659,7 @@ func TestPollMessages(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.EnqueueToSession(ctx, sid, dbID, 0) err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -780,11 +777,6 @@ func TestGetHistoryReusedNames(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, oldChID, oldSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hi"]`) body := json.RawMessage(`["hi"]`)
_, _, err = database.InsertMessage( _, _, err = database.InsertMessage(
@@ -807,9 +799,12 @@ func TestGetHistoryReusedNames(t *testing.T) {
t.Fatal("expected one DM and one channel message") t.Fatal("expected one DM and one channel message")
} }
// The session is the channel's only member, so quitting err = database.DeleteSession(ctx, oldSID)
// deletes the channel too. if err != nil {
_, err = database.QuitSession(ctx, oldSID, "reused", nil) t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, oldChID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -831,42 +826,34 @@ func TestGetHistoryReusedNames(t *testing.T) {
} }
} }
// TestQuitSession checks that QuitSession deletes the func TestDeleteSession(t *testing.T) {
// session, keeps a channel another session is still on,
// deletes a channel it leaves with no members, and queues
// one QUIT to the other session; and that a second call for
// the same session changes nothing.
func TestQuitSession(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sid, sharedID, _ := createSessionWithChannels( sid, _, _, err := database.CreateSession(
t, database, "deleteme", "#shared", "#alone", ctx, "deleteme", "", "", "",
)
peerSID, peerClientID, _, err := database.CreateSession(
ctx, "peer", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, sharedID, peerSID) chID, err := database.GetOrCreateChannel(
ctx, "#delchan",
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
body := json.RawMessage(`["bye"]`) err = database.JoinChannel(ctx, chID, sid)
peerIDs, err := database.QuitSession(ctx, sid, "deleteme", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !slices.Equal(peerIDs, []int64{peerSID}) { err = database.DeleteSession(ctx, sid)
t.Fatalf("got peers %v, want [%d]", peerIDs, peerSID) if err != nil {
t.Fatal(err)
} }
_, err = database.GetSessionByNick(ctx, "deleteme") _, err = database.GetSessionByNick(ctx, "deleteme")
@@ -874,80 +861,9 @@ func TestQuitSession(t *testing.T) {
t.Fatal("session should be deleted") t.Fatal("session should be deleted")
} }
ids, _ := database.GetChannelMemberIDs(ctx, sharedID) ids, _ := database.GetChannelMemberIDs(ctx, chID)
if !slices.Equal(ids, []int64{peerSID}) { if len(ids) != 0 {
t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID) t.Fatal("expected no members after deletion")
}
_, err = database.GetChannelByName(ctx, "#alone")
if err == nil {
t.Fatal("expected the emptied channel to be deleted")
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
peerIDs, err = database.QuitSession(ctx, sid, "deleteme", body)
if err != nil || len(peerIDs) != 0 {
t.Fatalf("second quit: got %v, %v; want none", peerIDs, err)
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
}
// checkOneQuitQueued fails the test unless the queue of the
// client clientID holds one message, a QUIT from nick.
func checkOneQuitQueued(
t *testing.T,
database *db.Database,
clientID int64,
nick string,
) {
t.Helper()
msgs, _, err := database.PollMessages(t.Context(), clientID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Command != "QUIT" || msgs[0].From != nick {
t.Fatalf("queue: got %v, want one QUIT from %s", msgs, nick)
}
}
// TestLogoutClientFailureKeepsClient checks that when the
// session of a logout's last client cannot be deleted,
// LogoutClient fails and the client stays logged in.
func TestLogoutClientFailureKeepsClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, clientID, token, err := database.CreateSession(
ctx, "stuck", "", "", "",
)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(ctx,
`CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions
BEGIN
SELECT RAISE(ABORT, 'sessions are kept');
END`)
if err != nil {
t.Fatal(err)
}
_, err = database.LogoutClient(ctx, sid, clientID, "stuck", nil)
if err == nil {
t.Fatal("logout succeeded although the session was kept")
}
_, _, nick, err := database.GetSessionByToken(ctx, token)
if err != nil || nick != "stuck" {
t.Fatalf("client gone after a failed logout: %q, %v", nick, err)
} }
} }
+25
View File
@@ -0,0 +1,25 @@
package db
import (
"context"
"database/sql"
"log/slog"
)
// NewTestDatabaseFromConn creates a Database wrapping an
// existing *sql.DB connection. Intended for integration
// tests in other packages.
func NewTestDatabaseFromConn(conn *sql.DB) *Database {
return &Database{ //nolint:exhaustruct
conn: conn,
log: slog.Default(),
}
}
// RunMigrations applies all schema migrations. Exposed
// for integration tests in other packages.
func (database *Database) RunMigrations(
ctx context.Context,
) error {
return database.runMigrations(ctx)
}
-59
View File
@@ -1,59 +0,0 @@
// Package dbtest gives each test a database of its own and runs raw
// SQL on it. Only tests may import it.
package dbtest
import (
"crypto/rand"
"database/sql"
"testing"
_ "modernc.org/sqlite" // registers the sqlite driver
)
// NewURL returns the URL of a new, empty in-memory database for one
// test. SQLite deletes an in-memory database when its last connection
// closes. A pool closes a connection when a query on it is cancelled
// while it runs. So this function holds a connection of its own open
// until the test ends.
func NewURL(tb testing.TB) string {
tb.Helper()
// A random name, so that no other test can share the database.
dbURL := "file:" + rand.Text() + "?mode=memory&cache=shared"
pool, err := sql.Open("sqlite", dbURL)
if err != nil {
tb.Fatalf("open test database: %v", err)
}
tb.Cleanup(func() { _ = pool.Close() })
conn, err := pool.Conn(tb.Context())
if err != nil {
tb.Fatalf("connect to test database: %v", err)
}
tb.Cleanup(func() { _ = conn.Close() })
return dbURL
}
// Exec runs one raw SQL statement on the database at dbURL, over a
// connection of its own. A test uses it for what the methods of
// db.Database cannot do, such as creating a trigger that makes a
// write fail.
func Exec(tb testing.TB, dbURL, statement string) {
tb.Helper()
pool, err := sql.Open("sqlite", dbURL)
if err != nil {
tb.Fatalf("open test database: %v", err)
}
defer func() { _ = pool.Close() }()
_, err = pool.ExecContext(tb.Context(), statement)
if err != nil {
tb.Fatalf("run %q: %v", statement, err)
}
}
+68 -125
View File
@@ -188,7 +188,7 @@ func (hdlr *Handlers) handleCreateSession(
var payload createRequest var payload createRequest
err := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
if !hdlr.checkHashcash( if !hdlr.validateHashcash(
writer, request, payload.Hashcash, writer, request, payload.Hashcash,
) { ) {
return return
@@ -232,15 +232,14 @@ func (hdlr *Handlers) handleCreateSession(
} }
hdlr.executeCreateSession( hdlr.executeCreateSession(
writer, request, writer, request, payload.Nick, username,
payload.Nick, username, payload.Hashcash,
) )
} }
func (hdlr *Handlers) executeCreateSession( func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
nick, username, stamp string, nick, username string,
) { ) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
@@ -248,17 +247,12 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP, request.Context(), remoteIP,
) )
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), request.Context(),
nick, username, hostname, remoteIP, nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
writer, request, err, writer, request, err,
) )
@@ -279,10 +273,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated) }, http.StatusCreated)
} }
// checkHashcash checks a hashcash stamp if required, // validateHashcash validates a hashcash stamp if required.
// without spending it. Returns false if the check failed // Returns false if validation failed and a response was
// and a response was already sent. // already sent.
func (hdlr *Handlers) checkHashcash( func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
stamp string, stamp string,
@@ -301,31 +295,7 @@ func (hdlr *Handlers) checkHashcash(
return false return false
} }
err := hdlr.hashcashVal.Check( err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if Claim refused the stamp, normally because
// it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
err := hdlr.hashcashVal.Claim(
stamp, hdlr.params.Config.HashcashBits, stamp, hdlr.params.Config.HashcashBits,
) )
if err != nil { if err != nil {
@@ -412,7 +382,7 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO // 004 RPL_MYINFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick, ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, service.UserModes, "Hbiklmnostv"}, []string{srvName, version, "", "ikmnostl"},
"", "",
) )
@@ -813,14 +783,7 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
timeout = maxLongPollTimeout timeout = maxLongPollTimeout
} }
// Register with the broker before reading the queue. msgs, lastQID, err := hdlr.params.Database.PollMessages(
// Notify wakes only the channels registered at that
// moment, so a message queued after the read below
// must find this one there already.
waitCh := hdlr.broker.Wait(sessionID)
defer hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.pollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -848,22 +811,19 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll( hdlr.longPoll(
writer, request, writer, request,
waitCh, clientID, afterID, timeout, sessionID, clientID, afterID, timeout,
) )
} }
} }
// longPoll waits up to timeout seconds for the broker to
// signal waitCh, then answers with the messages queued for
// clientID after afterID. It answers nothing if the client
// goes away first.
func (hdlr *Handlers) longPoll( func (hdlr *Handlers) longPoll(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
waitCh <-chan struct{}, sessionID, clientID, afterID int64,
clientID, afterID int64,
timeout int, timeout int,
) { ) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer( timer := time.NewTimer(
time.Duration(timeout) * time.Second, time.Duration(timeout) * time.Second,
) )
@@ -874,10 +834,14 @@ func (hdlr *Handlers) longPoll(
case <-waitCh: case <-waitCh:
case <-timer.C: case <-timer.C:
case <-request.Context().Done(): case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return return
} }
msgs, lastQID, err := hdlr.pollMessages( hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -925,7 +889,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
var payload commandRequest var payload commandRequest
err := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -994,7 +958,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, command, target, body, meta, bodyLines,
) )
case irc.CmdJoin: case irc.CmdJoin:
hdlr.handleJoin( hdlr.handleJoin(
@@ -1139,18 +1103,8 @@ func (hdlr *Handlers) handlePrivmsg(
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
bodyLines func() []string,
) { ) {
lines, ok := checkBodyAndMeta(body, meta)
if !ok {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
if target == "" { if target == "" {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1165,6 +1119,7 @@ func (hdlr *Handlers) handlePrivmsg(
return return
} }
lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1198,44 +1153,6 @@ func (hdlr *Handlers) handlePrivmsg(
) )
} }
// checkBodyAndMeta returns the lines in body. It returns false
// when body is present and is not an array of strings, or meta
// is present and is not an object. Each line is decoded as any
// because decoding into []string turns a null line into "". A
// null body or meta decodes without an error into nil.
func checkBodyAndMeta(body, meta json.RawMessage) ([]string, bool) {
var lines []string
if body != nil {
var values []any
err := json.Unmarshal(body, &values)
if err != nil || values == nil {
return nil, false
}
for _, value := range values {
line, isString := value.(string)
if !isString {
return nil, false
}
lines = append(lines, line)
}
}
if meta != nil {
var fields map[string]any
err := json.Unmarshal(meta, &fields)
if err != nil || fields == nil {
return nil, false
}
}
return lines, true
}
// respondIRCError enqueues a numeric error reply, notifies // respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}. // the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError( func (hdlr *Handlers) respondIRCError(
@@ -1304,11 +1221,9 @@ func (hdlr *Handlers) handleChannelMsg(
) { ) {
ctx := request.Context() ctx := request.Context()
// Delegate validation + fan-out to service layer. The // Delegate validation + fan-out to service layer.
// fan-out includes the sender's own session, so all dbID, uuid, err := hdlr.svc.SendChannelMessage(
// their clients see the message in long-poll responses. ctx, sessionID, nick,
uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, 0, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1318,6 +1233,13 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
// HTTP echo: enqueue to sender so all their clients
// see the message in long-poll responses.
_ = hdlr.params.Database.EnqueueToSession(
ctx, sessionID, dbID,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": uuid, statusKey: "sent"}, map[string]string{"id": uuid, statusKey: "sent"},
http.StatusOK) http.StatusOK)
@@ -1332,7 +1254,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage, meta json.RawMessage,
) { ) {
result, err := hdlr.svc.SendDirectMessage( result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1403,7 +1325,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) { ) {
result, err := hdlr.svc.JoinChannel( result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, suppliedKey, channel, suppliedKey,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1587,7 +1509,7 @@ func (hdlr *Handlers) handlePart(
} }
err := hdlr.svc.PartChannel( err := hdlr.svc.PartChannel(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, reason, nick, channel, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1658,7 +1580,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string, nick, newNick string,
) { ) {
err := hdlr.svc.ChangeNick( err := hdlr.svc.ChangeNick(
request.Context(), sessionID, 0, nick, newNick, request.Context(), sessionID, nick, newNick,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
writer, request, writer, request,
@@ -1713,7 +1635,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ") topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic( err := hdlr.svc.SetTopic(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, topic, nick, channel, topic,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -2199,7 +2121,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeText, modeText,
) )
@@ -2245,7 +2167,7 @@ func (hdlr *Handlers) setChannelFlag(
} }
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeStr, modeStr,
) )
@@ -3238,7 +3160,7 @@ func (hdlr *Handlers) handleWho(
// 315 RPL_ENDOFWHO (empty result) // 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfWho, nick, ctx, clientID, irc.RplEndOfWho, nick,
[]string{channel}, []string{target},
"End of /WHO list", "End of /WHO list",
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
@@ -3473,11 +3395,15 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
err := hdlr.svc.Logout( ctx := request.Context()
request.Context(), sessionID, clientID, nick,
err := hdlr.params.Database.DeleteClient(
ctx, clientID,
) )
if err != nil { if err != nil {
hdlr.log.Error("logout failed", "error", err) hdlr.log.Error(
"delete client failed", "error", err,
)
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"internal error", "internal error",
@@ -3487,6 +3413,23 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.clearAuthCookie(writer) hdlr.clearAuthCookie(writer)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -3692,7 +3635,7 @@ func (hdlr *Handlers) handleKick(
} }
err := hdlr.svc.KickUser( err := hdlr.svc.KickUser(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -3765,7 +3708,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession( enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID, 0, request.Context(), sid, dbID,
) )
if enqErr != nil { if enqErr != nil {
hdlr.log.Error("enqueue failed", hdlr.log.Error("enqueue failed",
File diff suppressed because it is too large Load Diff
+5 -10
View File
@@ -1,18 +1,14 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
// A session password is 8 to 72 bytes; bcrypt cannot hash const minPasswordLength = 8
// a longer one.
const (
minPasswordLength = 8
maxPasswordLength = 72
)
// 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 {
@@ -54,7 +50,7 @@ func (hdlr *Handlers) handleLogin(
var payload loginRequest var payload loginRequest
err := decodeJSON(request, &payload) err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil { if err != nil {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
@@ -153,13 +149,12 @@ func (hdlr *Handlers) handlePass(
password := lines[0] password := lines[0]
if len(password) < minPasswordLength || if len(password) < minPasswordLength {
len(password) > maxPasswordLength {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass}, []string{irc.CmdPass},
"Password must be 8 to 72 bytes", "Password must be at least 8 characters",
) )
return return
-19
View File
@@ -1,19 +0,0 @@
package handlers
import (
"context"
"sneak.berlin/go/neoirc/internal/db"
)
// SetPollMessages makes HandleGetMessages read the client
// output queue with poll in place of Database.PollMessages.
func (hdlr *Handlers) SetPollMessages(
poll func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error),
) {
hdlr.pollMessages = poll
}
-24
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@@ -65,16 +64,6 @@ type Handlers struct {
loginLimiter *ratelimit.Limiter loginLimiter *ratelimit.Limiter
stats *stats.Tracker stats *stats.Tracker
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
// pollMessages is Database.PollMessages, with which
// HandleGetMessages reads the client output queue. Tests
// replace it to act just after a read, or to make a read
// fail.
pollMessages func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error)
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -106,7 +95,6 @@ func New(
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
loginLimiter: ratelimit.New(loginRate, loginBurst), loginLimiter: ratelimit.New(loginRate, loginBurst),
stats: params.Stats, stats: params.Stats,
pollMessages: params.Database.PollMessages,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
@@ -160,18 +148,6 @@ func (hdlr *Handlers) respondError(
) )
} }
// decodeJSON reads the whole request body and decodes it into dest.
// It fails if the body is larger than the http.MaxBytesReader limit,
// or holds anything other than one JSON value and whitespace.
func decodeJSON(request *http.Request, dest any) error {
body, err := io.ReadAll(request.Body)
if err != nil {
return err
}
return json.Unmarshal(body, dest)
}
func (hdlr *Handlers) idleTimeout() time.Duration { func (hdlr *Handlers) idleTimeout() time.Duration {
raw := hdlr.params.Config.SessionIdleTimeout raw := hdlr.params.Config.SessionIdleTimeout
if raw == "" { if raw == "" {
+1 -10
View File
@@ -1,3 +1,4 @@
//nolint:paralleltest // global viper, as in api_test.go
package handlers_test package handlers_test
import ( import (
@@ -71,8 +72,6 @@ func numericBody(
} }
func TestUserhost(t *testing.T) { func TestUserhost(t *testing.T) {
t.Parallel()
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("alice") token := tserver.createSession("alice")
@@ -110,8 +109,6 @@ func TestUserhost(t *testing.T) {
} }
func TestVersionAdminInfoTime(t *testing.T) { func TestVersionAdminInfoTime(t *testing.T) {
t.Parallel()
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("frank") token := tserver.createSession("frank")
@@ -150,8 +147,6 @@ func TestVersionAdminInfoTime(t *testing.T) {
} }
func TestKill(t *testing.T) { func TestKill(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t) tserver := newTestServerWithOper(t)
victimToken := tserver.createSession("victim") victimToken := tserver.createSession("victim")
@@ -218,8 +213,6 @@ func TestKill(t *testing.T) {
} }
func TestWallops(t *testing.T) { func TestWallops(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t) tserver := newTestServerWithOper(t)
receiverToken := tserver.createSession("receiver") receiverToken := tserver.createSession("receiver")
@@ -265,8 +258,6 @@ func TestWallops(t *testing.T) {
} }
func TestUserMode(t *testing.T) { func TestUserMode(t *testing.T) {
t.Parallel()
const ( const (
nick = "alice" nick = "alice"
other = "other" other = "other"
+9 -53
View File
@@ -77,13 +77,9 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Check returns nil if requiredBits is not positive, or if // Validate checks a hashcash stamp. It returns nil if the
// the stamp is a well-formed, recently dated proof of work // stamp is valid and has not been seen before.
// for this resource with at least requiredBits. It does not func (v *Validator) Validate(
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other
// checks.
func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
) error { ) error {
@@ -91,49 +87,9 @@ func (v *Validator) Check(
return nil return nil
} }
_, err := v.check(stamp, requiredBits)
return err
}
// Claim checks the stamp as Check does and, when
// requiredBits is positive, records it as spent until it
// expires. It returns an error if the stamp is already
// spent. Release gives a claimed stamp back.
func (v *Validator) Claim(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
stampTime, err := v.check(stamp, requiredBits)
if err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
// Release forgets a claimed stamp so that it can be claimed
// again, for a request that failed after its Claim.
// Releasing a stamp that is not claimed does nothing.
func (v *Validator) Release(stamp string) {
v.mu.Lock()
defer v.mu.Unlock()
delete(v.spent, stamp)
}
// check returns the stamp's date if Check accepts it.
func (v *Validator) check(
stamp string,
requiredBits int,
) (time.Time, error) {
parts := strings.Split(stamp, ":") parts := strings.Split(stamp, ":")
if len(parts) != stampFields { if len(parts) != stampFields {
return time.Time{}, fmt.Errorf( return fmt.Errorf(
"%w: expected %d, got %d", "%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts), errInvalidFields, stampFields, len(parts),
) )
@@ -148,25 +104,25 @@ func (v *Validator) check(
version, bitsStr, resource, requiredBits, version, bitsStr, resource, requiredBits,
) )
if err != nil { if err != nil {
return time.Time{}, err return err
} }
stampTime, err := parseStampDate(dateStr) stampTime, err := parseStampDate(dateStr)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateTime(stampTime) err = validateTime(stampTime)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateProof(stamp, requiredBits) err = validateProof(stamp, requiredBits)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
return stampTime, nil return v.checkAndRecordStamp(stamp, stampTime)
} }
func (v *Validator) validateHeader( func (v *Validator) validateHeader(
+14 -76
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("valid stamp rejected: %v", err) t.Fatalf("valid stamp rejected: %v", err)
} }
@@ -99,74 +99,17 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("first use failed: %v", err) t.Fatalf("first use failed: %v", err)
} }
err = validator.Claim(stamp, testBits) err = validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("replay not detected") t.Fatal("replay not detected")
} }
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once, and that
// Check still accepts it once it is spent.
func TestCheckDoesNotSpendStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
for range 2 {
err := validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after check failed: %v", err)
}
err = validator.Claim(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
err = validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("spent stamp rejected by Check: %v", err)
}
}
// TestReleasedStampCanBeClaimedAgain checks that Release
// gives a claimed stamp back.
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("first claim failed: %v", err)
}
validator.Release(stamp)
err = validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after release failed: %v", err)
}
}
func TestResourceMismatch(t *testing.T) { func TestResourceMismatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -175,7 +118,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(), t, testBits, "wrong-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected resource mismatch error") t.Fatal("expected resource mismatch error")
} }
@@ -186,7 +129,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Claim( err := validator.Validate(
"not:a:valid:stamp", testBits, "not:a:valid:stamp", testBits,
) )
if err == nil { if err == nil {
@@ -203,7 +146,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource", testBits, todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad version error") t.Fatal("expected bad version error")
} }
@@ -219,7 +162,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource", todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected insufficient bits error") t.Fatal("expected insufficient bits error")
} }
@@ -235,7 +178,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate, t, testBits, "test-resource", oldDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected expired stamp error") t.Fatal("expected expired stamp error")
} }
@@ -246,14 +189,9 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Check("garbage", 0) err := validator.Validate("garbage", 0)
if err != nil { if err != nil {
t.Fatalf("zero bits should skip the check: %v", err) t.Fatalf("zero bits should skip: %v", err)
}
err = validator.Claim("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the claim: %v", err)
} }
} }
@@ -266,7 +204,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate, t, testBits, "test-resource", longDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("long date stamp rejected: %v", err) t.Fatalf("long date stamp rejected: %v", err)
} }
@@ -281,7 +219,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource", testBits, "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad date error") t.Fatal("expected bad date error")
} }
@@ -297,7 +235,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("unique stamp rejected: %v", err) t.Fatalf("unique stamp rejected: %v", err)
} }
@@ -313,7 +251,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(), t, 4, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"higher-difficulty stamp rejected: %v", "higher-difficulty stamp rejected: %v",
+29 -32
View File
@@ -81,10 +81,10 @@ func (c *Conn) handleNick(
} }
oldMask := c.hostmask() oldMask := c.hostmask()
oldNick := c.currentNick() oldNick := c.nick
err := c.svc.ChangeNick( err := c.svc.ChangeNick(
ctx, c.sessionID, c.clientID, oldNick, newNick, ctx, c.sessionID, oldNick, newNick,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -92,10 +92,9 @@ func (c *Conn) handleNick(
return return
} }
// Take the nick the session holds, not newNick: another c.mu.Lock()
// client of the session may have changed it again since c.nick = newNick
// ChangeNick stored newNick. c.mu.Unlock()
c.loadSessionNick(ctx)
// Echo NICK change to the client on wire. // Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick)) c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -129,8 +128,8 @@ func (c *Conn) handlePrivmsg(
body, _ := json.Marshal([]string{text}) //nolint:errchkjson body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
_, err := c.svc.SendChannelMessage( _, _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -138,7 +137,7 @@ func (c *Conn) handlePrivmsg(
} }
} else { } else {
result, err := c.svc.SendDirectMessage( result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -200,7 +199,7 @@ func (c *Conn) joinChannel(
channel, key string, channel, key string,
) { ) {
result, err := c.svc.JoinChannel( result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, key, ctx, c.sessionID, c.nick, channel, key,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -338,7 +337,7 @@ func (c *Conn) partChannel(
channel, reason string, channel, reason string,
) { ) {
err := c.svc.PartChannel( err := c.svc.PartChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason, ctx, c.sessionID, c.nick, channel, reason,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -371,7 +370,6 @@ func (c *Conn) handleQuit(msg *Message) {
c.mu.Lock() c.mu.Lock()
c.closed = true c.closed = true
c.quitReason = reason
c.mu.Unlock() c.mu.Unlock()
} }
@@ -403,7 +401,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1] newTopic := msg.Params[1]
err := c.svc.SetTopic( err := c.svc.SetTopic(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic, ctx, c.sessionID, c.nick, channel, newTopic,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -710,7 +708,7 @@ func (c *Conn) applyChannelModes(
c.send(FormatMessage(c.hostmask(), "MODE", params...)) c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.svc.BroadcastMode( c.svc.BroadcastMode(
ctx, c.clientID, c.currentNick(), channel, chID, modeReply, ctx, c.nick, channel, chID, modeReply,
) )
} }
} }
@@ -1187,7 +1185,7 @@ func (c *Conn) handleKick(
} }
err := c.svc.KickUser( err := c.svc.KickUser(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if err != nil { if err != nil {
@@ -1218,21 +1216,19 @@ func (c *Conn) handlePassPostReg(
} }
password := msg.Params[0] password := msg.Params[0]
if !c.checkPasswordLength(password) { if len(password) < minPasswordLen {
c.sendFromServer("NOTICE", c.nick,
"Password must be at least 8 characters",
)
return return
} }
err := c.setPassword(ctx, password) c.setPassword(ctx, password)
if err != nil {
return
}
// The IRC listener cannot add a connection to an existing c.sendFromServer("NOTICE", c.nick,
// session; only the HTTP API login can. "Password set. You can reconnect using "+
c.sendFromServer("NOTICE", c.currentNick(), "PASS <password> with your nick.",
"Password set. Another client can log in to this "+
"session over the HTTP API: POST /api/v1/login "+
"with your nick and this password.",
) )
} }
@@ -1254,7 +1250,7 @@ func (c *Conn) handleInvite(
channel := msg.Params[1] channel := msg.Params[1]
targetID, err := c.svc.InviteUser( targetID, err := c.svc.InviteUser(
ctx, c.sessionID, c.currentNick(), targetNick, channel, ctx, c.sessionID, c.nick, targetNick, channel,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -1266,13 +1262,14 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel, irc.RplInviting, targetNick, channel,
) )
// Queue the INVITE to the target with the body the HTTP // Send INVITE notice to target via service fan-out.
// API uses. body, _ := json.Marshal( //nolint:errchkjson
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson []string{"You have been invited to " + channel},
)
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.currentNick(), targetNick, targetID, 0, ctx, "INVITE", c.nick, targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, 0, nil, body, nil, []int64{targetID},
) )
} }
+17 -72
View File
@@ -27,7 +27,6 @@ const (
pingInterval = 90 * time.Second pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second pongDeadline = 30 * time.Second
minPasswordLen = 8 minPasswordLen = 8
maxPasswordLen = 72 // bcrypt cannot hash a longer one
maxHashcashBits = 40 maxHashcashBits = 40
) )
@@ -63,20 +62,15 @@ type Conn struct {
registered bool registered bool
gotNick bool gotNick bool
gotUser bool gotUser bool
gotPass bool
passWord string passWord string
lastQueueID int64 lastQueueID int64
closed bool closed bool
killed bool killed bool
// quitReason is the reason in the QUIT that peers are
// sent when the connection ends. The client's QUIT
// replaces it.
quitReason string
} }
func newConn( func newConn(
ctx context.Context,
tcpConn net.Conn, tcpConn net.Conn,
log *slog.Logger, log *slog.Logger,
database *db.Database, database *db.Database,
@@ -100,7 +94,7 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
quitReason: "Connection closed", hostname: resolveHost(ctx, host),
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -218,15 +212,12 @@ func resolveHost(ctx context.Context, addr string) string {
} }
// serve is the main loop for a single IRC client connection. // serve is the main loop for a single IRC client connection.
// It first sets the client's hostname to what resolve returns
// for the client's address.
// Cancelling ctx when it returns stops the relay goroutine. // Cancelling ctx when it returns stops the relay goroutine.
// When nothing has been read for interval the client is sent // When nothing has been read for interval the client is sent
// a PING, and when nothing is read within deadline after that // a PING, and when nothing is read within deadline after that
// the connection is closed. // the connection is closed.
func (c *Conn) serve( func (c *Conn) serve(
ctx context.Context, ctx context.Context,
resolve func(ctx context.Context, addr string) string,
interval, deadline time.Duration, interval, deadline time.Duration,
) { ) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -235,11 +226,6 @@ func (c *Conn) serve(
// still end the session then. // still end the session then.
defer c.cleanup(context.WithoutCancel(ctx)) defer c.cleanup(context.WithoutCancel(ctx))
// The hostname is looked up here, on this connection's
// goroutine, and not when the connection is accepted, so
// that a slow lookup holds up only this client.
c.hostname = resolve(ctx, c.remoteIP)
ping := time.AfterFunc(interval, func() { ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx) c.send("PING :" + c.serverSfx)
}) })
@@ -284,7 +270,6 @@ func (c *Conn) cleanup(ctx context.Context) {
wasKilled := c.killed wasKilled := c.killed
sessID := c.sessionID sessID := c.sessionID
nick := c.nick nick := c.nick
quitReason := c.quitReason
c.closed = true c.closed = true
c.mu.Unlock() c.mu.Unlock()
@@ -294,7 +279,9 @@ func (c *Conn) cleanup(ctx context.Context) {
// KILL has already sent the QUIT and deleted the // KILL has already sent the QUIT and deleted the
// session. // session.
if !wasKilled { if !wasKilled {
c.svc.BroadcastQuit(ctx, sessID, nick, quitReason) c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
} }
} }
@@ -318,22 +305,6 @@ func (c *Conn) currentNick() string {
return c.nick return c.nick
} }
// loadSessionNick sets c.nick to the nick the connection's
// session holds in the database, read by session ID. The
// connection's own NICK and the relay passing on a NICK line
// both call it, and it reads and sets the nick under c.mu, so
// whichever runs last leaves the connection on its session's
// nick. If the read fails, c.nick is left as it is.
func (c *Conn) loadSessionNick(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
if err == nil {
c.nick = nick
}
}
// send writes a formatted IRC line to the connection. // send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) { func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line) c.sendWithin(writeTimeout, line)
@@ -366,7 +337,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType, code irc.IRCMessageType,
params ...string, params ...string,
) { ) {
nick := c.currentNick() nick := c.nick
if nick == "" { if nick == "" {
nick = "*" nick = "*"
} }
@@ -390,11 +361,9 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask // hostmask returns the client's full hostmask
// (nick!user@host). // (nick!user@host).
func (c *Conn) hostmask() string { func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username user := c.username
if user == "" { if user == "" {
user = nick user = c.nick
} }
host := c.hostname host := c.hostname
@@ -402,7 +371,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP host = c.remoteIP
} }
return nick + "!" + user + "@" + host return c.nick + "!" + user + "@" + host
} }
// handleMessage dispatches a parsed IRC message using // handleMessage dispatches a parsed IRC message using
@@ -461,7 +430,6 @@ func (c *Conn) handlePreRegistration(
} }
c.passWord = msg.Params[0] c.passWord = msg.Params[0]
c.gotPass = true
case irc.CmdNick: case irc.CmdNick:
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
@@ -576,20 +544,17 @@ func (c *Conn) completeRegistration(ctx context.Context) {
// So that KILL, from either transport, can close it. // So that KILL, from either transport, can close it.
c.svc.RegisterWireConn(sessionID, c) c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
c.setPassword(ctx, c.passWord)
}
// Send welcome burst. // Send welcome burst.
c.deliverWelcome() c.deliverWelcome()
c.deliverLusers(ctx) c.deliverLusers(ctx)
c.deliverMOTD() c.deliverMOTD()
// If PASS was given before registration, store its
// password now, after the welcome burst, so that a
// NOTICE refusing it reaches a registered client. A
// failure to store it is only logged, as it is for PASS
// after registration.
if c.gotPass && c.checkPasswordLength(c.passWord) {
_ = c.setPassword(ctx, c.passWord)
}
// Start the message relay goroutine. // Start the message relay goroutine.
go c.relayMessages(ctx) go c.relayMessages(ctx)
} }
@@ -610,7 +575,7 @@ func (c *Conn) deliverWelcome() {
) )
c.sendNumeric( c.sendNumeric(
irc.RplMyInfo, irc.RplMyInfo,
c.serverSfx, "neoirc", service.UserModes, "Himnostv", c.serverSfx, "neoirc", "", "mnst",
) )
c.sendNumeric( c.sendNumeric(
irc.RplIsupport, irc.RplIsupport,
@@ -672,33 +637,13 @@ func (c *Conn) deliverMOTD() {
) )
} }
// checkPasswordLength reports whether pw is 8 to 72 bytes // setPassword sets a bcrypt password on the session.
// long, and sends the client a NOTICE saying so when it is func (c *Conn) setPassword(ctx context.Context, pw string) {
// not.
func (c *Conn) checkPasswordLength(pw string) bool {
if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen {
return true
}
c.sendFromServer("NOTICE", c.currentNick(),
"Password must be 8 to 72 bytes",
)
return false
}
// setPassword sets a bcrypt password on the session, and
// logs and returns the error when it was not stored.
func (c *Conn) setPassword(ctx context.Context, pw string) error {
// Use the database's auth module to hash and store. // Use the database's auth module to hash and store.
err := c.database.SetPassword(ctx, c.sessionID, pw) err := c.database.SetPassword(ctx, c.sessionID, pw)
if err != nil { if err != nil {
c.log.Error( c.log.Error(
"failed to set password", "error", err, "failed to set password", "error", err,
) )
return err
} }
return nil
} }
+5 -16
View File
@@ -15,8 +15,7 @@ import (
// NewTestServer creates a Server suitable for testing, whose // NewTestServer creates a Server suitable for testing, whose
// connections use interval and deadline in place of // connections use interval and deadline in place of
// pingInterval and pongDeadline, and resolve in place of // pingInterval and pongDeadline.
// resolveHost.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
@@ -24,7 +23,6 @@ func NewTestServer(
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline time.Duration, interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *Server { ) *Server {
globs := &globals.Globals{ globs := &globals.Globals{
Appname: "neoirc", Appname: "neoirc",
@@ -46,16 +44,9 @@ func NewTestServer(
pingInterval: interval, pingInterval: interval,
pongDeadline: deadline, pongDeadline: deadline,
resolveHost: resolve,
} }
} }
// ResolveHost is the reverse DNS lookup that a server made by
// New does for each client.
func ResolveHost(ctx context.Context, addr string) string {
return resolveHost(ctx, addr)
}
// Start exposes the unexported start method for tests. // Start exposes the unexported start method for tests.
func (s *Server) Start(addr string) error { func (s *Server) Start(addr string) error {
return s.start(context.Background(), addr) return s.start(context.Background(), addr)
@@ -71,11 +62,6 @@ func (s *Server) Listener() net.Listener {
return s.listener return s.listener
} }
// Service returns the service the server's connections use.
func (s *Server) Service() *service.Service {
return s.svc
}
// PingTimes returns how long the server's connections wait // PingTimes returns how long the server's connections wait
// with nothing read before they are sent a PING, and after it // with nothing read before they are sent a PING, and after it
// before they are closed. // before they are closed.
@@ -111,7 +97,10 @@ func NewTestConn(
tcpConn net.Conn, tcpConn net.Conn,
nick string, nick string,
) *Conn { ) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil) conn := newConn(
context.Background(), tcpConn, log,
nil, nil, cfg, nil,
)
conn.nick = nick conn.nick = nick
return conn return conn
-36
View File
@@ -1373,42 +1373,6 @@ func TestIntegrationInviteUserOnChannel(t *testing.T) {
) )
} }
// TestIntegrationInviteLine checks that an IRC listener user
// invited by an IRC listener user, and then over the HTTP
// API, gets an INVITE line from the inviter for the channel
// each time.
func TestIntegrationInviteLine(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#inv")
bob := env.dial(t)
bob.register("bob")
alice.sendAndExpect("INVITE bob #inv", " 341 ")
got := bob.readLine()
want := ":alice!alice@* INVITE bob #inv"
if got != want {
t.Errorf("from the IRC listener: got %q, want %q", got, want)
}
// The HTTP API queues an INVITE with the channel as its body.
env.queueMessage(t, "bob", "INVITE", "carol", "bob", []string{"#inv"})
got = bob.readLine()
want = ":carol!carol@* INVITE bob #inv"
if got != want {
t.Errorf("over the HTTP API: got %q, want %q", got, want)
}
}
// TestIntegrationModeSecretList checks that LIST shows a +s // TestIntegrationModeSecretList checks that LIST shows a +s
// channel only to its members. // channel only to its members.
func TestIntegrationModeSecretList(t *testing.T) { func TestIntegrationModeSecretList(t *testing.T) {
+52 -33
View File
@@ -27,26 +27,17 @@ func (c *Conn) relayMessages(ctx context.Context) {
default: default:
} }
// Register with the broker before draining, so that a // Drain any available messages.
// message queued after the drain has read the queue delivered := c.drainQueue(ctx)
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. The drain ignores the
// cancel that comes when the connection ends, because a
// query cancelled while it runs leaves the SQLite
// connection marked broken, and the pool closes it. The
// loop still stops on ctx.
delivered := c.drainQueue(context.WithoutCancel(ctx))
if delivered { if delivered {
c.brk.Remove(c.sessionID, waitCh)
// Tight loop while there are messages. // Tight loop while there are messages.
continue continue
} }
// Wait for notification or timeout. // Wait for notification or timeout.
waitCh := c.brk.Wait(c.sessionID)
select { select {
case <-waitCh: case <-waitCh:
// New message notification — loop back. // New message notification — loop back.
@@ -96,7 +87,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
// //
//nolint:cyclop // dispatch table //nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage( func (c *Conn) deliverIRCMessage(
ctx context.Context, _ context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
) { ) {
command := msg.Command command := msg.Command
@@ -124,7 +115,7 @@ func (c *Conn) deliverIRCMessage(
case command == irc.CmdPart: case command == irc.CmdPart:
c.deliverPart(msg, text) c.deliverPart(msg, text)
case command == irc.CmdNick: case command == irc.CmdNick:
c.deliverNickChange(ctx, msg, text) c.deliverNickChange(msg, text)
case command == irc.CmdQuit: case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text) c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic: case command == irc.CmdTopic:
@@ -202,6 +193,15 @@ func (c *Conn) deliverTextMessage(
from := msg.From from := msg.From
target := msg.To target := msg.To
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own messages back.
if from == nick {
return
}
prefix := from prefix := from
if !strings.Contains(prefix, "!") { if !strings.Contains(prefix, "!") {
prefix = from + "!" + from + "@*" prefix = from + "!" + from + "@*"
@@ -214,6 +214,16 @@ func (c *Conn) deliverTextMessage(
// deliverJoin sends a JOIN notification. // deliverJoin sends a JOIN notification.
func (c *Conn) deliverJoin(msg *db.IRCMessage) { func (c *Conn) deliverJoin(msg *db.IRCMessage) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own JOINs (we already sent them
// during joinChannel).
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -222,6 +232,14 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification. // deliverPart sends a PART notification.
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -234,17 +252,18 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
} }
} }
// deliverNickChange sends a NICK change notification. The // deliverNickChange sends a NICK change notification.
// change may be its own session's, made from another client,
// so the connection first takes the nick its session holds
// now. It does not go by the line's old nick: the connection
// may have taken that nick after another user gave it up.
func (c *Conn) deliverNickChange( func (c *Conn) deliverNickChange(
ctx context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
newNick string, newNick string,
) { ) {
c.loadSessionNick(ctx) c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
@@ -256,6 +275,14 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage, msg *db.IRCMessage,
text string, text string,
) { ) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
if text != "" { if text != "" {
@@ -308,24 +335,16 @@ func (c *Conn) deliverKickMsg(
} }
} }
// deliverInviteMsg sends an INVITE line from the inviter // deliverInviteMsg sends an INVITE notification.
// for the channel named in text, or nothing when text names
// no channel.
func (c *Conn) deliverInviteMsg( func (c *Conn) deliverInviteMsg(
msg *db.IRCMessage, _ *db.IRCMessage,
text string, text string,
) { ) {
if text == "" {
return
}
c.mu.Lock() c.mu.Lock()
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
prefix := msg.From + "!" + msg.From + "@*" c.sendFromServer("NOTICE", nick, text)
c.send(FormatMessage(prefix, "INVITE", nick, text))
} }
// deliverWallops sends a WALLOPS notification. // deliverWallops sends a WALLOPS notification.
+15 -454
View File
@@ -1,17 +1,13 @@
package ircserver_test package ircserver_test
import ( import (
"context"
"encoding/json" "encoding/json"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
) )
// queueMessage queues a message for the user with nick // queueMessage queues a message for the user with nick
@@ -43,7 +39,7 @@ func (env *testEnv) queueMessage(
t.Fatalf("get session of %s: %v", recipient, err) t.Fatalf("get session of %s: %v", recipient, err)
} }
err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0) err = env.database.EnqueueToSession(ctx, sessionID, msgID)
if err != nil { if err != nil {
t.Fatalf("enqueue: %v", err) t.Fatalf("enqueue: %v", err)
} }
@@ -51,13 +47,14 @@ func (env *testEnv) queueMessage(
env.brk.Notify(sessionID) env.brk.Notify(sessionID)
} }
// TestRelayKeepsOneWaitChannel checks that an IRC listener // TestRelayKeepsOneWaitChannel checks that an idle IRC
// connection holds at most one broker wait channel across // listener connection holds at most one broker wait channel
// several relay ticks, both while idle and after the relay // across several relay ticks, and none once it closes.
// has delivered it a message, and none once it closes.
func TestRelayKeepsOneWaitChannel(t *testing.T) { func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel() t.Parallel()
const idleTicks = 5
env := newTestEnv(t) env := newTestEnv(t)
client := env.dial(t) client := env.dial(t)
@@ -68,17 +65,15 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Fatalf("get session of idle: %v", err) t.Fatalf("get session of idle: %v", err)
} }
env.checkOneWaitChannel(t, sessionID) for tick := range idleTicks {
time.Sleep(ircserver.PollInterval)
env.queueMessage( got := env.brk.ListenerCount(sessionID)
t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"}, if got > 1 {
) t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
client.readUntil(func(l string) bool { }
return strings.Contains(l, "hello") }
})
env.checkOneWaitChannel(t, sessionID)
err = client.conn.Close() err = client.conn.Close()
if err != nil { if err != nil {
@@ -97,28 +92,6 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) {
} }
} }
// checkOneWaitChannel fails the test if the session
// sessionID holds more than one broker wait channel at any of
// several relay ticks.
func (env *testEnv) checkOneWaitChannel(
t *testing.T,
sessionID int64,
) {
t.Helper()
const ticks = 5
for tick := range ticks {
time.Sleep(ircserver.PollInterval)
got := env.brk.ListenerCount(sessionID)
if got > 1 {
t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
}
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG // TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC // or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line. // listener user as its own line.
@@ -215,38 +188,6 @@ func TestRelayModeParameters(t *testing.T) {
} }
} }
// TestRelayInviteWithoutChannel checks that an INVITE whose
// body names no channel sends the invited IRC listener user
// no INVITE line.
func TestRelayInviteWithoutChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
// A nil body is stored as JSON null, which is not an array.
for _, body := range [][]string{nil, {}, {""}} {
env.queueMessage(t, "bob", "INVITE", "carol", "bob", body)
}
// The relay sends a user's queue in order, so this arrives
// after anything sent for the INVITEs above.
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
for _, line := range lines {
if strings.Contains(line, " INVITE ") {
t.Errorf("got %q, want no INVITE line", line)
}
}
}
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored // TestRelayLineBreaks checks that a CR, LF or NUL in a stored
// body line, key or mask never reaches an IRC listener user // body line, key or mask never reaches an IRC listener user
// as a line break, so no extra line arrives, and that a body // as a line break, so no extra line arrives, and that a body
@@ -318,7 +259,7 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
{"JOIN #case", ":Alice!Alice@* JOIN #case"}, {"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"}, {"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT {"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Client quit"}, {"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} { } {
capitalAlice.send(step.line) capitalAlice.send(step.line)
@@ -328,383 +269,3 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
} }
} }
} }
// TestRelayOwnChangeOnce checks that an IRC listener user who
// sets a topic, changes a channel mode or kicks gets one TOPIC,
// MODE or KICK line for it, and so does the other member.
func TestRelayOwnChangeOnce(t *testing.T) {
t.Parallel()
for _, step := range []struct {
command, line string
}{
{"TOPIC", "TOPIC #dup :new topic"},
{"MODE", "MODE #dup +m"},
{"KICK", "KICK #dup carl :bye"},
} {
t.Run(step.command, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#dup")
carl := env.dial(t)
carl.register("carl")
carl.joinAndDrain("#dup")
alice.send(step.line)
// The reply to alice's next command comes after her
// command has finished, so her own copy comes before it.
beforePong := alice.sendAndExpect("PING :done", "PONG")
// The relay sends a user's queue in order, so a message
// queued now arrives after any relayed copy.
for nick, client := range map[string]*testClient{
"alice": alice, "carl": carl,
} {
env.queueMessage(
t, nick, "PRIVMSG", "dave", nick,
[]string{"after the change"},
)
lines := client.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :after the change")
})
if client == alice {
lines = slices.Concat(beforePong, lines)
}
var changes []string
for _, line := range lines {
if strings.Contains(line, " "+step.command+" #dup ") {
changes = append(changes, line)
}
}
if len(changes) != 1 {
t.Errorf("%s got %q, want one %s line",
nick, changes, step.command)
}
}
})
}
}
// TestRelayOwnSessionOnce checks that a command a user makes
// from one client of their session reaches each of the
// session's clients once: an IRC listener connection and an
// HTTP API client. The connection that sent a command gets
// only its own echo, and none for PRIVMSG and NOTICE, which
// IRC does not echo.
//
//nolint:funlen // table-driven test
func TestRelayOwnSessionOnce(t *testing.T) {
t.Parallel()
hello := json.RawMessage(`["hello"]`)
for _, step := range []struct {
// line is the command as alice sends it on the IRC
// listener.
line string
// api makes the same command for her session the way
// the HTTP API does.
api func(
ctx context.Context, svc *service.Service, sessionID int64,
) error
// echoes is how many copies alice's connection gets of
// line when it sends line itself.
echoes int
}{
{"PRIVMSG #chan :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendChannelMessage(
ctx, sessionID, 0, "alice", "PRIVMSG", "#chan", hello, nil,
)
return err
}, 0},
{"NOTICE bob :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendDirectMessage(
ctx, sessionID, 0, "alice", "NOTICE", "bob", hello, nil,
)
return err
}, 0},
{"JOIN #other", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.JoinChannel(ctx, sessionID, 0, "alice", "#other", "")
return err
}, 1},
{"PART #chan :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.PartChannel(ctx, sessionID, 0, "alice", "#chan", "bye")
}, 1},
{"NICK alice2", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.ChangeNick(ctx, sessionID, 0, "alice", "alice2")
}, 1},
{"TOPIC #chan :new topic", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.SetTopic(
ctx, sessionID, 0, "alice", "#chan", "new topic",
)
}, 1},
{"MODE #chan +m", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
chID, err := svc.ValidateChannelOp(ctx, sessionID, "#chan")
if err != nil {
return err
}
err = svc.SetChannelFlag(ctx, chID, 'm', true)
if err != nil {
return err
}
svc.BroadcastMode(ctx, 0, "alice", "#chan", chID, "+m")
return nil
}, 1},
{"KICK #chan bob :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.KickUser(
ctx, sessionID, 0, "alice", "#chan", "bob", "bye",
)
}, 1},
} {
command := strings.Fields(step.line)[0]
t.Run(command+" from IRC", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, nil)
if ircCopies != step.echoes || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want %d and 1",
ircCopies, httpCopies, step.echoes)
}
})
t.Run(command+" from the HTTP API", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, step.api)
if ircCopies != 1 || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want 1 and 1",
ircCopies, httpCopies)
}
})
}
}
// ownSessionCopies sets up alice with aliceWithHTTPClient. It
// makes the command line from her connection, or with api from
// the HTTP API client when api is not nil, and returns how many
// copies of it her connection and her HTTP API client got.
func ownSessionCopies(
t *testing.T,
line string,
api func(ctx context.Context, svc *service.Service, sessionID int64) error,
) (int, int) {
t.Helper()
ctx := t.Context()
env := newTestEnv(t)
alice, _, sessionID, httpClientID := aliceWithHTTPClient(t, env)
var lines []string
if api == nil {
alice.send(line)
// Her echo comes before the reply to her next command.
lines = alice.sendAndExpect("PING :done", "PONG")
} else {
err := api(ctx, env.srv.Service(), sessionID)
if err != nil {
t.Fatalf("%s over the HTTP API: %v", line, err)
}
}
msgs, _, err := env.database.PollMessages(ctx, httpClientID, 0, 0)
if err != nil {
t.Fatalf("poll the HTTP API client: %v", err)
}
httpCopies := 0
for _, msg := range msgs {
if msg.Command == strings.Fields(line)[0] {
httpCopies++
}
}
// The relay sends a queue in order, so this arrives after any
// copy. It is queued by session, as NICK changes her nick.
endID, _, err := env.database.InsertMessage(
ctx, "PRIVMSG", "dave", "alice", 0, 0,
nil, json.RawMessage(`["the end"]`), nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
err = env.database.EnqueueToSession(ctx, sessionID, endID, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
lines = append(lines, alice.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})...)
// A copy starts with the command and its first parameter.
start := " " + strings.Join(strings.Fields(line)[:2], " ")
ircCopies := strings.Count(strings.Join(lines, "\n"), start)
return ircCopies, httpCopies
}
// aliceWithHTTPClient connects alice to the IRC listener and
// has her join #chan, which bob is on too, then adds an HTTP API
// client to her session with a password, as POST /api/v1/login
// does. It stores that password without sending PASS. It hashes
// at bcrypt.MinCost, and a login compares at the cost stored in
// the hash, so both are fast. It returns her connection, bob's
// connection, her session and her HTTP API client.
func aliceWithHTTPClient(
t *testing.T, env *testEnv,
) (*testClient, *testClient, int64, int64) {
t.Helper()
const password = "alicepass"
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#chan")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#chan")
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcrypt.MinCost,
)
if err != nil {
t.Fatalf("hash password: %v", err)
}
dbtest.Exec(t, env.dbURL,
"UPDATE sessions SET password_hash = '"+string(hash)+
"' WHERE nick = 'alice'")
sessionID, httpClientID, _, err := env.database.LoginUser(
t.Context(), "alice", password, "127.0.0.1", "localhost",
)
if err != nil {
t.Fatalf("log in as alice: %v", err)
}
return alice, bob, sessionID, httpClientID
}
// TestRelayNickChangeFromHTTPAPI checks that once alice's nick
// is changed from her HTTP API client, her IRC listener
// connection uses the new nick: what she then sends from it
// reaches bob under the new nick.
func TestRelayNickChangeFromHTTPAPI(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice, bob, sessionID, _ := aliceWithHTTPClient(t, env)
bob.joinAndDrain("#other")
// As the HTTP API does.
err := env.srv.Service().ChangeNick(
t.Context(), sessionID, 0, "alice", "alice2",
)
if err != nil {
t.Fatalf("change nick over the HTTP API: %v", err)
}
// Her connection has taken the new nick once it has sent her
// the change.
const change = ":alice!alice@* NICK alice2"
alice.readUntil(func(l string) bool { return l == change })
bob.readUntil(func(l string) bool { return l == change })
for _, step := range []struct {
line, want string
}{
{"PRIVMSG #chan :hi there", ":alice2!alice2@* PRIVMSG #chan :hi there"},
{"JOIN #other", ":alice2!alice2@* JOIN #other"},
{"NICK alice3", ":alice2!alice2@* NICK alice3"},
} {
alice.send(step.line)
got := bob.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// TestRelayNickChangeKeepsTakenNick checks that an IRC
// listener connection which takes a nick another user has
// given up keeps it when that user's change reaches it
// afterwards: what it then sends goes out under the nick its
// session holds.
func TestRelayNickChangeKeepsTakenNick(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("NICK bob2", " NICK bob2")
alice := env.dial(t)
alice.register("alice")
alice.sendAndExpect("NICK bob", " NICK bob")
alice.joinAndDrain("#chan")
bob.joinAndDrain("#chan")
// Bob's change reaches alice only now, as it does when she
// takes his old nick before her connection has passed the
// change on. She holds bob now, so it is queued to bob.
env.queueMessage(t, "bob", "NICK", "bob", "", []string{"bob2"})
alice.readUntil(func(l string) bool {
return l == ":bob!bob@* NICK bob2"
})
alice.send("PRIVMSG #chan :hi there")
const want = ":bob!bob@* PRIVMSG #chan :hi there"
got := bob.readLine()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
+2 -10
View File
@@ -52,10 +52,6 @@ type Server struct {
// same names, except in tests, which shorten them. // same names, except in tests, which shorten them.
pingInterval time.Duration pingInterval time.Duration
pongDeadline time.Duration pongDeadline time.Duration
// resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string
} }
// New creates a new IRC Server and registers its lifecycle // New creates a new IRC Server and registers its lifecycle
@@ -79,7 +75,6 @@ func New(
pingInterval: pingInterval, pingInterval: pingInterval,
pongDeadline: pongDeadline, pongDeadline: pongDeadline,
resolveHost: resolveHost,
} }
listenAddr := params.Config.IRCListenAddr listenAddr := params.Config.IRCListenAddr
@@ -180,7 +175,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
} }
client := newConn( client := newConn(
tcpConn, s.log, ctx, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.database, s.brk, s.cfg, s.svc,
) )
@@ -209,10 +204,7 @@ func (s *Server) acceptLoop(ctx context.Context) {
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve( client.serve(ctx, s.pingInterval, s.pongDeadline)
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
}() }()
} }
} }
+67 -385
View File
@@ -2,18 +2,15 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context" "crypto/rand"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -21,20 +18,23 @@ import (
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
const testTimeout = 5 * time.Second const testTimeout = 5 * time.Second
func TestMain(m *testing.M) {
db.SetBcryptCost(4)
os.Exit(m.Run())
}
// testEnv holds the shared test infrastructure. // testEnv holds the shared test infrastructure.
type testEnv struct { type testEnv struct {
dbURL string
database *db.Database database *db.Database
brk *broker.Broker brk *broker.Broker
cfg *config.Config cfg *config.Config
@@ -90,56 +90,67 @@ func newTestEnvWithPingTimes(
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
return newTestEnvWithResolver( // A random name, so that no other test environment, not
t, cfg, interval, deadline, ircserver.ResolveHost, // even an earlier run of the same test, can share it.
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared",
rand.Text(),
) )
}
// newTestEnvWithResolver is newTestEnvWithPingTimes, except conn, err := sql.Open("sqlite", dsn)
// that the server looks up a client's hostname with resolve.
func newTestEnvWithResolver(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *testEnv {
t.Helper()
dbURL := dbtest.NewURL(t)
lifecycle := fxtest.NewLifecycle(t)
dbLog, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil { if err != nil {
t.Fatalf("new logger: %v", err) t.Fatalf("open db: %v", err)
} }
database, err := db.New(lifecycle, db.Params{ t.Cleanup(func() {
Logger: dbLog, err := conn.Close()
Config: &config.Config{DBURL: dbURL},
})
if err != nil { if err != nil {
t.Fatalf("new database: %v", err) t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
} }
lifecycle.RequireStart() database := db.NewTestDatabaseFromConn(conn)
t.Cleanup(lifecycle.RequireStop)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New() brk := broker.New()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler( log := slog.New(slog.NewTextHandler(
os.Stderr, os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, resolve, log, cfg, database, brk, interval, deadline,
) )
// Port 0 makes the system pick a free port. dial reads it err = srv.Start(addr)
// back from the server's listener.
err = srv.Start("127.0.0.1:0")
if err != nil { if err != nil {
t.Fatalf("start irc server: %v", err) t.Fatalf("start irc server: %v", err)
} }
@@ -149,7 +160,6 @@ func newTestEnvWithResolver(
t.Cleanup(srv.Stop) t.Cleanup(srv.Stop)
return &testEnv{ return &testEnv{
dbURL: dbURL,
database: database, database: database,
brk: brk, brk: brk,
cfg: cfg, cfg: cfg,
@@ -405,49 +415,6 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME") assertContains(t, lines, " 001 ", "RPL_WELCOME")
} }
// TestRegistrationWhileAnotherLookupIsPending checks that a
// client is accepted and registered while the hostname lookup
// of an earlier client has not returned, and that it
// registers with the hostname its own lookup returned.
func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
t.Parallel()
const lookedUpHost = "client.example"
var lookups atomic.Int32
firstLookupStarted := make(chan struct{})
// The first lookup returns only when the server stops.
resolve := func(ctx context.Context, _ string) string {
if lookups.Add(1) == 1 {
close(firstLookupStarted)
<-ctx.Done()
}
return lookedUpHost
}
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve,
)
env.dial(t)
select {
case <-firstLookupStarted:
case <-time.After(testTimeout):
t.Fatal("the first client's lookup did not start")
}
lines := env.dial(t).register("second")
assertContains(
t, lines, "second!second@"+lookedUpHost,
"RPL_WELCOME with the looked-up hostname",
)
}
// TestEachTestEnvHasItsOwnDatabase checks that a test // TestEachTestEnvHasItsOwnDatabase checks that a test
// environment starts on an empty database while another one // environment starts on an empty database while another one
// is still open. // is still open.
@@ -478,22 +445,6 @@ func TestWelcomeContainsNick(t *testing.T) {
} }
} }
// TestMyInfoListsModes checks that RPL_MYINFO lists the user
// modes o and w, then the channel modes MODE accepts on the
// IRC listener. It compares the whole line, so a missing or
// extra mode fails it.
func TestMyInfoListsModes(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
lines := env.dial(t).register("carol")
want := ":test.irc 004 carol test.irc neoirc ow Himnostv"
if !slices.Contains(lines, want) {
t.Errorf("no line %q in %v", want, lines)
}
}
func TestPingPong(t *testing.T) { func TestPingPong(t *testing.T) {
t.Parallel() t.Parallel()
@@ -634,6 +585,8 @@ func TestNewPingTimes(t *testing.T) {
func TestIdleCleanupKeepsActiveUser(t *testing.T) { func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel() t.Parallel()
const idleTimeout = 500 * time.Millisecond
env := newTestEnv(t) env := newTestEnv(t)
active := env.dial(t) active := env.dial(t)
@@ -642,17 +595,13 @@ func TestIdleCleanupKeepsActiveUser(t *testing.T) {
idle := env.dial(t) idle := env.dial(t)
idle.register("idle") idle.register("idle")
// Both users were last seen before cutoff, and only active time.Sleep(idleTimeout)
// sends a command after it, so the result does not depend
// on how long the PONG takes to arrive.
cutoff := time.Now()
active.sendAndExpect("PING :still here", "PONG") active.sendAndExpect("PING :still here", "PONG")
// The idle cleanup in internal/handlers removes exactly // The idle cleanup in internal/handlers removes exactly
// the users this returns. // the users this returns.
stale, err := env.database.GetStaleOrphanSessions( stale, err := env.database.GetStaleOrphanSessions(
t.Context(), cutoff, t.Context(), time.Now().Add(-idleTimeout),
) )
if err != nil { if err != nil {
t.Fatalf("get stale sessions: %v", err) t.Fatalf("get stale sessions: %v", err)
@@ -864,107 +813,6 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") assertContains(t, lines, "goodbye", "QUIT reason")
} }
// TestQuitReasonReachesPeers checks the reason that an IRC
// listener user's channel peers see in its QUIT, on both the
// IRC listener and the HTTP API. It is the reason the user
// gave, Client quit for a QUIT without one, or Connection
// closed when the connection ends without a QUIT.
func TestQuitReasonReachesPeers(t *testing.T) {
t.Parallel()
for _, step := range []struct {
name, quit, want string
}{
{"QUIT with a reason", "QUIT :gone home", "gone home"},
{"QUIT without a reason", irc.CmdQuit, "Client quit"},
{"connection closed without QUIT", "", "Connection closed"},
} {
t.Run(step.name, func(t *testing.T) {
t.Parallel()
ircLine, apiBody := quitSeenByPeers(t, step.quit)
want := ":quitter!quitter@* QUIT :Quit: " + step.want
if ircLine != want {
t.Errorf("IRC peer got %q, want %q", ircLine, want)
}
if len(apiBody) != 1 || apiBody[0] != step.want {
t.Errorf("HTTP API peer got %q, want [%q]", apiBody, step.want)
}
})
}
}
// quitSeenByPeers starts a server on which quitter, ircpeer on
// the IRC listener and apipeer on the HTTP API share a channel.
// quitter sends the line quit, or closes its connection when
// quit is empty. It returns the QUIT line ircpeer is sent and
// the body of the QUIT apipeer is sent.
func quitSeenByPeers(t *testing.T, quit string) (string, []string) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
ircPeer := env.dial(t)
ircPeer.register("ircpeer")
ircPeer.joinAndDrain("#bye")
apiPeerID, apiPeerClientID, _, err := env.database.CreateSession(
ctx, "apipeer", "apipeer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
_, _, err = env.database.CreateOrJoinChannel(ctx, "#bye", apiPeerID, "")
if err != nil {
t.Fatalf("join peer: %v", err)
}
quitter := env.dial(t)
quitter.register("quitter")
quitter.joinAndDrain("#bye")
if quit == "" {
err = quitter.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
} else {
quitter.send(quit)
}
lines := ircPeer.readUntil(func(l string) bool {
return strings.Contains(l, " QUIT ")
})
// The QUIT is queued to both peers at once, so apipeer has
// it by now.
msgs, _, err := env.database.PollMessages(ctx, apiPeerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == irc.CmdQuit && msg.From == "quitter" {
var body []string
err = json.Unmarshal(msg.Body, &body)
if err != nil {
t.Fatalf("decode QUIT body %s: %v", msg.Body, err)
}
return lines[len(lines)-1], body
}
}
t.Fatalf("apipeer was not sent the QUIT: %v", msgs)
return "", nil
}
// TestStopEndsSessions checks that stopping the server ends // TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as // a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and // closing the connection does: the session, its client and
@@ -989,9 +837,12 @@ func TestStopEndsSessions(t *testing.T) {
t.Fatalf("create peer session: %v", err) t.Fatalf("create peer session: %v", err)
} }
channelID, _, err := env.database.CreateOrJoinChannel( channelID, err := env.database.GetChannelByName(ctx, "#shut")
ctx, "#shut", peerID, "", if err != nil {
) t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil { if err != nil {
t.Fatalf("join peer: %v", err) t.Fatalf("join peer: %v", err)
} }
@@ -1049,16 +900,11 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
client.register("stuck") client.register("stuck")
// While a connection of the test's own holds a write // The test database has one connection. While the test
// transaction, the cleanup's first write waits for it to end. // holds it, the cleanup's first query waits for it.
holder, err := sql.Open("sqlite", env.dbURL) held, err := env.database.GetDB().Conn(t.Context())
if err != nil { if err != nil {
t.Fatalf("open a second database connection: %v", err) t.Fatalf("take the database connection: %v", err)
}
_, err = holder.ExecContext(t.Context(), "BEGIN IMMEDIATE")
if err != nil {
t.Fatalf("begin a write transaction: %v", err)
} }
stopped := make(chan struct{}) stopped := make(chan struct{})
@@ -1074,11 +920,10 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Error("Stop did not return while a cleanup was stuck") t.Error("Stop did not return while a cleanup was stuck")
} }
// Closing ends the transaction, which lets the cleanup finish // Lets the cleanup finish before the database is closed.
// before the database is closed. err = held.Close()
err = holder.Close()
if err != nil { if err != nil {
t.Errorf("close the second database connection: %v", err) t.Errorf("release the database connection: %v", err)
} }
} }
@@ -1237,9 +1082,6 @@ func TestAwaySetAndClear(t *testing.T) {
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY") assertContains(t, clearLines, " 305 ", "RPL_UNAWAY")
} }
// TestHandlePassPostRegistration checks the NOTICE that
// confirms PASS after registration. It points to the HTTP API
// login, the only way to add a client to an existing session.
func TestHandlePassPostRegistration(t *testing.T) { func TestHandlePassPostRegistration(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1252,170 +1094,10 @@ func TestHandlePassPostRegistration(t *testing.T) {
) )
assertContains( assertContains(
t, lines, t, lines, "Password set", "password confirmation",
" NOTICE passuser :Password set. Another client can log "+
"in to this session over the HTTP API: "+
"POST /api/v1/login with your nick and this password.",
"password confirmation",
) )
} }
// The shortest and longest password PASS accepts; bcrypt
// cannot hash more than 72 bytes.
const (
minPasswordBytes = 8
maxPasswordBytes = 72
)
// passwordLengthNotice is the NOTICE text for a password that
// is too short or too long.
const passwordLengthNotice = "Password must be 8 to 72 bytes"
// passwordCase is a password a PASS test sends, and whether
// the server should store it.
type passwordCase struct {
name string
password string
stored bool
}
// passwordCases returns the passwords the PASS length tests
// send: one of 8 to 72 bytes is stored and any other is
// refused. The limit counts bytes: "é" is two bytes in UTF-8,
// so 72 of them are refused.
func passwordCases() []passwordCase {
return []passwordCase{
{"empty", "", false},
{"7 bytes", strings.Repeat("a", minPasswordBytes-1), false},
{"8 bytes", strings.Repeat("a", minPasswordBytes), true},
{"72 bytes", strings.Repeat("a", maxPasswordBytes), true},
{"73 bytes", strings.Repeat("a", maxPasswordBytes+1), false},
{"72 characters", strings.Repeat("é", maxPasswordBytes), false},
}
}
// assertStored fails the test unless nick can log in with
// password exactly when stored is true.
func assertStored(
t *testing.T, env *testEnv, nick, password string, stored bool,
) {
t.Helper()
_, _, token, err := env.database.LoginUser(
t.Context(), nick, password, "127.0.0.1", "localhost",
)
loggedIn := err == nil && token != ""
if loggedIn != stored {
t.Errorf(
"log in as %s: logged in %v, want %v (error: %v)",
nick, loggedIn, stored, err,
)
}
}
// TestPassPostRegistrationLength checks that PASS after
// registration stores and confirms a password of 8 to 72
// bytes, and refuses any other with a NOTICE.
func TestPassPostRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("postpass%d", i)
client := env.dial(t)
client.register(nick)
client.send("PASS :" + tc.password)
// The server answers commands in order, so its
// reply to PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
reply := passwordLengthNotice
if tc.stored {
reply = "Password set"
}
assertContains(t, lines, reply, "reply to PASS")
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
// TestPassPostRegistrationNotStored checks that PASS is not
// confirmed when the password could not be stored.
func TestPassPostRegistrationNotStored(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("failpass")
dbtest.Exec(t, env.dbURL,
`CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
)
client.send("PASS :mypassword123")
// The server answers commands in order, so anything it
// sent about PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(
t, lines, "Password set",
"confirmation of a password that was not stored",
)
}
// TestPassBeforeRegistrationLength checks that a PASS before
// registration stores a password of 8 to 72 bytes, and that
// any other is refused with a NOTICE once registration
// completes.
func TestPassBeforeRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("prepass%d", i)
client := env.dial(t)
client.send("PASS :" + tc.password)
client.register(nick)
// The password is checked after the welcome
// burst, which register reads; the PONG comes
// after that.
lines := client.sendAndExpect("PING :after", "PONG")
if tc.stored {
assertNotContains(
t, lines, passwordLengthNotice,
"refusal of a valid password",
)
} else {
assertContains(
t, lines, passwordLengthNotice,
"refusal after registration",
)
}
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
func TestPreRegistrationNotRegistered(t *testing.T) { func TestPreRegistrationNotRegistered(t *testing.T) {
t.Parallel() t.Parallel()
+1 -17
View File
@@ -1,10 +1,6 @@
package server package server
import ( import "github.com/go-chi/chi/v5"
"net"
"github.com/go-chi/chi/v5"
)
// NewTestSPAServer creates a Server whose router serves only // NewTestSPAServer creates a Server whose router serves only
// the embedded web client. // the embedded web client.
@@ -14,15 +10,3 @@ func NewTestSPAServer() *Server {
return srv return srv
} }
// Listener returns the listener the start hook bound for the
// HTTP server.
func (srv *Server) Listener() net.Listener {
return srv.listener
}
// ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
srv.serveUntilShutdown(listener)
}
-3
View File
@@ -50,7 +50,6 @@ type Server struct {
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
httpServer *http.Server httpServer *http.Server
listener net.Listener
router *chi.Mux router *chi.Mux
params Params params Params
mw *middleware.Middleware mw *middleware.Middleware
@@ -180,8 +179,6 @@ func (srv *Server) startHTTP(ctx context.Context) error {
return fmt.Errorf("http listen: %w", err) return fmt.Errorf("http listen: %w", err)
} }
srv.listener = listener
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: httpReadTimeout, ReadTimeout: httpReadTimeout,
+44 -73
View File
@@ -1,10 +1,7 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package server_test package server_test
import ( import (
"crypto/rand"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -18,7 +15,6 @@ import (
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers" "sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck" "sneak.berlin/go/neoirc/internal/healthcheck"
@@ -45,9 +41,12 @@ const (
// for a long poll the HTTP server is serving to finish, and // for a long poll the HTTP server is serving to finish, and
// that the poll gets its response. // that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) { func TestStopWaitsForRequestInProgress(t *testing.T) {
var srv *server.Server t.Parallel()
app, brk, database := startApp(t, fx.Populate(&srv)) port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession( sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "", t.Context(), "poller", "poller", "", "",
@@ -56,12 +55,9 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
tcpAddr, ok := srv.Listener().Addr().(*net.TCPAddr) waitUntil(t, "the HTTP server to listen", func() bool {
if !ok { return canConnect(t, addr)
t.Fatalf("listener address %v is not TCP", srv.Listener().Addr()) })
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(tcpAddr.Port))
statuses := make(chan int, 1) statuses := make(chan int, 1)
@@ -113,6 +109,8 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
// returns an error naming the HTTP listen address when // returns an error naming the HTTP listen address when
// something else is already listening on its port. // something else is already listening on its port.
func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) { func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
t.Parallel()
var listenConfig net.ListenConfig var listenConfig net.ListenConfig
taken, err := listenConfig.Listen(t.Context(), "tcp", ":0") taken, err := listenConfig.Listen(t.Context(), "tcp", ":0")
@@ -141,62 +139,15 @@ func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
} }
} }
// TestServeFailureStopsApp checks that when serving HTTP fails // startApp starts the app newApp builds, with the HTTP server
// after the start hook has bound the listen address, the // listening on port.
// server stops the app with exit code 1.
func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv))
shutdowns := app.Wait()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
if shutdown.ExitCode != 1 {
t.Errorf("exit code %d, want 1", shutdown.ExitCode)
}
default:
t.Error("the server did not stop the app")
}
}
// TestServeAfterStopDoesNotStopApp checks that serving HTTP
// after the app has stopped, which ends at once because the
// stop hook has shut the HTTP server down, does not stop the
// app. Serving ends the same way on every normal stop.
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv))
shutdowns := app.Wait()
app.RequireStop()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
t.Errorf(
"the server stopped the app with exit code %d",
shutdown.ExitCode,
)
default:
}
}
// startApp starts the app newApp builds, with the HTTP port
// set to 0, so the system gives the server a free port.
func startApp( func startApp(
t *testing.T, t *testing.T,
options ...fx.Option, port int,
) (*fxtest.App, *broker.Broker, *db.Database) { ) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper() t.Helper()
app, brk, database := newApp(t, 0, options...) app, brk, database := newApp(t, port)
app.RequireStart() app.RequireStart()
@@ -209,12 +160,9 @@ func startApp(
// newApp builds, without starting it, an app that runs the // newApp builds, without starting it, an app that runs the
// server with the dependencies cmd/neoircd gives it, on a // server with the dependencies cmd/neoircd gives it, on a
// fresh in-memory database, with the HTTP port set to port. // fresh in-memory database, with the HTTP port set to port.
// options are added to the app, for a test that needs more
// from it than the broker and the database.
func newApp( func newApp(
t *testing.T, t *testing.T,
port int, port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) { ) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper() t.Helper()
@@ -242,22 +190,24 @@ func newApp(
stats.New, stats.New,
), ),
fx.Decorate(func(cfg *config.Config) *config.Config { fx.Decorate(func(cfg *config.Config) *config.Config {
cfg.DBURL = dbtest.NewURL(t) // A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.Port = port cfg.Port = port
return cfg return cfg
}), }),
fx.Invoke(func(*server.Server) {}), fx.Invoke(func(*server.Server) {}),
fx.Populate(&brk, &database), fx.Populate(&brk, &database),
fx.Options(options...),
) )
return app, brk, database return app, brk, database
} }
// closedListener returns a TCP listener that has already been // freePort returns a TCP port that nothing was listening on a
// closed, so that accepting a connection on it fails. // moment ago.
func closedListener(t *testing.T) net.Listener { func freePort(t *testing.T) int {
t.Helper() t.Helper()
var listenConfig net.ListenConfig var listenConfig net.ListenConfig
@@ -269,12 +219,33 @@ func closedListener(t *testing.T) net.Listener {
t.Fatalf("listen: %v", err) t.Fatalf("listen: %v", err)
} }
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
err = listener.Close() err = listener.Close()
if err != nil { if err != nil {
t.Fatalf("close listener: %v", err) t.Fatalf("close listener: %v", err)
} }
return listener return tcpAddr.Port
}
// canConnect reports whether addr accepts a TCP connection.
func canConnect(t *testing.T, addr string) bool {
t.Helper()
var dialer net.Dialer
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
if err != nil {
return false
}
_ = conn.Close()
return true
} }
// waitUntil calls done every checkStep until it returns true, // waitUntil calls done every checkStep until it returns true,
+117 -114
View File
@@ -312,7 +312,7 @@ func (s *Service) SendWallops(
_, _, err = s.FanOut( _, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*", 0, 0, ctx, irc.CmdWallops, nick, "*", 0, 0,
nil, body, nil, recipients, 0, nil, body, nil, recipients,
) )
if err != nil { if err != nil {
return fmt.Errorf("wallops: %w", err) return fmt.Errorf("wallops: %w", err)
@@ -347,19 +347,12 @@ type DirectMsgResult struct {
// session IDs, notifying each via the broker. toSessionID // session IDs, notifying each via the broker. toSessionID
// and toChannelID are the recipient InsertMessage records // and toChannelID are the recipient InsertMessage records
// for history, 0 when there is none. // for history, 0 when there is none.
//
// skipClientID is the client whose queue the message is
// not added to: the IRC listener connection that sent the
// command, which has already shown it to its user. The HTTP
// API passes 0, as its clients see their own commands
// through their queues.
func (s *Service) FanOut( func (s *Service) FanOut(
ctx context.Context, ctx context.Context,
command, from, to string, command, from, to string,
toSessionID, toChannelID int64, toSessionID, toChannelID int64,
params, body, meta json.RawMessage, params, body, meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
skipClientID int64,
) (int64, string, error) { ) (int64, string, error) {
dbID, msgUUID, err := s.db.InsertMessage( dbID, msgUUID, err := s.db.InsertMessage(
ctx, command, from, to, ctx, command, from, to,
@@ -370,40 +363,66 @@ func (s *Service) FanOut(
} }
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID) _ = s.db.EnqueueToSession(ctx, sid, dbID)
s.broker.Notify(sid) s.broker.Notify(sid)
} }
return dbID, msgUUID, nil return dbID, msgUUID, nil
} }
// SendChannelMessage checks membership, bans and // excludeSession returns a copy of ids without the given
// moderation, then the hashcash stamp of a PRIVMSG to a +H // session.
// channel, then fans out a message to all channel members, func excludeSession(
// the sender's session included, skipping client ids []int64,
// skipClientID as FanOut does. The stamp is checked after exclude int64,
// the other checks so that a message they refuse does not ) []int64 {
// spend it. Returns the message UUID and any error. out := make([]int64, 0, len(ids))
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
// SendChannelMessage validates the hashcash stamp of a
// PRIVMSG to a +H channel, membership and moderation, then
// fans out a message to all channel members except the
// sender. Returns the database row ID, message UUID, and
// any error. The dbID lets callers enqueue the same
// message to the sender when echo is needed (HTTP
// transport).
func (s *Service) SendChannelMessage( func (s *Service) SendChannelMessage(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, command, channel string, nick, command, channel string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (string, error) { ) (int64, string, error) {
noSuchChannel := &IRCError{ noSuchChannel := &IRCError{
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel, irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
} }
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil { if err != nil {
return "", noSuchChannel return 0, "", noSuchChannel
}
if command == irc.CmdPrivmsg {
err = s.checkChannelHashcash(
ctx, chID, channel, body, meta,
)
if err != nil {
return 0, "", err
}
} }
isMember, _ := s.db.IsChannelMember( isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if !isMember { if !isMember {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel", "Cannot send to channel",
@@ -415,7 +434,7 @@ func (s *Service) SendChannelMessage(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if banErr == nil && isBanned { if banErr == nil && isBanned {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+b)", "Cannot send to channel (+b)",
@@ -432,7 +451,7 @@ func (s *Service) SendChannelMessage(
) )
if !isOp && !isVoiced { if !isOp && !isVoiced {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+m)", "Cannot send to channel (+m)",
@@ -440,40 +459,31 @@ func (s *Service) SendChannelMessage(
} }
} }
if command == irc.CmdPrivmsg {
err = s.checkChannelHashcash(
ctx, chID, channel, body, meta,
)
if err != nil {
return "", err
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
_, uuid, fanErr := s.FanOut( dbID, uuid, fanErr := s.FanOut(
ctx, command, nick, channel, 0, chID, ctx, command, nick, channel, 0, chID,
nil, body, meta, memberIDs, skipClientID, nil, body, meta, recipients,
) )
// The channel was deleted after the lookup above. // The channel was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) { if db.IsForeignKeyConstraintError(fanErr) {
return "", noSuchChannel return 0, "", noSuchChannel
} }
if fanErr != nil { if fanErr != nil {
return "", fanErr return 0, "", fanErr
} }
return uuid, nil return dbID, uuid, nil
} }
// SendDirectMessage validates the target and sends a // SendDirectMessage validates the target and sends a
// direct message to the target and the sender's session, // direct message, returning the message UUID and any away
// skipping client skipClientID as FanOut does, and returns // message set on the target.
// the message UUID and any away message set on the target.
func (s *Service) SendDirectMessage( func (s *Service) SendDirectMessage(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, command, target string, nick, command, target string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (*DirectMsgResult, error) { ) (*DirectMsgResult, error) {
@@ -495,7 +505,7 @@ func (s *Service) SendDirectMessage(
_, uuid, fanErr := s.FanOut( _, uuid, fanErr := s.FanOut(
ctx, command, nick, target, targetSID, 0, ctx, command, nick, target, targetSID, 0,
nil, body, meta, recipients, skipClientID, nil, body, meta, recipients,
) )
// The target's session was deleted after the lookup above. // The target's session was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) { if db.IsForeignKeyConstraintError(fanErr) {
@@ -511,12 +521,11 @@ func (s *Service) SendDirectMessage(
// JoinChannel creates or joins a channel, making the // JoinChannel creates or joins a channel, making the
// first joiner the operator. Fans out the JOIN to all // first joiner the operator. Fans out the JOIN to all
// channel members, skipping client skipClientID as FanOut // channel members. A channel name that validChannelRe
// does. A channel name that validChannelRe does not match // does not match gets ERR_NOSUCHCHANNEL.
// gets ERR_NOSUCHCHANNEL.
func (s *Service) JoinChannel( func (s *Service) JoinChannel(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) (*JoinResult, error) { ) (*JoinResult, error) {
if !validChannelRe.MatchString(channel) { if !validChannelRe.MatchString(channel) {
@@ -539,7 +548,7 @@ func (s *Service) JoinChannel(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdJoin, nick, channel, 0, chID, ctx, irc.CmdJoin, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
return &JoinResult{ return &JoinResult{
@@ -548,12 +557,12 @@ func (s *Service) JoinChannel(
}, nil }, nil
} }
// PartChannel validates membership, broadcasts PART to all // PartChannel validates membership, broadcasts PART to
// channel members, skipping client skipClientID as FanOut // remaining members, removes the user, and cleans up empty
// does, removes the user, and cleans up empty channels. // channels.
func (s *Service) PartChannel( func (s *Service) PartChannel(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, reason string, nick, channel, reason string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -577,11 +586,12 @@ func (s *Service) PartChannel(
} }
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdPart, nick, channel, 0, chID, ctx, irc.CmdPart, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, recipients,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec
@@ -590,11 +600,10 @@ func (s *Service) PartChannel(
} }
// SetTopic validates membership and topic-lock, sets the // SetTopic validates membership and topic-lock, sets the
// topic, and broadcasts the change to all channel members, // topic, and broadcasts the change.
// skipping client skipClientID as FanOut does.
func (s *Service) SetTopic( func (s *Service) SetTopic(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, topic string, nick, channel, topic string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -643,19 +652,18 @@ func (s *Service) SetTopic(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdTopic, nick, channel, 0, chID, ctx, irc.CmdTopic, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
return nil return nil
} }
// KickUser validates operator status and target // KickUser validates operator status and target
// membership, broadcasts the KICK to all channel members, // membership, broadcasts the KICK, removes the target,
// skipping client skipClientID as FanOut does, removes the // and cleans up empty channels.
// target, and cleans up empty channels.
func (s *Service) KickUser( func (s *Service) KickUser(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, targetNick, reason string, nick, channel, targetNick, reason string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -708,7 +716,7 @@ func (s *Service) KickUser(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdKick, nick, channel, 0, chID, ctx, irc.CmdKick, nick, channel, 0, chID,
params, body, nil, memberIDs, skipClientID, params, body, nil, memberIDs,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec
@@ -789,11 +797,10 @@ func (s *Service) InviteUser(
} }
// ChangeNick changes a user's nickname and broadcasts the // ChangeNick changes a user's nickname and broadcasts the
// change to the user's own session and all users sharing // change to all users sharing channels.
// channels, skipping client skipClientID as FanOut does.
func (s *Service) ChangeNick( func (s *Service) ChangeNick(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
oldNick, newNick string, oldNick, newNick string,
) error { ) error {
err := s.db.ChangeNick(ctx, sessionID, newNick) err := s.db.ChangeNick(ctx, sessionID, newNick)
@@ -814,62 +821,66 @@ func (s *Service) ChangeNick(
} }
} }
s.broadcastNickChange( s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
ctx, sessionID, skipClientID, oldNick, newNick,
)
return nil return nil
} }
// BroadcastQuit parts the session from all its channels, // BroadcastQuit broadcasts a QUIT to all channel peers,
// deletes it, and queues a QUIT to the users who shared a // parts all channels, and deletes the session. Uses the
// channel with it, in one transaction, so that the QUIT is // FanOut pattern: one message row fanned out to all unique
// queued before anyone else can take the nick, and then // peer sessions.
// wakes those users' long polls. Of several calls for one
// session at once, only the one that deletes it sends the
// QUIT.
func (s *Service) BroadcastQuit( func (s *Service) BroadcastQuit(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick, reason string, nick, reason string,
) { ) {
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson channels, err := s.db.GetSessionChannels(
ctx, sessionID,
peerIDs, err := s.db.QuitSession(ctx, sessionID, nick, body) )
if err != nil { if err != nil {
s.log.Error("quit failed", "error", err)
return return
} }
for _, peerID := range peerIDs { notified := make(map[int64]bool)
s.broker.Notify(peerID)
}
}
// Logout deletes a client of the session and, if that was for _, ch := range channels {
// the session's last client, does what BroadcastQuit does. memberIDs, memErr := s.db.GetChannelMemberIDs(
// If that fails, nothing changes and Logout returns the ctx, ch.ID,
// error.
func (s *Service) Logout(
ctx context.Context,
sessionID, clientID int64,
nick string,
) error {
body, _ := json.Marshal([]string{"Connection closed"}) //nolint:errchkjson
peerIDs, err := s.db.LogoutClient(
ctx, sessionID, clientID, nick, body,
) )
if err != nil { if memErr != nil {
return fmt.Errorf("logout: %w", err) continue
} }
for _, peerID := range peerIDs { for _, mid := range memberIDs {
s.broker.Notify(peerID) if mid == sessionID || notified[mid] {
continue
} }
return nil notified[mid] = true
}
}
if len(notified) > 0 {
recipients := make([]int64, 0, len(notified))
for sid := range notified {
recipients = append(recipients, sid)
}
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut(
ctx, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil, recipients,
)
}
for _, ch := range channels {
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
}
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
} }
// SetAway sets or clears the away message. Returns true // SetAway sets or clears the away message. Returns true
@@ -1036,10 +1047,9 @@ func (s *Service) SetChannelFlag(
} }
// BroadcastMode fans out a MODE change to all channel // BroadcastMode fans out a MODE change to all channel
// members, skipping client skipClientID as FanOut does. // members.
func (s *Service) BroadcastMode( func (s *Service) BroadcastMode(
ctx context.Context, ctx context.Context,
skipClientID int64,
nick, channel string, nick, channel string,
chID int64, chID int64,
modeText string, modeText string,
@@ -1049,7 +1059,7 @@ func (s *Service) BroadcastMode(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdMode, nick, channel, 0, chID, ctx, irc.CmdMode, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
} }
@@ -1169,12 +1179,6 @@ func (s *Service) ApplyUserMode(
return s.QueryUserMode(ctx, sessionID) return s.QueryUserMode(ctx, sessionID)
} }
// UserModes lists the user modes the server supports, as
// RPL_MYINFO sends them: o (server operator) and w
// (receives WALLOPS). parseUserModeString must accept the
// same letters.
const UserModes = "ow"
// parseUserModeString parses a user mode string such as // parseUserModeString parses a user mode string such as
// "+w", "-o" or "+w-o" into the new value of each flag it // "+w", "-o" or "+w-o" into the new value of each flag it
// names, or nil for a flag it does not name; a later letter // names, or nil for a flag it does not name; a later letter
@@ -1307,12 +1311,11 @@ func (s *Service) checkChannelHashcash(
return nil return nil
} }
// broadcastNickChange notifies the user's own session, // broadcastNickChange notifies channel peers of a nick
// except client skipClientID, and channel peers of a nick
// change. // change.
func (s *Service) broadcastNickChange( func (s *Service) broadcastNickChange(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
oldNick, newNick string, oldNick, newNick string,
) { ) {
channels, err := s.db.GetSessionChannels( channels, err := s.db.GetSessionChannels(
@@ -1334,7 +1337,7 @@ func (s *Service) broadcastNickChange(
} }
// Notify the user themselves (for multi-client sync). // Notify the user themselves (for multi-client sync).
_ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID) _ = s.db.EnqueueToSession(ctx, sessionID, dbID)
s.broker.Notify(sessionID) s.broker.Notify(sessionID)
notified[sessionID] = true notified[sessionID] = true
@@ -1353,7 +1356,7 @@ func (s *Service) broadcastNickChange(
notified[mid] = true notified[mid] = true
_ = s.db.EnqueueToSession(ctx, mid, dbID, 0) _ = s.db.EnqueueToSession(ctx, mid, dbID)
s.broker.Notify(mid) s.broker.Notify(mid)
} }
} }
+69 -333
View File
@@ -6,9 +6,11 @@ package service_test
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -17,21 +19,24 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
// testEnv holds all dependencies for a service test. // testEnv holds all dependencies for a service test.
type testEnv struct { type testEnv struct {
svc *service.Service svc *service.Service
dbURL string
db *db.Database db *db.Database
broker *broker.Broker broker *broker.Broker
app *fxtest.App app *fxtest.App
@@ -40,7 +45,13 @@ type testEnv struct {
func newTestEnv(t *testing.T) *testEnv { func newTestEnv(t *testing.T) *testEnv {
t.Helper() t.Helper()
dbURL := dbtest.NewURL(t) // A random name, so that no other test can share it, not
// even one that has finished but still holds its database
// open.
dbURL := fmt.Sprintf(
"file:%s?mode=memory&cache=shared",
rand.Text(),
)
var ( var (
database *db.Database database *db.Database
@@ -105,7 +116,6 @@ func newTestEnv(t *testing.T) *testEnv {
return &testEnv{ return &testEnv{
svc: svc, svc: svc,
dbURL: dbURL,
db: database, db: database,
broker: brk, broker: brk,
app: app, app: app,
@@ -157,7 +167,7 @@ func TestFanOut(t *testing.T) {
dbID, uuid, err := env.svc.FanOut( dbID, uuid, err := env.svc.FanOut(
ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0, ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
nil, body, nil, nil, body, nil,
[]int64{sid1, sid2}, 0, []int64{sid1, sid2},
) )
if err != nil { if err != nil {
t.Fatalf("FanOut: %v", err) t.Fatalf("FanOut: %v", err)
@@ -179,7 +189,7 @@ func TestJoinChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel( result, err := env.svc.JoinChannel(
ctx, sid, 0, "alice", "#general", "", ctx, sid, "alice", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel: %v", err) t.Fatalf("JoinChannel: %v", err)
@@ -197,7 +207,7 @@ func TestJoinChannel(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel( result2, err := env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#general", "", ctx, sid2, "bob", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel bob: %v", err) t.Fatalf("JoinChannel bob: %v", err)
@@ -219,14 +229,14 @@ func TestPartChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, sid, 0, "alice", "#general", "", ctx, sid, "alice", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel: %v", err) t.Fatalf("JoinChannel: %v", err)
} }
err = env.svc.PartChannel( err = env.svc.PartChannel(
ctx, sid, 0, "alice", "#general", "bye", ctx, sid, "alice", "#general", "bye",
) )
if err != nil { if err != nil {
t.Fatalf("PartChannel: %v", err) t.Fatalf("PartChannel: %v", err)
@@ -234,7 +244,7 @@ func TestPartChannel(t *testing.T) {
// Parting a non-existent channel returns error. // Parting a non-existent channel returns error.
err = env.svc.PartChannel( err = env.svc.PartChannel(
ctx, sid, 0, "alice", "#nonexistent", "", ctx, sid, "alice", "#nonexistent", "",
) )
if err == nil { if err == nil {
t.Error("expected error for non-existent channel") t.Error("expected error for non-existent channel")
@@ -298,7 +308,7 @@ func joinConcurrently(
joins[i] = func() error { joins[i] = func() error {
result, err := env.svc.JoinChannel( result, err := env.svc.JoinChannel(
ctx, sessionID, 0, nick, channel, "", ctx, sessionID, nick, channel, "",
) )
results[i] = result results[i] = result
@@ -324,7 +334,7 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
opSID := createSession(ctx, t, env.db, opNick) opSID := createSession(ctx, t, env.db, opNick)
created, err := env.svc.JoinChannel( created, err := env.svc.JoinChannel(
ctx, opSID, 0, opNick, channel, "", ctx, opSID, opNick, channel, "",
) )
if err != nil { if err != nil {
t.Fatalf("join %s: %v", opNick, err) t.Fatalf("join %s: %v", opNick, err)
@@ -437,7 +447,7 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick) joinerSID := createSession(ctx, t, env.db, joinerNick)
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, leaverSID, 0, leaverNick, channel, "", ctx, leaverSID, leaverNick, channel, "",
) )
if err != nil { if err != nil {
t.Fatalf("join %s: %v", leaverNick, err) t.Fatalf("join %s: %v", leaverNick, err)
@@ -446,12 +456,12 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
errs := runConcurrently( errs := runConcurrently(
func() error { func() error {
return env.svc.PartChannel( return env.svc.PartChannel(
ctx, leaverSID, 0, leaverNick, channel, "", ctx, leaverSID, leaverNick, channel, "",
) )
}, },
func() error { func() error {
_, joinErr := env.svc.JoinChannel( _, joinErr := env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "", ctx, joinerSID, joinerNick, channel, "",
) )
return joinErr return joinErr
@@ -485,208 +495,6 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
} }
} }
// TestJoinConcurrentWithLastQuit checks that a user who
// sends JOIN without the key to a +k channel, again after
// each ERR_BADCHANNELKEY, while the channel's only member
// quits, gets in only once the channel is deleted, into a
// new channel of that name with no key.
func TestJoinConcurrentWithLastQuit(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
channel := fmt.Sprintf("#keyed%d", trial)
leaverNick := fmt.Sprintf("leaver%d", trial)
leaverSID := createSession(ctx, t, env.db, leaverNick)
joinerNick := fmt.Sprintf("joiner%d", trial)
joinerSID := createSession(ctx, t, env.db, joinerNick)
created, err := env.svc.JoinChannel(
ctx, leaverSID, 0, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
}
err = env.db.SetChannelKey(ctx, created.ChannelID, "secret")
if err != nil {
t.Fatalf("set +k: %v", err)
}
var joined *service.JoinResult
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, leaverSID, leaverNick, "")
return nil
},
// The database has one connection, so retried
// JOINs take turns with the QUIT's statements
// and land between any two that are not in one
// transaction. A JOIN after the QUIT gets in, so
// the loop ends.
func() error {
for {
var (
joinErr error
ircErr *service.IRCError
)
joined, joinErr = env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "",
)
if !errors.As(joinErr, &ircErr) ||
ircErr.Code != irc.ErrBadChannelKey {
return joinErr
}
}
},
)
if errs[1] != nil {
t.Fatalf("trial %d: JOIN: %v", trial, errs[1])
}
key, _ := env.db.GetChannelKey(ctx, joined.ChannelID)
if key != "" {
t.Errorf(
"trial %d: %s got into +k %s without the key",
trial, joinerNick, channel,
)
}
}
}
// quitCrowdSize is how many sessions share a second channel
// with the quitting session in
// TestQuitQueuedAheadOfNewNickHoldersJoin.
const quitCrowdSize = 10
// TestQuitQueuedAheadOfNewNickHoldersJoin checks that when
// a new session takes a quitting session's nick and joins a
// channel the quitting session shared with a watcher, while
// the QUIT is going out, the watcher has the QUIT queued
// ahead of that JOIN. The quitting session is also on a
// channel with quitCrowdSize other sessions, made before the
// watcher's channel, so that its QUIT reaches the watcher
// last.
func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
nick := fmt.Sprintf("reused%d", trial)
crowd := fmt.Sprintf("#crowd%d", trial)
shared := fmt.Sprintf("#shared%d", trial)
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
}
}
for member := range quitCrowdSize {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "")
if err != nil {
t.Fatalf("join %s: %v", memberNick, err)
}
}
watcherNick := fmt.Sprintf("watcher%d", trial)
watcherSID, watcherClientID, _, err := env.db.CreateSession(
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("trial %d: new session: %v", trial, errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"trial %d: watcher got %v from %s, want QUIT then JOIN",
trial, commands, nick,
)
}
}
}
// takeNickAndJoin creates a session with nick, trying again
// while another session holds the nick, and joins it to
// channel.
func takeNickAndJoin(
ctx context.Context,
env *testEnv,
nick, channel string,
) error {
for {
sessionID, _, _, err := env.db.CreateSession(
ctx, nick, nick, "localhost", "127.0.0.1",
)
if db.IsUniqueConstraintError(err) {
continue
}
if err != nil {
return err
}
_, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "")
return err
}
}
// commandsFrom returns the commands of the messages in msgs
// that nick sent, in order.
func commandsFrom(msgs []db.IRCMessage, nick string) []string {
commands := make([]string, 0, len(msgs))
for _, msg := range msgs {
if msg.From == nick {
commands = append(commands, msg.Command)
}
}
return commands
}
// TestJoinUsesUpInvite checks that the JOIN an invite let // TestJoinUsesUpInvite checks that the JOIN an invite let
// into a +i channel uses the invite up, so that a JOIN // into a +i channel uses the invite up, so that a JOIN
// after leaving gets ERR_INVITEONLYCHAN. // after leaving gets ERR_INVITEONLYCHAN.
@@ -699,7 +507,7 @@ func TestJoinUsesUpInvite(t *testing.T) {
opSID := createSession(ctx, t, env.db, "op") opSID := createSession(ctx, t, env.db, "op")
guestSID := createSession(ctx, t, env.db, "guest") guestSID := createSession(ctx, t, env.db, "guest")
created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil { if err != nil {
t.Fatalf("join op: %v", err) t.Fatalf("join op: %v", err)
} }
@@ -714,17 +522,17 @@ func TestJoinUsesUpInvite(t *testing.T) {
t.Fatalf("invite guest: %v", err) t.Fatalf("invite guest: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
if err != nil { if err != nil {
t.Fatalf("invited join: %v", err) t.Fatalf("invited join: %v", err)
} }
err = env.svc.PartChannel(ctx, guestSID, 0, "guest", channel, "") err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "")
if err != nil { if err != nil {
t.Fatalf("part guest: %v", err) t.Fatalf("part guest: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan { if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan {
@@ -743,7 +551,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
opSID := createSession(ctx, t, env.db, "op") opSID := createSession(ctx, t, env.db, "op")
created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil { if err != nil {
t.Fatalf("join op: %v", err) t.Fatalf("join op: %v", err)
} }
@@ -762,7 +570,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
t.Fatalf("create session guest: %v", err) t.Fatalf("create session guest: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan { if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan {
@@ -782,17 +590,17 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
aliceSID := createSession(ctx, t, env.db, "alice") aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob") bobSID := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "") _, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "") _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
} }
err = env.svc.PartChannel(ctx, aliceSID, 0, "alice", channel, "") err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "")
if err != nil { if err != nil {
t.Fatalf("part alice: %v", err) t.Fatalf("part alice: %v", err)
} }
@@ -807,7 +615,7 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
t.Fatal("bob is not on the channel after alice parted") t.Fatal("bob is not on the channel after alice parted")
} }
err = env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "") err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
if err != nil { if err != nil {
t.Fatalf("part bob: %v", err) t.Fatalf("part bob: %v", err)
} }
@@ -828,12 +636,12 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, 0, "alice", channel, "") _, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "")
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
err = env.svc.KickUser(ctx, sid, 0, "alice", channel, "alice", "") err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "")
if err != nil { if err != nil {
t.Fatalf("kick alice: %v", err) t.Fatalf("kick alice: %v", err)
} }
@@ -852,14 +660,14 @@ func TestSendChannelMessage(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#chat", "", ctx, sid1, "alice", "#chat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#chat", "", ctx, sid2, "bob", "#chat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -867,14 +675,18 @@ func TestSendChannelMessage(t *testing.T) {
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
uuid, err := env.svc.SendChannelMessage( dbID, uuid, err := env.svc.SendChannelMessage(
ctx, sid1, 0, "alice", ctx, sid1, "alice",
irc.CmdPrivmsg, "#chat", body, nil, irc.CmdPrivmsg, "#chat", body, nil,
) )
if err != nil { if err != nil {
t.Fatalf("SendChannelMessage: %v", err) t.Fatalf("SendChannelMessage: %v", err)
} }
if dbID == 0 {
t.Error("expected non-zero dbID")
}
if uuid == "" { if uuid == "" {
t.Error("expected non-empty UUID") t.Error("expected non-empty UUID")
} }
@@ -882,8 +694,8 @@ func TestSendChannelMessage(t *testing.T) {
// Non-member cannot send. // Non-member cannot send.
sid3 := createSession(ctx, t, env.db, "charlie") sid3 := createSession(ctx, t, env.db, "charlie")
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid3, 0, "charlie", ctx, sid3, "charlie",
irc.CmdPrivmsg, "#chat", body, nil, irc.CmdPrivmsg, "#chat", body, nil,
) )
if err == nil { if err == nil {
@@ -899,14 +711,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#room", "", ctx, sid1, "alice", "#room", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#room", "", ctx, sid2, "bob", "#room", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -935,17 +747,20 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
createSession(ctx, t, env.db, "bob") createSession(ctx, t, env.db, "bob")
dbtest.Exec(t, env.dbURL, _, err := env.db.GetDB().ExecContext(ctx,
`CREATE TRIGGER delete_recipient `CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages BEFORE INSERT ON messages
BEGIN BEGIN
DELETE FROM sessions WHERE id = NEW.to_session_id; DELETE FROM sessions WHERE id = NEW.to_session_id;
END`) END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err := env.svc.SendDirectMessage( _, err = env.svc.SendDirectMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil, ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil,
) )
var ircErr *service.IRCError var ircErr *service.IRCError
@@ -965,22 +780,25 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, 0, "alice", "#gone", "") _, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "")
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
dbtest.Exec(t, env.dbURL, _, err = env.db.GetDB().ExecContext(ctx,
`CREATE TRIGGER delete_recipient `CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages BEFORE INSERT ON messages
BEGIN BEGIN
DELETE FROM channels WHERE id = NEW.to_channel_id; DELETE FROM channels WHERE id = NEW.to_channel_id;
END`) END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil, ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
) )
var ircErr *service.IRCError var ircErr *service.IRCError
@@ -998,14 +816,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel( result, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#modchat", "", ctx, sid1, "alice", "#modchat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#modchat", "", ctx, sid2, "bob", "#modchat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -1018,8 +836,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
// Bob (non-op, non-voiced) should fail to send. // Bob (non-op, non-voiced) should fail to send.
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid2, 0, "bob", ctx, sid2, "bob",
irc.CmdPrivmsg, "#modchat", body, nil, irc.CmdPrivmsg, "#modchat", body, nil,
) )
if err == nil { if err == nil {
@@ -1027,8 +845,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
} }
// Alice (operator) should succeed. // Alice (operator) should succeed.
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid1, 0, "alice", ctx, sid1, "alice",
irc.CmdPrivmsg, "#modchat", body, nil, irc.CmdPrivmsg, "#modchat", body, nil,
) )
if err != nil { if err != nil {
@@ -1036,88 +854,6 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
} }
} }
// TestRefusedChannelMessageLeavesStampUnspent checks that a
// PRIVMSG to a +H channel refused because the sender is not
// a member, is banned, or has no voice on a +m channel does
// not spend its hashcash stamp: the operator can then send
// the same message with it.
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
const (
channel = "#paid"
bits = 2
)
refusals := []struct {
name string
// refuse stops bob from sending to the channel.
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
}{
{"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error {
return env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "")
}},
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
}},
{"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.svc.SetChannelFlag(ctx, chID, 'm', true)
}},
}
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
for _, refusal := range refusals {
t.Run(refusal.name, func(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob")
joined, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "")
if err != nil {
t.Fatalf("join bob: %v", err)
}
err = env.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits)
if err != nil {
t.Fatalf("set +H: %v", err)
}
err = refusal.refuse(ctx, env, joined.ChannelID, bobSID)
if err != nil {
t.Fatalf("refuse bob: %v", err)
}
stamp := hashcash.MintChannelStamp(
bits, channel, hashcash.BodyHash(body),
)
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
_, err = env.svc.SendChannelMessage(
ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta,
)
var ircErr *service.IRCError
if !errors.As(err, &ircErr) ||
ircErr.Code != irc.ErrCannotSendToChan {
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
}
_, err = env.svc.SendChannelMessage(
ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta,
)
if err != nil {
t.Fatalf("alice with the same stamp: %v", err)
}
})
}
}
func TestQueryUserMode(t *testing.T) { func TestQueryUserMode(t *testing.T) {
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
+82 -85
View File
@@ -1,101 +1,98 @@
# Message schemas # Message Schemas
This directory holds JSON Schema files (draft 2020-12) for the messages that the JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
neoirc HTTP API carries. `message.json` describes the fields that every message **IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
can have. `commands/` has files for some commands. `numerics/` has files for over HTTP.
some of the numeric replies. A numeric reply is a server reply named by a
three-digit number, such as `001`. `README.md` at the repository root documents
the protocol.
The server can also run an IRC listener for ordinary IRC clients. The IRC ## Envelope
listener sends plain IRC lines, which these files do not describe.
## Message fields Every message is a JSON object with a `command` field. The format maps directly
to IRC wire format:
Every message is one JSON object, which `message.json` calls the envelope. These
are the fields it can have:
- `command`: the IRC command name, such as `PRIVMSG`, or the name of a numeric
reply, such as `RPL_WELCOME`. Always present.
- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent
on other messages.
- `id`: the message's UUID, a unique ID that the server assigns.
- `from`: the sender's nick, or the server name on a numeric reply or a `PONG`.
- `to`: the target, such as a channel name starting with `#` or a nick.
- `params`: an array of strings with the message's other parameters, in order.
`KICK` events and many numeric replies have it. The HTTP API ignores `params`
sent by a client.
- `body`: an array of strings. It is never a bare string. In a `PRIVMSG` or
`NOTICE`, each string is one line of text.
- `ts`: the time the server stored the message. It is an RFC 3339 time in UTC,
such as `2026-10-27T12:00:00.123Z`.
- `meta`: an object of extra data, such as a hashcash stamp in `meta.hashcash`.
A hashcash stamp is a short string that takes a computer some work to make. A
`PRIVMSG` to a channel with mode `+H` needs one. The HTTP API relays the
`meta` of a `PRIVMSG` or `NOTICE` unchanged. It drops the `meta` of every
other command.
Here is one `PRIVMSG` as the IRC listener sends it and as the HTTP API sends it.
The JSON leaves out `id`, `ts` and `meta`:
``` ```
IRC: :alice!alice@* PRIVMSG #general :hello world IRC: :nick PRIVMSG #channel :hello world
JSON: {"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]}
IRC: :server 353 nick = #channel :user1 @op1 +voice1
JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["user1 @op1 +voice1"]}
Multiline: {"command": "PRIVMSG", "to": "#ch", "body": ["line 1", "line 2"]}
Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}
``` ```
In the JSON, a numeric reply has its name in `command` and its number in `code`. Common fields (see `message.json` for full schema):
This `353` reply lists the members of `#general`. The JSON again leaves out
`id`, `ts` and `meta`:
``` | Field | Type | Description |
{"command": "RPL_NAMREPLY", "code": 353, "from": "neoirc", "to": "alice", | --------- | --------------- | ---------------------------------------------------- |
"params": ["=", "#general"], "body": ["@op1!op1@host1 alice!alice@host2"]} | `id` | string (uuid) | Server-assigned message UUID |
``` | `command` | string | IRC command or 3-digit numeric code |
| `from` | string | Source nick or server name (IRC prefix) |
| `to` | string | Target: #channel or nick |
| `params` | string[] | Middle parameters (mainly for numerics) |
| `body` | array \| object | Structured body — never a raw string (see below) |
| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) |
| `meta` | object | Extensible metadata (signatures, hashes, etc.) |
A `PRIVMSG` whose `body` has two strings is a two-line message: **Structured bodies:** `body` is always an array of strings (for text) or an
object (for structured data like PUBKEY). Never a raw string. This enables:
``` - Multiline messages without escape sequences
{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} - Deterministic canonicalization via RFC 8785 JCS for signing
``` - Structured data where needed
## Files ## Commands
Most command files describe both the message a client sends and the message the IRC commands used for client↔server and server↔server communication.
server sends. Their field descriptions say where the two differ. The examples in
the files leave out `id`, `ts` and `meta`.
Commands, in `commands/`: | Command | File | RFC | Description |
| --------- | ----------------------- | ----------- | -------------------------- |
| `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user |
| `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) |
| `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel |
| `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel |
| `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected |
| `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname |
| `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic |
| `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes |
| `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel |
| `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive |
| `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response |
| `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key |
- `PRIVMSG.json`: a message to a channel or a user. ## Numeric Replies
- `NOTICE.json`: a notice, which is a message that a client must not answer
automatically.
- `JOIN.json`: joining a channel.
- `PART.json`: leaving a channel.
- `QUIT.json`: leaving the server.
- `NICK.json`: changing nick.
- `TOPIC.json`: setting a channel's topic.
- `MODE.json`: querying or changing a channel's modes or the user's own modes.
- `KICK.json`: a channel operator removing a user from a channel.
- `PING.json`: a check that the server still answers.
- `PONG.json`: the answer to a `PING`, which is the HTTP response to it.
Numeric replies, in `numerics/`: Three-digit codes for server responses, per IRC convention.
- `001.json`: `RPL_WELCOME`, which welcomes the user. ### Success / Informational (0xx–3xx)
- `002.json`: `RPL_YOURHOST`, the server's name and version.
- `003.json`: `RPL_CREATED`, the date the server last started. | Code | Name | File | Description |
- `004.json`: `RPL_MYINFO`, the server's name, its version and its lists of | ----- | -------------- | ------------------- | ------------------------------ |
modes. | `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation |
- `322.json`: `RPL_LIST`, one channel in the answer to `LIST`. | `002` | RPL_YOURHOST | `numerics/002.json` | Server host info |
- `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`. | `003` | RPL_CREATED | `numerics/003.json` | Server creation date |
- `332.json`: `RPL_TOPIC`, a channel's topic. | `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes |
- `353.json`: `RPL_NAMREPLY`, a channel's members. | `322` | RPL_LIST | `numerics/322.json` | Channel list entry |
- `366.json`: `RPL_ENDOFNAMES`, the end of the member list. | `323` | RPL_LISTEND | `numerics/323.json` | End of channel list |
- `372.json`: `RPL_MOTD`, one line of the message of the day. | `332` | RPL_TOPIC | `numerics/332.json` | Channel topic |
- `375.json`: `RPL_MOTDSTART`, the start of the message of the day. | `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list |
- `376.json`: `RPL_ENDOFMOTD`, the end of the message of the day. | `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list |
- `401.json`: `ERR_NOSUCHNICK`, the nick does not exist. | `372` | RPL_MOTD | `numerics/372.json` | MOTD line |
- `403.json`: `ERR_NOSUCHCHANNEL`, the channel does not exist. | `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD |
- `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken. | `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD |
- `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel.
- `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator. ### Errors (4xx)
| Code | Name | File | Description |
| ----- | -------------------- | ------------------- | ----------------------- |
| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel |
| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel |
| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use |
| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel |
| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator |
## Federation (S2S)
Server-to-server messages use the same command format. Federated servers relay
messages with an additional `origin` field in `meta` to track the source server.
The PING/PONG commands serve as inter-server keepalives. State sync after link
establishment uses a burst of JOIN, NICK, TOPIC, and MODE commands.
+4 -14
View File
@@ -2,25 +2,15 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
"title": "JOIN", "title": "JOIN",
"description": "Join a channel. A client sends it to join a channel, which the server creates if it does not exist. The server sends it to every member of the channel when a user joins, the new member included. RFC 1459 §4.2.1.", "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "JOIN" }, "command": { "const": "JOIN" },
"from": { "type": "string", "description": "The nick that joined. The server sets it." }, "from": { "type": "string", "description": "Nick that joined (S2C)." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }
"type": "string",
"description": "The channel name: # and then 1 to 63 of a-z, A-Z, 0-9, _ and -. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "From a client: the channel key as the first string, which a channel with mode +k needs. From the server: the channel name. After a login, and for GET /api/v1/state?initChannelState=1, the server queues a JOIN for each of the session's channels. That JOIN has an empty body."
}
}, },
"required": ["command", "to"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "JOIN", "to": "#general" }, { "command": "JOIN", "from": "alice", "to": "#general" }
{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] }
] ]
} }
+7 -12
View File
@@ -2,33 +2,28 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
"title": "KICK", "title": "KICK",
"description": "Remove a user from a channel. A client sends it, and only a channel operator may. The server sends it to every member of the channel, the removed user included. RFC 1459 §4.2.8.", "description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "KICK" }, "command": { "const": "KICK" },
"from": { "type": "string", "description": "The nick that removed the user. The server sets it." }, "from": { "type": "string", "description": "Nick that performed the kick." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"params": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The removed nick. Only the server sends it.", "description": "Kicked nick. e.g. [\"alice\"].",
"minItems": 1, "minItems": 1,
"maxItems": 1 "maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "From a client: the nick to remove, then an optional reason. From the server: the reason, as one string.", "description": "Optional kick reason.",
"minItems": 1 "maxItems": 1
} }
}, },
"required": ["command", "to", "body"], "required": ["command", "to", "params"],
"examples": [ "examples": [
{ "command": "KICK", "to": "#general", "body": ["troll", "Behave"] },
{ "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] } { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] }
] ]
} }
+9 -10
View File
@@ -2,29 +2,28 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
"title": "MODE", "title": "MODE",
"description": "Query or change a channel's modes or the user's own modes. A client sends it. The server sends it to every member of the channel when a channel mode changes. A +H or -H sent over the HTTP API is the exception. The server answers it with RPL_CHANNELMODEIS (324) and sends no MODE. RFC 1459 §4.2.3.", "description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "MODE" }, "command": { "const": "MODE" },
"from": { "from": {
"type": "string", "type": "string",
"description": "The nick that changed the mode. The server sets it." "description": "Nick that set the mode (S2C only)."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "A channel name, which starts with #, or the user's own nick." "description": "Channel name.",
"pattern": "^#[a-zA-Z0-9_-]+$"
}, },
"body": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "From a client: the mode change and then its value, as separate strings, such as \"+o\" and \"bob\". A MODE without a body is a query. From the server: the change and its value, as one string, such as \"+o bob\", or as separate strings, such as \"+k\" and \"secret\".", "description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].",
"examples": [["+o", "bob"], ["-m"], ["+o bob"], ["+k", "secret"]] "examples": [["+o", "alice"], ["-m"], ["+v", "bob"]]
} }
}, },
"required": ["command", "to"], "required": ["command", "to", "params"],
"examples": [ "examples": [
{ "command": "MODE", "to": "#general", "body": ["+o", "bob"] }, { "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] }
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+o bob"] },
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+k", "secret"] }
] ]
} }
+5 -5
View File
@@ -2,21 +2,21 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
"title": "NICK", "title": "NICK",
"description": "Change nick. A client sends it to change its nick. The server sends it to the user's own clients and to the users who share a channel with the user. RFC 1459 §4.1.2.", "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "NICK" }, "command": { "const": "NICK" },
"from": { "type": "string", "description": "The old nick. The server sets it." }, "from": { "type": "string", "description": "Old nick (S2C)." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The new nick, as the first string. The server sends one string.", "description": "New nick (single-element array).",
"minItems": 1 "minItems": 1,
"maxItems": 1
} }
}, },
"required": ["command", "body"], "required": ["command", "body"],
"examples": [ "examples": [
{ "command": "NICK", "body": ["newnick"] },
{ "command": "NICK", "from": "oldnick", "body": ["newnick"] } { "command": "NICK", "from": "oldnick", "body": ["newnick"] }
] ]
} }
+5 -6
View File
@@ -2,21 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE", "title": "NOTICE",
"description": "A notice: a message to a channel or a user that a client must not answer automatically. The server relays it as it relays a PRIVMSG. A NOTICE to a channel with mode +H needs no hashcash stamp. RFC 1459 §4.4.2.", "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "NOTICE" }, "command": { "const": "NOTICE" },
"from": { "type": "string", "description": "The sender's nick. The server sets it." }, "from": { "type": "string" },
"to": { "type": "string", "description": "A channel name, which starts with #, or a nick." }, "to": { "type": "string", "description": "Target: #channel, nick, or * (global)." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The lines of the notice, one string per line.", "description": "Notice text lines."
"minItems": 1
} }
}, },
"required": ["command", "to", "body"], "required": ["command", "to", "body"],
"examples": [ "examples": [
{ "command": "NOTICE", "from": "alice", "to": "#general", "body": ["Back in five minutes"] } { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] }
] ]
} }
+5 -8
View File
@@ -2,20 +2,17 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
"title": "PART", "title": "PART",
"description": "Leave a channel. A client sends it to leave. The server sends it to every member of the channel when a user leaves, the leaving user included. RFC 1459 §4.2.2.", "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PART" }, "command": { "const": "PART" },
"from": { "type": "string", "description": "The nick that left. The server sets it." }, "from": { "type": "string", "description": "Nick that left (S2C)." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The reason for leaving. A client may send one as the first string. The server always sends one string, which is empty when no reason was given." "description": "Optional part reason.",
"maxItems": 1
} }
}, },
"required": ["command", "to"], "required": ["command", "to"],
+9 -3
View File
@@ -2,13 +2,19 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
"title": "PING", "title": "PING",
"description": "A check that the server still answers. A client sends it. The server ignores every field but 'command'. Its answer is a PONG in the HTTP response. RFC 1459 §4.6.2.", "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PING" } "command": { "const": "PING" },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Opaque token to be echoed in PONG (single-element array).",
"maxItems": 1
}
}, },
"required": ["command"], "required": ["command"],
"examples": [ "examples": [
{ "command": "PING" } { "command": "PING", "body": ["1707580000"] }
] ]
} }
+10 -4
View File
@@ -2,14 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
"title": "PONG", "title": "PONG",
"description": "The answer to a PING. The server sends it as the HTTP response to the PING, with only 'command' and 'from'. A client's PONG gets ERR_UNKNOWNCOMMAND (421). RFC 1459 §4.6.3.", "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PONG" }, "command": { "const": "PONG" },
"from": { "type": "string", "description": "The server name." } "from": { "type": "string", "description": "Responding server name." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Echoed token from PING (single-element array).",
"maxItems": 1
}
}, },
"required": ["command", "from"], "required": ["command"],
"examples": [ "examples": [
{ "command": "PONG", "from": "neoirc" } { "command": "PONG", "from": "server.example.com", "body": ["1707580000"] }
] ]
} }
+5 -5
View File
@@ -2,16 +2,16 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
"title": "PRIVMSG", "title": "PRIVMSG",
"description": "A message to a channel or a user. A client sends it. The server relays it to every member of the channel, or to the user, and to the sender's own clients. RFC 1459 §4.4.1.", "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PRIVMSG" }, "command": { "const": "PRIVMSG" },
"from": { "type": "string", "description": "The sender's nick. The server sets it." }, "from": { "type": "string", "description": "Sender nick (set by server on relay)." },
"to": { "type": "string", "description": "A channel name, which starts with #, or a nick.", "examples": ["#general", "alice"] }, "to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The lines of the message, one string per line.", "description": "Message lines. One string per line.",
"minItems": 1 "minItems": 1
} }
}, },
@@ -19,6 +19,6 @@
"examples": [ "examples": [
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] }, { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] },
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] }, { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] },
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"] } { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } }
] ]
} }
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
"title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PUBKEY" },
"from": { "type": "string", "description": "Nick announcing the key (set by server on relay)." },
"to": {
"type": "string",
"description": "Target: #channel to announce to channel members, or omit for server-wide announcement."
},
"body": {
"type": "object",
"description": "Key material.",
"properties": {
"alg": {
"type": "string",
"description": "Key algorithm.",
"enum": ["ed25519"]
},
"key": {
"type": "string",
"description": "Base64-encoded public key."
}
},
"required": ["alg", "key"],
"additionalProperties": false
}
},
"required": ["command", "body"],
"examples": [
{ "command": "PUBKEY", "from": "alice", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } },
{ "command": "PUBKEY", "from": "alice", "to": "#general", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } }
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
"title": "QUIT", "title": "QUIT",
"description": "A user left the server. A client sends it to end its session. The server sends it to the users who shared a channel with the user who left. RFC 1459 §4.1.6.", "description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "QUIT" }, "command": { "const": "QUIT" },
"from": { "type": "string", "description": "The nick that quit. The server sets it." }, "from": { "type": "string", "description": "Nick that quit." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The reason for leaving. A client may send one as the first string, and the server uses 'Client quit' when it does not. The server always sends one string." "description": "Optional quit reason.",
"maxItems": 1
} }
}, },
"required": ["command"], "required": ["command", "from"],
"examples": [ "examples": [
{ "command": "QUIT", "body": ["Gone for the day"] }, { "command": "QUIT", "from": "alice", "body": ["Connection reset"] }
{ "command": "QUIT", "from": "alice", "body": ["Connection closed"] }
] ]
} }
+6 -10
View File
@@ -2,24 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC", "title": "TOPIC",
"description": "Set a channel's topic. A client sends it to set the topic. The server sends it to every member of the channel when the topic changes. RFC 1459 §4.2.4.", "description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "TOPIC" }, "command": { "const": "TOPIC" },
"from": { "type": "string", "description": "The nick that set the topic. The server sets it." }, "from": { "type": "string", "description": "Nick that changed the topic (S2C)." },
"to": { "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The new topic. The server joins a client's strings with spaces, and sends the topic as one string. [\"\"] clears the topic.", "description": "New topic text (single-element array). Empty array clears the topic.",
"minItems": 1 "maxItems": 1
} }
}, },
"required": ["command", "to", "body"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] } { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
] ]
+30 -18
View File
@@ -2,55 +2,67 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/message.json", "$id": "https://sneak.berlin/go/neoirc/schema/message.json",
"title": "IRC Message Envelope", "title": "IRC Message Envelope",
"description": "Every message in the HTTP API is one JSON object with these fields. Only 'command' is always present.", "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
"description": "The message's UUID, which the server assigns. Present on every message the server queues for a client." "description": "Server-assigned message UUID. Present on all server-originated messages."
}, },
"command": { "command": {
"type": "string", "type": "string",
"description": "An IRC command name, such as PRIVMSG, or the name of a numeric reply, such as RPL_WELCOME. The server reads a client's command in any case and sends it in upper case.", "description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).",
"examples": ["PRIVMSG", "JOIN", "RPL_WELCOME", "ERR_NICKNAMEINUSE"] "examples": ["PRIVMSG", "JOIN", "001", "353", "433"]
},
"code": {
"type": "integer",
"minimum": 1,
"maximum": 999,
"description": "The number of a numeric reply, such as 1 for RPL_WELCOME. Absent on other messages."
}, },
"from": { "from": {
"type": "string", "type": "string",
"description": "The sender's nick, or the server name on a numeric reply or a PONG. The server sets it and ignores it from a client." "description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "The target: a channel name, such as #general, or a nick. A WALLOPS has * here." "description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "The message's other parameters, in order. KICK events and many numeric replies have it. The server ignores it from a client." "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters."
}, },
"body": { "body": {
"oneOf": [
{
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "An array of strings, never a bare string. In a PRIVMSG or NOTICE, each string is one line of text." "description": "Array of strings (one per line for text messages)."
},
{
"type": "object",
"description": "Structured data (e.g. PUBKEY key material).",
"additionalProperties": true
}
],
"description": "Message body. MUST be an array or object, never a raw string. Arrays represent lines of text; objects carry structured data. This enables deterministic canonicalization (RFC 8785 JCS) for signing."
}, },
"ts": { "ts": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"description": "The time the server stored the message, as an RFC 3339 time in UTC." "description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport."
}, },
"meta": { "meta": {
"type": "object", "type": "object",
"description": "An object of extra data. Every message the server queues for a client has meta, which is {} when there is none. The server keeps a client's meta only on a PRIVMSG or NOTICE, and relays it unchanged.", "description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.",
"properties": { "properties": {
"hashcash": { "sig": {
"type": "string", "type": "string",
"description": "A hashcash stamp. A PRIVMSG to a channel with mode +H needs one." "description": "Base64-encoded cryptographic signature over the canonical message form."
},
"alg": {
"type": "string",
"description": "Signature algorithm (e.g. 'ed25519')."
},
"hash": {
"type": "string",
"description": "Hash of the canonical message form (e.g. 'sha256:base64...')."
} }
}, },
"additionalProperties": true "additionalProperties": true
+5 -18
View File
@@ -2,45 +2,32 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME", "title": "001 RPL_WELCOME",
"description": "Welcomes the user. Sent after a session is created, after a login, and in answer to MOTD. RFC 2812 §5.1.", "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_WELCOME" "const": "001"
},
"code": {
"const": 1
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "The user's nick." "description": "Target nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'Welcome to the network, ' and the user's nick.", "description": "Welcome text lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_WELCOME", "command": "001",
"code": 1,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"Welcome to the network, alice" "Welcome to the network, alice"
+6 -20
View File
@@ -2,48 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST", "title": "002 RPL_YOURHOST",
"description": "The server's name and version. Sent right after RPL_WELCOME. RFC 2812 §5.1.", "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_YOURHOST" "const": "002"
},
"code": {
"const": 2
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'Your host is ', the server name, ', running version ' and the version.", "description": "Host info lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_YOURHOST", "command": "002",
"code": 2,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"Your host is neoirc, running version dev" "Your host is neoirc.example.com, running version 0.1.0"
] ]
} }
] ]
+5 -19
View File
@@ -2,45 +2,31 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED", "title": "003 RPL_CREATED",
"description": "The date the server last started. Sent right after RPL_YOURHOST. RFC 2812 §5.1.", "description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_CREATED" "const": "003"
},
"code": {
"const": 3
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'This server was created ' and the start date as YYYY-MM-DD.", "description": "Text lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_CREATED", "command": "003",
"code": 3,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"This server was created 2026-02-01" "This server was created 2026-02-01"
+10 -37
View File
@@ -2,64 +2,37 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO", "title": "004 RPL_MYINFO",
"description": "The server's name, its version, and its lists of user and channel modes. Sent right after RPL_CREATED. RFC 2812 §5.1.", "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_MYINFO" "const": "004"
},
"code": {
"const": 4
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The server name, the version, the user modes 'ow' and the channel modes 'Hbiklmnostv'.", "description": "[server_name, version, user_modes, channel_modes]."
"minItems": 4,
"maxItems": 4
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One empty string.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_MYINFO", "command": "004",
"code": 4,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"neoirc", "neoirc.example.com",
"dev", "0.1.0",
"ow", "o",
"Hbiklmnostv" "imnst+ov"
],
"body": [
""
] ]
} }
] ]
+7 -24
View File
@@ -2,55 +2,38 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST", "title": "322 RPL_LIST",
"description": "One channel in the answer to LIST. The answer has one per channel, except a secret channel, one with mode +s, that the user is not on. RFC 1459 §6.2.", "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_LIST" "const": "322"
},
"code": {
"const": 322
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name and its number of members.", "description": "[channel, visible_count]."
"minItems": 2,
"maxItems": 2
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: the channel's topic, empty when it has none.", "description": "Channel topic."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_LIST", "command": "322",
"code": 322,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#general", "#general",
+5 -28
View File
@@ -2,49 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND", "title": "323 RPL_LISTEND",
"description": "The end of the answer to LIST. RFC 1459 §6.2.", "description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_LISTEND" "const": "323"
},
"code": {
"const": 323
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'End of /LIST'.", "description": "End of /LIST",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code", "to"
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_LISTEND",
"code": 323,
"from": "neoirc",
"to": "alice",
"body": [
"End of /LIST"
]
}
] ]
} }
+6 -22
View File
@@ -2,55 +2,39 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC", "title": "332 RPL_TOPIC",
"description": "A channel's topic. Sent to a client after it sets the topic, and after it joins a channel that has one. Also sent for each of a session's channels that has one after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.", "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_TOPIC" "const": "332"
},
"code": {
"const": 332
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: the topic.", "description": "Topic text."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_TOPIC", "command": "332",
"code": 332,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#general" "#general"
+7 -23
View File
@@ -2,62 +2,46 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY", "title": "353 RPL_NAMREPLY",
"description": "A channel's members. Sent to a client after it joins a channel, and in answer to NAMES. Also sent for each of a session's channels after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.", "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_NAMREPLY" "const": "353"
},
"code": {
"const": 353
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "'=' and the channel name. The server always sends '='.", "description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)."
"minItems": 2,
"maxItems": 2
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: the members as nick!user@host, separated by spaces. '@' comes before a channel operator and '+' before a voiced member. The host is '*' when the server does not know it.", "description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_NAMREPLY", "command": "353",
"code": 353,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"=", "=",
"#general" "#general"
], ],
"body": [ "body": [
"@op1!op1@host1 alice!alice@host2 +bob!bob@host3" "@op1 alice bob +voiced1"
] ]
} }
] ]
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES", "title": "366 RPL_ENDOFNAMES",
"description": "The end of a channel's member list. It comes after the RPL_NAMREPLY for the channel. RFC 1459 §6.2.", "description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_ENDOFNAMES" "const": "366"
},
"code": {
"const": 366
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'End of /NAMES list'.", "description": "End of /NAMES list",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
],
"examples": [
{
"command": "RPL_ENDOFNAMES",
"code": 366,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"End of /NAMES list"
]
}
] ]
} }
+5 -19
View File
@@ -2,45 +2,31 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD", "title": "372 RPL_MOTD",
"description": "One line of the message of the day. The server sends one per line, after RPL_MOTDSTART. RFC 2812 §5.1.", "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_MOTD" "const": "372"
},
"code": {
"const": 372
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: '- ' and the line.", "description": "MOTD line text (prefixed with '- ')."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "RPL_MOTD", "command": "372",
"code": 372,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"- Welcome to our server!" "- Welcome to our server!"
+5 -29
View File
@@ -2,49 +2,25 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART", "title": "375 RPL_MOTDSTART",
"description": "The start of the message of the day. Sent after RPL_LUSERME (255) when the server has a message of the day. RFC 2812 §5.1.", "description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_MOTDSTART" "const": "375"
},
"code": {
"const": 375
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: '- ', the server name and ' Message of the Day -'.", "description": "Text lines."
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code", "to"
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_MOTDSTART",
"code": 375,
"from": "neoirc",
"to": "alice",
"body": [
"- neoirc Message of the Day -"
]
}
] ]
} }
+5 -28
View File
@@ -2,49 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD", "title": "376 RPL_ENDOFMOTD",
"description": "The end of the message of the day. RFC 2812 §5.1.", "description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "RPL_ENDOFMOTD" "const": "376"
},
"code": {
"const": 376
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'End of /MOTD command.'.", "description": "End of /MOTD command",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code", "to"
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_ENDOFMOTD",
"code": 376,
"from": "neoirc",
"to": "alice",
"body": [
"End of /MOTD command."
]
}
] ]
} }
+8 -24
View File
@@ -2,61 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK", "title": "401 ERR_NOSUCHNICK",
"description": "No user has that nick. RFC 1459 §6.1.", "description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NOSUCHNICK" "const": "401"
},
"code": {
"const": 401
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The nick that was not found.", "description": "[target_nick]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'No such nick' in answer to a PRIVMSG or NOTICE, and 'No such nick/channel' in answer to anything else.", "description": "No such nick/channel",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "ERR_NOSUCHNICK", "command": "401",
"code": 401,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"bob" "bob"
], ],
"body": [ "body": [
"No such nick" "No such nick/channel"
] ]
} }
] ]
+7 -23
View File
@@ -2,55 +2,39 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL", "title": "403 ERR_NOSUCHCHANNEL",
"description": "No channel has that name. A JOIN with a channel name that the server does not allow gets it too. RFC 1459 §6.1.", "description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NOSUCHCHANNEL" "const": "403"
},
"code": {
"const": 403
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel_name]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'No such channel'.", "description": "No such channel",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "ERR_NOSUCHCHANNEL", "command": "403",
"code": 403,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#nonexistent" "#nonexistent"
+9 -25
View File
@@ -2,58 +2,42 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE", "title": "433 ERR_NICKNAMEINUSE",
"description": "Another user has the nick. Sent in answer to a NICK. RFC 1459 §6.1.", "description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NICKNAMEINUSE" "const": "433"
},
"code": {
"const": 433
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick, which has not changed."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The nick that was asked for.", "description": "[requested_nick]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'Nickname is already in use'.", "description": "Nickname is already in use",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
], ],
"examples": [ "examples": [
{ {
"command": "ERR_NICKNAMEINUSE", "command": "433",
"code": 433, "to": "*",
"from": "neoirc",
"to": "alice",
"params": [ "params": [
"bob" "alice"
], ],
"body": [ "body": [
"Nickname is already in use" "Nickname is already in use"
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL", "title": "442 ERR_NOTONCHANNEL",
"description": "The user is not on that channel. RFC 1459 §6.1.", "description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_NOTONCHANNEL" "const": "442"
},
"code": {
"const": 442
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'You're not on that channel'.", "description": "You're not on that channel",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
],
"examples": [
{
"command": "ERR_NOTONCHANNEL",
"code": 442,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not on that channel"
]
}
] ]
} }
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED", "title": "482 ERR_CHANOPRIVSNEEDED",
"description": "The user is not a channel operator, and the command needs one. RFC 1459 §6.1.", "description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "ERR_CHANOPRIVSNEEDED" "const": "482"
},
"code": {
"const": 482
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string"
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "The channel name.", "description": "[channel]."
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "One string: 'You're not channel operator'.", "description": "You're not channel operator",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params"
"body"
],
"examples": [
{
"command": "ERR_CHANOPRIVSNEEDED",
"code": 482,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not channel operator"
]
}
] ]
} }
+110 -233
View File
@@ -7,11 +7,6 @@ const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000; const MEMBER_REFRESH_INTERVAL = 10000;
const ACTION_PREFIX = "\x01ACTION "; const ACTION_PREFIX = "\x01ACTION ";
const ACTION_SUFFIX = "\x01"; const ACTION_SUFFIX = "\x01";
// SERVER_TAB is the Server tab's name, under which its messages and
// unread count are stored. No nick or channel name can contain *, so a
// user with the nick Server gets a direct-message tab of its own. The
// tab bar labels this tab Server.
const SERVER_TAB = "*server*";
// Hashcash proof-of-work helpers using Web Crypto API. // Hashcash proof-of-work helpers using Web Crypto API.
@@ -63,13 +58,13 @@ async function mintHashcash(bits, resource) {
} }
} }
// Requests are authenticated by the auth cookie, which the browser
// sends on its own.
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(opts.headers || {}), ...(opts.headers || {}),
}; };
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts; const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
@@ -127,11 +122,12 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name; if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
// Resume the session if the browser still holds a valid auth const saved = localStorage.getItem("neoirc_token");
// cookie; a 401 leaves the login form up. if (saved) {
api("/state?initChannelState=1") api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => {}); .catch(() => localStorage.removeItem("neoirc_token"));
}
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -156,6 +152,7 @@ function LoginScreen({ onLogin }) {
method: "POST", method: "POST",
body: JSON.stringify(reqBody), body: JSON.stringify(reqBody),
}); });
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);
} catch (err) { } catch (err) {
setError(err.data?.error || "Connection failed"); setError(err.data?.error || "Connection failed");
@@ -230,9 +227,10 @@ function UserList({ members, onNickClick }) {
const regular = []; const regular = [];
for (const m of members) { for (const m of members) {
if (m.isOperator) { const mode = m.mode || "";
if (mode === "o") {
ops.push(m); ops.push(m);
} else if (m.isVoiced) { } else if (mode === "v") {
voiced.push(m); voiced.push(m);
} else { } else {
regular.push(m); regular.push(m);
@@ -275,9 +273,9 @@ function UserList({ members, onNickClick }) {
function App() { function App() {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [nick, setNick] = useState(""); const [nick, setNick] = useState("");
const [tabs, setTabs] = useState([{ type: "server", name: SERVER_TAB }]); const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]);
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ [SERVER_TAB]: [] }); const [messages, setMessages] = useState({ Server: [] });
const [members, setMembers] = useState({}); const [members, setMembers] = useState({});
const [topics, setTopics] = useState({}); const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({}); const [unread, setUnread] = useState({});
@@ -305,14 +303,11 @@ function App() {
nickRef.current = nick; nickRef.current = nick;
}, [nick]); }, [nick]);
// Persist joined channels, once logged in: before then only the // Persist joined channels.
// Server tab exists, and writing would erase the saved channels
// that onLogin rejoins.
useEffect(() => { useEffect(() => {
if (!loggedIn) return;
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name); const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels)); localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [loggedIn, tabs]); }, [tabs]);
// Clear unread on tab switch. // Clear unread on tab switch.
useEffect(() => { useEffect(() => {
@@ -360,26 +355,6 @@ function App() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
// removeChannelTab closes a channel's tab. The tab the user was
// reading stays active, found by its name; when it was the closed tab,
// the Server tab shows.
const removeChannelTab = useCallback((name) => {
setTabs((prev) => {
const remaining = prev.filter(
(t) => !(t.type === "channel" && t.name === name),
);
// Preact runs this update at once, on the newest active tab.
setActiveTab((active) => {
const reading = prev[active]?.name;
const idx = remaining.findIndex((t) => t.name === reading);
return idx >= 0 ? idx : 0;
});
return remaining;
});
}, []);
const processMessage = useCallback( const processMessage = useCallback(
(msg) => { (msg) => {
const body = Array.isArray(msg.body) ? msg.body.join("\n") : ""; const body = Array.isArray(msg.body) ? msg.body.join("\n") : "";
@@ -391,9 +366,7 @@ function App() {
command: msg.command, command: msg.command,
}; };
// A numeric reply carries its number in `code` and its name in switch (msg.command) {
// `command`; other messages have no `code`.
switch (msg.code || msg.command) {
case "PRIVMSG": case "PRIVMSG":
case "NOTICE": { case "NOTICE": {
let text = body; let text = body;
@@ -449,13 +422,6 @@ function App() {
break; break;
} }
case "PART": { case "PART": {
// The user's own PART, sent from this client or another,
// closes the tab and adds no message or unread count.
if (msg.from === nickRef.current) {
removeChannelTab(msg.to);
break;
}
const reason = body ? " (" + body + ")" : ""; const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has parted ${msg.to}${reason}`; const text = `${msg.from} has parted ${msg.to}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to) addMessage(msg.to, { ...base, text, system: true });
@@ -463,29 +429,6 @@ function App() {
break; break;
} }
case "KICK": {
// The kicked nick is in params; the body is the reason.
const kicked = Array.isArray(msg.params) ? msg.params[0] : "";
const reason = body ? " (" + body + ")" : "";
const text = `${kicked} was kicked from ${msg.to} by ${msg.from}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (kicked === nickRef.current) {
// The channel's tab closes, so the Server tab gets the line
// too. It needs an id of its own: addMessage drops a message
// whose id it has already seen.
addMessage(SERVER_TAB, {
...base,
id: msg.id + "-server",
text,
system: true,
});
removeChannelTab(msg.to);
} else if (msg.to && msg.to.startsWith("#")) {
refreshMembers(msg.to);
}
break;
}
case "QUIT": { case "QUIT": {
const reason = body ? " (" + body + ")" : ""; const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has quit${reason}`; const text = `${msg.from} has quit${reason}`;
@@ -521,18 +464,8 @@ function App() {
break; break;
} }
case "MODE": { case "353": {
// The body is the change, with its value where it has one: // RPL_NAMREPLY — parse mode prefixes from names list.
// ["+o bob"], ["+m"], ["+k", "secretpass"].
const modes = Array.isArray(msg.body) ? msg.body.join(" ") : "";
const text = `${msg.from} sets mode ${modes} on ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
break;
}
case 353: {
// RPL_NAMREPLY — each name is a hostmask (nick!user@host),
// prefixed with @ for an operator or + for a voiced member.
if ( if (
Array.isArray(msg.params) && Array.isArray(msg.params) &&
msg.params.length >= 2 && msg.params.length >= 2 &&
@@ -544,18 +477,21 @@ function App() {
: String(msg.body); : String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean); const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => { const parsed = names.map((n) => {
const isOperator = n.startsWith("@"); if (n.startsWith("@")) {
const isVoiced = n.startsWith("+"); return { nick: n.slice(1), mode: "o" };
const hostmask = isOperator || isVoiced ? n.slice(1) : n; }
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
return { nick: hostmask.split("!")[0], isOperator, isVoiced }; return { nick: n, mode: "" };
}); });
setMembers((prev) => ({ ...prev, [channel]: parsed })); setMembers((prev) => ({ ...prev, [channel]: parsed }));
} }
break; break;
} }
case 332: { case "332": {
// RPL_TOPIC — set topic from server. // RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0]; const channel = msg.params[0];
@@ -567,13 +503,13 @@ function App() {
break; break;
} }
case 322: { case "322": {
// RPL_LIST — channel, member count, topic. // RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0]; const chName = msg.params[0];
const count = msg.params[1]; const count = msg.params[1];
const chTopic = body || ""; const chTopic = body || "";
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${chName} (${count} users): ${chTopic.trim()}`, text: `${chName} (${count} users): ${chTopic.trim()}`,
system: true, system: true,
@@ -582,21 +518,21 @@ function App() {
break; break;
} }
case 323: case "323":
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: body || "End of channel list", text: body || "End of channel list",
system: true, system: true,
}); });
break; break;
case 352: { case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags. // RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) { if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0]; const whoCh = msg.params[0];
const whoNick = msg.params[4]; const whoNick = msg.params[4];
const whoFlags = msg.params.length > 5 ? msg.params[5] : ""; const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${whoCh} ${whoNick} ${whoFlags}`, text: `${whoCh} ${whoNick} ${whoFlags}`,
system: true, system: true,
@@ -605,33 +541,33 @@ function App() {
break; break;
} }
case 315: case "315":
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: body || "End of /WHO list", text: body || "End of /WHO list",
system: true, system: true,
}); });
break; break;
case 311: { case "311": {
// RPL_WHOISUSER — params nick, user, host, *; body realname. // RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const [wiNick, wiUser, wiHost] = msg.params; const wiNick = msg.params[0];
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, text: `${wiNick} (${body})`,
system: true, system: true,
}); });
} }
break; break;
} }
case 312: { case "312": {
// RPL_WHOISSERVER — nick, server, server info. // RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
const wiServer = msg.params[1]; const wiServer = msg.params[1];
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${wiNick} on ${wiServer}`, text: `${wiNick} on ${wiServer}`,
system: true, system: true,
@@ -640,37 +576,11 @@ function App() {
break; break;
} }
case 313: { case "319": {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage(SERVER_TAB, {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
});
}
break;
}
case 317: {
// RPL_WHOISIDLE — nick, idle seconds, signon time (Unix).
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage(SERVER_TAB, {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
});
}
break;
}
case 319: {
// RPL_WHOISCHANNELS — nick, channels. // RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: `${wiNick} is on: ${body}`, text: `${wiNick} is on: ${body}`,
system: true, system: true,
@@ -679,39 +589,27 @@ function App() {
break; break;
} }
case 318: case "318":
addMessage(SERVER_TAB, { addMessage("Server", {
...base, ...base,
text: body || "End of /WHOIS list", text: body || "End of /WHOIS list",
system: true, system: true,
}); });
break; break;
case 324: { case "375":
// RPL_CHANNELMODEIS — channel, mode string. case "372":
if (Array.isArray(msg.params) && msg.params.length >= 2) { case "376":
addMessage(SERVER_TAB, { addMessage("Server", { ...base, text: body, system: true });
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
});
}
break;
}
case 375:
case 372:
case 376:
addMessage(SERVER_TAB, { ...base, text: body, system: true });
break; break;
default: default:
if (body) { if (body) {
addMessage(SERVER_TAB, { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
} }
} }
}, },
[addMessage, refreshMembers, removeChannelTab], [addMessage, refreshMembers],
); );
// Long-poll loop. // Long-poll loop.
@@ -739,14 +637,6 @@ function App() {
} catch (err) { } catch (err) {
if (!alive) break; if (!alive) break;
if (err.name === "AbortError") continue; if (err.name === "AbortError") continue;
// 401: the session is gone (QUIT from another client, or
// expiry). Reloading drops this page's state and brings
// back the login screen.
if (err.status === 401) {
window.location.reload();
return;
}
setConnected(false); setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
} }
@@ -826,14 +716,9 @@ function App() {
const onLogin = useCallback( const onLogin = useCallback(
async (userNick, isResumed) => { async (userNick, isResumed) => {
// Read before setLoggedIn(true): from then on the effect under
// "Persist joined channels" writes the open channel tabs over it.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage(SERVER_TAB, `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
if (isResumed) { if (isResumed) {
// Request MOTD on resumed sessions (new sessions // Request MOTD on resumed sessions (new sessions
@@ -855,13 +740,15 @@ function App() {
} }
// Fresh session — join any previously saved channels. // Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) { for (const ch of saved) {
try { try {
const res = await api("/messages", { await api("/messages", {
method: "POST", method: "POST",
body: JSON.stringify({ command: "JOIN", to: ch }), body: JSON.stringify({ command: "JOIN", to: ch }),
}); });
if (res?.status !== "joined") continue;
setTabs((prev) => { setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === ch)) if (prev.find((t) => t.type === "channel" && t.name === ch))
return prev; return prev;
@@ -881,30 +768,18 @@ function App() {
name = name.trim(); name = name.trim();
if (!name.startsWith("#")) name = "#" + name; if (!name.startsWith("#")) name = "#" + name;
try { try {
const res = await api("/messages", { await api("/messages", {
method: "POST", method: "POST",
body: JSON.stringify({ command: "JOIN", to: name }), body: JSON.stringify({ command: "JOIN", to: name }),
}); });
// A refused join opens no tab. The error numeric that the
// server sends to the message queue tells the user why.
if (res?.status !== "joined") return;
// Show the channel's tab. Preact runs this update at once on the
// newest tab list, so the tab is found there by name: the user's
// own JOIN event may already have added it, and other tabs may
// have closed.
setTabs((prev) => { setTabs((prev) => {
const idx = prev.findIndex( if (prev.find((t) => t.type === "channel" && t.name === name))
(t) => t.type === "channel" && t.name === name,
);
if (idx >= 0) {
setActiveTab(idx);
return prev; return prev;
}
setActiveTab(prev.length);
return [...prev, { type: "channel", name }]; return [...prev, { type: "channel", name }];
}); });
const newIdx = tabsRef.current.length;
setActiveTab(newIdx);
try { try {
const hist = await api( const hist = await api(
`/history?target=${encodeURIComponent(name)}&limit=50`, `/history?target=${encodeURIComponent(name)}&limit=50`,
@@ -917,7 +792,7 @@ function App() {
} }
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Failed to join ${name}: ${err.data?.error || "error"}`, `Failed to join ${name}: ${err.data?.error || "error"}`,
); );
} }
@@ -933,7 +808,10 @@ function App() {
} catch (e) { } catch (e) {
// Ignore. // Ignore.
} }
removeChannelTab(name); setTabs((prev) =>
prev.filter((t) => !(t.type === "channel" && t.name === name)),
);
setActiveTab(0);
}; };
const closeTab = (idx) => { const closeTab = (idx) => {
@@ -948,23 +826,16 @@ function App() {
const openDM = (targetNick) => { const openDM = (targetNick) => {
if (targetNick === nickRef.current) return; if (targetNick === nickRef.current) return;
// Show the direct-message tab. Preact runs this update at once on
// the newest tab list, so the tab is found there by name: the
// server's copy of the user's message may already have added it,
// and other tabs may have opened or closed.
setTabs((prev) => { setTabs((prev) => {
const idx = prev.findIndex( if (prev.find((t) => t.type === "dm" && t.name === targetNick))
(t) => t.type === "dm" && t.name === targetNick,
);
if (idx >= 0) {
setActiveTab(idx);
return prev; return prev;
}
setActiveTab(prev.length);
return [...prev, { type: "dm", name: targetNick }]; return [...prev, { type: "dm", name: targetNick }];
}); });
const idx = tabs.findIndex(
(t) => t.type === "dm" && t.name === targetNick,
);
setActiveTab(idx >= 0 ? idx : tabs.length);
}; };
const handleCommand = async (text) => { const handleCommand = async (text) => {
@@ -975,7 +846,7 @@ function App() {
switch (cmd) { switch (cmd) {
case "/join": { case "/join": {
if (parts[1]) joinChannel(parts[1]); if (parts[1]) joinChannel(parts[1]);
else addSystemMessage(SERVER_TAB, "Usage: /join #channel"); else addSystemMessage("Server", "Usage: /join #channel");
break; break;
} }
@@ -984,7 +855,7 @@ function App() {
const reason = parts.slice(1).join(" ") || undefined; const reason = parts.slice(1).join(" ") || undefined;
partChannel(tab.name, reason); partChannel(tab.name, reason);
} else { } else {
addSystemMessage(SERVER_TAB, "You are not in a channel"); addSystemMessage("Server", "You are not in a channel");
} }
break; break;
@@ -1005,19 +876,19 @@ function App() {
openDM(target); openDM(target);
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Message failed: ${err.data?.error || "error"}`, `Message failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /msg <nick> <message>"); addSystemMessage("Server", "Usage: /msg <nick> <message>");
} }
break; break;
} }
case "/me": { case "/me": {
if (tab.type === "server") { if (tab.type === "server") {
addSystemMessage(SERVER_TAB, "Cannot use /me in server window"); addSystemMessage("Server", "Cannot use /me in server window");
break; break;
} }
@@ -1039,7 +910,7 @@ function App() {
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /me <action>"); addSystemMessage("Server", "Usage: /me <action>");
} }
break; break;
@@ -1053,19 +924,19 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Nick change failed: ${err.data?.error || "error"}`, `Nick change failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /nick <newnick>"); addSystemMessage("Server", "Usage: /nick <newnick>");
} }
break; break;
} }
case "/topic": { case "/topic": {
if (tab.type !== "channel") { if (tab.type !== "channel") {
addSystemMessage(SERVER_TAB, "You are not in a channel"); addSystemMessage("Server", "You are not in a channel");
break; break;
} }
@@ -1082,13 +953,13 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Topic change failed: ${err.data?.error || "error"}`, `Topic change failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`, `Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`,
); );
} }
@@ -1097,26 +968,31 @@ function App() {
} }
case "/mode": { case "/mode": {
if (tab.type !== "channel") { if (tab.type !== "channel") {
addSystemMessage(SERVER_TAB, "You are not in a channel"); addSystemMessage("Server", "You are not in a channel");
break; break;
} }
// With no arguments MODE is a query. A change is one sign and
// letter, then its value where it takes one: +m, +o nick.
const modeArgs = parts.slice(1); const modeArgs = parts.slice(1);
const body = if (modeArgs.length > 0) {
modeArgs.length > 0
? { command: "MODE", to: tab.name, body: modeArgs }
: { command: "MODE", to: tab.name };
try { try {
await api("/messages", { await api("/messages", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify({
command: "MODE",
to: tab.name,
params: modeArgs,
}),
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`MODE failed: ${err.data?.error || "error"}`, `Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(
"Server",
"Usage: /mode <+/-mode> [params]",
); );
} }
@@ -1133,6 +1009,7 @@ function App() {
} catch (e) { } catch (e) {
// Ignore. // Ignore.
} }
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels"); localStorage.removeItem("neoirc_channels");
window.location.reload(); window.location.reload();
@@ -1146,7 +1023,7 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Failed to request MOTD: ${err.data?.error || "error"}`, `Failed to request MOTD: ${err.data?.error || "error"}`,
); );
} }
@@ -1170,13 +1047,13 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Message failed: ${err.data?.error || "error"}`, `Message failed: ${err.data?.error || "error"}`,
); );
} }
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /query <nick> [message]"); addSystemMessage("Server", "Usage: /query <nick> [message]");
} }
break; break;
@@ -1189,7 +1066,7 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`Failed to list channels: ${err.data?.error || "error"}`, `Failed to list channels: ${err.data?.error || "error"}`,
); );
} }
@@ -1206,12 +1083,12 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`WHO failed: ${err.data?.error || "error"}`, `WHO failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /who #channel"); addSystemMessage("Server", "Usage: /who #channel");
} }
break; break;
@@ -1225,12 +1102,12 @@ function App() {
}); });
} catch (err) { } catch (err) {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`WHOIS failed: ${err.data?.error || "error"}`, `WHOIS failed: ${err.data?.error || "error"}`,
); );
} }
} else { } else {
addSystemMessage(SERVER_TAB, "Usage: /whois <nick>"); addSystemMessage("Server", "Usage: /whois <nick>");
} }
break; break;
@@ -1251,7 +1128,7 @@ function App() {
" /me action — Send an action", " /me action — Send an action",
" /nick newnick — Change your nickname", " /nick newnick — Change your nickname",
" /topic [text] — View or set channel topic", " /topic [text] — View or set channel topic",
" /mode [+/-flag [arg]] — View or set channel modes", " /mode +/-flags — Set channel modes",
" /motd — Display the message of the day", " /motd — Display the message of the day",
" /list — List all channels", " /list — List all channels",
" /who [#channel] — List users in a channel", " /who [#channel] — List users in a channel",
@@ -1261,13 +1138,13 @@ function App() {
" /help — Show this help", " /help — Show this help",
]; ];
for (const line of helpLines) { for (const line of helpLines) {
addSystemMessage(SERVER_TAB, line); addSystemMessage("Server", line);
} }
break; break;
} }
default: default:
addSystemMessage(SERVER_TAB, `Unknown command: ${cmd}`); addSystemMessage("Server", `Unknown command: ${cmd}`);
} }
}; };
@@ -1296,7 +1173,7 @@ function App() {
if (tab.type === "server") { if (tab.type === "server") {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
"Cannot send messages to the server window. Use /join #channel first.", "Cannot send messages to the server window. Use /join #channel first.",
); );
@@ -1367,7 +1244,7 @@ function App() {
key={tab.name} key={tab.name}
> >
<span class="tab-label"> <span class="tab-label">
{tab.type === "server" ? "Server" : tab.name} {tab.type === "dm" ? tab.name : tab.name}
</span> </span>
{unread[tab.name] > 0 && i !== activeTab && ( {unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-count">({unread[tab.name]})</span> <span class="unread-count">({unread[tab.name]})</span>