8 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
19 changed files with 1331 additions and 336 deletions
+17 -12
View File
@@ -268,10 +268,15 @@ rules). Case-sensitive comparison is unambiguous.
A single user session can have multiple clients (phone, laptop, terminal).
- 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
DMs and the channel messages it sends over the HTTP API, to every client queue
for that user session. Replies to a command (numerics) go only to the client
that sent it.
- The server fans out messages and events from other users to every client queue
for that user session.
- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`,
`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,
identified by the auth cookie.
- Client queues have **independent expiry/pruning** — one client going offline
@@ -744,7 +749,7 @@ Leave a channel.
{"command": "PART", "to": "#general", "body": ["goodbye"]}
```
**S2C (broadcast to the other channel members):**
**S2C (broadcast to all channel members, including the departing user):**
```json
{
@@ -760,9 +765,8 @@ Leave a channel.
**Behavior:**
- The PART event goes to the other channel members, not to the departing user;
the HTTP response confirms the part. (The IRC listener echoes the PART to its
own connection.)
- The PART event goes to every channel member, the departing user's own clients
included. (An IRC listener connection that sends a PART echoes it itself.)
- If the channel is empty after the user leaves, the channel is **deleted**
(ephemeral channels).
- If the user is not in the channel, the server returns an error.
@@ -1019,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
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
missing is skipped. The changes made are echoed to the operator's connection and
also sent to every channel member, the operator included, as one `MODE` line,
e.g. `+o-v alice bob`.
missing is skipped. The changes made are sent to every channel member as one
`MODE` line, e.g. `+o-v alice bob`; the operator gets that line once.
**Channel errors:**
@@ -2720,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
API reaches IRC listener members as an IRC `MODE` line with its value as a
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
+7 -3
View File
@@ -757,7 +757,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp)
case irc.CmdPart:
a.handlePartEvent(msg, timestamp)
a.handlePartEvent(msg, timestamp, myNick)
case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp)
case irc.CmdNick:
@@ -813,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(
msg *api.Message, timestamp string,
msg *api.Message, timestamp, myNick string,
) {
if msg.To == "" {
if msg.To == "" || msg.From == myNick {
return
}
+65
View File
@@ -3,7 +3,9 @@ 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) {
@@ -49,3 +51,66 @@ func TestMemberPrefix(t *testing.T) {
})
}
}
// 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)
}
})
}
+30 -8
View File
@@ -264,6 +264,25 @@ func (database *Database) GetSessionByNick(
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
// for a session.
type SessionHostInfo struct {
@@ -1208,11 +1227,12 @@ func insertMessageInTx(
return dbID, msgUUID, nil
}
// EnqueueToSession adds a message to all clients of a
// session's queues.
// EnqueueToSession adds a message to the queues of all
// clients of a session except client skipClientID, or of all
// of them when skipClientID is 0.
func (database *Database) EnqueueToSession(
ctx context.Context,
sessionID, messageID int64,
sessionID, messageID, skipClientID int64,
) error {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
@@ -1221,7 +1241,9 @@ func (database *Database) EnqueueToSession(
defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID)
err = enqueueToSessionInTx(
ctx, transaction, sessionID, messageID, skipClientID,
)
if err != nil {
return err
}
@@ -1239,15 +1261,15 @@ func (database *Database) EnqueueToSession(
func enqueueToSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID, messageID int64,
sessionID, messageID, skipClientID int64,
) error {
_, err := transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues
(client_id, message_id, created_at)
SELECT c.id, ?, ?
FROM clients c
WHERE c.session_id = ?`,
messageID, time.Now(), sessionID)
WHERE c.session_id = ? AND c.id != ?`,
messageID, time.Now(), sessionID, skipClientID)
if err != nil {
return fmt.Errorf(
"enqueue to session: %w", err,
@@ -1673,7 +1695,7 @@ func quitSessionInTx(
for _, peerID := range peerIDs {
err = enqueueToSessionInTx(
ctx, transaction, peerID, messageID,
ctx, transaction, peerID, messageID, 0,
)
if err != nil {
return nil, err
+1 -1
View File
@@ -675,7 +675,7 @@ func TestPollMessages(t *testing.T) {
t.Fatal(err)
}
err = database.EnqueueToSession(ctx, sid, dbID)
err = database.EnqueueToSession(ctx, sid, dbID, 0)
if err != nil {
t.Fatal(err)
}
+30 -29
View File
@@ -813,7 +813,14 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
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,
afterID, pollMessageLimit,
)
@@ -841,19 +848,22 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll(
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(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID, afterID int64,
waitCh <-chan struct{},
clientID, afterID int64,
timeout int,
) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer(
time.Duration(timeout) * time.Second,
)
@@ -864,14 +874,10 @@ func (hdlr *Handlers) longPoll(
case <-waitCh:
case <-timer.C:
case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return
}
hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
msgs, lastQID, err := hdlr.pollMessages(
request.Context(), clientID,
afterID, pollMessageLimit,
)
@@ -1251,9 +1257,11 @@ func (hdlr *Handlers) handleChannelMsg(
) {
ctx := request.Context()
// Delegate validation + fan-out to service layer.
dbID, uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, nick,
// Delegate validation + fan-out to service layer. The
// fan-out includes the sender's own session, so all
// their clients see the message in long-poll responses.
uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, 0, nick,
command, target, body, meta,
)
if hdlr.handleServiceError(
@@ -1263,13 +1271,6 @@ func (hdlr *Handlers) handleChannelMsg(
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,
map[string]string{"id": uuid, statusKey: "sent"},
http.StatusOK)
@@ -1284,7 +1285,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage,
) {
result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, nick,
request.Context(), sessionID, 0, nick,
command, target, body, meta,
)
if hdlr.handleServiceError(
@@ -1355,7 +1356,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string,
) {
result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, nick,
request.Context(), sessionID, 0, nick,
channel, suppliedKey,
)
if hdlr.handleServiceError(
@@ -1539,7 +1540,7 @@ func (hdlr *Handlers) handlePart(
}
err := hdlr.svc.PartChannel(
request.Context(), sessionID,
request.Context(), sessionID, 0,
nick, channel, reason,
)
if hdlr.handleServiceError(
@@ -1610,7 +1611,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string,
) {
err := hdlr.svc.ChangeNick(
request.Context(), sessionID, nick, newNick,
request.Context(), sessionID, 0, nick, newNick,
)
if hdlr.handleServiceError(
writer, request,
@@ -1665,7 +1666,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic(
request.Context(), sessionID,
request.Context(), sessionID, 0,
nick, channel, topic,
)
if hdlr.handleServiceError(
@@ -2151,7 +2152,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode(
request.Context(), nick, channel, chID,
request.Context(), 0, nick, channel, chID,
modeText,
)
@@ -2197,7 +2198,7 @@ func (hdlr *Handlers) setChannelFlag(
}
hdlr.svc.BroadcastMode(
request.Context(), nick, channel, chID,
request.Context(), 0, nick, channel, chID,
modeStr,
)
@@ -3644,7 +3645,7 @@ func (hdlr *Handlers) handleKick(
}
err := hdlr.svc.KickUser(
request.Context(), sessionID, nick,
request.Context(), sessionID, 0, nick,
channel, targetNick, reason,
)
if hdlr.handleServiceError(
@@ -3717,7 +3718,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID,
request.Context(), sid, dbID, 0,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
+362 -1
View File
@@ -140,6 +140,7 @@ type testServer struct {
fxApp *fxtest.App
broker *broker.Broker
database *db.Database
handlers *handlers.Handlers
}
func newTestServer(
@@ -170,6 +171,7 @@ func newTestServerWith(
srv *server.Server
brk *broker.Broker
database *db.Database
hdlr *handlers.Handlers
)
app := fxtest.New(t,
@@ -210,7 +212,7 @@ func newTestServerWith(
newTestHandlers,
newTestServerFx,
),
fx.Populate(&srv, &brk, &database),
fx.Populate(&srv, &brk, &database, &hdlr),
)
app.RequireStart()
@@ -228,6 +230,7 @@ func newTestServerWith(
fxApp: app,
broker: brk,
database: database,
handlers: hdlr,
}
}
@@ -2554,6 +2557,364 @@ func TestLongPollTimeout(t *testing.T) {
}
}
// TestLongPollReturnsMessageQueuedAfterFirstRead checks that
// a message queued, and the broker notified, after the long
// poll's first read of the queue found nothing but before the
// poll waits, is returned at once and not when the poll times
// out, and that the poll then holds no broker wait channel.
func TestLongPollReturnsMessageQueuedAfterFirstRead(t *testing.T) {
const (
nick = "lp_late"
sender = "lp_late_sender"
// pollTimeout is the long poll's timeout, in seconds.
pollTimeout = 10
// atOnce is the longest the long poll may take to
// return the message.
atOnce = 5 * time.Second
)
tserver := newTestServer(t)
token := tserver.createSession(nick)
_, lastID := tserver.pollMessages(token, 0)
sessionID, err := tserver.database.GetSessionByNick(t.Context(), nick)
if err != nil {
t.Fatalf("get session of %s: %v", nick, err)
}
queued := make(chan error, 1)
var once sync.Once
tserver.handlers.SetPollMessages(func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error) {
msgs, lastQID, pollErr := tserver.database.PollMessages(
ctx, clientID, afterQueueID, limit,
)
once.Do(func() {
queued <- tserver.queueMessage(ctx, sessionID, sender, nick)
})
return msgs, lastQID, pollErr
})
start := time.Now()
msgs, err := tserver.longPoll(token, lastID, pollTimeout)
if err != nil {
t.Fatalf("long poll: %v", err)
}
elapsed := time.Since(start)
select {
case queueErr := <-queued:
if queueErr != nil {
t.Fatalf("queue message: %v", queueErr)
}
default:
t.Fatal("the long poll did not read the queue")
}
if elapsed > atOnce {
t.Fatalf("the long poll returned after %v, want within %v",
elapsed, atOnce)
}
if !findMessage(msgs, privmsgCmd, sender) {
t.Fatalf("long poll: got %v, want the PRIVMSG from %s",
msgs, sender)
}
tserver.checkWaitChannelRemoved(sessionID)
}
// errReadFailed is what a test's replacement for
// Database.PollMessages returns to make a read of the queue
// fail.
var errReadFailed = errors.New("read failed")
// TestGetMessagesRemovesWaitChannel checks that a request to
// GET /api/v1/messages leaves no broker wait channel behind,
// whether it is answered at once, after its timeout, or with
// an error because a read of the queue failed.
func TestGetMessagesRemovesWaitChannel(t *testing.T) {
const (
// longTimeout, in seconds, is the timeout of the
// requests that must be answered without waiting.
longTimeout = 10
// shortTimeout, in seconds, is the timeout of the
// requests that wait it out.
shortTimeout = 1
)
// queued is whether a message is queued before the request,
// whose timeout is in seconds. failingRead is as for
// getMessagesFailingRead. wantReads is how many times the
// handler reads the queue: once if it answers at once,
// twice if it waits first.
tests := []struct {
name string
queued bool
timeout, failingRead int
wantReads, wantStatus int
}{
{
name: "nothing queued, timeout 0", timeout: 0,
wantReads: 1, wantStatus: http.StatusOK,
},
{
name: "message queued", queued: true, timeout: longTimeout,
wantReads: 1, wantStatus: http.StatusOK,
},
{
name: "timed out", timeout: shortTimeout,
wantReads: 2, wantStatus: http.StatusOK,
},
{
name: "first read failed", timeout: longTimeout, failingRead: 1,
wantReads: 1, wantStatus: http.StatusInternalServerError,
},
{
name: "read after the wait failed", timeout: shortTimeout,
failingRead: 2, wantReads: 2,
wantStatus: http.StatusInternalServerError,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
const nick = "wc_poller"
tserver := newTestServer(t)
token := tserver.createSession(nick)
_, lastID := tserver.pollMessages(token, 0)
sessionID, err := tserver.database.GetSessionByNick(
t.Context(), nick,
)
if err != nil {
t.Fatalf("get session of %s: %v", nick, err)
}
if tc.queued {
err = tserver.queueMessage(
t.Context(), sessionID, "wc_sender", nick,
)
if err != nil {
t.Fatalf("queue message: %v", err)
}
}
status, reads := tserver.getMessagesFailingRead(
token, lastID, tc.timeout, tc.failingRead,
)
if status != tc.wantStatus {
t.Errorf("status %d, want %d", status, tc.wantStatus)
}
if reads != tc.wantReads {
t.Errorf("the handler read the queue %d times, want %d",
reads, tc.wantReads)
}
tserver.checkWaitChannelRemoved(sessionID)
})
}
}
// getMessagesFailingRead sends GET /api/v1/messages for the
// client token with timeout and after afterID. The handler's
// read of the queue numbered failingRead, counting from 1,
// fails; with 0, none does. It returns the response status
// and how many times the handler read the queue.
func (tserver *testServer) getMessagesFailingRead(
token string,
afterID int64,
timeout, failingRead int,
) (int, int) {
tserver.t.Helper()
reads := 0
tserver.handlers.SetPollMessages(func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error) {
reads++
if reads == failingRead {
return nil, 0, errReadFailed
}
return tserver.database.PollMessages(
ctx, clientID, afterQueueID, limit,
)
})
resp, err := doRequestAuth(
tserver.t, http.MethodGet,
tserver.url(fmt.Sprintf(
"%s?timeout=%d&after=%d", apiMessages, timeout, afterID,
)),
token, nil,
)
if err != nil {
tserver.t.Fatalf("get messages: %v", err)
}
_ = resp.Body.Close()
return resp.StatusCode, reads
}
// TestLongPollRemovesWaitChannelWhenClientGivesUp checks that
// a long poll whose client gives up while it waits leaves no
// broker wait channel behind.
func TestLongPollRemovesWaitChannelWhenClientGivesUp(t *testing.T) {
const (
nick = "wc_leaver"
// pollTimeout is the long poll's timeout, in seconds.
pollTimeout = 10
// readDeadline is how long the handler has to read
// the queue.
readDeadline = 5 * time.Second
)
tserver := newTestServer(t)
token := tserver.createSession(nick)
_, lastID := tserver.pollMessages(token, 0)
sessionID, err := tserver.database.GetSessionByNick(t.Context(), nick)
if err != nil {
t.Fatalf("get session of %s: %v", nick, err)
}
read := make(chan struct{})
var once sync.Once
tserver.handlers.SetPollMessages(func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error) {
msgs, lastQID, pollErr := tserver.database.PollMessages(
ctx, clientID, afterQueueID, limit,
)
once.Do(func() { close(read) })
return msgs, lastQID, pollErr
})
ctx, giveUp := context.WithCancel(t.Context())
defer giveUp()
request, err := http.NewRequestWithContext(
ctx, http.MethodGet,
tserver.url(fmt.Sprintf(
"%s?timeout=%d&after=%d", apiMessages, pollTimeout, lastID,
)),
nil,
)
if err != nil {
t.Fatalf("new request: %v", err)
}
request.AddCookie(&http.Cookie{ //nolint:exhaustruct,gosec // only name+value
Name: authCookieName,
Value: token,
})
done := make(chan struct{})
go func() {
defer close(done)
resp, doErr := http.DefaultClient.Do(request)
if doErr == nil {
_ = resp.Body.Close()
}
}()
select {
case <-read:
case <-time.After(readDeadline):
t.Fatal("the long poll did not read the queue")
}
giveUp()
<-done
tserver.checkWaitChannelRemoved(sessionID)
}
// checkWaitChannelRemoved fails the test unless the broker
// holds no wait channel for the session sessionID. A client
// can have its response before the handler has returned, and
// a handler notices a client that gives up only later, so it
// waits up to removeDeadline for that.
func (tserver *testServer) checkWaitChannelRemoved(sessionID int64) {
tserver.t.Helper()
const (
removeDeadline = 5 * time.Second
checkStep = 10 * time.Millisecond
)
deadline := time.Now().Add(removeDeadline)
for tserver.broker.ListenerCount(sessionID) != 0 {
if time.Now().After(deadline) {
tserver.t.Fatalf("got %d broker wait channels, want 0",
tserver.broker.ListenerCount(sessionID))
}
time.Sleep(checkStep)
}
}
// queueMessage stores a PRIVMSG from sender to nick, queues
// it to the session sessionID and notifies the broker, as the
// server does for a DM. It returns errors instead of failing
// the test, so a handler's goroutine can call it.
func (tserver *testServer) queueMessage(
ctx context.Context,
sessionID int64,
sender, nick string,
) error {
msgID, _, err := tserver.database.InsertMessage(
ctx, privmsgCmd, sender, nick, sessionID, 0,
nil, json.RawMessage(`["hello"]`), nil,
)
if err != nil {
return fmt.Errorf("insert message: %w", err)
}
err = tserver.database.EnqueueToSession(ctx, sessionID, msgID, 0)
if err != nil {
return fmt.Errorf("enqueue: %w", err)
}
tserver.broker.Notify(sessionID)
return nil
}
func TestEphemeralChannelCleanup(t *testing.T) {
const channel = "#ephemeral"
+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
stats *stats.Tracker
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.
@@ -95,6 +105,7 @@ func New(
hashcashVal: hashcash.NewValidator(resource),
loginLimiter: ratelimit.New(loginRate, loginBurst),
stats: params.Stats,
pollMessages: params.Database.PollMessages,
}
lifecycle.Append(fx.Hook{
+21 -21
View File
@@ -81,10 +81,10 @@ func (c *Conn) handleNick(
}
oldMask := c.hostmask()
oldNick := c.nick
oldNick := c.currentNick()
err := c.svc.ChangeNick(
ctx, c.sessionID, oldNick, newNick,
ctx, c.sessionID, c.clientID, oldNick, newNick,
)
if err != nil {
c.sendIRCError(err)
@@ -92,9 +92,10 @@ func (c *Conn) handleNick(
return
}
c.mu.Lock()
c.nick = newNick
c.mu.Unlock()
// Take the nick the session holds, not newNick: another
// client of the session may have changed it again since
// ChangeNick stored newNick.
c.loadSessionNick(ctx)
// Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -128,8 +129,8 @@ func (c *Conn) handlePrivmsg(
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") {
_, _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.nick,
_, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.clientID, c.currentNick(),
msg.Command, target, body, nil,
)
if err != nil {
@@ -137,7 +138,7 @@ func (c *Conn) handlePrivmsg(
}
} else {
result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.nick,
ctx, c.sessionID, c.clientID, c.currentNick(),
msg.Command, target, body, nil,
)
if err != nil {
@@ -199,7 +200,7 @@ func (c *Conn) joinChannel(
channel, key string,
) {
result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.nick, channel, key,
ctx, c.sessionID, c.clientID, c.currentNick(), channel, key,
)
if err != nil {
c.sendIRCError(err)
@@ -337,7 +338,7 @@ func (c *Conn) partChannel(
channel, reason string,
) {
err := c.svc.PartChannel(
ctx, c.sessionID, c.nick, channel, reason,
ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason,
)
if err != nil {
c.sendIRCError(err)
@@ -401,7 +402,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1]
err := c.svc.SetTopic(
ctx, c.sessionID, c.nick, channel, newTopic,
ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic,
)
if err != nil {
c.sendIRCError(err)
@@ -708,7 +709,7 @@ func (c *Conn) applyChannelModes(
c.send(FormatMessage(c.hostmask(), "MODE", params...))
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(
ctx, c.sessionID, c.nick,
ctx, c.sessionID, c.clientID, c.currentNick(),
channel, targetNick, reason,
)
if err != nil {
@@ -1225,7 +1226,7 @@ func (c *Conn) handlePassPostReg(
return
}
c.sendFromServer("NOTICE", c.nick,
c.sendFromServer("NOTICE", c.currentNick(),
"Password set. You can reconnect using "+
"PASS <password> with your nick.",
)
@@ -1249,7 +1250,7 @@ func (c *Conn) handleInvite(
channel := msg.Params[1]
targetID, err := c.svc.InviteUser(
ctx, c.sessionID, c.nick, targetNick, channel,
ctx, c.sessionID, c.currentNick(), targetNick, channel,
)
if err != nil {
c.sendIRCError(err)
@@ -1261,14 +1262,13 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel,
)
// Send INVITE notice to target via service fan-out.
body, _ := json.Marshal( //nolint:errchkjson
[]string{"You have been invited to " + channel},
)
// Queue the INVITE to the target with the body the HTTP
// API uses.
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.nick, targetNick, targetID, 0,
nil, body, nil, []int64{targetID},
ctx, "INVITE", c.currentNick(), targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, 0,
)
}
+22 -4
View File
@@ -313,6 +313,22 @@ func (c *Conn) currentNick() string {
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.
func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line)
@@ -345,7 +361,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType,
params ...string,
) {
nick := c.nick
nick := c.currentNick()
if nick == "" {
nick = "*"
}
@@ -369,9 +385,11 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask
// (nick!user@host).
func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username
if user == "" {
user = c.nick
user = nick
}
host := c.hostname
@@ -379,7 +397,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP
}
return c.nick + "!" + user + "@" + host
return nick + "!" + user + "@" + host
}
// handleMessage dispatches a parsed IRC message using
@@ -657,7 +675,7 @@ func (c *Conn) checkPasswordLength(pw string) bool {
return true
}
c.sendFromServer("NOTICE", c.nick,
c.sendFromServer("NOTICE", c.currentNick(),
"Password must be 8 to 72 bytes",
)
+5
View File
@@ -71,6 +71,11 @@ func (s *Server) Listener() net.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
// with nothing read before they are sent a PING, and after it
// before they are closed.
+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
// channel only to its members.
func TestIntegrationModeSecretList(t *testing.T) {
+27 -50
View File
@@ -27,17 +27,22 @@ func (c *Conn) relayMessages(ctx context.Context) {
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.
delivered := c.drainQueue(ctx)
if delivered {
c.brk.Remove(c.sessionID, waitCh)
// Tight loop while there are messages.
continue
}
// Wait for notification or timeout.
waitCh := c.brk.Wait(c.sessionID)
select {
case <-waitCh:
// New message notification — loop back.
@@ -87,7 +92,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
//
//nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage(
_ context.Context,
ctx context.Context,
msg *db.IRCMessage,
) {
command := msg.Command
@@ -115,7 +120,7 @@ func (c *Conn) deliverIRCMessage(
case command == irc.CmdPart:
c.deliverPart(msg, text)
case command == irc.CmdNick:
c.deliverNickChange(msg, text)
c.deliverNickChange(ctx, msg, text)
case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic:
@@ -193,15 +198,6 @@ func (c *Conn) deliverTextMessage(
from := msg.From
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
if !strings.Contains(prefix, "!") {
prefix = from + "!" + from + "@*"
@@ -214,16 +210,6 @@ func (c *Conn) deliverTextMessage(
// deliverJoin sends a JOIN notification.
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 + "@*"
channel := msg.To
@@ -232,14 +218,6 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification.
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 + "@*"
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(
ctx context.Context,
msg *db.IRCMessage,
newNick string,
) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
c.loadSessionNick(ctx)
prefix := msg.From + "!" + msg.From + "@*"
@@ -275,14 +252,6 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage,
text string,
) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*"
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(
_ *db.IRCMessage,
msg *db.IRCMessage,
text string,
) {
if text == "" {
return
}
c.mu.Lock()
nick := c.nick
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.
+440 -14
View File
@@ -1,6 +1,7 @@
package ircserver_test
import (
"context"
"encoding/json"
"slices"
"strings"
@@ -8,6 +9,7 @@ import (
"time"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
)
// 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)
}
err = env.database.EnqueueToSession(ctx, sessionID, msgID)
err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
@@ -47,14 +49,13 @@ func (env *testEnv) queueMessage(
env.brk.Notify(sessionID)
}
// TestRelayKeepsOneWaitChannel checks that an idle IRC
// listener connection holds at most one broker wait channel
// across several relay ticks, and none once it closes.
// TestRelayKeepsOneWaitChannel checks that an IRC listener
// connection holds at most one broker wait channel across
// 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) {
t.Parallel()
const idleTicks = 5
env := newTestEnv(t)
client := env.dial(t)
@@ -65,15 +66,17 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Fatalf("get session of idle: %v", err)
}
for tick := range idleTicks {
time.Sleep(ircserver.PollInterval)
env.checkOneWaitChannel(t, sessionID)
got := env.brk.ListenerCount(sessionID)
if got > 1 {
t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
}
}
env.queueMessage(
t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"},
)
client.readUntil(func(l string) bool {
return strings.Contains(l, "hello")
})
env.checkOneWaitChannel(t, sessionID)
err = client.conn.Close()
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
// or NOTICE body sent over the HTTP API reaches an IRC
// 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
// body line, key or mask never reaches an IRC listener user
// 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)
}
}
+6 -4
View File
@@ -645,8 +645,6 @@ func TestNewPingTimes(t *testing.T) {
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel()
const idleTimeout = 500 * time.Millisecond
env := newTestEnv(t)
active := env.dial(t)
@@ -655,13 +653,17 @@ func TestIdleCleanupKeepsActiveUser(t *testing.T) {
idle := env.dial(t)
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")
// The idle cleanup in internal/handlers removes exactly
// the users this returns.
stale, err := env.database.GetStaleOrphanSessions(
t.Context(), time.Now().Add(-idleTimeout),
t.Context(), cutoff,
)
if err != nil {
t.Fatalf("get stale sessions: %v", err)
+64 -68
View File
@@ -312,7 +312,7 @@ func (s *Service) SendWallops(
_, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*", 0, 0,
nil, body, nil, recipients,
nil, body, nil, recipients, 0,
)
if err != nil {
return fmt.Errorf("wallops: %w", err)
@@ -347,12 +347,19 @@ type DirectMsgResult struct {
// session IDs, notifying each via the broker. toSessionID
// and toChannelID are the recipient InsertMessage records
// 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(
ctx context.Context,
command, from, to string,
toSessionID, toChannelID int64,
params, body, meta json.RawMessage,
sessionIDs []int64,
skipClientID int64,
) (int64, string, error) {
dbID, msgUUID, err := s.db.InsertMessage(
ctx, command, from, to,
@@ -363,58 +370,40 @@ func (s *Service) FanOut(
}
for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID)
_ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID)
s.broker.Notify(sid)
}
return dbID, msgUUID, nil
}
// excludeSession returns a copy of ids without the given
// session.
func excludeSession(
ids []int64,
exclude int64,
) []int64 {
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
// SendChannelMessage checks membership, bans and
// moderation, then the hashcash stamp of a PRIVMSG to a +H
// channel, then fans out a message to all channel members
// except the sender. The stamp is checked after the other
// checks so that a message they refuse does not spend it.
// 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).
// channel, then fans out a message to all channel members,
// the sender's session included, skipping client
// skipClientID as FanOut does. The stamp is checked after
// the other checks so that a message they refuse does not
// spend it. Returns the message UUID and any error.
func (s *Service) SendChannelMessage(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
nick, command, channel string,
body, meta json.RawMessage,
) (int64, string, error) {
) (string, error) {
noSuchChannel := &IRCError{
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
}
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return 0, "", noSuchChannel
return "", noSuchChannel
}
isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
return 0, "", &IRCError{
return "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel",
@@ -426,7 +415,7 @@ func (s *Service) SendChannelMessage(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
return 0, "", &IRCError{
return "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel (+b)",
@@ -443,7 +432,7 @@ func (s *Service) SendChannelMessage(
)
if !isOp && !isVoiced {
return 0, "", &IRCError{
return "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel (+m)",
@@ -456,35 +445,35 @@ func (s *Service) SendChannelMessage(
ctx, chID, channel, body, meta,
)
if err != nil {
return 0, "", err
return "", err
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
dbID, uuid, fanErr := s.FanOut(
_, uuid, fanErr := s.FanOut(
ctx, command, nick, channel, 0, chID,
nil, body, meta, recipients,
nil, body, meta, memberIDs, skipClientID,
)
// The channel was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) {
return 0, "", noSuchChannel
return "", noSuchChannel
}
if fanErr != nil {
return 0, "", fanErr
return "", fanErr
}
return dbID, uuid, nil
return uuid, nil
}
// SendDirectMessage validates the target and sends a
// direct message, returning the message UUID and any away
// message set on the target.
// direct message to the target and the sender's session,
// skipping client skipClientID as FanOut does, and returns
// the message UUID and any away message set on the target.
func (s *Service) SendDirectMessage(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
nick, command, target string,
body, meta json.RawMessage,
) (*DirectMsgResult, error) {
@@ -506,7 +495,7 @@ func (s *Service) SendDirectMessage(
_, uuid, fanErr := s.FanOut(
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.
if db.IsForeignKeyConstraintError(fanErr) {
@@ -522,11 +511,12 @@ func (s *Service) SendDirectMessage(
// JoinChannel creates or joins a channel, making the
// first joiner the operator. Fans out the JOIN to all
// channel members. A channel name that validChannelRe
// does not match gets ERR_NOSUCHCHANNEL.
// channel members, skipping client skipClientID as FanOut
// does. A channel name that validChannelRe does not match
// gets ERR_NOSUCHCHANNEL.
func (s *Service) JoinChannel(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
nick, channel, suppliedKey string,
) (*JoinResult, error) {
if !validChannelRe.MatchString(channel) {
@@ -549,7 +539,7 @@ func (s *Service) JoinChannel(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdJoin, nick, channel, 0, chID,
nil, body, nil, memberIDs,
nil, body, nil, memberIDs, skipClientID,
)
return &JoinResult{
@@ -558,12 +548,12 @@ func (s *Service) JoinChannel(
}, nil
}
// PartChannel validates membership, broadcasts PART to
// remaining members, removes the user, and cleans up empty
// channels.
// PartChannel validates membership, broadcasts PART to all
// channel members, skipping client skipClientID as FanOut
// does, removes the user, and cleans up empty channels.
func (s *Service) PartChannel(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
nick, channel, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
@@ -587,12 +577,11 @@ func (s *Service) PartChannel(
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
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
@@ -601,10 +590,11 @@ func (s *Service) PartChannel(
}
// 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(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
nick, channel, topic string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
@@ -653,18 +643,19 @@ func (s *Service) SetTopic(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdTopic, nick, channel, 0, chID,
nil, body, nil, memberIDs,
nil, body, nil, memberIDs, skipClientID,
)
return nil
}
// KickUser validates operator status and target
// membership, broadcasts the KICK, removes the target,
// and cleans up empty channels.
// membership, broadcasts the KICK to all channel members,
// skipping client skipClientID as FanOut does, removes the
// target, and cleans up empty channels.
func (s *Service) KickUser(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
nick, channel, targetNick, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
@@ -717,7 +708,7 @@ func (s *Service) KickUser(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
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
@@ -798,10 +789,11 @@ func (s *Service) InviteUser(
}
// 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(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
oldNick, newNick string,
) error {
err := s.db.ChangeNick(ctx, sessionID, newNick)
@@ -822,7 +814,9 @@ func (s *Service) ChangeNick(
}
}
s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
s.broadcastNickChange(
ctx, sessionID, skipClientID, oldNick, newNick,
)
return nil
}
@@ -1042,9 +1036,10 @@ func (s *Service) SetChannelFlag(
}
// BroadcastMode fans out a MODE change to all channel
// members.
// members, skipping client skipClientID as FanOut does.
func (s *Service) BroadcastMode(
ctx context.Context,
skipClientID int64,
nick, channel string,
chID int64,
modeText string,
@@ -1054,7 +1049,7 @@ func (s *Service) BroadcastMode(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdMode, nick, channel, 0, chID,
nil, body, nil, memberIDs,
nil, body, nil, memberIDs, skipClientID,
)
}
@@ -1306,11 +1301,12 @@ func (s *Service) checkChannelHashcash(
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.
func (s *Service) broadcastNickChange(
ctx context.Context,
sessionID int64,
sessionID, skipClientID int64,
oldNick, newNick string,
) {
channels, err := s.db.GetSessionChannels(
@@ -1332,7 +1328,7 @@ func (s *Service) broadcastNickChange(
}
// 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)
notified[sessionID] = true
@@ -1351,7 +1347,7 @@ func (s *Service) broadcastNickChange(
notified[mid] = true
_ = s.db.EnqueueToSession(ctx, mid, dbID)
_ = s.db.EnqueueToSession(ctx, mid, dbID, 0)
s.broker.Notify(mid)
}
}
+54 -58
View File
@@ -168,7 +168,7 @@ func TestFanOut(t *testing.T) {
dbID, uuid, err := env.svc.FanOut(
ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
nil, body, nil,
[]int64{sid1, sid2},
[]int64{sid1, sid2}, 0,
)
if err != nil {
t.Fatalf("FanOut: %v", err)
@@ -190,7 +190,7 @@ func TestJoinChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
ctx, sid, 0, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -208,7 +208,7 @@ func TestJoinChannel(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel(
ctx, sid2, "bob", "#general", "",
ctx, sid2, 0, "bob", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel bob: %v", err)
@@ -230,14 +230,14 @@ func TestPartChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(
ctx, sid, "alice", "#general", "",
ctx, sid, 0, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
}
err = env.svc.PartChannel(
ctx, sid, "alice", "#general", "bye",
ctx, sid, 0, "alice", "#general", "bye",
)
if err != nil {
t.Fatalf("PartChannel: %v", err)
@@ -245,7 +245,7 @@ func TestPartChannel(t *testing.T) {
// Parting a non-existent channel returns error.
err = env.svc.PartChannel(
ctx, sid, "alice", "#nonexistent", "",
ctx, sid, 0, "alice", "#nonexistent", "",
)
if err == nil {
t.Error("expected error for non-existent channel")
@@ -309,7 +309,7 @@ func joinConcurrently(
joins[i] = func() error {
result, err := env.svc.JoinChannel(
ctx, sessionID, nick, channel, "",
ctx, sessionID, 0, nick, channel, "",
)
results[i] = result
@@ -335,7 +335,7 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
opSID := createSession(ctx, t, env.db, opNick)
created, err := env.svc.JoinChannel(
ctx, opSID, opNick, channel, "",
ctx, opSID, 0, opNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", opNick, err)
@@ -448,7 +448,7 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick)
_, err := env.svc.JoinChannel(
ctx, leaverSID, leaverNick, channel, "",
ctx, leaverSID, 0, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
@@ -457,12 +457,12 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
errs := runConcurrently(
func() error {
return env.svc.PartChannel(
ctx, leaverSID, leaverNick, channel, "",
ctx, leaverSID, 0, leaverNick, channel, "",
)
},
func() error {
_, joinErr := env.svc.JoinChannel(
ctx, joinerSID, joinerNick, channel, "",
ctx, joinerSID, 0, joinerNick, channel, "",
)
return joinErr
@@ -513,7 +513,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick)
created, err := env.svc.JoinChannel(
ctx, leaverSID, leaverNick, channel, "",
ctx, leaverSID, 0, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
@@ -545,7 +545,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
)
joined, joinErr = env.svc.JoinChannel(
ctx, joinerSID, joinerNick, channel, "",
ctx, joinerSID, 0, joinerNick, channel, "",
)
if !errors.As(joinErr, &ircErr) ||
ircErr.Code != irc.ErrBadChannelKey {
@@ -592,7 +592,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "")
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
}
@@ -602,7 +602,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "")
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "")
if err != nil {
t.Fatalf("join %s: %v", memberNick, err)
}
@@ -617,7 +617,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "")
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
@@ -678,7 +678,7 @@ func takeNickAndJoin(
return err
}
_, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "")
_, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "")
return err
}
@@ -710,7 +710,7 @@ func TestJoinUsesUpInvite(t *testing.T) {
opSID := createSession(ctx, t, env.db, "op")
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 {
t.Fatalf("join op: %v", err)
}
@@ -725,17 +725,17 @@ func TestJoinUsesUpInvite(t *testing.T) {
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 {
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 {
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
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan {
@@ -754,7 +754,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
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 {
t.Fatalf("join op: %v", err)
}
@@ -773,7 +773,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
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
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan {
@@ -793,17 +793,17 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
aliceSID := createSession(ctx, t, env.db, "alice")
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 {
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 {
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 {
t.Fatalf("part alice: %v", err)
}
@@ -818,7 +818,7 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
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 {
t.Fatalf("part bob: %v", err)
}
@@ -839,12 +839,12 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
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 {
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 {
t.Fatalf("kick alice: %v", err)
}
@@ -863,14 +863,14 @@ func TestSendChannelMessage(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#chat", "",
ctx, sid1, 0, "alice", "#chat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#chat", "",
ctx, sid2, 0, "bob", "#chat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -878,18 +878,14 @@ func TestSendChannelMessage(t *testing.T) {
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
dbID, uuid, err := env.svc.SendChannelMessage(
ctx, sid1, "alice",
uuid, err := env.svc.SendChannelMessage(
ctx, sid1, 0, "alice",
irc.CmdPrivmsg, "#chat", body, nil,
)
if err != nil {
t.Fatalf("SendChannelMessage: %v", err)
}
if dbID == 0 {
t.Error("expected non-zero dbID")
}
if uuid == "" {
t.Error("expected non-empty UUID")
}
@@ -897,8 +893,8 @@ func TestSendChannelMessage(t *testing.T) {
// Non-member cannot send.
sid3 := createSession(ctx, t, env.db, "charlie")
_, _, err = env.svc.SendChannelMessage(
ctx, sid3, "charlie",
_, err = env.svc.SendChannelMessage(
ctx, sid3, 0, "charlie",
irc.CmdPrivmsg, "#chat", body, nil,
)
if err == nil {
@@ -914,14 +910,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#room", "",
ctx, sid1, 0, "alice", "#room", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#room", "",
ctx, sid2, 0, "bob", "#room", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -963,7 +959,7 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, 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
@@ -983,7 +979,7 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
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 {
t.Fatalf("join alice: %v", err)
}
@@ -1000,8 +996,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, _, err = env.svc.SendChannelMessage(
ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
_, err = env.svc.SendChannelMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil,
)
var ircErr *service.IRCError
@@ -1019,14 +1015,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel(
ctx, sid1, "alice", "#modchat", "",
ctx, sid1, 0, "alice", "#modchat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, "bob", "#modchat", "",
ctx, sid2, 0, "bob", "#modchat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -1039,8 +1035,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
// Bob (non-op, non-voiced) should fail to send.
_, _, err = env.svc.SendChannelMessage(
ctx, sid2, "bob",
_, err = env.svc.SendChannelMessage(
ctx, sid2, 0, "bob",
irc.CmdPrivmsg, "#modchat", body, nil,
)
if err == nil {
@@ -1048,8 +1044,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
}
// Alice (operator) should succeed.
_, _, err = env.svc.SendChannelMessage(
ctx, sid1, "alice",
_, err = env.svc.SendChannelMessage(
ctx, sid1, 0, "alice",
irc.CmdPrivmsg, "#modchat", body, nil,
)
if err != nil {
@@ -1074,7 +1070,7 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
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, "bob", channel, "")
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")
@@ -1094,12 +1090,12 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob")
joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
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, "bob", channel, "")
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "")
if err != nil {
t.Fatalf("join bob: %v", err)
}
@@ -1119,8 +1115,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
)
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
_, _, err = env.svc.SendChannelMessage(
ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta,
_, err = env.svc.SendChannelMessage(
ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta,
)
var ircErr *service.IRCError
@@ -1129,8 +1125,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
}
_, _, err = env.svc.SendChannelMessage(
ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta,
_, 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)
+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 = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = {
"Content-Type": "application/json",
...(opts.headers || {}),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
@@ -122,12 +122,11 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name;
})
.catch(() => {});
const saved = localStorage.getItem("neoirc_token");
if (saved) {
api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token"));
}
// Resume the session if the browser still holds a valid auth
// cookie; a 401 leaves the login form up.
api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true))
.catch(() => {});
inputRef.current?.focus();
}, []);
@@ -152,7 +151,6 @@ function LoginScreen({ onLogin }) {
method: "POST",
body: JSON.stringify(reqBody),
});
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick);
} catch (err) {
setError(err.data?.error || "Connection failed");
@@ -227,10 +225,9 @@ function UserList({ members, onNickClick }) {
const regular = [];
for (const m of members) {
const mode = m.mode || "";
if (mode === "o") {
if (m.isOperator) {
ops.push(m);
} else if (mode === "v") {
} else if (m.isVoiced) {
voiced.push(m);
} else {
regular.push(m);
@@ -303,11 +300,14 @@ function App() {
nickRef.current = 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(() => {
if (!loggedIn) return;
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [tabs]);
}, [loggedIn, tabs]);
// Clear unread on tab switch.
useEffect(() => {
@@ -366,7 +366,9 @@ function App() {
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 "NOTICE": {
let text = body;
@@ -464,8 +466,18 @@ function App() {
break;
}
case "353": {
// RPL_NAMREPLY — parse mode prefixes from names list.
case "MODE": {
// 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 (
Array.isArray(msg.params) &&
msg.params.length >= 2 &&
@@ -477,21 +489,18 @@ function App() {
: String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => {
if (n.startsWith("@")) {
return { nick: n.slice(1), mode: "o" };
}
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
const isOperator = n.startsWith("@");
const isVoiced = n.startsWith("+");
const hostmask = isOperator || isVoiced ? n.slice(1) : n;
return { nick: n, mode: "" };
return { nick: hostmask.split("!")[0], isOperator, isVoiced };
});
setMembers((prev) => ({ ...prev, [channel]: parsed }));
}
break;
}
case "332": {
case 332: {
// RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0];
@@ -503,7 +512,7 @@ function App() {
break;
}
case "322": {
case 322: {
// RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0];
@@ -518,7 +527,7 @@ function App() {
break;
}
case "323":
case 323:
addMessage("Server", {
...base,
text: body || "End of channel list",
@@ -526,7 +535,7 @@ function App() {
});
break;
case "352": {
case 352: {
// RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0];
@@ -541,7 +550,7 @@ function App() {
break;
}
case "315":
case 315:
addMessage("Server", {
...base,
text: body || "End of /WHO list",
@@ -549,20 +558,20 @@ function App() {
});
break;
case "311": {
// RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
case 311: {
// RPL_WHOISUSER — params nick, user, host, *; body realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, wiUser, wiHost] = msg.params;
addMessage("Server", {
...base,
text: `${wiNick} (${body})`,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`,
system: true,
});
}
break;
}
case "312": {
case 312: {
// RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0];
@@ -576,7 +585,33 @@ function App() {
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.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
@@ -589,7 +624,7 @@ function App() {
break;
}
case "318":
case 318:
addMessage("Server", {
...base,
text: body || "End of /WHOIS list",
@@ -597,9 +632,21 @@ function App() {
});
break;
case "375":
case "372":
case "376":
case 324: {
// RPL_CHANNELMODEIS — channel, mode string.
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 });
break;
@@ -637,6 +684,14 @@ function App() {
} catch (err) {
if (!alive) break;
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);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
}
@@ -716,6 +771,11 @@ function App() {
const onLogin = useCallback(
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);
setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`);
@@ -740,9 +800,6 @@ function App() {
}
// Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) {
try {
await api("/messages", {
@@ -972,27 +1029,22 @@ function App() {
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);
if (modeArgs.length > 0) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "MODE",
to: tab.name,
params: modeArgs,
}),
});
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
const body =
modeArgs.length > 0
? { command: "MODE", to: tab.name, body: modeArgs }
: { command: "MODE", to: tab.name };
try {
await api("/messages", {
method: "POST",
body: JSON.stringify(body),
});
} catch (err) {
addSystemMessage(
"Server",
"Usage: /mode <+/-mode> [params]",
`MODE failed: ${err.data?.error || "error"}`,
);
}
@@ -1009,7 +1061,6 @@ function App() {
} catch (e) {
// Ignore.
}
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels");
window.location.reload();
@@ -1128,7 +1179,7 @@ function App() {
" /me action — Send an action",
" /nick newnick — Change your nickname",
" /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",
" /list — List all channels",
" /who [#channel] — List users in a channel",