Author SHA1 Message Date
sneak f6f2d2a038 Spend a hashcash stamp only when the request succeeds (closes #158)
check / check (push) Canceled after 0s
A session request refused with 400 or 409, or a +H channel message
refused for membership, a ban or +m, used to spend its stamp, so a
retry with the same stamp was refused as already used.

hashcash.Validator.Validate is split into Check, which spends
nothing, and Claim, which records the stamp; Release undoes a claim.
Session creation checks the stamp first, claims it just before
CreateSession and releases it if CreateSession fails.
SendChannelMessage checks the stamp after the membership, ban and +m
checks.

Model: opus-5-5
2026-10-21 00:40:56 +00:00
37 changed files with 685 additions and 3288 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
- Outside test files, every package has a package doc comment. - Merge to main only when ready and passing all checks
- Outside test files, every exported type, function and variable has a doc - PR titles: `Description (closes #N)` when closing an issue
comment. - 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.
- Outside test files, every blank import has a comment saying why it is there.
## 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)
+22 -32
View File
@@ -180,7 +180,7 @@ For users who want to access the same session from multiple devices:
- **Set password via IRC PASS command**: the authenticated client sends - **Set password via IRC PASS command**: the authenticated client sends
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The `POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The
server hashes the password with bcrypt and stores it on the session. Password server hashes the password with bcrypt and stores it on the session. Password
must be 8 to 72 bytes. must be at least 8 characters.
- **Login from another client**: `POST /api/v1/login` with nick and password → - **Login from another client**: `POST /api/v1/login` with nick and password →
server verifies the password, creates a new client for the existing session, server verifies the password, creates a new client for the existing session,
and sets an auth cookie. Channel memberships are shared; the new client gets and sets an auth cookie. Channel memberships are shared; the new client gets
@@ -268,15 +268,10 @@ rules). Case-sensitive comparison is unambiguous.
A single user session can have multiple clients (phone, laptop, terminal). A single user session can have multiple clients (phone, laptop, terminal).
- Each client gets a **separate server-to-client (S2C) message queue**. - Each client gets a **separate server-to-client (S2C) message queue**.
- The server fans out messages and events from other users to every client queue - The server fans out messages and events from other users, and the user's own
for that user session. DMs and the channel messages it sends over the HTTP API, to every client queue
- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`, for that user session. Replies to a command (numerics) go only to the client
`MODE` or `KICK` from one of its clients, the resulting event goes to every that sent it.
client queue of the user's own session, as well as to the other users it is
meant for. The one exception is an IRC listener connection that sent the
command, which gets nothing queued for it: it has already sent its user the
echo, or, for `PRIVMSG` and `NOTICE`, IRC sends none.
- Replies to a command (numerics) go only to the client that sent it.
- `GET /api/v1/messages` delivers from the calling client's specific queue, - `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth cookie. identified by the auth cookie.
- Client queues have **independent expiry/pruning** — one client going offline - Client queues have **independent expiry/pruning** — one client going offline
@@ -749,7 +744,7 @@ Leave a channel.
{"command": "PART", "to": "#general", "body": ["goodbye"]} {"command": "PART", "to": "#general", "body": ["goodbye"]}
``` ```
**S2C (broadcast to all channel members, including the departing user):** **S2C (broadcast to the other channel members):**
```json ```json
{ {
@@ -765,8 +760,9 @@ Leave a channel.
**Behavior:** **Behavior:**
- The PART event goes to every channel member, the departing user's own clients - The PART event goes to the other channel members, not to the departing user;
included. (An IRC listener connection that sends a PART echoes it itself.) the HTTP response confirms the part. (The IRC listener echoes the PART to its
own connection.)
- If the channel is empty after the user leaves, the channel is **deleted** - If the channel is empty after the user leaves, the channel is **deleted**
(ephemeral channels). (ephemeral channels).
- If the user is not in the channel, the server returns an error. - If the user is not in the channel, the server returns an error.
@@ -839,11 +835,10 @@ Set a password on the current session, enabling multi-client login via
**Behavior:** **Behavior:**
- `body[0]` is the password. Must be 8 to 72 bytes; bcrypt cannot hash a longer - `body[0]` is the password. Must be at least 8 characters.
one.
- On success, the server responds with `{"status": "ok"}`. - On success, the server responds with `{"status": "ok"}`.
- If the password is too short, too long or missing, the server sends - If the password is too short or missing, the server sends ERR_NEEDMOREPARAMS
ERR_NEEDMOREPARAMS (461) via the message queue. (461) via the message queue.
- Calling PASS again overwrites the previous password. - Calling PASS again overwrites the previous password.
- Once a password is set, `POST /api/v1/login` can be used with the nick and - Once a password is set, `POST /api/v1/login` can be used with the nick and
password to create additional clients on the same session. password to create additional clients on the same session.
@@ -1023,8 +1018,9 @@ On the IRC listener, `MODE #channel <modes> [values...]` takes several letters
in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It
accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits
from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is
missing is skipped. The changes made are sent to every channel member as one missing is skipped. The changes made are echoed to the operator's connection and
`MODE` line, e.g. `+o-v alice bob`; the operator gets that line once. also sent to every channel member, the operator included, as one `MODE` line,
e.g. `+o-v alice bob`.
**Channel errors:** **Channel errors:**
@@ -2611,7 +2607,7 @@ listener.
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | int | `8080` | HTTP listen port | | `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. It must not set `foreign_keys`. See [SQLite Considerations](#sqlite-considerations). | | `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. See [SQLite Considerations](#sqlite-considerations). |
| `DEBUG` | bool | `false` | Switch logging to debug level | | `DEBUG` | bool | `false` | Switch logging to debug level |
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | | `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. | | `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
@@ -2682,11 +2678,9 @@ IRC_LISTEN_ADDR: ""
- **Wire format**: CR-LF delimited lines, max 512 bytes per line - **Wire format**: CR-LF delimited lines, max 512 bytes per line
- **Connection registration**: Clients must send `NICK` and `USER` to register. - **Connection registration**: Clients must send `NICK` and `USER` to register.
An optional `PASS` before registration sets the session password (8 to 72 An optional `PASS` before registration sets the session password (minimum 8
bytes); one outside that range is refused with a `NOTICE` once registration characters). Registering creates a session without a hashcash stamp; closing
completes, as `PASS` after registration is. Registering creates a session the connection ends the session, as `QUIT` does.
without a hashcash stamp; closing the connection ends the session, as `QUIT`
does.
- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and - **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and
is closed if it sends nothing in the 30 seconds after that; any line, such as is closed if it sends nothing in the 30 seconds after that; any line, such as
the client's `PONG`, starts both waits again. the client's `PONG`, starts both waits again.
@@ -2723,9 +2717,7 @@ Each line of a multi-line `body` reaches an IRC listener user as its own
is sent, so it cannot start a line of its own. A mode change made over the HTTP is sent, so it cannot start a line of its own. A mode change made over the HTTP
API reaches IRC listener members as an IRC `MODE` line with its value as a API reaches IRC listener members as an IRC `MODE` line with its value as a
separate parameter, e.g. `MODE #general +k secretpass` or separate parameter, e.g. `MODE #general +k secretpass` or
`MODE #general +o bob`. An `INVITE` from either transport reaches the invited `MODE #general +o bob`.
IRC listener user as an IRC `INVITE` line from the inviter, e.g.
`:alice!alice@* INVITE bob #general`.
### Docker Usage ### Docker Usage
@@ -3169,13 +3161,12 @@ creating one session pays once and keeps their session.
### Why Hashcash and Not Rate Limits? ### Why Hashcash and Not Rate Limits?
- **Little state to track**: No IP tables, no token buckets, no sliding windows. - **Little state to track**: No IP tables, no token buckets, no sliding windows.
The server hashes the stamp and remembers the stamps already spent. The server verifies a single hash and remembers the stamps already spent.
- **Works through NATs and proxies**: Doesn't punish shared IPs (university - **Works through NATs and proxies**: Doesn't punish shared IPs (university
campuses, corporate networks, Tor exits). Every client computes their own campuses, corporate networks, Tor exits). Every client computes their own
proof independently. proof independently.
- **Cost falls on the requester**: The server's verification cost is constant - **Cost falls on the requester**: The server's verification cost is constant
regardless of difficulty: at most two SHA-256 hashes of the stamp per session (one SHA-256 hash) regardless of difficulty. Only the client does more work.
request, one to check it and one to spend it. Only the client does more work.
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No - **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No
registration, no email, no phone number, no CAPTCHA. Just compute. registration, no email, no phone number, no CAPTCHA. Just compute.
- **Language-agnostic**: SHA-256 is available in every programming language. The - **Language-agnostic**: SHA-256 is available in every programming language. The
@@ -3342,7 +3333,6 @@ neoirc/
│ │ └── schema/ │ │ └── schema/
│ │ ├── 000.sql # schema_migrations bootstrap │ │ ├── 000.sql # schema_migrations bootstrap
│ │ └── 001_initial.sql │ │ └── 001_initial.sql
│ ├── dbtest/ # Gives each test its own database; only tests import it
│ ├── globals/ # Application-wide metadata │ ├── globals/ # Application-wide metadata
│ │ └── globals.go │ │ └── globals.go
│ ├── handlers/ # HTTP request handlers │ ├── handlers/ # HTTP request handlers
+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)
} }
+9 -16
View File
@@ -16,9 +16,9 @@ type SessionResponse struct {
// StateResponse is the response from GET /api/v1/state. // StateResponse is the response from GET /api/v1/state.
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.
+59 -47
View File
@@ -96,7 +96,20 @@ func (a *App) handleInput(text string) {
a.ui.AddStatus( a.ui.AddStatus(
"[red]Send error: " + 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) {
@@ -288,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")
} }
@@ -322,6 +338,10 @@ func (a *App) cmdPart(channel string) {
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 = ""
@@ -348,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 {
@@ -365,7 +386,16 @@ func (a *App) cmdMsg(args string) {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: %v", err, "[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) {
@@ -404,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
} }
@@ -450,32 +487,12 @@ func (a *App) cmdNames() {
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",
target, 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
@@ -500,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", ch.Name, ch.Topic, " %s (%d members) %s",
ch.Name, ch.Members, ch.Topic,
)) ))
} }
@@ -658,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",
@@ -727,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:
@@ -755,10 +773,12 @@ 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
} }
@@ -781,11 +801,8 @@ func (a *App) handleJoinEvent(
)) ))
} }
// 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
@@ -794,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, msg.From, msg.To,
)
if reason != "" { if reason != "" {
line += " (" + 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(
-337
View File
@@ -1,337 +0,0 @@
package cli
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
// The user's nick, a channel it is in, another user's nick and
// a message text, for the tests below.
const (
testNick = "erin"
testChannel = "#chan"
testOtherNick = "bob"
testText = "hello"
)
func TestMemberPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
member api.Member
want string
}{
{
name: "operator",
member: api.Member{Nick: "alice", IsOperator: true},
want: "@",
},
{
name: "voiced member",
member: api.Member{Nick: "bob", IsVoiced: true},
want: "+",
},
{
name: "voiced operator",
member: api.Member{
Nick: "Carol", IsOperator: true, IsVoiced: true,
},
want: "@",
},
{
name: "plain member",
member: api.Member{Nick: "dave"},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := memberPrefix(test.member)
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except that the
// user's own part does not mark it unread.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantUnread int
}{
{name: "own part", from: testNick, wantUnread: 0},
{name: "other member", from: testOtherNick, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: testChannel,
})
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want 1 and %d",
lines, unread, test.wantUnread)
}
})
}
}
// TestOwnCommandShownOnce checks that a message, join or
// part made from neoirc-cli shows as one line in the
// channel's buffer once the server's event for it arrives,
// and does not mark the channel unread.
func TestOwnCommandShownOnce(t *testing.T) {
t.Parallel()
tests := []struct {
name string
send func(app *App)
event api.Message
}{
{
name: "message",
send: func(app *App) { app.handleInput(testText) },
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
},
{
name: "join",
send: func(app *App) { app.cmdJoin(testChannel) },
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
},
{
name: "part",
send: func(app *App) { app.cmdPart(testChannel) },
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// The server accepts every command. Only the response
// to a JOIN is read, and it says the session joined.
server := newServer(t, `{"status": "joined"}`)
app := &App{
ui: NewUI(),
client: api.NewClient(server.URL),
nick: testNick,
target: testChannel,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testChannel)
test.send(app)
app.handleServerMessage(&test.event)
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != 0 {
t.Fatalf("got %d lines and %d unread, want 1 and 0",
lines, unread)
}
})
}
}
// TestJoinResponse checks that /join switches to the channel's
// buffer and makes it the target only when the server answers
// that the session joined.
func TestJoinResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
response string
// The buffer shown and the target after the join.
want string
}{
{
name: "joined",
response: `{"status": "joined", "channel": "#chan"}`,
want: testChannel,
},
{
name: "refused",
response: `{"status": "error"}`,
want: testOtherNick,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{
ui: NewUI(),
client: api.NewClient(newServer(t, test.response).URL),
nick: testNick,
target: testOtherNick,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testOtherNick)
app.cmdJoin(testChannel)
var shown string
app.ui.app.QueueUpdate(func() {
shown = app.ui.buffers[app.ui.currentBuffer].Name
})
if shown != test.want || app.target != test.want {
t.Fatalf("got buffer %s and target %s, want %s",
shown, app.target, test.want)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to.
func TestOwnEventFromOtherClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
event api.Message
buffer string
}{
{
name: "channel message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: testChannel,
},
{
name: "direct message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testOtherNick,
Body: []string{testText},
},
buffer: testOtherNick,
},
{
name: "notice",
event: api.Message{
Command: irc.CmdNotice, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: "(status)",
},
{
name: "join",
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
buffer: testChannel,
},
{
name: "part",
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
buffer: testChannel,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&test.event)
lines, _ := bufferState(app.ui, test.buffer)
if lines != 1 {
t.Fatalf("got %d lines in %s, want 1", lines, test.buffer)
}
})
}
}
// bufferState returns the number of lines in the named
// buffer and how many of them are unread.
func bufferState(ui *UI, name string) (int, int) {
var lines, unread int
ui.app.QueueUpdate(func() {
buf := ui.getOrCreateBuffer(name)
lines = len(buf.Lines)
unread = buf.Unread
})
return lines, unread
}
// newServer starts a server that answers every request with
// body until the test ends.
func newServer(t *testing.T, body string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(writer, body)
},
))
t.Cleanup(server.Close)
return server
}
// runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) {
t.Helper()
ui.app.SetScreen(tcell.NewSimulationScreen(""))
done := make(chan error)
go func() { done <- ui.Run() }()
t.Cleanup(func() {
ui.Stop()
err := <-done
if err != nil {
t.Errorf("run ui: %v", err)
}
})
}
-13
View File
@@ -95,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")
+7 -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
@@ -96,33 +90,6 @@ func (database *Database) connect(ctx context.Context) error {
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,
) )
@@ -142,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 -7
View File
@@ -5,16 +5,21 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"testing" "sync/atomic"
"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 {
+8 -30
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 {
@@ -1227,12 +1208,11 @@ 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) transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -1241,9 +1221,7 @@ func (database *Database) EnqueueToSession(
defer func() { _ = transaction.Rollback() }() defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx( err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID)
ctx, transaction, sessionID, messageID, skipClientID,
)
if err != nil { if err != nil {
return err return err
} }
@@ -1261,15 +1239,15 @@ func (database *Database) EnqueueToSession(
func enqueueToSessionInTx( func enqueueToSessionInTx(
ctx context.Context, ctx context.Context,
transaction *sql.Tx, transaction *sql.Tx,
sessionID, messageID, skipClientID int64, sessionID, messageID int64,
) error { ) error {
_, err := transaction.ExecContext(ctx, _, 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,
@@ -1695,7 +1673,7 @@ func quitSessionInTx(
for _, peerID := range peerIDs { for _, peerID := range peerIDs {
err = enqueueToSessionInTx( err = enqueueToSessionInTx(
ctx, transaction, peerID, messageID, 0, ctx, transaction, peerID, messageID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
+2 -2
View File
@@ -13,7 +13,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)
} }
@@ -675,7 +675,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)
} }
-39
View File
@@ -1,39 +0,0 @@
// Package dbtest gives each test a database of its own. 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
}
+29 -30
View File
@@ -813,14 +813,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 +841,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 +864,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,
) )
@@ -1257,11 +1251,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(
@@ -1271,6 +1263,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)
@@ -1285,7 +1284,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(
@@ -1356,7 +1355,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(
@@ -1540,7 +1539,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(
@@ -1611,7 +1610,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,
@@ -1666,7 +1665,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(
@@ -2152,7 +2151,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,
) )
@@ -2198,7 +2197,7 @@ func (hdlr *Handlers) setChannelFlag(
} }
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeStr, modeStr,
) )
@@ -3645,7 +3644,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(
@@ -3718,7 +3717,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
+3 -9
View File
@@ -8,12 +8,7 @@ import (
"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 {
@@ -154,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
}
-11
View File
@@ -64,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.
@@ -105,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{
+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"
+2 -2
View File
@@ -81,8 +81,8 @@ func NewValidator(resource string) *Validator {
// the stamp is a well-formed, recently dated proof of work // the stamp is a well-formed, recently dated proof of work
// for this resource with at least requiredBits. It does not // for this resource with at least requiredBits. It does not
// look at or change the set of spent stamps, so a request // look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other // can check its stamp first and Claim it once nothing else
// checks. // can refuse the request.
func (v *Validator) Check( func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
+3 -14
View File
@@ -111,8 +111,7 @@ func TestReplayDetection(t *testing.T) {
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check // TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once, and that // accepts can still be claimed, and only once.
// Check still accepts it once it is spent.
func TestCheckDoesNotSpendStamp(t *testing.T) { func TestCheckDoesNotSpendStamp(t *testing.T) {
t.Parallel() t.Parallel()
@@ -137,11 +136,6 @@ func TestCheckDoesNotSpendStamp(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("replay not detected") 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 // TestReleasedStampCanBeClaimedAgain checks that Release
@@ -246,14 +240,9 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Check("garbage", 0) err := validator.Claim("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)
} }
} }
+27 -26
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)
@@ -402,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)
@@ -709,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,
) )
} }
} }
@@ -1186,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 {
@@ -1217,16 +1216,17 @@ 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
}
c.sendFromServer("NOTICE", c.currentNick(), c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+ "Password set. You can reconnect using "+
"PASS <password> with your nick.", "PASS <password> with your nick.",
) )
@@ -1250,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)
@@ -1262,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},
) )
} }
+13 -63
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,7 +62,6 @@ 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
@@ -72,6 +70,7 @@ type Conn struct {
} }
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,
@@ -95,6 +94,7 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
hostname: resolveHost(ctx, host),
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -212,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)
@@ -229,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)
}) })
@@ -313,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)
@@ -361,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 = "*"
} }
@@ -385,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
@@ -397,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
@@ -456,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(
@@ -571,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)
} }
@@ -667,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.
+14 -440
View File
@@ -1,7 +1,6 @@
package ircserver_test package ircserver_test
import ( import (
"context"
"encoding/json" "encoding/json"
"slices" "slices"
"strings" "strings"
@@ -9,7 +8,6 @@ import (
"time" "time"
"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
@@ -41,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)
} }
@@ -49,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)
@@ -66,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 {
@@ -95,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.
@@ -213,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
@@ -326,372 +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 the password her connection set,
// as POST /api/v1/login does. 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.send("PASS " + password)
alice.register("alice")
// Registration stores the password before the JOIN runs.
alice.joinAndDrain("#chan")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#chan")
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,
)
}() }()
} }
} }
+27 -229
View File
@@ -2,7 +2,7 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context" "crypto/rand"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -11,7 +11,6 @@ import (
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -19,7 +18,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/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
@@ -92,22 +90,14 @@ 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()
conn, err := sql.Open("sqlite", dbtest.NewURL(t))
if err != nil { if err != nil {
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
@@ -137,18 +127,30 @@ func newTestEnvWithResolver(
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)
} }
@@ -413,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.
@@ -626,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)
@@ -634,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)
@@ -1141,165 +1098,6 @@ func TestHandlePassPostRegistration(t *testing.T) {
) )
} }
// 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")
_, err := env.database.GetDB().ExecContext(t.Context(),
`CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
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()
-6
View File
@@ -15,12 +15,6 @@ 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 // ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does. // HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) { func (srv *Server) ServeUntilShutdown(listener net.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,
+48 -15
View File
@@ -5,6 +5,7 @@
package server_test package server_test
import ( import (
"crypto/rand"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
@@ -18,7 +19,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 +45,10 @@ 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 port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, fx.Populate(&srv)) 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 +57,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)
@@ -147,7 +145,7 @@ func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
func TestServeFailureStopsApp(t *testing.T) { func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv)) app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait() shutdowns := app.Wait()
@@ -170,7 +168,7 @@ func TestServeFailureStopsApp(t *testing.T) {
func TestServeAfterStopDoesNotStopApp(t *testing.T) { func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv)) app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait() shutdowns := app.Wait()
@@ -188,15 +186,16 @@ func TestServeAfterStopDoesNotStopApp(t *testing.T) {
} }
} }
// startApp starts the app newApp builds, with the HTTP port // startApp starts the app newApp builds, with the HTTP server
// set to 0, so the system gives the server a free port. // listening on port.
func startApp( func startApp(
t *testing.T, t *testing.T,
port int,
options ...fx.Option, options ...fx.Option,
) (*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, options...)
app.RequireStart() app.RequireStart()
@@ -242,7 +241,10 @@ 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
@@ -255,6 +257,21 @@ func newApp(
return app, brk, database return app, brk, database
} }
// freePort returns a TCP port that nothing was listening on a
// moment ago.
func freePort(t *testing.T) int {
t.Helper()
listener := closedListener(t)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
return tcpAddr.Port
}
// closedListener returns a TCP listener that has already been // closedListener returns a TCP listener that has already been
// closed, so that accepting a connection on it fails. // closed, so that accepting a connection on it fails.
func closedListener(t *testing.T) net.Listener { func closedListener(t *testing.T) net.Listener {
@@ -277,6 +294,22 @@ func closedListener(t *testing.T) net.Listener {
return listener return listener
} }
// 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,
// and fails the test if it does not within testTimeout. // and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) { func waitUntil(t *testing.T, what string, done func() bool) {
+68 -64
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,58 @@ 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
} }
// excludeSession returns a copy of ids without the given
// session.
func excludeSession(
ids []int64,
exclude int64,
) []int64 {
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
// SendChannelMessage checks membership, bans and // SendChannelMessage checks membership, bans and
// moderation, then the hashcash stamp of a PRIVMSG to a +H // moderation, then the hashcash stamp of a PRIVMSG to a +H
// channel, then fans out a message to all channel members, // channel, then fans out a message to all channel members
// the sender's session included, skipping client // except the sender. The stamp is checked after the other
// skipClientID as FanOut does. The stamp is checked after // checks so that a message they refuse does not spend it.
// the other checks so that a message they refuse does not // Returns the database row ID, message UUID, and any error.
// spend it. Returns the 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
} }
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 +426,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 +443,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)",
@@ -445,35 +456,35 @@ func (s *Service) SendChannelMessage(
ctx, chID, channel, body, meta, ctx, chID, channel, body, meta,
) )
if err != nil { if err != nil {
return "", err return 0, "", 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 +506,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 +522,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 +549,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 +558,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 +587,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 +601,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 +653,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 +717,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 +798,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,9 +822,7 @@ func (s *Service) ChangeNick(
} }
} }
s.broadcastNickChange( s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
ctx, sessionID, skipClientID, oldNick, newNick,
)
return nil return nil
} }
@@ -1036,10 +1042,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 +1054,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,
) )
} }
@@ -1301,12 +1306,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(
@@ -1328,7 +1332,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
@@ -1347,7 +1351,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)
} }
} }
+66 -56
View File
@@ -6,6 +6,7 @@ package service_test
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -22,7 +23,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/hashcash" "sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
@@ -46,7 +46,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
@@ -162,7 +168,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)
@@ -184,7 +190,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)
@@ -202,7 +208,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)
@@ -224,14 +230,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)
@@ -239,7 +245,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")
@@ -303,7 +309,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
@@ -329,7 +335,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)
@@ -442,7 +448,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)
@@ -451,12 +457,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
@@ -507,7 +513,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick) joinerSID := createSession(ctx, t, env.db, joinerNick)
created, err := env.svc.JoinChannel( created, 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)
@@ -539,7 +545,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
) )
joined, joinErr = env.svc.JoinChannel( joined, joinErr = env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "", ctx, joinerSID, joinerNick, channel, "",
) )
if !errors.As(joinErr, &ircErr) || if !errors.As(joinErr, &ircErr) ||
ircErr.Code != irc.ErrBadChannelKey { ircErr.Code != irc.ErrBadChannelKey {
@@ -586,7 +592,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
quitterSID := createSession(ctx, t, env.db, nick) quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} { for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "") _, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", channel, err) t.Fatalf("join %s: %v", channel, err)
} }
@@ -596,7 +602,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member) memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick) memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "") _, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", memberNick, err) t.Fatalf("join %s: %v", memberNick, err)
} }
@@ -611,7 +617,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
t.Fatalf("create session %s: %v", watcherNick, err) t.Fatalf("create session %s: %v", watcherNick, err)
} }
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "") _, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", watcherNick, err) t.Fatalf("join %s: %v", watcherNick, err)
} }
@@ -672,7 +678,7 @@ func takeNickAndJoin(
return err return err
} }
_, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "") _, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "")
return err return err
} }
@@ -704,7 +710,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)
} }
@@ -719,17 +725,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 {
@@ -748,7 +754,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)
} }
@@ -767,7 +773,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 {
@@ -787,17 +793,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)
} }
@@ -812,7 +818,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)
} }
@@ -833,12 +839,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)
} }
@@ -857,14 +863,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)
@@ -872,14 +878,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")
} }
@@ -887,8 +897,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 {
@@ -904,14 +914,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)
@@ -953,7 +963,7 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
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
@@ -973,7 +983,7 @@ 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)
} }
@@ -990,8 +1000,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
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
@@ -1009,14 +1019,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)
@@ -1029,8 +1039,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 {
@@ -1038,8 +1048,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 {
@@ -1064,7 +1074,7 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
}{ }{
{"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error { {"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error {
return env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "") return env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
}}, }},
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error { {"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice") return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
@@ -1084,12 +1094,12 @@ func TestRefusedChannelMessageLeavesStampUnspent(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")
joined, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "") joined, 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)
} }
@@ -1109,8 +1119,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
) )
meta, _ := json.Marshal(map[string]string{"hashcash": stamp}) meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta, ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta,
) )
var ircErr *service.IRCError var ircErr *service.IRCError
@@ -1119,8 +1129,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err) t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
} }
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta, ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta,
) )
if err != nil { if err != nil {
t.Fatalf("alice with the same stamp: %v", err) t.Fatalf("alice with the same stamp: %v", err)
+117 -240
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 try {
? { command: "MODE", to: tab.name, body: modeArgs } await api("/messages", {
: { command: "MODE", to: tab.name }; method: "POST",
try { body: JSON.stringify({
await api("/messages", { command: "MODE",
method: "POST", to: tab.name,
body: JSON.stringify(body), params: modeArgs,
}); }),
} catch (err) { });
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage( addSystemMessage(
SERVER_TAB, "Server",
`MODE failed: ${err.data?.error || "error"}`, "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>