16 Commits
Author SHA1 Message Date
clawbot d31fd529e2 Make an IRC listener connection follow its session's nick (closes #184)
check / check (push) Waiting to run
An IRC listener connection kept its own copy of its nick, so after the session's nick was changed from another client it went on sending under the old nick, which another user could then take. Now, whenever the relay passes on a NICK line and after the connection's own NICK is stored, the connection takes the nick its session holds in the database, read by session ID under the connection's lock. Taking the nick from the database rather than from the line means a connection can never follow another user's rename and send as them. Handlers read the nick under the same lock.

Untested: an HTTP API rename landing while the connection's own NICK is being stored; that order cannot be produced reliably.

Model: opus-5-5
2026-10-21 17:56:02 +02:00
clawbot 129d87d578 Show an IRC listener user what their session sends from other clients (closes #179)
check / check (push) Waiting to run
The IRC listener's relay dropped every line sent under the connection's own nick, so a user on the web client and an IRC client at once never saw on IRC what they did from the web client. The relay now drops nothing by nick. Instead the fan-out skips only the sending client's own queue: the IRC listener passes its own client, whose handler already wrote the echo, and the HTTP API skips none. Channel messages and PARTs now reach the sender's other clients, so an HTTP client gets its own PART event; neoirc-cli ignores it, as it already shows its own part. README.md names the commands covered.

Judgement call: a PRIVMSG or NOTICE sent on an IRC connection is not echoed back to it, as in IRC.

Model: opus-5-5
2026-10-21 15:23:13 +02:00
clawbot d539b4c558 Make TestIdleCleanupKeepsActiveUser independent of timing (closes #181)
check / check (push) Waiting to run
The test slept 500 ms, had one user send PING, and then asked for sessions last seen before the current time minus 500 ms. On a loaded host the PONG could arrive more than 500 ms later, so the active user counted as idle and the build went red. The code was right: activity is recorded before the reply and the stored time keeps nanoseconds. The test now reads the clock once after both users register and before the PING, and uses that as the cutoff, so the result follows from the order of events.

Judgement call: the test relies on the wall clock not stepping backwards between the cutoff and the PING.

Model: opus-5-5
2026-10-21 13:06:01 +02:00
clawbot 3cbd7554e2 Relay INVITE to IRC listener users as an INVITE line (closes #147)
check / check (push) Waiting to run
An invite for an IRC listener user arrived as a server NOTICE that did
not name the inviter, so no IRC client treated it as an invite. The
relay now sends it as an INVITE line from the inviter, with the channel
taken from the message body, and sends nothing when the body names no
channel. The IRC listener now queues the INVITE with the channel as its
body, as the HTTP API already did, so an invite from either transport
has the same shape. The README's IRC listener section says how an
invite arrives.

Model: opus-5-5
2026-10-21 07:31:38 +02:00
clawbot ddfbdc1277 Keep the web client's saved channels and show who changed a mode (closes #176)
check / check (push) Waiting to run
The web client saved its channel tabs to localStorage whenever the
tabs changed, including on page load, when only the Server tab
exists. That wrote an empty list over the saved one before login
read it, so a new session never rejoined anything. The save now
waits until the user is logged in, and login reads the saved list
before it marks the user logged in.

A MODE message from the server fell through to the Server tab as its
bare body. It now shows in the channel's tab as "alice sets mode
+v bob on #chan".

Model: opus-5-5
2026-10-21 06:31:29 +02:00
clawbot 3cbdac7bda Send an IRC listener user their own TOPIC, MODE and KICK once (closes #143)
check / check (push) Waiting to run
The service sends a TOPIC, MODE or KICK to every channel member, the
issuer included, and the IRC listener also echoes it to the issuer's
connection, so an IRC listener user got each of their own changes
twice. The relay now drops one whose sender is the connection's own
nick, as it already does for PRIVMSG, NOTICE, JOIN, PART, NICK and
QUIT. The direct echo stays: it carries the real hostmask and arrives
in order. The README MODE section no longer describes a second copy.

Model: opus-5-5
2026-10-21 06:14:42 +02:00
clawbot d37c130ee8 Bring the web client in line with the current API (closes #145)
check / check (push) Waiting to run
The web client was written against an older shape of the API.

MODE now carries its change in `body`, so /mode sets modes; /mode
alone queries them and the 324 reply is shown. Numeric replies are
matched on `code`, so /list, /who and /whois print their lines, and
353 names are read as hostmasks. The member list takes @ and + from
`isOperator` and `isVoiced`. A 401 on the poll reloads the page, which
shows the login screen, where it used to retry for ever. The token
code is gone: no Authorization header, nothing stored, and the page
always asks /state whether it can resume a session.

Model: opus-5-5
2026-10-21 05:48:33 +02:00
clawbot 24b9b58cac Register the broker wait before the first poll (closes #157)
check / check (push) Waiting to run
GET /api/v1/messages read the client output queue and only then
registered with the broker. Notify wakes only the channels registered
at that moment, so a message queued between the empty read and the
registration waited out the whole long-poll timeout. The handler now
registers first and removes its channel on every exit path; the IRC
relay loop registers before each drain in the same way.

The handlers read the queue through a function field that defaults to
Database.PollMessages, so tests can act just after a read: one checks
that a message queued there comes back at once, others that no wait
channel stays registered however a request ends.

Model: opus-5-5
2026-10-21 05:31:24 +02:00
clawbot c267cd215c Refuse passwords over 72 bytes and confirm only stored ones (closes #144)
check / check (push) Waiting to run
bcrypt cannot hash a password longer than 72 bytes. PASS over the HTTP
API answered one with a 500, and the IRC listener confirmed it without
storing it.

Both transports now refuse a password outside 8 to 72 bytes with the
reply a short one already got: 461 over the HTTP API, a NOTICE over the
IRC listener. setPassword returns its error, and "Password set" is sent
only when the password was stored. A PASS given before registration, an
empty one included, is checked after the welcome burst and gets the
same NOTICE.

Model: opus-5-5
2026-10-21 05:02:00 +02:00
clawbot 0e6d06a7fc Spend a hashcash stamp only when the request succeeds (closes #158)
check / check (push) Waiting to run
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 04:02:06 +02:00
clawbot e7e408c93e Resolve IRC client hostnames off the accept loop (closes #156)
check / check (push) Waiting to run
acceptLoop called newConn, which did the reverse DNS lookup, with its
three-second timeout, before the loop could accept the next
connection, so one client whose address had no answering reverse DNS
held up every other IRC connect. serve now does the lookup at its
start, on the connection's own goroutine, and the accept loop only
accepts.

The server keeps the lookup function in a field, as it does the ping
times, so that a test can give it one that blocks. The new test
checks that a second client registers while the first one's lookup
has not returned.

Model: opus-5-5
2026-10-21 03:31:27 +02:00
clawbot 3822cdaf1d Decode the API's actual fields in neoirc-cli (closes #146)
check / check (push) Waiting to run
The CLI read the poll cursor from `lastId` while the server sends
`last_id`, so the cursor stayed at 0 and every poll returned the
whole queue at once. It now reads `last_id`.

/names decodes the member objects the server sends and prints each
nick with `@` for an operator or `+` for a voiced member. /list
prints each channel's name and topic, since the server sends no
member count. StateResponse.Channels decodes channel objects.

/topic with no text prints a usage line and sends nothing, because
the HTTP API has no topic query and a TOPIC without text gets 461.
The help says "Set topic".

The client tests serve the bodies the handlers send, so a wrong JSON
tag in types.go fails a test; another test covers the /names prefixes.

Model: opus-5-5
2026-10-21 02:48:31 +02:00
clawbot b879644e41 Broadcast a session's QUIT once when its clients leave together (closes #154)
check / check (push) Waiting to run
Logout deleted the client, counted the session's clients and ran
BroadcastQuit as separate statements, and BroadcastQuit read the peers,
parted, deleted empty channels and deleted the session separately too.
Two logouts or QUITs of one session at once both sent its QUIT, and a
JOIN landing between a part and the channel's deletion got into a +k
channel without the key.

db.QuitSession now does all of that in one transaction, parting each
channel as PartChannelAndDeleteIfEmpty does, and stores and queues the
QUIT in it only when it deleted the session, so the QUIT is queued
before the nick is free. db.LogoutClient deletes the client and counts
the rest in the same transaction.

Model: opus-5-5
2026-10-21 01:48:26 +02:00
clawbot bbdf3a70af Fail startup when the HTTP listener cannot bind (closes #167)
check / check (push) Waiting to run
The HTTP server ran ListenAndServe in a goroutine, so a failed bind
was only logged and neoircd kept running without its HTTP API.

The start hook now binds the listen address and returns the bind
error, so neoircd exits 1 with an error naming the address. If serving
the bound listener later ends with anything other than a normal
shutdown, the server stops the app through fx.Shutdowner with exit
code 1; the handler tests now pass it that Shutdowner.

Tests check that a taken port fails startup, that a serve failure
stops the app with exit code 1, and that a normal stop does not. The
server tests no longer run in parallel: building the app writes the
global viper instance.

Model: opus-5-5
2026-10-21 01:14:24 +02:00
clawbot 497514d3df Wait for IRC listener cleanup and HTTP shutdown on stop (closes #159)
check / check (push) Waiting to run
Stopping the server returned before the IRC listener's connections had
run their cleanup, and the database closed under them, so their
sessions, clients and channel memberships stayed and their nicks stayed
taken after a restart.

The IRC listener's stop now waits up to five seconds for every
connection's cleanup, which now runs on a context that stopping does
not cancel. The HTTP server's stop hook now runs Shutdown itself and
waits for it; httpServer is set before serving starts, and the server's
own signal watcher, which raced fx's stop hooks, is gone.

Model: opus-5-5
2026-10-20 23:48:30 +02:00
clawbot 108e381428 Run JOIN, PART and KICK as one transaction each (closes #153)
check / check (push) Waiting to run
JoinChannel made the channel, counted its members, ran the join
checks and inserted the member as separate statements, so two
requests could each decide on a count the other was about to change:
+l was exceeded, two first joiners both became operator, and a JOIN
racing the last PART failed its foreign key and answered 500.

db.CreateOrJoinChannel now does all of that, and uses up the invite,
in one transaction; the join checks moved into it, and the service
turns its refusals into the same IRC errors as before. PART and KICK
remove the member and delete an empty channel in one transaction
(db.PartChannelAndDeleteIfEmpty). The db functions this leaves with
no caller are removed.

Model: opus-5-5
2026-10-20 22:48:25 +02:00
29 changed files with 4224 additions and 721 deletions
+43 -26
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 at least 8 characters. must be 8 to 72 bytes.
- **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,10 +268,15 @@ 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, and the user's own - The server fans out messages and events from other users to every client queue
DMs and the channel messages it sends over the HTTP API, to every client queue for that user session.
for that user session. Replies to a command (numerics) go only to the client - When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`,
that sent it. `MODE` or `KICK` from one of its clients, the resulting event goes to every
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
@@ -744,7 +749,7 @@ Leave a channel.
{"command": "PART", "to": "#general", "body": ["goodbye"]} {"command": "PART", "to": "#general", "body": ["goodbye"]}
``` ```
**S2C (broadcast to the other channel members):** **S2C (broadcast to all channel members, including the departing user):**
```json ```json
{ {
@@ -760,9 +765,8 @@ Leave a channel.
**Behavior:** **Behavior:**
- The PART event goes to the other channel members, not to the departing user; - The PART event goes to every channel member, the departing user's own clients
the HTTP response confirms the part. (The IRC listener echoes the PART to its included. (An IRC listener connection that sends a PART echoes it itself.)
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.
@@ -835,10 +839,11 @@ Set a password on the current session, enabling multi-client login via
**Behavior:** **Behavior:**
- `body[0]` is the password. Must be at least 8 characters. - `body[0]` is the password. Must be 8 to 72 bytes; bcrypt cannot hash a longer
one.
- On success, the server responds with `{"status": "ok"}`. - On success, the server responds with `{"status": "ok"}`.
- If the password is too short or missing, the server sends ERR_NEEDMOREPARAMS - If the password is too short, too long or missing, the server sends
(461) via the message queue. ERR_NEEDMOREPARAMS (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.
@@ -1018,9 +1023,8 @@ 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 echoed to the operator's connection and missing is skipped. The changes made are sent to every channel member as one
also sent to every channel member, the operator included, as one `MODE` line, `MODE` line, e.g. `+o-v alice bob`; the operator gets that line once.
e.g. `+o-v alice bob`.
**Channel errors:** **Channel errors:**
@@ -1404,7 +1408,8 @@ Include the hashcash stamp in the `meta` field:
**Server validation:** The server checks that the stamp is well-formed, meets **Server validation:** The server checks that the stamp is well-formed, meets
the required difficulty, is bound to the correct channel and message body, has a the required difficulty, is bound to the correct channel and message body, has a
recent date, and has not been previously used. Spent stamps are cached for 1 recent date, and has not been previously used. Spent stamps are cached for 1
year to prevent replay attacks. year to prevent replay attacks. The stamp is checked after membership, bans and
`+m`, so a message refused for one of those does not spend it.
**Error responses:** If the channel requires hashcash and the stamp is missing, **Error responses:** If the channel requires hashcash and the stamp is missing,
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
@@ -2569,10 +2574,13 @@ issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Sessions**: Both anonymous and password-protected sessions are deleted on - **Sessions**: Both anonymous and password-protected sessions are deleted on
`QUIT` or when the last client logs out (`POST /api/v1/logout` with no `QUIT` or when the last client logs out (`POST /api/v1/logout` with no
remaining clients triggers session cleanup). There is no distinction between remaining clients triggers session cleanup). There is no distinction between
session types in the cleanup path — `handleQuit` and `cleanupUser` both call session types in the cleanup path: the session is parted from its channels,
`DeleteSession` unconditionally. Idle clients are automatically removed after deleted, and its QUIT queued to its channel peers in one database transaction,
`SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API so when several QUITs or logouts end one session at once only the one that
request or, on the IRC listener, a command — the server runs a background deletes it broadcasts QUIT, and whatever a new holder of the nick sends
reaches those peers after the QUIT. Idle clients are automatically removed
after `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP
API request or, on the IRC listener, a command — the server runs a background
cleanup loop that, for a session left with no clients, parts the user from all cleanup loop that, for a session left with no clients, parts the user from all
channels, broadcasts QUIT, and releases the nick. channels, broadcasts QUIT, and releases the nick.
- **Clients**: Individual client auth cookies are invalidated on - **Clients**: Individual client auth cookies are invalidated on
@@ -2674,9 +2682,11 @@ 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 (minimum 8 An optional `PASS` before registration sets the session password (8 to 72
characters). Registering creates a session without a hashcash stamp; closing bytes); one outside that range is refused with a `NOTICE` once registration
the connection ends the session, as `QUIT` does. completes, as `PASS` after registration is. Registering creates a session
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.
@@ -2713,7 +2723,9 @@ 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`. `MODE #general +o bob`. An `INVITE` from either transport reaches the invited
IRC listener user as an IRC `INVITE` line from the inviter, e.g.
`:alice!alice@* INVITE bob #general`.
### Docker Usage ### Docker Usage
@@ -3081,6 +3093,10 @@ limits that punish shared networks.
- Stamp has not been used before (replay prevention; the spent stamps are - Stamp has not been used before (replay prevention; the spent stamps are
kept in memory, so a restart forgets them) kept in memory, so a restart forgets them)
The stamp is spent only when the session is created. A request refused with
400 or 409 leaves it unspent, and the client can send the same stamp again
with another nick or username.
### Stamp Format ### Stamp Format
Standard hashcash format: Standard hashcash format:
@@ -3153,12 +3169,13 @@ 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 verifies a single hash and remembers the stamps already spent. The server hashes the stamp 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
(one SHA-256 hash) regardless of difficulty. Only the client does more work. regardless of difficulty: at most two SHA-256 hashes of the stamp per session
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
+2 -2
View File
@@ -250,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,
) ([]string, error) { ) ([]Member, error) {
name := strings.TrimPrefix(channel, "#") name := strings.TrimPrefix(channel, "#")
data, err := client.do( data, err := client.do(
@@ -263,7 +263,7 @@ func (client *Client) GetMembers(
return nil, err return nil, err
} }
var members []string var members []Member
err = json.Unmarshal(data, &members) err = json.Unmarshal(data, &members)
if err != nil { if err != nil {
+366 -7
View File
@@ -2,18 +2,28 @@ 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 cookieValue = "opaque-value" const (
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
@@ -24,10 +34,16 @@ 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(
@@ -56,9 +72,14 @@ func newSessionServer(t *testing.T) *httptest.Server {
return return
} }
_, _ = io.WriteString( _, _ = io.WriteString(writer, `{
writer, `{"id":1,"nick":"alice","channels":[]}`, "id": 1,
) "nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}`)
}) })
server := httptest.NewServer(mux) server := httptest.NewServer(mux)
@@ -67,6 +88,344 @@ 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()
@@ -85,7 +444,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
"http://" + net.JoinHostPort(host, serverURL.Port()), "http://" + net.JoinHostPort(host, serverURL.Port()),
) )
_, err := client.CreateSession("alice") _, err := client.CreateSession(testNick)
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
@@ -118,7 +477,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) {
}, },
} }
_, err := client.CreateSession("alice") _, err := client.CreateSession(testNick)
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) t.Fatalf("create session: %v", err)
} }
+16 -9
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 []string `json:"channels"` Channels []Channel `json:"channels"`
} }
// Message represents a neoirc message envelope. // Message represents a neoirc message envelope.
@@ -53,12 +53,19 @@ func (m *Message) BodyLines() []string {
} }
} }
// Channel represents a channel in the list response. // Channel is a channel as GET /api/v1/channels and
// 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.
@@ -72,7 +79,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:"lastId"` LastID int64 `json:"last_id"` //nolint:tagliatelle
} }
// PollResult wraps the poll response including the cursor. // PollResult wraps the poll response including the cursor.
+32 -16
View File
@@ -434,16 +434,9 @@ func (a *App) cmdTopic(args string) {
return return
} }
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" { if args == "" {
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct a.ui.AddStatus("[red]Usage: /topic <text>")
Command: irc.CmdTopic,
To: target,
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic query failed: %v", err,
))
}
return return
} }
@@ -487,12 +480,32 @@ 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(members, " "), target, strings.Join(nicks, " "),
)) ))
} }
// memberPrefix returns "@" for an operator, "+" for a voiced
// member, and "" for anyone else.
func memberPrefix(member api.Member) string {
if member.IsOperator {
return "@"
}
if member.IsVoiced {
return "+"
}
return ""
}
func (a *App) cmdList() { func (a *App) cmdList() {
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
@@ -517,8 +530,7 @@ func (a *App) cmdList() {
for _, ch := range channels { for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
" %s (%d members) %s", " %s %s", ch.Name, ch.Topic,
ch.Name, ch.Members, ch.Topic,
)) ))
} }
@@ -676,7 +688,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] — View/set topic", " /topic <text> — 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",
@@ -745,7 +757,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) a.handlePartEvent(msg, timestamp, myNick)
case irc.CmdQuit: case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp) a.handleQuitEvent(msg, timestamp)
case irc.CmdNick: case irc.CmdNick:
@@ -801,10 +813,14 @@ func (a *App) handleJoinEvent(
)) ))
} }
// handlePartEvent shows another user's part. The server
// also sends the user's own part back to it, which cmdPart
// has already shown; showing it again would add a second
// line and mark the channel just left as unread.
func (a *App) handlePartEvent( func (a *App) handlePartEvent(
msg *api.Message, timestamp string, msg *api.Message, timestamp, myNick string,
) { ) {
if msg.To == "" { if msg.To == "" || msg.From == myNick {
return return
} }
+116
View File
@@ -0,0 +1,116 @@
package cli
import (
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
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 for the
// user's own part, which cmdPart has already shown.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantLines int
wantUnread int
}{
{name: "own part", from: "erin", wantLines: 0, wantUnread: 0},
{name: "other member", from: "bob", wantLines: 1, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: "erin"}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: "#chan",
})
var lines, unread int
app.ui.app.QueueUpdate(func() {
buf := app.ui.getOrCreateBuffer("#chan")
lines = len(buf.Lines)
unread = buf.Unread
})
if lines != test.wantLines || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want %d and %d",
lines, unread, test.wantLines, test.wantUnread)
}
})
}
}
// 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)
}
})
}
+273 -112
View File
@@ -264,6 +264,25 @@ 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 {
@@ -558,8 +577,8 @@ func (database *Database) CreateOrJoinChannel(
// ErrInviteOnlyChannel when the channel is +i and the // ErrInviteOnlyChannel when the channel is +i and the
// session has no invite, ErrBadChannelKey when suppliedKey // session has no invite, ErrBadChannelKey when suppliedKey
// is not the +k key, or ErrChannelIsFull when the channel // is not the +k key, or ErrChannelIsFull when the channel
// has its +l number of members. It returns nil when none // has at least its +l number of members. It returns nil
// applies. // when none applies.
func checkJoinRestrictions( func checkJoinRestrictions(
ctx context.Context, ctx context.Context,
transaction *sql.Tx, transaction *sql.Tx,
@@ -854,42 +873,6 @@ func (database *Database) SetChannelTopicLocked(
return nil return nil
} }
// PartChannel removes a session from a channel.
func (database *Database) PartChannel(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_members
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("part channel: %w", err)
}
return nil
}
// DeleteChannelIfEmpty removes a channel with no members.
func (database *Database) DeleteChannelIfEmpty(
ctx context.Context,
channelID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channels WHERE id = ?
AND NOT EXISTS
(SELECT 1 FROM channel_members
WHERE channel_id = ?)`,
channelID, channelID)
if err != nil {
return fmt.Errorf(
"delete channel if empty: %w", err,
)
}
return nil
}
// PartChannelAndDeleteIfEmpty removes a session from a // PartChannelAndDeleteIfEmpty removes a session from a
// channel and deletes the channel if that left it with no // channel and deletes the channel if that left it with no
// members, in one transaction, so that no JOIN lands // members, in one transaction, so that no JOIN lands
@@ -905,7 +888,29 @@ func (database *Database) PartChannelAndDeleteIfEmpty(
defer func() { _ = transaction.Rollback() }() defer func() { _ = transaction.Rollback() }()
_, err = transaction.ExecContext(ctx, err = partChannelAndDeleteIfEmptyInTx(
ctx, transaction, channelID, sessionID,
)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit part: %w", err)
}
return nil
}
// partChannelAndDeleteIfEmptyInTx is
// PartChannelAndDeleteIfEmpty, writing through transaction.
func partChannelAndDeleteIfEmptyInTx(
ctx context.Context,
transaction *sql.Tx,
channelID, sessionID int64,
) error {
_, err := transaction.ExecContext(ctx,
`DELETE FROM channel_members `DELETE FROM channel_members
WHERE channel_id = ? AND session_id = ?`, WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID) channelID, sessionID)
@@ -925,11 +930,6 @@ func (database *Database) PartChannelAndDeleteIfEmpty(
) )
} }
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit part: %w", err)
}
return nil return nil
} }
@@ -1156,6 +1156,40 @@ func (database *Database) InsertMessage(
params json.RawMessage, params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
) (int64, string, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, "", fmt.Errorf("begin insert message: %w", err)
}
defer func() { _ = transaction.Rollback() }()
dbID, msgUUID, err := insertMessageInTx(
ctx, transaction, command, from, target,
toSessionID, toChannelID, params, body, meta,
)
if err != nil {
return 0, "", err
}
err = transaction.Commit()
if err != nil {
return 0, "", fmt.Errorf("commit insert message: %w", err)
}
return dbID, msgUUID, nil
}
// insertMessageInTx is InsertMessage, writing through
// transaction.
func insertMessageInTx(
ctx context.Context,
transaction *sql.Tx,
command, from, target string,
toSessionID, toChannelID int64,
params json.RawMessage,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
msgUUID := uuid.New().String() msgUUID := uuid.New().String()
now := time.Now().UTC() now := time.Now().UTC()
@@ -1172,7 +1206,7 @@ func (database *Database) InsertMessage(
meta = json.RawMessage("{}") meta = json.RawMessage("{}")
} }
res, err := database.conn.ExecContext(ctx, res, err := transaction.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
to_session_id, to_channel_id, to_session_id, to_channel_id,
@@ -1193,19 +1227,49 @@ func (database *Database) InsertMessage(
return dbID, msgUUID, nil return dbID, msgUUID, nil
} }
// EnqueueToSession adds a message to all clients of a // EnqueueToSession adds a message to the queues of all
// session's queues. // clients of a session except client skipClientID, or of all
// of them when skipClientID is 0.
func (database *Database) EnqueueToSession( func (database *Database) EnqueueToSession(
ctx context.Context, ctx context.Context,
sessionID, messageID int64, sessionID, messageID, skipClientID int64,
) error { ) error {
_, err := database.conn.ExecContext(ctx, transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin enqueue: %w", err)
}
defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx(
ctx, transaction, sessionID, messageID, skipClientID,
)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit enqueue: %w", err)
}
return nil
}
// enqueueToSessionInTx is EnqueueToSession, writing through
// transaction.
func enqueueToSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID, messageID, skipClientID int64,
) error {
_, err := transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues `INSERT OR IGNORE INTO client_queues
(client_id, message_id, created_at) (client_id, message_id, created_at)
SELECT c.id, ?, ? SELECT c.id, ?, ?
FROM clients c FROM clients c
WHERE c.session_id = ?`, WHERE c.session_id = ? AND c.id != ?`,
messageID, time.Now(), sessionID) messageID, time.Now(), sessionID, skipClientID)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"enqueue to session: %w", err, "enqueue to session: %w", err,
@@ -1470,38 +1534,175 @@ func (database *Database) SetTopic(
return nil return nil
} }
// DeleteSession removes a session and all its data. // QuitSession ends a session in one transaction: it parts
func (database *Database) DeleteSession( // the session from its channels, deleting each channel this
// leaves with no members, deletes the session with its
// clients, and stores a QUIT from nick with body and queues
// it to the other sessions that were on those channels. It
// returns those sessions, for the caller to wake once the
// QUIT is committed. Of several calls for one session at
// once, only the first finds the session; the others change
// nothing and return none.
func (database *Database) QuitSession(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
) error { nick string,
_, err := database.conn.ExecContext( body json.RawMessage,
ctx, ) ([]int64, error) {
"DELETE FROM sessions WHERE id = ?", transaction, err := database.conn.BeginTx(ctx, nil)
sessionID,
)
if err != nil { if err != nil {
return fmt.Errorf("delete session: %w", err) return nil, fmt.Errorf("begin quit: %w", err)
} }
return nil defer func() { _ = transaction.Rollback() }()
peerIDs, err := quitSessionInTx(
ctx, transaction, sessionID, nick, body,
)
if err != nil {
return nil, err
}
err = transaction.Commit()
if err != nil {
return nil, fmt.Errorf("commit quit: %w", err)
}
return peerIDs, nil
} }
// DeleteClient removes a single client record by ID. // LogoutClient deletes a client of a session and, if that
func (database *Database) DeleteClient( // leaves the session with no clients, does what QuitSession
// does, in one transaction. It returns what QuitSession
// returns, or none if the session keeps a client.
func (database *Database) LogoutClient(
ctx context.Context, ctx context.Context,
clientID int64, sessionID, clientID int64,
) error { nick string,
_, err := database.conn.ExecContext( body json.RawMessage,
ctx, ) ([]int64, error) {
"DELETE FROM clients WHERE id = ?", transaction, err := database.conn.BeginTx(ctx, nil)
clientID,
)
if err != nil { if err != nil {
return fmt.Errorf("delete client: %w", err) return nil, fmt.Errorf("begin logout: %w", err)
} }
return nil defer func() { _ = transaction.Rollback() }()
_, err = transaction.ExecContext(ctx,
"DELETE FROM clients WHERE id = ?", clientID)
if err != nil {
return nil, fmt.Errorf("delete client: %w", err)
}
var remaining int64
err = transaction.QueryRowContext(ctx,
"SELECT COUNT(*) FROM clients WHERE session_id = ?",
sessionID,
).Scan(&remaining)
if err != nil {
return nil, fmt.Errorf("count clients: %w", err)
}
var peerIDs []int64
if remaining == 0 {
peerIDs, err = quitSessionInTx(
ctx, transaction, sessionID, nick, body,
)
if err != nil {
return nil, err
}
}
err = transaction.Commit()
if err != nil {
return nil, fmt.Errorf("commit logout: %w", err)
}
return peerIDs, nil
}
// quitSessionInTx is QuitSession, writing through
// transaction.
func quitSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID int64,
nick string,
body json.RawMessage,
) ([]int64, error) {
rows, err := transaction.QueryContext(ctx,
`SELECT DISTINCT session_id FROM channel_members
WHERE session_id != ? AND channel_id IN
(SELECT channel_id FROM channel_members
WHERE session_id = ?)`,
sessionID, sessionID)
if err != nil {
return nil, fmt.Errorf("get channel peers: %w", err)
}
peerIDs, err := scanInt64s(rows)
if err != nil {
return nil, err
}
rows, err = transaction.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get session channel ids: %w", err,
)
}
channelIDs, err := scanInt64s(rows)
if err != nil {
return nil, err
}
for _, channelID := range channelIDs {
err = partChannelAndDeleteIfEmptyInTx(
ctx, transaction, channelID, sessionID,
)
if err != nil {
return nil, err
}
}
result, err := transaction.ExecContext(ctx,
"DELETE FROM sessions WHERE id = ?", sessionID)
if err != nil {
return nil, fmt.Errorf("delete session: %w", err)
}
deletedRows, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("delete session: %w", err)
}
if deletedRows == 0 || len(peerIDs) == 0 {
return nil, nil
}
messageID, _, err := insertMessageInTx(
ctx, transaction, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil,
)
if err != nil {
return nil, err
}
for _, peerID := range peerIDs {
err = enqueueToSessionInTx(
ctx, transaction, peerID, messageID, 0,
)
if err != nil {
return nil, err
}
}
return peerIDs, nil
} }
// GetUserCount returns the number of active users. // GetUserCount returns the number of active users.
@@ -1543,29 +1744,6 @@ func (database *Database) GetOperCount(
return count, nil return count, nil
} }
// ClientCountForSession returns the number of clients
// belonging to a session.
func (database *Database) ClientCountForSession(
ctx context.Context,
sessionID int64,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
`SELECT COUNT(*) FROM clients
WHERE session_id = ?`,
sessionID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"client count for session: %w", err,
)
}
return count, nil
}
// DeleteStaleUsers removes clients not seen since the // DeleteStaleUsers removes clients not seen since the
// cutoff and cleans up orphaned users (sessions). // cutoff and cleans up orphaned users (sessions).
func (database *Database) DeleteStaleUsers( func (database *Database) DeleteStaleUsers(
@@ -2304,23 +2482,6 @@ func (database *Database) HasChannelInvite(
return count > 0, nil return count > 0, nil
} }
// ClearChannelInvite removes a session's invite to a
// channel (called after successful JOIN).
func (database *Database) ClearChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("clear invite: %w", err)
}
return nil
}
// --- Tier 2: Secret (+s) --- // --- Tier 2: Secret (+s) ---
// IsChannelSecret checks if a channel has +s mode. // IsChannelSecret checks if a channel has +s mode.
+128 -42
View File
@@ -2,6 +2,7 @@ package db_test
import ( import (
"encoding/json" "encoding/json"
"slices"
"testing" "testing"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -411,7 +412,7 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.PartChannel(ctx, chID, sid) err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -422,7 +423,10 @@ func TestJoinAndPart(t *testing.T) {
} }
} }
func TestDeleteChannelIfEmpty(t *testing.T) { // TestPartChannelAndDeleteIfEmpty checks that a part keeps
// a channel another session is still on and deletes the
// channel when its last member parts.
func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
@@ -435,22 +439,34 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "") nicks := []string{"temp1", "temp2"}
sids := make([]int64, 0, len(nicks))
for _, nick := range nicks {
sid, _, _, createErr := database.CreateSession(ctx, nick, "", "", "")
if createErr != nil {
t.Fatal(createErr)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
sids = append(sids, sid)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[0])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, chID, sid) _, err = database.GetChannelByName(ctx, "#empty")
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("channel deleted while a member is on it: %v", err)
} }
err = database.PartChannel(ctx, chID, sid) err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[1])
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -659,7 +675,7 @@ func TestPollMessages(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.EnqueueToSession(ctx, sid, dbID) err = database.EnqueueToSession(ctx, sid, dbID, 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -777,6 +793,11 @@ func TestGetHistoryReusedNames(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, oldChID, oldSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hi"]`) body := json.RawMessage(`["hi"]`)
_, _, err = database.InsertMessage( _, _, err = database.InsertMessage(
@@ -799,12 +820,9 @@ func TestGetHistoryReusedNames(t *testing.T) {
t.Fatal("expected one DM and one channel message") t.Fatal("expected one DM and one channel message")
} }
err = database.DeleteSession(ctx, oldSID) // The session is the channel's only member, so quitting
if err != nil { // deletes the channel too.
t.Fatal(err) _, err = database.QuitSession(ctx, oldSID, "reused", nil)
}
err = database.DeleteChannelIfEmpty(ctx, oldChID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -826,34 +844,42 @@ func TestGetHistoryReusedNames(t *testing.T) {
} }
} }
func TestDeleteSession(t *testing.T) { // TestQuitSession checks that QuitSession deletes the
// session, keeps a channel another session is still on,
// deletes a channel it leaves with no members, and queues
// one QUIT to the other session; and that a second call for
// the same session changes nothing.
func TestQuitSession(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
sid, _, _, err := database.CreateSession( sid, sharedID, _ := createSessionWithChannels(
ctx, "deleteme", "", "", "", t, database, "deleteme", "#shared", "#alone",
)
peerSID, peerClientID, _, err := database.CreateSession(
ctx, "peer", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
chID, err := database.GetOrCreateChannel( err = database.JoinChannel(ctx, sharedID, peerSID)
ctx, "#delchan",
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.JoinChannel(ctx, chID, sid) body := json.RawMessage(`["bye"]`)
peerIDs, err := database.QuitSession(ctx, sid, "deleteme", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.DeleteSession(ctx, sid) if !slices.Equal(peerIDs, []int64{peerSID}) {
if err != nil { t.Fatalf("got peers %v, want [%d]", peerIDs, peerSID)
t.Fatal(err)
} }
_, err = database.GetSessionByNick(ctx, "deleteme") _, err = database.GetSessionByNick(ctx, "deleteme")
@@ -861,9 +887,80 @@ func TestDeleteSession(t *testing.T) {
t.Fatal("session should be deleted") t.Fatal("session should be deleted")
} }
ids, _ := database.GetChannelMemberIDs(ctx, chID) ids, _ := database.GetChannelMemberIDs(ctx, sharedID)
if len(ids) != 0 { if !slices.Equal(ids, []int64{peerSID}) {
t.Fatal("expected no members after deletion") t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID)
}
_, err = database.GetChannelByName(ctx, "#alone")
if err == nil {
t.Fatal("expected the emptied channel to be deleted")
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
peerIDs, err = database.QuitSession(ctx, sid, "deleteme", body)
if err != nil || len(peerIDs) != 0 {
t.Fatalf("second quit: got %v, %v; want none", peerIDs, err)
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
}
// checkOneQuitQueued fails the test unless the queue of the
// client clientID holds one message, a QUIT from nick.
func checkOneQuitQueued(
t *testing.T,
database *db.Database,
clientID int64,
nick string,
) {
t.Helper()
msgs, _, err := database.PollMessages(t.Context(), clientID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Command != "QUIT" || msgs[0].From != nick {
t.Fatalf("queue: got %v, want one QUIT from %s", msgs, nick)
}
}
// TestLogoutClientFailureKeepsClient checks that when the
// session of a logout's last client cannot be deleted,
// LogoutClient fails and the client stays logged in.
func TestLogoutClientFailureKeepsClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, clientID, token, err := database.CreateSession(
ctx, "stuck", "", "", "",
)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(ctx,
`CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions
BEGIN
SELECT RAISE(ABORT, 'sessions are kept');
END`)
if err != nil {
t.Fatal(err)
}
_, err = database.LogoutClient(ctx, sid, clientID, "stuck", nil)
if err == nil {
t.Fatal("logout succeeded although the session was kept")
}
_, _, nick, err := database.GetSessionByToken(ctx, token)
if err != nil || nick != "stuck" {
t.Fatalf("client gone after a failed logout: %q, %v", nick, err)
} }
} }
@@ -1342,17 +1439,6 @@ func TestChannelInviteCRUD(t *testing.T) {
if !has { if !has {
t.Fatal("should have invite") t.Fatal("should have invite")
} }
// Clear invite.
err = database.ClearChannelInvite(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
has, _ = database.HasChannelInvite(ctx, chID, sid)
if has {
t.Fatal("invite should be cleared")
}
} }
func TestChannelSecret(t *testing.T) { func TestChannelSecret(t *testing.T) {
+71 -61
View File
@@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
if !hdlr.validateHashcash( if !hdlr.checkHashcash(
writer, request, payload.Hashcash, writer, request, payload.Hashcash,
) { ) {
return return
@@ -232,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession(
} }
hdlr.executeCreateSession( hdlr.executeCreateSession(
writer, request, payload.Nick, username, writer, request,
payload.Nick, username, payload.Hashcash,
) )
} }
func (hdlr *Handlers) executeCreateSession( func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
nick, username string, nick, username, stamp string,
) { ) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
@@ -247,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP, request.Context(), remoteIP,
) )
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), request.Context(),
nick, username, hostname, remoteIP, nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
writer, request, err, writer, request, err,
) )
@@ -273,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated) }, http.StatusCreated)
} }
// validateHashcash validates a hashcash stamp if required. // checkHashcash checks a hashcash stamp if required,
// Returns false if validation failed and a response was // without spending it. Returns false if the check failed
// already sent. // and a response was already sent.
func (hdlr *Handlers) validateHashcash( func (hdlr *Handlers) checkHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
stamp string, stamp string,
@@ -295,7 +301,31 @@ func (hdlr *Handlers) validateHashcash(
return false return false
} }
err := hdlr.hashcashVal.Validate( err := hdlr.hashcashVal.Check(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if Claim refused the stamp, normally because
// it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
err := hdlr.hashcashVal.Claim(
stamp, hdlr.params.Config.HashcashBits, stamp, hdlr.params.Config.HashcashBits,
) )
if err != nil { if err != nil {
@@ -783,7 +813,14 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
timeout = maxLongPollTimeout timeout = maxLongPollTimeout
} }
msgs, lastQID, err := hdlr.params.Database.PollMessages( // Register with the broker before reading the queue.
// 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,
) )
@@ -811,19 +848,22 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll( hdlr.longPoll(
writer, request, writer, request,
sessionID, clientID, afterID, timeout, waitCh, 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,
sessionID, clientID, afterID int64, waitCh <-chan struct{},
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,
) )
@@ -834,14 +874,10 @@ 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
} }
hdlr.broker.Remove(sessionID, waitCh) msgs, lastQID, err := hdlr.pollMessages(
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -1221,9 +1257,11 @@ func (hdlr *Handlers) handleChannelMsg(
) { ) {
ctx := request.Context() ctx := request.Context()
// Delegate validation + fan-out to service layer. // Delegate validation + fan-out to service layer. The
dbID, uuid, err := hdlr.svc.SendChannelMessage( // fan-out includes the sender's own session, so all
ctx, sessionID, nick, // their clients see the message in long-poll responses.
uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, 0, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1233,13 +1271,6 @@ 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)
@@ -1254,7 +1285,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage, meta json.RawMessage,
) { ) {
result, err := hdlr.svc.SendDirectMessage( result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, nick, request.Context(), sessionID, 0, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1325,7 +1356,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, nick, request.Context(), sessionID, 0, nick,
channel, suppliedKey, channel, suppliedKey,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1509,7 +1540,7 @@ func (hdlr *Handlers) handlePart(
} }
err := hdlr.svc.PartChannel( err := hdlr.svc.PartChannel(
request.Context(), sessionID, request.Context(), sessionID, 0,
nick, channel, reason, nick, channel, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1580,7 +1611,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string, nick, newNick string,
) { ) {
err := hdlr.svc.ChangeNick( err := hdlr.svc.ChangeNick(
request.Context(), sessionID, nick, newNick, request.Context(), sessionID, 0, nick, newNick,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
writer, request, writer, request,
@@ -1635,7 +1666,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ") topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic( err := hdlr.svc.SetTopic(
request.Context(), sessionID, request.Context(), sessionID, 0,
nick, channel, topic, nick, channel, topic,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -2121,7 +2152,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), nick, channel, chID, request.Context(), 0, nick, channel, chID,
modeText, modeText,
) )
@@ -2167,7 +2198,7 @@ func (hdlr *Handlers) setChannelFlag(
} }
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), nick, channel, chID, request.Context(), 0, nick, channel, chID,
modeStr, modeStr,
) )
@@ -3395,15 +3426,11 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
ctx := request.Context() err := hdlr.svc.Logout(
request.Context(), sessionID, clientID, nick,
err := hdlr.params.Database.DeleteClient(
ctx, clientID,
) )
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error("logout failed", "error", err)
"delete client failed", "error", err,
)
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"internal error", "internal error",
@@ -3413,23 +3440,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.clearAuthCookie(writer) hdlr.clearAuthCookie(writer)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -3635,7 +3645,7 @@ func (hdlr *Handlers) handleKick(
} }
err := hdlr.svc.KickUser( err := hdlr.svc.KickUser(
request.Context(), sessionID, nick, request.Context(), sessionID, 0, nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -3708,7 +3718,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, request.Context(), sid, dbID, 0,
) )
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
+9 -3
View File
@@ -8,7 +8,12 @@ import (
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
const minPasswordLength = 8 // A session password is 8 to 72 bytes; bcrypt cannot hash
// 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 {
@@ -149,12 +154,13 @@ 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 at least 8 characters", "Password must be 8 to 72 bytes",
) )
return return
+19
View File
@@ -0,0 +1,19 @@
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,6 +64,16 @@ 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.
@@ -95,6 +105,7 @@ 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{
+53 -9
View File
@@ -77,9 +77,13 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Validate checks a hashcash stamp. It returns nil if the // Check returns nil if requiredBits is not positive, or if
// stamp is valid and has not been seen before. // the stamp is a well-formed, recently dated proof of work
func (v *Validator) Validate( // for this resource with at least requiredBits. It does not
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other
// checks.
func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
) error { ) error {
@@ -87,9 +91,49 @@ func (v *Validator) Validate(
return nil return nil
} }
_, err := v.check(stamp, requiredBits)
return err
}
// Claim checks the stamp as Check does and, when
// requiredBits is positive, records it as spent until it
// expires. It returns an error if the stamp is already
// spent. Release gives a claimed stamp back.
func (v *Validator) Claim(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
stampTime, err := v.check(stamp, requiredBits)
if err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
// Release forgets a claimed stamp so that it can be claimed
// again, for a request that failed after its Claim.
// Releasing a stamp that is not claimed does nothing.
func (v *Validator) Release(stamp string) {
v.mu.Lock()
defer v.mu.Unlock()
delete(v.spent, stamp)
}
// check returns the stamp's date if Check accepts it.
func (v *Validator) check(
stamp string,
requiredBits int,
) (time.Time, error) {
parts := strings.Split(stamp, ":") parts := strings.Split(stamp, ":")
if len(parts) != stampFields { if len(parts) != stampFields {
return fmt.Errorf( return time.Time{}, fmt.Errorf(
"%w: expected %d, got %d", "%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts), errInvalidFields, stampFields, len(parts),
) )
@@ -104,25 +148,25 @@ func (v *Validator) Validate(
version, bitsStr, resource, requiredBits, version, bitsStr, resource, requiredBits,
) )
if err != nil { if err != nil {
return err return time.Time{}, err
} }
stampTime, err := parseStampDate(dateStr) stampTime, err := parseStampDate(dateStr)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
err = validateTime(stampTime) err = validateTime(stampTime)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
err = validateProof(stamp, requiredBits) err = validateProof(stamp, requiredBits)
if err != nil { if err != nil {
return err return time.Time{}, err
} }
return v.checkAndRecordStamp(stamp, stampTime) return stampTime, nil
} }
func (v *Validator) validateHeader( func (v *Validator) validateHeader(
+76 -14
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("valid stamp rejected: %v", err) t.Fatalf("valid stamp rejected: %v", err)
} }
@@ -99,17 +99,74 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("first use failed: %v", err) t.Fatalf("first use failed: %v", err)
} }
err = validator.Validate(stamp, testBits) err = validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("replay not detected") t.Fatal("replay not detected")
} }
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once, and that
// Check still accepts it once it is spent.
func TestCheckDoesNotSpendStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
for range 2 {
err := validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after check failed: %v", err)
}
err = validator.Claim(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
err = validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("spent stamp rejected by Check: %v", err)
}
}
// TestReleasedStampCanBeClaimedAgain checks that Release
// gives a claimed stamp back.
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("first claim failed: %v", err)
}
validator.Release(stamp)
err = validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after release failed: %v", err)
}
}
func TestResourceMismatch(t *testing.T) { func TestResourceMismatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -118,7 +175,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(), t, testBits, "wrong-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected resource mismatch error") t.Fatal("expected resource mismatch error")
} }
@@ -129,7 +186,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Validate( err := validator.Claim(
"not:a:valid:stamp", testBits, "not:a:valid:stamp", testBits,
) )
if err == nil { if err == nil {
@@ -146,7 +203,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource", testBits, todayDate(), "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad version error") t.Fatal("expected bad version error")
} }
@@ -162,7 +219,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource", todayDate(), "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected insufficient bits error") t.Fatal("expected insufficient bits error")
} }
@@ -178,7 +235,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate, t, testBits, "test-resource", oldDate,
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected expired stamp error") t.Fatal("expected expired stamp error")
} }
@@ -189,9 +246,14 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Validate("garbage", 0) err := validator.Check("garbage", 0)
if err != nil { if err != nil {
t.Fatalf("zero bits should skip: %v", err) t.Fatalf("zero bits should skip the check: %v", err)
}
err = validator.Claim("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the claim: %v", err)
} }
} }
@@ -204,7 +266,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate, t, testBits, "test-resource", longDate,
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("long date stamp rejected: %v", err) t.Fatalf("long date stamp rejected: %v", err)
} }
@@ -219,7 +281,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource", testBits, "test-resource",
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad date error") t.Fatal("expected bad date error")
} }
@@ -235,7 +297,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("unique stamp rejected: %v", err) t.Fatalf("unique stamp rejected: %v", err)
} }
@@ -251,7 +313,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(), t, 4, "test-resource", todayDate(),
) )
err := validator.Validate(stamp, testBits) err := validator.Claim(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"higher-difficulty stamp rejected: %v", "higher-difficulty stamp rejected: %v",
+26 -27
View File
@@ -81,10 +81,10 @@ func (c *Conn) handleNick(
} }
oldMask := c.hostmask() oldMask := c.hostmask()
oldNick := c.nick oldNick := c.currentNick()
err := c.svc.ChangeNick( err := c.svc.ChangeNick(
ctx, c.sessionID, oldNick, newNick, ctx, c.sessionID, c.clientID, oldNick, newNick,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -92,9 +92,10 @@ func (c *Conn) handleNick(
return return
} }
c.mu.Lock() // Take the nick the session holds, not newNick: another
c.nick = newNick // client of the session may have changed it again since
c.mu.Unlock() // ChangeNick stored newNick.
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))
@@ -128,8 +129,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.nick, ctx, c.sessionID, c.clientID, c.currentNick(),
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -137,7 +138,7 @@ func (c *Conn) handlePrivmsg(
} }
} else { } else {
result, err := c.svc.SendDirectMessage( result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.nick, ctx, c.sessionID, c.clientID, c.currentNick(),
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -199,7 +200,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.nick, channel, key, ctx, c.sessionID, c.clientID, c.currentNick(), channel, key,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -337,7 +338,7 @@ func (c *Conn) partChannel(
channel, reason string, channel, reason string,
) { ) {
err := c.svc.PartChannel( err := c.svc.PartChannel(
ctx, c.sessionID, c.nick, channel, reason, ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -401,7 +402,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.nick, channel, newTopic, ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -708,7 +709,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.nick, channel, chID, modeReply, ctx, c.clientID, c.currentNick(), channel, chID, modeReply,
) )
} }
} }
@@ -1185,7 +1186,7 @@ func (c *Conn) handleKick(
} }
err := c.svc.KickUser( err := c.svc.KickUser(
ctx, c.sessionID, c.nick, ctx, c.sessionID, c.clientID, c.currentNick(),
channel, targetNick, reason, channel, targetNick, reason,
) )
if err != nil { if err != nil {
@@ -1216,17 +1217,16 @@ func (c *Conn) handlePassPostReg(
} }
password := msg.Params[0] password := msg.Params[0]
if len(password) < minPasswordLen { if !c.checkPasswordLength(password) {
c.sendFromServer("NOTICE", c.nick,
"Password must be at least 8 characters",
)
return return
} }
c.setPassword(ctx, password) err := c.setPassword(ctx, password)
if err != nil {
return
}
c.sendFromServer("NOTICE", c.nick, c.sendFromServer("NOTICE", c.currentNick(),
"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.nick, targetNick, channel, ctx, c.sessionID, c.currentNick(), targetNick, channel,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -1262,14 +1262,13 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel, irc.RplInviting, targetNick, channel,
) )
// Send INVITE notice to target via service fan-out. // Queue the INVITE to the target with the body the HTTP
body, _ := json.Marshal( //nolint:errchkjson // API uses.
[]string{"You have been invited to " + channel}, body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
)
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.nick, targetNick, targetID, 0, ctx, "INVITE", c.currentNick(), targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, nil, body, nil, []int64{targetID}, 0,
) )
} }
+66 -14
View File
@@ -27,6 +27,7 @@ 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
) )
@@ -62,6 +63,7 @@ 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
@@ -70,7 +72,6 @@ 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,
@@ -94,7 +95,6 @@ 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,17 +212,27 @@ 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)
defer cancel() defer cancel()
defer c.cleanup(ctx) // Stopping the server cancels ctx, and the cleanup must
// still end the session then.
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)
@@ -303,6 +313,22 @@ 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)
@@ -335,7 +361,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType, code irc.IRCMessageType,
params ...string, params ...string,
) { ) {
nick := c.nick nick := c.currentNick()
if nick == "" { if nick == "" {
nick = "*" nick = "*"
} }
@@ -359,9 +385,11 @@ 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 = c.nick user = nick
} }
host := c.hostname host := c.hostname
@@ -369,7 +397,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP host = c.remoteIP
} }
return c.nick + "!" + user + "@" + host return nick + "!" + user + "@" + host
} }
// handleMessage dispatches a parsed IRC message using // handleMessage dispatches a parsed IRC message using
@@ -428,6 +456,7 @@ 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(
@@ -542,17 +571,20 @@ 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)
} }
@@ -635,13 +667,33 @@ func (c *Conn) deliverMOTD() {
) )
} }
// setPassword sets a bcrypt password on the session. // checkPasswordLength reports whether pw is 8 to 72 bytes
func (c *Conn) setPassword(ctx context.Context, pw string) { // long, and sends the client a NOTICE saying so when it is
// 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
} }
+20 -5
View File
@@ -15,7 +15,8 @@ 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. // pingInterval and pongDeadline, and resolve in place of
// 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,
@@ -23,6 +24,7 @@ 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",
@@ -44,9 +46,16 @@ 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)
@@ -62,6 +71,11 @@ 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.
@@ -85,6 +99,10 @@ const PingInterval = pingInterval
// for a line before it closes the connection. // for a line before it closes the connection.
const PongDeadline = pongDeadline const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a // NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it. // server, database or service behind it.
func NewTestConn( func NewTestConn(
@@ -93,10 +111,7 @@ func NewTestConn(
tcpConn net.Conn, tcpConn net.Conn,
nick string, nick string,
) *Conn { ) *Conn {
conn := newConn( conn := newConn(tcpConn, log, nil, nil, cfg, nil)
context.Background(), tcpConn, log,
nil, nil, cfg, nil,
)
conn.nick = nick conn.nick = nick
return conn return conn
+36
View File
@@ -1373,6 +1373,42 @@ 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) {
+27 -50
View File
@@ -27,17 +27,22 @@ func (c *Conn) relayMessages(ctx context.Context) {
default: default:
} }
// Register with the broker before draining, so that a
// message queued after the drain has read the queue
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. // Drain any available messages.
delivered := c.drainQueue(ctx) delivered := c.drainQueue(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.
@@ -87,7 +92,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(
_ context.Context, ctx context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
) { ) {
command := msg.Command command := msg.Command
@@ -115,7 +120,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(msg, text) c.deliverNickChange(ctx, 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:
@@ -193,15 +198,6 @@ 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,16 +210,6 @@ 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
@@ -232,14 +218,6 @@ 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
@@ -252,18 +230,17 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
} }
} }
// deliverNickChange sends a NICK change notification. // deliverNickChange sends a NICK change notification. The
// 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.mu.Lock() c.loadSessionNick(ctx)
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
@@ -275,14 +252,6 @@ 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 != "" {
@@ -335,16 +304,24 @@ func (c *Conn) deliverKickMsg(
} }
} }
// deliverInviteMsg sends an INVITE notification. // deliverInviteMsg sends an INVITE line from the inviter
// for the channel named in text, or nothing when text names
// no channel.
func (c *Conn) deliverInviteMsg( func (c *Conn) deliverInviteMsg(
_ *db.IRCMessage, msg *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()
c.sendFromServer("NOTICE", nick, text) prefix := msg.From + "!" + msg.From + "@*"
c.send(FormatMessage(prefix, "INVITE", nick, text))
} }
// deliverWallops sends a WALLOPS notification. // deliverWallops sends a WALLOPS notification.
+440 -14
View File
@@ -1,6 +1,7 @@
package ircserver_test package ircserver_test
import ( import (
"context"
"encoding/json" "encoding/json"
"slices" "slices"
"strings" "strings"
@@ -8,6 +9,7 @@ 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
@@ -39,7 +41,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) err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0)
if err != nil { if err != nil {
t.Fatalf("enqueue: %v", err) t.Fatalf("enqueue: %v", err)
} }
@@ -47,14 +49,13 @@ func (env *testEnv) queueMessage(
env.brk.Notify(sessionID) env.brk.Notify(sessionID)
} }
// TestRelayKeepsOneWaitChannel checks that an idle IRC // TestRelayKeepsOneWaitChannel checks that an IRC listener
// listener connection holds at most one broker wait channel // connection holds at most one broker wait channel across
// across several relay ticks, and none once it closes. // several relay ticks, both while idle and after the relay
// 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)
@@ -65,15 +66,17 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Fatalf("get session of idle: %v", err) t.Fatalf("get session of idle: %v", err)
} }
for tick := range idleTicks { env.checkOneWaitChannel(t, sessionID)
time.Sleep(ircserver.PollInterval)
got := env.brk.ListenerCount(sessionID) env.queueMessage(
if got > 1 { t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"},
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 {
@@ -92,6 +95,28 @@ 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.
@@ -188,6 +213,38 @@ 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
@@ -269,3 +326,372 @@ 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)
}
}
+53 -3
View File
@@ -16,6 +16,10 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC // Params defines the dependencies for creating an IRC
// Server. // Server.
type Params struct { type Params struct {
@@ -40,10 +44,18 @@ type Server struct {
conns map[*Conn]struct{} conns map[*Conn]struct{}
cancel context.CancelFunc cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the // pingInterval and pongDeadline are the constants of the
// 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
@@ -63,9 +75,11 @@ func New(
listener: nil, listener: nil,
cancel: nil, cancel: nil,
mu: sync.Mutex{}, mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval, pingInterval: pingInterval,
pongDeadline: pongDeadline, pongDeadline: pongDeadline,
resolveHost: resolveHost,
} }
listenAddr := params.Config.IRCListenAddr listenAddr := params.Config.IRCListenAddr
@@ -112,7 +126,10 @@ func (s *Server) start(ctx context.Context, addr string) error {
return nil return nil
} }
// stop shuts down the listener and all connections. // stop shuts down the listener and all connections, then
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
func (s *Server) stop() { func (s *Server) stop() {
if s.cancel != nil { if s.cancel != nil {
s.cancel() s.cancel()
@@ -127,6 +144,22 @@ func (s *Server) stop() {
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
s.mu.Unlock() s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
)
}
} }
// acceptLoop accepts new connections. // acceptLoop accepts new connections.
@@ -147,22 +180,39 @@ func (s *Server) acceptLoop(ctx context.Context) {
} }
client := newConn( client := newConn(
ctx, tcpConn, s.log, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.database, s.brk, s.cfg, s.svc,
) )
s.mu.Lock() s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{} s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock() s.mu.Unlock()
go func() { go func() {
defer s.serving.Done()
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
delete(s.conns, client) delete(s.conns, client)
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve(ctx, s.pingInterval, s.pongDeadline) client.serve(
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
}() }()
} }
} }
+341 -5
View File
@@ -2,14 +2,17 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -89,6 +92,21 @@ func newTestEnvWithPingTimes(
) *testEnv { ) *testEnv {
t.Helper() t.Helper()
return newTestEnvWithResolver(
t, cfg, interval, deadline, ircserver.ResolveHost,
)
}
// newTestEnvWithResolver is newTestEnvWithPingTimes, except
// 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()
// A random name, so that no other test environment, not // A random name, so that no other test environment, not
// even an earlier run of the same test, can share it. // even an earlier run of the same test, can share it.
dsn := fmt.Sprintf( dsn := fmt.Sprintf(
@@ -146,7 +164,7 @@ func newTestEnvWithPingTimes(
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, log, cfg, database, brk, interval, deadline, resolve,
) )
err = srv.Start(addr) err = srv.Start(addr)
@@ -414,6 +432,49 @@ 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.
@@ -584,8 +645,6 @@ 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)
@@ -594,13 +653,17 @@ func TestIdleCleanupKeepsActiveUser(t *testing.T) {
idle := env.dial(t) idle := env.dial(t)
idle.register("idle") idle.register("idle")
time.Sleep(idleTimeout) // Both users were last seen before cutoff, and only active
// 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(), time.Now().Add(-idleTimeout), t.Context(), cutoff,
) )
if err != nil { if err != nil {
t.Fatalf("get stale sessions: %v", err) t.Fatalf("get stale sessions: %v", err)
@@ -812,6 +875,120 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") assertContains(t, lines, "goodbye", "QUIT reason")
} }
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
// its channel membership are gone from the database, and a
// channel peer has been sent the QUIT.
func TestStopEndsSessions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("shutirc")
client.joinAndDrain("#shut")
ctx := t.Context()
// A peer on the HTTP API, whose session outlives the stop.
peerID, peerClientID, _, err := env.database.CreateSession(
ctx, "peer", "peer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
env.srv.Stop()
_, err = env.database.GetSessionByNick(ctx, "shutirc")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("look up shutirc after stop: %v, want no rows", err)
}
clients, err := env.database.GetClientCount(ctx)
if err != nil {
t.Fatalf("count clients: %v", err)
}
if clients != 1 {
t.Errorf("%d clients after stop, want only the peer's", clients)
}
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
if err != nil {
t.Fatalf("get members: %v", err)
}
if len(members) != 1 || members[0] != peerID {
t.Errorf(
"members of #shut after stop: %v, want only the peer %d",
members, peerID,
)
}
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == "QUIT" && msg.From == "shutirc" {
return
}
}
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
if err != nil {
t.Fatalf("take the database connection: %v", err)
}
stopped := make(chan struct{})
go func() {
env.srv.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
if err != nil {
t.Errorf("release the database connection: %v", err)
}
}
func TestTopicSetAndGet(t *testing.T) { func TestTopicSetAndGet(t *testing.T) {
t.Parallel() t.Parallel()
@@ -983,6 +1160,165 @@ 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()
+11 -1
View File
@@ -1,6 +1,10 @@
package server package server
import "github.com/go-chi/chi/v5" import (
"net"
"github.com/go-chi/chi/v5"
)
// NewTestSPAServer creates a Server whose router serves only // NewTestSPAServer creates a Server whose router serves only
// the embedded web client. // the embedded web client.
@@ -10,3 +14,9 @@ func NewTestSPAServer() *Server {
return srv return srv
} }
// ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
srv.serveUntilShutdown(listener)
}
+47 -50
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -39,17 +40,15 @@ type Params struct {
Config *config.Config Config *config.Config
Middleware *middleware.Middleware Middleware *middleware.Middleware
Handlers *handlers.Handlers Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
} }
// Server is the main HTTP server. // Server is the main HTTP server.
// It manages routing, middleware, and lifecycle. // It manages routing, middleware, and lifecycle.
type Server struct { type Server struct {
startupTime time.Time startupTime time.Time
exitCode int
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
params Params params Params
@@ -69,7 +68,7 @@ func New(
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(ctx context.Context) error {
srv.startupTime = time.Now() srv.startupTime = time.Now()
// Build the router before OnStart returns, so srv can // Build the router before OnStart returns, so srv can
@@ -78,13 +77,14 @@ func New(
srv.enableSentry() srv.enableSentry()
srv.SetupRoutes() srv.SetupRoutes()
// The start hook's context ends when the hook // A closed stdout or stderr must not end the process.
// returns; serving must outlive it. signal.Ignore(syscall.SIGPIPE)
go srv.serve() //nolint:contextcheck,gosec // G118
return nil return srv.startHTTP(ctx)
}, },
OnStop: func(_ context.Context) error { OnStop: func(ctx context.Context) error {
srv.cleanShutdown(ctx)
return nil return nil
}, },
}) })
@@ -129,44 +129,15 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true srv.sentryEnabled = true
} }
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go srv.serveUntilShutdown()
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() { func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up") srv.log.Info("cleaning up")
} }
func (srv *Server) cleanShutdown() { // cleanShutdown stops the HTTP server, waiting up to
srv.exitCode = 0 // shutdownTimeout for the requests in progress to finish.
func (srv *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout( ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), shutdownTimeout, ctx, shutdownTimeout,
) )
err := srv.httpServer.Shutdown(ctxShutdown) err := srv.httpServer.Shutdown(ctxShutdown)
@@ -191,11 +162,23 @@ func (srv *Server) configure() {
// Server configuration placeholder. // Server configuration placeholder.
} }
func (srv *Server) serveUntilShutdown() { // startHTTP binds the HTTP listen address, so that a port
// that is taken fails startup, then serves the HTTP server
// from a new goroutine. srv.httpServer is set before that
// goroutine starts and never changes, so it and cleanShutdown
// read it without a lock.
func (srv *Server) startHTTP(ctx context.Context) error {
listenAddr := fmt.Sprintf( listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port, ":%d", srv.params.Config.Port,
) )
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(ctx, "tcp", listenAddr)
if err != nil {
return fmt.Errorf("http listen: %w", err)
}
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: httpReadTimeout, ReadTimeout: httpReadTimeout,
@@ -204,16 +187,30 @@ func (srv *Server) serveUntilShutdown() {
Handler: srv, Handler: srv,
} }
go func() {
srv.serveUntilShutdown(listener)
}()
return nil
}
// serveUntilShutdown serves the HTTP server on listener until
// cleanShutdown stops it. If serving fails before then, it
// stops the app with exit code 1.
func (srv *Server) serveUntilShutdown(listener net.Listener) {
srv.log.Info( srv.log.Info(
"http begin listen", "listenaddr", listenAddr, "http begin listen", "listenaddr", srv.httpServer.Addr,
) )
err := srv.httpServer.ListenAndServe() err := srv.httpServer.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
srv.log.Error("listen error", "error", err) return
}
if srv.cancelFunc != nil { srv.log.Error("listen error", "error", err)
srv.cancelFunc()
} err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1))
if err != nil {
srv.log.Error("shutdown failed", "error", err)
} }
} }
+358
View File
@@ -0,0 +1,358 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package server_test
import (
"crypto/rand"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
const (
testTimeout = 5 * time.Second
// checkStep is how often waitUntil checks again.
checkStep = 10 * time.Millisecond
// stopHoldTime is how long a test waits to see that the
// app has not stopped.
stopHoldTime = 200 * time.Millisecond
)
// TestStopWaitsForRequestInProgress checks that stopping the
// app, which runs the stop hook server.New registers, waits
// for a long poll the HTTP server is serving to finish, and
// that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) {
port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "",
)
if err != nil {
t.Fatalf("create session: %v", err)
}
waitUntil(t, "the HTTP server to listen", func() bool {
return canConnect(t, addr)
})
statuses := make(chan int, 1)
go func() {
statuses <- getAuthenticated(
t, "http://"+addr+"/api/v1/messages?timeout=30", token,
)
}()
waitUntil(t, "the long poll to wait for a message", func() bool {
return brk.ListenerCount(sessionID) == 1
})
var stopErr error
stopped := make(chan struct{})
go func() {
stopErr = app.Stop(t.Context())
close(stopped)
}()
select {
case <-stopped:
t.Error("the app stopped while a request was in progress")
case <-time.After(stopHoldTime):
}
brk.Notify(sessionID)
status := <-statuses
if status != http.StatusOK {
t.Errorf("status %d, want %d", status, http.StatusOK)
}
select {
case <-stopped:
if stopErr != nil {
t.Errorf("stop: %v", stopErr)
}
case <-time.After(testTimeout):
t.Fatal("the app did not stop after the request finished")
}
}
// TestStartFailsWhenHTTPPortIsTaken checks that starting the
// app, which runs the start hook server.New registers,
// returns an error naming the HTTP listen address when
// something else is already listening on its port.
func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
var listenConfig net.ListenConfig
taken, err := listenConfig.Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = taken.Close() })
tcpAddr, ok := taken.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", taken.Addr())
}
app, _, _ := newApp(t, tcpAddr.Port)
err = app.Start(t.Context())
if err == nil {
app.RequireStop()
t.Fatal("the app started while its HTTP port was taken")
}
listenAddr := fmt.Sprintf(":%d", tcpAddr.Port)
if !strings.Contains(err.Error(), listenAddr) {
t.Errorf("start error %q does not name %s", err, listenAddr)
}
}
// TestServeFailureStopsApp checks that when serving HTTP fails
// after the start hook has bound the listen address, the
// server stops the app with exit code 1.
func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
if shutdown.ExitCode != 1 {
t.Errorf("exit code %d, want 1", shutdown.ExitCode)
}
default:
t.Error("the server did not stop the app")
}
}
// TestServeAfterStopDoesNotStopApp checks that serving HTTP
// after the app has stopped, which ends at once because the
// stop hook has shut the HTTP server down, does not stop the
// app. Serving ends the same way on every normal stop.
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
app.RequireStop()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
t.Errorf(
"the server stopped the app with exit code %d",
shutdown.ExitCode,
)
default:
}
}
// startApp starts the app newApp builds, with the HTTP server
// listening on port.
func startApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
app, brk, database := newApp(t, port, options...)
app.RequireStart()
// Stops the app if the test ends before stopping it.
t.Cleanup(app.RequireStop)
return app, brk, database
}
// newApp builds, without starting it, an app that runs the
// server with the dependencies cmd/neoircd gives it, on a
// fresh in-memory database, with the HTTP port set to port.
// options are added to the app, for a test that needs more
// from it than the broker and the database.
func newApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
var (
brk *broker.Broker
database *db.Database
)
app := fxtest.New(t,
fx.Supply(&globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}),
fx.Provide(
broker.New,
config.New,
db.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
server.New,
service.New,
stats.New,
),
fx.Decorate(func(cfg *config.Config) *config.Config {
// 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
return cfg
}),
fx.Invoke(func(*server.Server) {}),
fx.Populate(&brk, &database),
fx.Options(options...),
)
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
// closed, so that accepting a connection on it fails.
func closedListener(t *testing.T) net.Listener {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(
t.Context(), "tcp", "127.0.0.1:0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
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,
// and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for !done() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(checkStep)
}
}
// getAuthenticated sends a GET request to url as the session
// whose auth token is token, and returns the response status,
// or 0 if there was no response.
func getAuthenticated(t *testing.T, url, token string) int {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), http.MethodGet, url, nil,
)
if err != nil {
t.Errorf("new request: %v", err)
return 0
}
request.Header.Set("Cookie", "neoirc_auth="+token)
client := http.Client{Timeout: testTimeout}
response, err := client.Do(request)
if err != nil {
t.Errorf("get %s: %v", url, err)
return 0
}
_ = response.Body.Close()
return response.StatusCode
}
+109 -118
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, nil, body, nil, recipients, 0,
) )
if err != nil { if err != nil {
return fmt.Errorf("wallops: %w", err) return fmt.Errorf("wallops: %w", err)
@@ -347,12 +347,19 @@ 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,
@@ -363,66 +370,40 @@ func (s *Service) FanOut(
} }
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID) _ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID)
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 // SendChannelMessage checks membership, bans and
// session. // moderation, then the hashcash stamp of a PRIVMSG to a +H
func excludeSession( // channel, then fans out a message to all channel members,
ids []int64, // the sender's session included, skipping client
exclude int64, // skipClientID as FanOut does. The stamp is checked after
) []int64 { // the other checks so that a message they refuse does not
out := make([]int64, 0, len(ids)) // spend it. Returns the message UUID and any error.
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
// SendChannelMessage validates the hashcash stamp of a
// PRIVMSG to a +H channel, membership and moderation, then
// fans out a message to all channel members except the
// sender. Returns the database row ID, message UUID, and
// any error. The dbID lets callers enqueue the same
// message to the sender when echo is needed (HTTP
// transport).
func (s *Service) SendChannelMessage( func (s *Service) SendChannelMessage(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID, skipClientID int64,
nick, command, channel string, nick, command, channel string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (int64, string, error) { ) (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 0, "", noSuchChannel return "", noSuchChannel
}
if command == irc.CmdPrivmsg {
err = s.checkChannelHashcash(
ctx, chID, channel, body, meta,
)
if err != nil {
return 0, "", err
}
} }
isMember, _ := s.db.IsChannelMember( isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if !isMember { if !isMember {
return 0, "", &IRCError{ return "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel", "Cannot send to channel",
@@ -434,7 +415,7 @@ func (s *Service) SendChannelMessage(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if banErr == nil && isBanned { if banErr == nil && isBanned {
return 0, "", &IRCError{ return "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+b)", "Cannot send to channel (+b)",
@@ -451,7 +432,7 @@ func (s *Service) SendChannelMessage(
) )
if !isOp && !isVoiced { if !isOp && !isVoiced {
return 0, "", &IRCError{ return "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+m)", "Cannot send to channel (+m)",
@@ -459,31 +440,40 @@ func (s *Service) SendChannelMessage(
} }
} }
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) if command == irc.CmdPrivmsg {
recipients := excludeSession(memberIDs, sessionID) err = s.checkChannelHashcash(
ctx, chID, channel, body, meta,
)
if err != nil {
return "", err
}
}
dbID, uuid, fanErr := s.FanOut( memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
_, uuid, fanErr := s.FanOut(
ctx, command, nick, channel, 0, chID, ctx, command, nick, channel, 0, chID,
nil, body, meta, recipients, nil, body, meta, memberIDs, skipClientID,
) )
// 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 0, "", noSuchChannel return "", noSuchChannel
} }
if fanErr != nil { if fanErr != nil {
return 0, "", fanErr return "", fanErr
} }
return dbID, uuid, nil return uuid, nil
} }
// SendDirectMessage validates the target and sends a // SendDirectMessage validates the target and sends a
// direct message, returning the message UUID and any away // direct message to the target and the sender's session,
// message set on the target. // skipping client skipClientID as FanOut does, and returns
// 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 int64, sessionID, skipClientID int64,
nick, command, target string, nick, command, target string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (*DirectMsgResult, error) { ) (*DirectMsgResult, error) {
@@ -505,7 +495,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, nil, body, meta, recipients, skipClientID,
) )
// 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) {
@@ -521,11 +511,12 @@ 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. A channel name that validChannelRe // channel members, skipping client skipClientID as FanOut
// does not match gets ERR_NOSUCHCHANNEL. // does. A channel name that validChannelRe does not match
// gets ERR_NOSUCHCHANNEL.
func (s *Service) JoinChannel( func (s *Service) JoinChannel(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID, skipClientID int64,
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) (*JoinResult, error) { ) (*JoinResult, error) {
if !validChannelRe.MatchString(channel) { if !validChannelRe.MatchString(channel) {
@@ -548,7 +539,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, nil, body, nil, memberIDs, skipClientID,
) )
return &JoinResult{ return &JoinResult{
@@ -557,12 +548,12 @@ func (s *Service) JoinChannel(
}, nil }, nil
} }
// PartChannel validates membership, broadcasts PART to // PartChannel validates membership, broadcasts PART to all
// remaining members, removes the user, and cleans up empty // channel members, skipping client skipClientID as FanOut
// channels. // does, removes the user, and cleans up empty channels.
func (s *Service) PartChannel( func (s *Service) PartChannel(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID, skipClientID 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)
@@ -586,12 +577,11 @@ 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, recipients, nil, body, nil, memberIDs, skipClientID,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec
@@ -600,10 +590,11 @@ 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. // topic, and broadcasts the change to all channel members,
// skipping client skipClientID as FanOut does.
func (s *Service) SetTopic( func (s *Service) SetTopic(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID, skipClientID 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)
@@ -652,18 +643,19 @@ 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, nil, body, nil, memberIDs, skipClientID,
) )
return nil return nil
} }
// KickUser validates operator status and target // KickUser validates operator status and target
// membership, broadcasts the KICK, removes the target, // membership, broadcasts the KICK to all channel members,
// and cleans up empty channels. // skipping client skipClientID as FanOut does, removes the
// target, and cleans up empty channels.
func (s *Service) KickUser( func (s *Service) KickUser(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID, skipClientID 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)
@@ -716,7 +708,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, params, body, nil, memberIDs, skipClientID,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec
@@ -797,10 +789,11 @@ func (s *Service) InviteUser(
} }
// ChangeNick changes a user's nickname and broadcasts the // ChangeNick changes a user's nickname and broadcasts the
// change to all users sharing channels. // change to the user's own session and all users sharing
// channels, skipping client skipClientID as FanOut does.
func (s *Service) ChangeNick( func (s *Service) ChangeNick(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID, skipClientID int64,
oldNick, newNick string, oldNick, newNick string,
) error { ) error {
err := s.db.ChangeNick(ctx, sessionID, newNick) err := s.db.ChangeNick(ctx, sessionID, newNick)
@@ -821,66 +814,62 @@ func (s *Service) ChangeNick(
} }
} }
s.broadcastNickChange(ctx, sessionID, oldNick, newNick) s.broadcastNickChange(
ctx, sessionID, skipClientID, oldNick, newNick,
)
return nil return nil
} }
// BroadcastQuit broadcasts a QUIT to all channel peers, // BroadcastQuit parts the session from all its channels,
// parts all channels, and deletes the session. Uses the // deletes it, and queues a QUIT to the users who shared a
// FanOut pattern: one message row fanned out to all unique // channel with it, in one transaction, so that the QUIT is
// peer sessions. // queued before anyone else can take the nick, and then
// wakes those users' long polls. Of several calls for one
// session at once, only the one that deletes it sends the
// QUIT.
func (s *Service) BroadcastQuit( func (s *Service) BroadcastQuit(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick, reason string, nick, reason string,
) { ) {
channels, err := s.db.GetSessionChannels( body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
ctx, sessionID,
) peerIDs, err := s.db.QuitSession(ctx, sessionID, nick, body)
if err != nil { if err != nil {
s.log.Error("quit failed", "error", err)
return return
} }
notified := make(map[int64]bool) for _, peerID := range peerIDs {
s.broker.Notify(peerID)
}
}
for _, ch := range channels { // Logout deletes a client of the session and, if that was
memberIDs, memErr := s.db.GetChannelMemberIDs( // the session's last client, does what BroadcastQuit does.
ctx, ch.ID, // If that fails, nothing changes and Logout returns the
) // error.
if memErr != nil { func (s *Service) Logout(
continue ctx context.Context,
} sessionID, clientID int64,
nick string,
) error {
body, _ := json.Marshal([]string{"Connection closed"}) //nolint:errchkjson
for _, mid := range memberIDs { peerIDs, err := s.db.LogoutClient(
if mid == sessionID || notified[mid] { ctx, sessionID, clientID, nick, body,
continue )
} if err != nil {
return fmt.Errorf("logout: %w", err)
notified[mid] = true
}
} }
if len(notified) > 0 { for _, peerID := range peerIDs {
recipients := make([]int64, 0, len(notified)) s.broker.Notify(peerID)
for sid := range notified {
recipients = append(recipients, sid)
}
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut(
ctx, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil, recipients,
)
} }
for _, ch := range channels { return nil
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
}
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
} }
// SetAway sets or clears the away message. Returns true // SetAway sets or clears the away message. Returns true
@@ -1047,9 +1036,10 @@ func (s *Service) SetChannelFlag(
} }
// BroadcastMode fans out a MODE change to all channel // BroadcastMode fans out a MODE change to all channel
// members. // members, skipping client skipClientID as FanOut does.
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,
@@ -1059,7 +1049,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, nil, body, nil, memberIDs, skipClientID,
) )
} }
@@ -1311,11 +1301,12 @@ func (s *Service) checkChannelHashcash(
return nil return nil
} }
// broadcastNickChange notifies channel peers of a nick // broadcastNickChange notifies the user's own session,
// 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 int64, sessionID, skipClientID int64,
oldNick, newNick string, oldNick, newNick string,
) { ) {
channels, err := s.db.GetSessionChannels( channels, err := s.db.GetSessionChannels(
@@ -1337,7 +1328,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) _ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID)
s.broker.Notify(sessionID) s.broker.Notify(sessionID)
notified[sessionID] = true notified[sessionID] = true
@@ -1356,7 +1347,7 @@ func (s *Service) broadcastNickChange(
notified[mid] = true notified[mid] = true
_ = s.db.EnqueueToSession(ctx, mid, dbID) _ = s.db.EnqueueToSession(ctx, mid, dbID, 0)
s.broker.Notify(mid) s.broker.Notify(mid)
} }
} }
+326 -45
View File
@@ -24,6 +24,7 @@ import (
"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/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
@@ -167,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}, []int64{sid1, sid2}, 0,
) )
if err != nil { if err != nil {
t.Fatalf("FanOut: %v", err) t.Fatalf("FanOut: %v", err)
@@ -189,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, "alice", "#general", "", ctx, sid, 0, "alice", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel: %v", err) t.Fatalf("JoinChannel: %v", err)
@@ -207,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, "bob", "#general", "", ctx, sid2, 0, "bob", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel bob: %v", err) t.Fatalf("JoinChannel bob: %v", err)
@@ -229,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, "alice", "#general", "", ctx, sid, 0, "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, "alice", "#general", "bye", ctx, sid, 0, "alice", "#general", "bye",
) )
if err != nil { if err != nil {
t.Fatalf("PartChannel: %v", err) t.Fatalf("PartChannel: %v", err)
@@ -244,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, "alice", "#nonexistent", "", ctx, sid, 0, "alice", "#nonexistent", "",
) )
if err == nil { if err == nil {
t.Error("expected error for non-existent channel") t.Error("expected error for non-existent channel")
@@ -308,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, nick, channel, "", ctx, sessionID, 0, nick, channel, "",
) )
results[i] = result results[i] = result
@@ -334,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, opNick, channel, "", ctx, opSID, 0, opNick, channel, "",
) )
if err != nil { if err != nil {
t.Fatalf("join %s: %v", opNick, err) t.Fatalf("join %s: %v", opNick, err)
@@ -447,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, leaverNick, channel, "", ctx, leaverSID, 0, leaverNick, channel, "",
) )
if err != nil { if err != nil {
t.Fatalf("join %s: %v", leaverNick, err) t.Fatalf("join %s: %v", leaverNick, err)
@@ -456,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, leaverNick, channel, "", ctx, leaverSID, 0, leaverNick, channel, "",
) )
}, },
func() error { func() error {
_, joinErr := env.svc.JoinChannel( _, joinErr := env.svc.JoinChannel(
ctx, joinerSID, joinerNick, channel, "", ctx, joinerSID, 0, joinerNick, channel, "",
) )
return joinErr return joinErr
@@ -495,6 +496,208 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
} }
} }
// TestJoinConcurrentWithLastQuit checks that a user who
// sends JOIN without the key to a +k channel, again after
// each ERR_BADCHANNELKEY, while the channel's only member
// quits, gets in only once the channel is deleted, into a
// new channel of that name with no key.
func TestJoinConcurrentWithLastQuit(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
channel := fmt.Sprintf("#keyed%d", trial)
leaverNick := fmt.Sprintf("leaver%d", trial)
leaverSID := createSession(ctx, t, env.db, leaverNick)
joinerNick := fmt.Sprintf("joiner%d", trial)
joinerSID := createSession(ctx, t, env.db, joinerNick)
created, err := env.svc.JoinChannel(
ctx, leaverSID, 0, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
}
err = env.db.SetChannelKey(ctx, created.ChannelID, "secret")
if err != nil {
t.Fatalf("set +k: %v", err)
}
var joined *service.JoinResult
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, leaverSID, leaverNick, "")
return nil
},
// The database has one connection, so retried
// JOINs take turns with the QUIT's statements
// and land between any two that are not in one
// transaction. A JOIN after the QUIT gets in, so
// the loop ends.
func() error {
for {
var (
joinErr error
ircErr *service.IRCError
)
joined, joinErr = env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "",
)
if !errors.As(joinErr, &ircErr) ||
ircErr.Code != irc.ErrBadChannelKey {
return joinErr
}
}
},
)
if errs[1] != nil {
t.Fatalf("trial %d: JOIN: %v", trial, errs[1])
}
key, _ := env.db.GetChannelKey(ctx, joined.ChannelID)
if key != "" {
t.Errorf(
"trial %d: %s got into +k %s without the key",
trial, joinerNick, channel,
)
}
}
}
// quitCrowdSize is how many sessions share a second channel
// with the quitting session in
// TestQuitQueuedAheadOfNewNickHoldersJoin.
const quitCrowdSize = 10
// TestQuitQueuedAheadOfNewNickHoldersJoin checks that when
// a new session takes a quitting session's nick and joins a
// channel the quitting session shared with a watcher, while
// the QUIT is going out, the watcher has the QUIT queued
// ahead of that JOIN. The quitting session is also on a
// channel with quitCrowdSize other sessions, made before the
// watcher's channel, so that its QUIT reaches the watcher
// last.
func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
nick := fmt.Sprintf("reused%d", trial)
crowd := fmt.Sprintf("#crowd%d", trial)
shared := fmt.Sprintf("#shared%d", trial)
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
}
}
for member := range quitCrowdSize {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "")
if err != nil {
t.Fatalf("join %s: %v", memberNick, err)
}
}
watcherNick := fmt.Sprintf("watcher%d", trial)
watcherSID, watcherClientID, _, err := env.db.CreateSession(
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("trial %d: new session: %v", trial, errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"trial %d: watcher got %v from %s, want QUIT then JOIN",
trial, commands, nick,
)
}
}
}
// takeNickAndJoin creates a session with nick, trying again
// while another session holds the nick, and joins it to
// channel.
func takeNickAndJoin(
ctx context.Context,
env *testEnv,
nick, channel string,
) error {
for {
sessionID, _, _, err := env.db.CreateSession(
ctx, nick, nick, "localhost", "127.0.0.1",
)
if db.IsUniqueConstraintError(err) {
continue
}
if err != nil {
return err
}
_, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "")
return err
}
}
// commandsFrom returns the commands of the messages in msgs
// that nick sent, in order.
func commandsFrom(msgs []db.IRCMessage, nick string) []string {
commands := make([]string, 0, len(msgs))
for _, msg := range msgs {
if msg.From == nick {
commands = append(commands, msg.Command)
}
}
return commands
}
// TestJoinUsesUpInvite checks that the JOIN an invite let // TestJoinUsesUpInvite checks that the JOIN an invite let
// into a +i channel uses the invite up, so that a JOIN // into a +i channel uses the invite up, so that a JOIN
// after leaving gets ERR_INVITEONLYCHAN. // after leaving gets ERR_INVITEONLYCHAN.
@@ -507,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, "op", channel, "") created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "")
if err != nil { if err != nil {
t.Fatalf("join op: %v", err) t.Fatalf("join op: %v", err)
} }
@@ -522,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, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, 0, "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, "guest", channel, "") err = env.svc.PartChannel(ctx, guestSID, 0, "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, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, 0, "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 {
@@ -551,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, "op", channel, "") created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "")
if err != nil { if err != nil {
t.Fatalf("join op: %v", err) t.Fatalf("join op: %v", err)
} }
@@ -570,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, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, 0, "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 {
@@ -590,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, "alice", channel, "") _, err := env.svc.JoinChannel(ctx, aliceSID, 0, "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, "bob", channel, "") _, err = env.svc.JoinChannel(ctx, bobSID, 0, "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, "alice", channel, "") err = env.svc.PartChannel(ctx, aliceSID, 0, "alice", channel, "")
if err != nil { if err != nil {
t.Fatalf("part alice: %v", err) t.Fatalf("part alice: %v", err)
} }
@@ -615,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, "bob", channel, "") err = env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "")
if err != nil { if err != nil {
t.Fatalf("part bob: %v", err) t.Fatalf("part bob: %v", err)
} }
@@ -636,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, "alice", channel, "") _, err := env.svc.JoinChannel(ctx, sid, 0, "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, "alice", channel, "alice", "") err = env.svc.KickUser(ctx, sid, 0, "alice", channel, "alice", "")
if err != nil { if err != nil {
t.Fatalf("kick alice: %v", err) t.Fatalf("kick alice: %v", err)
} }
@@ -660,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, "alice", "#chat", "", ctx, sid1, 0, "alice", "#chat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#chat", "", ctx, sid2, 0, "bob", "#chat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -675,18 +878,14 @@ func TestSendChannelMessage(t *testing.T) {
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
dbID, uuid, err := env.svc.SendChannelMessage( uuid, err := env.svc.SendChannelMessage(
ctx, sid1, "alice", ctx, sid1, 0, "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")
} }
@@ -694,8 +893,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, "charlie", ctx, sid3, 0, "charlie",
irc.CmdPrivmsg, "#chat", body, nil, irc.CmdPrivmsg, "#chat", body, nil,
) )
if err == nil { if err == nil {
@@ -711,14 +910,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#room", "", ctx, sid1, 0, "alice", "#room", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#room", "", ctx, sid2, 0, "bob", "#room", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -760,7 +959,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, "alice", irc.CmdPrivmsg, "bob", body, nil, ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil,
) )
var ircErr *service.IRCError var ircErr *service.IRCError
@@ -780,7 +979,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, "alice", "#gone", "") _, err := env.svc.JoinChannel(ctx, sid, 0, "alice", "#gone", "")
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
@@ -797,8 +996,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, "alice", irc.CmdPrivmsg, "#gone", body, nil, ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil,
) )
var ircErr *service.IRCError var ircErr *service.IRCError
@@ -816,14 +1015,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel( result, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#modchat", "", ctx, sid1, 0, "alice", "#modchat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#modchat", "", ctx, sid2, 0, "bob", "#modchat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -836,8 +1035,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, "bob", ctx, sid2, 0, "bob",
irc.CmdPrivmsg, "#modchat", body, nil, irc.CmdPrivmsg, "#modchat", body, nil,
) )
if err == nil { if err == nil {
@@ -845,8 +1044,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, "alice", ctx, sid1, 0, "alice",
irc.CmdPrivmsg, "#modchat", body, nil, irc.CmdPrivmsg, "#modchat", body, nil,
) )
if err != nil { if err != nil {
@@ -854,6 +1053,88 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
} }
} }
// TestRefusedChannelMessageLeavesStampUnspent checks that a
// PRIVMSG to a +H channel refused because the sender is not
// a member, is banned, or has no voice on a +m channel does
// not spend its hashcash stamp: the operator can then send
// the same message with it.
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
const (
channel = "#paid"
bits = 2
)
refusals := []struct {
name string
// refuse stops bob from sending to the channel.
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
}{
{"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error {
return env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "")
}},
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
}},
{"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.svc.SetChannelFlag(ctx, chID, 'm', true)
}},
}
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
for _, refusal := range refusals {
t.Run(refusal.name, func(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob")
joined, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "")
if err != nil {
t.Fatalf("join bob: %v", err)
}
err = env.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits)
if err != nil {
t.Fatalf("set +H: %v", err)
}
err = refusal.refuse(ctx, env, joined.ChannelID, bobSID)
if err != nil {
t.Fatalf("refuse bob: %v", err)
}
stamp := hashcash.MintChannelStamp(
bits, channel, hashcash.BodyHash(body),
)
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
_, err = env.svc.SendChannelMessage(
ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta,
)
var ircErr *service.IRCError
if !errors.As(err, &ircErr) ||
ircErr.Code != irc.ErrCannotSendToChan {
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
}
_, err = env.svc.SendChannelMessage(
ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta,
)
if err != nil {
t.Fatalf("alice with the same stamp: %v", err)
}
})
}
}
func TestQueryUserMode(t *testing.T) { func TestQueryUserMode(t *testing.T) {
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
+114 -63
View File
@@ -58,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) => {
@@ -122,12 +122,11 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name; if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
const saved = localStorage.getItem("neoirc_token"); // Resume the session if the browser still holds a valid auth
if (saved) { // cookie; a 401 leaves the login form up.
api("/state?initChannelState=1") api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token")); .catch(() => {});
}
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -152,7 +151,6 @@ 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");
@@ -227,10 +225,9 @@ function UserList({ members, onNickClick }) {
const regular = []; const regular = [];
for (const m of members) { for (const m of members) {
const mode = m.mode || ""; if (m.isOperator) {
if (mode === "o") {
ops.push(m); ops.push(m);
} else if (mode === "v") { } else if (m.isVoiced) {
voiced.push(m); voiced.push(m);
} else { } else {
regular.push(m); regular.push(m);
@@ -303,11 +300,14 @@ function App() {
nickRef.current = nick; nickRef.current = nick;
}, [nick]); }, [nick]);
// Persist joined channels. // Persist joined channels, once logged in: before then only the
// 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));
}, [tabs]); }, [loggedIn, tabs]);
// Clear unread on tab switch. // Clear unread on tab switch.
useEffect(() => { useEffect(() => {
@@ -366,7 +366,9 @@ function App() {
command: msg.command, command: msg.command,
}; };
switch (msg.command) { // A numeric reply carries its number in `code` and its name in
// `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;
@@ -464,8 +466,18 @@ function App() {
break; break;
} }
case "353": { case "MODE": {
// RPL_NAMREPLY — parse mode prefixes from names list. // The body is the change, with its value where it has one:
// ["+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 &&
@@ -477,21 +489,18 @@ 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) => {
if (n.startsWith("@")) { const isOperator = n.startsWith("@");
return { nick: n.slice(1), mode: "o" }; const isVoiced = n.startsWith("+");
} const hostmask = isOperator || isVoiced ? n.slice(1) : n;
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
return { nick: n, mode: "" }; return { nick: hostmask.split("!")[0], isOperator, isVoiced };
}); });
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];
@@ -503,7 +512,7 @@ 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];
@@ -518,7 +527,7 @@ function App() {
break; break;
} }
case "323": case 323:
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of channel list", text: body || "End of channel list",
@@ -526,7 +535,7 @@ function App() {
}); });
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];
@@ -541,7 +550,7 @@ function App() {
break; break;
} }
case "315": case 315:
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHO list", text: body || "End of /WHO list",
@@ -549,20 +558,20 @@ function App() {
}); });
break; break;
case "311": { case 311: {
// RPL_WHOISUSER — nick, user, host, *, realname. // RPL_WHOISUSER — params nick, user, host, *; body realname.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 3) {
const wiNick = msg.params[0]; const [wiNick, wiUser, wiHost] = msg.params;
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: `${wiNick} (${body})`, text: `${wiNick} is ${wiUser}@${wiHost} (${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];
@@ -576,7 +585,33 @@ function App() {
break; break;
} }
case "319": { case 313: {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage("Server", {
...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", {
...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];
@@ -589,7 +624,7 @@ function App() {
break; break;
} }
case "318": case 318:
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHOIS list", text: body || "End of /WHOIS list",
@@ -597,9 +632,21 @@ function App() {
}); });
break; break;
case "375": case 324: {
case "372": // RPL_CHANNELMODEIS — channel, mode string.
case "376": if (Array.isArray(msg.params) && msg.params.length >= 2) {
addMessage("Server", {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
});
}
break;
}
case 375:
case 372:
case 376:
addMessage("Server", { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
break; break;
@@ -637,6 +684,14 @@ 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));
} }
@@ -716,6 +771,11 @@ 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", `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
@@ -740,9 +800,6 @@ 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 {
await api("/messages", { await api("/messages", {
@@ -972,27 +1029,22 @@ function App() {
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);
if (modeArgs.length > 0) { const body =
try { modeArgs.length > 0
await api("/messages", { ? { command: "MODE", to: tab.name, body: modeArgs }
method: "POST", : { command: "MODE", to: tab.name };
body: JSON.stringify({ try {
command: "MODE", await api("/messages", {
to: tab.name, method: "POST",
params: modeArgs, body: JSON.stringify(body),
}), });
}); } catch (err) {
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage( addSystemMessage(
"Server", "Server",
"Usage: /mode <+/-mode> [params]", `MODE failed: ${err.data?.error || "error"}`,
); );
} }
@@ -1009,7 +1061,6 @@ 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();
@@ -1128,7 +1179,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 +/-flags — Set channel modes", " /mode [+/-flag [arg]] — View or 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",