8 Commits
Author SHA1 Message Date
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
20 changed files with 1459 additions and 369 deletions
+27 -19
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
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The
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 →
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
@@ -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.
@@ -835,10 +839,11 @@ Set a password on the current session, enabling multi-client login via
**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"}`.
- If the password is too short or missing, the server sends ERR_NEEDMOREPARAMS
(461) via the message queue.
- If the password is too short, too long or missing, the server sends
ERR_NEEDMOREPARAMS (461) via the message queue.
- Calling PASS again overwrites the previous password.
- 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.
@@ -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
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:**
@@ -2678,9 +2682,11 @@ IRC_LISTEN_ADDR: ""
- **Wire format**: CR-LF delimited lines, max 512 bytes per line
- **Connection registration**: Clients must send `NICK` and `USER` to register.
An optional `PASS` before registration sets the session password (minimum 8
characters). Registering creates a session without a hashcash stamp; closing
the connection ends the session, as `QUIT` does.
An optional `PASS` before registration sets the session password (8 to 72
bytes); one outside that range is refused with a `NOTICE` once registration
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
is closed if it sends nothing in the 30 seconds after that; any line, such as
the client's `PONG`, starts both waits again.
@@ -2717,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)
}
})
}
+11 -8
View File
@@ -1208,11 +1208,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 +1222,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 +1242,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 +1676,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",
+425 -23
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"
@@ -2906,32 +3267,73 @@ func TestPassCommand(t *testing.T) {
}
}
func TestPassCommandShortPassword(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("shortpw")
// The shortest and longest password PASS accepts; bcrypt
// cannot hash more than 72 bytes.
const (
minPasswordBytes = 8
maxPasswordBytes = 72
)
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try short password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{"short"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
// TestPassCommandLength checks that PASS stores a password of
// 8 to 72 bytes and refuses any other with 461. The limit
// counts bytes: "é" is two bytes in UTF-8, so 72 of them are
// refused.
func TestPassCommandLength(t *testing.T) {
cases := []struct {
name string
password string
stored bool
}{
{"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},
}
msgs, _ := tserver.pollMessages(token, lastID)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("pwlength")
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
_, lastID := tserver.pollMessages(token, 0)
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{tc.password},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d: %v", status, result)
}
if !tc.stored {
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
return
}
if result[statusKey] != "ok" {
t.Fatalf("expected ok, got %v", result)
}
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
nickKey: "pwlength", passwordKey: tc.password,
},
http.StatusOK,
)
})
}
}
+9 -3
View File
@@ -8,7 +8,12 @@ import (
"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.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
@@ -149,12 +154,13 @@ func (hdlr *Handlers) handlePass(
password := lines[0]
if len(password) < minPasswordLength {
if len(password) < minPasswordLength ||
len(password) > maxPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be at least 8 characters",
"Password must be 8 to 72 bytes",
)
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
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{
+18 -20
View File
@@ -84,7 +84,7 @@ func (c *Conn) handleNick(
oldNick := c.nick
err := c.svc.ChangeNick(
ctx, c.sessionID, oldNick, newNick,
ctx, c.sessionID, c.clientID, oldNick, newNick,
)
if err != nil {
c.sendIRCError(err)
@@ -128,8 +128,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.nick,
msg.Command, target, body, nil,
)
if err != nil {
@@ -137,7 +137,7 @@ func (c *Conn) handlePrivmsg(
}
} else {
result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.nick,
ctx, c.sessionID, c.clientID, c.nick,
msg.Command, target, body, nil,
)
if err != nil {
@@ -199,7 +199,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.nick, channel, key,
)
if err != nil {
c.sendIRCError(err)
@@ -337,7 +337,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.nick, channel, reason,
)
if err != nil {
c.sendIRCError(err)
@@ -401,7 +401,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.nick, channel, newTopic,
)
if err != nil {
c.sendIRCError(err)
@@ -708,7 +708,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.nick, channel, chID, modeReply,
)
}
}
@@ -1185,7 +1185,7 @@ func (c *Conn) handleKick(
}
err := c.svc.KickUser(
ctx, c.sessionID, c.nick,
ctx, c.sessionID, c.clientID, c.nick,
channel, targetNick, reason,
)
if err != nil {
@@ -1216,15 +1216,14 @@ func (c *Conn) handlePassPostReg(
}
password := msg.Params[0]
if len(password) < minPasswordLen {
c.sendFromServer("NOTICE", c.nick,
"Password must be at least 8 characters",
)
if !c.checkPasswordLength(password) {
return
}
c.setPassword(ctx, password)
err := c.setPassword(ctx, password)
if err != nil {
return
}
c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+
@@ -1262,14 +1261,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},
nil, body, nil, []int64{targetID}, 0,
)
}
+34 -8
View File
@@ -27,6 +27,7 @@ const (
pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second
minPasswordLen = 8
maxPasswordLen = 72 // bcrypt cannot hash a longer one
maxHashcashBits = 40
)
@@ -62,6 +63,7 @@ type Conn struct {
registered bool
gotNick bool
gotUser bool
gotPass bool
passWord string
lastQueueID int64
@@ -436,6 +438,7 @@ func (c *Conn) handlePreRegistration(
}
c.passWord = msg.Params[0]
c.gotPass = true
case irc.CmdNick:
if len(msg.Params) < 1 {
c.sendNumeric(
@@ -550,17 +553,20 @@ func (c *Conn) completeRegistration(ctx context.Context) {
// So that KILL, from either transport, can close it.
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.
c.deliverWelcome()
c.deliverLusers(ctx)
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.
go c.relayMessages(ctx)
}
@@ -643,13 +649,33 @@ func (c *Conn) deliverMOTD() {
)
}
// setPassword sets a bcrypt password on the session.
func (c *Conn) setPassword(ctx context.Context, pw string) {
// checkPasswordLength reports whether pw is 8 to 72 bytes
// 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.nick,
"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.
err := c.database.SetPassword(ctx, c.sessionID, pw)
if err != nil {
c.log.Error(
"failed to set password", "error", err,
)
return err
}
return nil
}
+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) {
+18 -48
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.
@@ -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
@@ -257,14 +235,6 @@ func (c *Conn) deliverNickChange(
msg *db.IRCMessage,
newNick string,
) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*"
c.send(FormatMessage(prefix, "NICK", newNick))
@@ -275,14 +245,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 +297,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.
+346 -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,278 @@ 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 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 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()
const password = "alicepass"
ctx := t.Context()
env := newTestEnv(t)
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(
ctx, "alice", password, "127.0.0.1", "localhost",
)
if err != nil {
t.Fatalf("log in as alice: %v", err)
}
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
}
+165 -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)
@@ -1158,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) {
t.Parallel()
+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",