Author SHA1 Message Date
sneak 1ef44fd760 Refuse passwords over 72 bytes and confirm only stored ones (closes #144)
check / check (push) Canceled after 0s
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 is
checked after the welcome burst and gets the same NOTICE.

Model: opus-5-5
2026-10-21 01:47:04 +00:00
23 changed files with 544 additions and 2046 deletions
+15 -26
View File
@@ -268,15 +268,10 @@ rules). Case-sensitive comparison is unambiguous.
A single user session can have multiple clients (phone, laptop, terminal). A single user session can have multiple clients (phone, laptop, terminal).
- Each client gets a **separate server-to-client (S2C) message queue**. - Each client gets a **separate server-to-client (S2C) message queue**.
- The server fans out messages and events from other users to every client queue - The server fans out messages and events from other users, and the user's own
for that user session. DMs and the channel messages it sends over the HTTP API, to every client queue
- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`, for that user session. Replies to a command (numerics) go only to the client
`MODE` or `KICK` from one of its clients, the resulting event goes to every that sent it.
client queue of the user's own session, as well as to the other users it is
meant for. The one exception is an IRC listener connection that sent the
command, which gets nothing queued for it: it has already sent its user the
echo, or, for `PRIVMSG` and `NOTICE`, IRC sends none.
- Replies to a command (numerics) go only to the client that sent it.
- `GET /api/v1/messages` delivers from the calling client's specific queue, - `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth cookie. identified by the auth cookie.
- Client queues have **independent expiry/pruning** — one client going offline - Client queues have **independent expiry/pruning** — one client going offline
@@ -749,7 +744,7 @@ Leave a channel.
{"command": "PART", "to": "#general", "body": ["goodbye"]} {"command": "PART", "to": "#general", "body": ["goodbye"]}
``` ```
**S2C (broadcast to all channel members, including the departing user):** **S2C (broadcast to the other channel members):**
```json ```json
{ {
@@ -765,8 +760,9 @@ Leave a channel.
**Behavior:** **Behavior:**
- The PART event goes to every channel member, the departing user's own clients - The PART event goes to the other channel members, not to the departing user;
included. (An IRC listener connection that sends a PART echoes it itself.) the HTTP response confirms the part. (The IRC listener echoes the PART to its
own connection.)
- If the channel is empty after the user leaves, the channel is **deleted** - If the channel is empty after the user leaves, the channel is **deleted**
(ephemeral channels). (ephemeral channels).
- If the user is not in the channel, the server returns an error. - If the user is not in the channel, the server returns an error.
@@ -1023,8 +1019,9 @@ On the IRC listener, `MODE #channel <modes> [values...]` takes several letters
in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It
accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits
from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is
missing is skipped. The changes made are sent to every channel member as one missing is skipped. The changes made are echoed to the operator's connection and
`MODE` line, e.g. `+o-v alice bob`; the operator gets that line once. also sent to every channel member, the operator included, as one `MODE` line,
e.g. `+o-v alice bob`.
**Channel errors:** **Channel errors:**
@@ -1408,8 +1405,7 @@ Include the hashcash stamp in the `meta` field:
**Server validation:** The server checks that the stamp is well-formed, meets **Server validation:** The server checks that the stamp is well-formed, meets
the required difficulty, is bound to the correct channel and message body, has a the required difficulty, is bound to the correct channel and message body, has a
recent date, and has not been previously used. Spent stamps are cached for 1 recent date, and has not been previously used. Spent stamps are cached for 1
year to prevent replay attacks. The stamp is checked after membership, bans and year to prevent replay attacks.
`+m`, so a message refused for one of those does not spend it.
**Error responses:** If the channel requires hashcash and the stamp is missing, **Error responses:** If the channel requires hashcash and the stamp is missing,
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
@@ -2723,9 +2719,7 @@ Each line of a multi-line `body` reaches an IRC listener user as its own
is sent, so it cannot start a line of its own. A mode change made over the HTTP is sent, so it cannot start a line of its own. A mode change made over the HTTP
API reaches IRC listener members as an IRC `MODE` line with its value as a API reaches IRC listener members as an IRC `MODE` line with its value as a
separate parameter, e.g. `MODE #general +k secretpass` or separate parameter, e.g. `MODE #general +k secretpass` or
`MODE #general +o bob`. An `INVITE` from either transport reaches the invited `MODE #general +o bob`.
IRC listener user as an IRC `INVITE` line from the inviter, e.g.
`:alice!alice@* INVITE bob #general`.
### Docker Usage ### Docker Usage
@@ -3093,10 +3087,6 @@ limits that punish shared networks.
- Stamp has not been used before (replay prevention; the spent stamps are - Stamp has not been used before (replay prevention; the spent stamps are
kept in memory, so a restart forgets them) kept in memory, so a restart forgets them)
The stamp is spent only when the session is created. A request refused with
400 or 409 leaves it unspent, and the client can send the same stamp again
with another nick or username.
### Stamp Format ### Stamp Format
Standard hashcash format: Standard hashcash format:
@@ -3169,13 +3159,12 @@ creating one session pays once and keeps their session.
### Why Hashcash and Not Rate Limits? ### Why Hashcash and Not Rate Limits?
- **Little state to track**: No IP tables, no token buckets, no sliding windows. - **Little state to track**: No IP tables, no token buckets, no sliding windows.
The server hashes the stamp and remembers the stamps already spent. The server verifies a single hash and remembers the stamps already spent.
- **Works through NATs and proxies**: Doesn't punish shared IPs (university - **Works through NATs and proxies**: Doesn't punish shared IPs (university
campuses, corporate networks, Tor exits). Every client computes their own campuses, corporate networks, Tor exits). Every client computes their own
proof independently. proof independently.
- **Cost falls on the requester**: The server's verification cost is constant - **Cost falls on the requester**: The server's verification cost is constant
regardless of difficulty: at most two SHA-256 hashes of the stamp per session (one SHA-256 hash) regardless of difficulty. Only the client does more work.
request, one to check it and one to spend it. Only the client does more work.
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No - **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No
registration, no email, no phone number, no CAPTCHA. Just compute. registration, no email, no phone number, no CAPTCHA. Just compute.
- **Language-agnostic**: SHA-256 is available in every programming language. The - **Language-agnostic**: SHA-256 is available in every programming language. The
+1 -1
View File
@@ -207,7 +207,7 @@ func TestCreateSessionSendsNickAndStamp(t *testing.T) {
return return
} }
err = validator.Claim(payload.Hashcash, stampBits) err = validator.Validate(payload.Hashcash, stampBits)
if err != nil { if err != nil {
writer.WriteHeader(http.StatusPaymentRequired) writer.WriteHeader(http.StatusPaymentRequired)
+46 -22
View File
@@ -96,7 +96,20 @@ func (a *App) handleInput(text string) {
a.ui.AddStatus( a.ui.AddStatus(
"[red]Send error: " + err.Error(), "[red]Send error: " + err.Error(),
) )
return
} }
timestamp := time.Now().Format(timeFormat)
a.mu.Lock()
nick := a.nick
a.mu.Unlock()
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
} }
func (a *App) handleCommand(text string) { func (a *App) handleCommand(text string) {
@@ -288,6 +301,9 @@ func (a *App) cmdJoin(channel string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.SwitchToBuffer(channel) a.ui.SwitchToBuffer(channel)
a.ui.AddLine(channel,
"[yellow]*** Joined "+channel,
)
a.ui.SetStatus(nick, channel, "connected") a.ui.SetStatus(nick, channel, "connected")
} }
@@ -322,6 +338,10 @@ func (a *App) cmdPart(channel string) {
return return
} }
a.ui.AddLine(channel,
"[yellow]*** Left "+channel,
)
a.mu.Lock() a.mu.Lock()
if a.target == channel { if a.target == channel {
a.target = "" a.target = ""
@@ -348,6 +368,7 @@ func (a *App) cmdMsg(args string) {
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
nick := a.nick
a.mu.Unlock() a.mu.Unlock()
if !connected { if !connected {
@@ -365,7 +386,16 @@ func (a *App) cmdMsg(args string) {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: %v", err, "[red]Send failed: %v", err,
)) ))
return
} }
timestamp := time.Now().Format(timeFormat)
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
} }
func (a *App) cmdQuery(nick string) { func (a *App) cmdQuery(nick string) {
@@ -727,7 +757,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin: case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp) a.handleJoinEvent(msg, timestamp)
case irc.CmdPart: case irc.CmdPart:
a.handlePartEvent(msg, timestamp, myNick) a.handlePartEvent(msg, timestamp)
case irc.CmdQuit: case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp) a.handleQuitEvent(msg, timestamp)
case irc.CmdNick: case irc.CmdNick:
@@ -755,10 +785,12 @@ func (a *App) handlePrivmsgEvent(
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") text := strings.Join(lines, " ")
// A direct message shows in the buffer of the other user: if msg.From == myNick {
// the recipient of one the user sent, else the sender. return
}
target := msg.To target := msg.To
if !strings.HasPrefix(target, "#") && msg.From != myNick { if !strings.HasPrefix(target, "#") {
target = msg.From target = msg.From
} }
@@ -781,11 +813,8 @@ func (a *App) handleJoinEvent(
)) ))
} }
// handlePartEvent shows a part in the channel's buffer. The
// user's own part does not mark the channel unread: the user
// has just left it.
func (a *App) handlePartEvent( func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string, msg *api.Message, timestamp string,
) { ) {
if msg.To == "" { if msg.To == "" {
return return
@@ -794,22 +823,17 @@ func (a *App) handlePartEvent(
lines := msg.BodyLines() lines := msg.BodyLines()
reason := strings.Join(lines, " ") reason := strings.Join(lines, " ")
line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
)
if reason != "" { if reason != "" {
line += " (" + reason + ")" a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s (%s)",
timestamp, msg.From, msg.To, reason,
))
} else {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
))
} }
if msg.From == myNick {
a.ui.AddLineAsRead(msg.To, line)
return
}
a.ui.AddLine(msg.To, line)
} }
func (a *App) handleQuitEvent( func (a *App) handleQuitEvent(
-218
View File
@@ -1,22 +1,9 @@
package cli package cli
import ( import (
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api" api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
// The user's nick, a channel it is in, another user's nick and
// a message text, for the tests below.
const (
testNick = "erin"
testChannel = "#chan"
testOtherNick = "bob"
testText = "hello"
) )
func TestMemberPrefix(t *testing.T) { func TestMemberPrefix(t *testing.T) {
@@ -62,208 +49,3 @@ func TestMemberPrefix(t *testing.T) {
}) })
} }
} }
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except that the
// user's own part does not mark it unread.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantUnread int
}{
{name: "own part", from: testNick, wantUnread: 0},
{name: "other member", from: testOtherNick, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: testChannel,
})
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want 1 and %d",
lines, unread, test.wantUnread)
}
})
}
}
// TestOwnCommandShownOnce checks that a message, join or
// part made from neoirc-cli shows as one line in the
// channel's buffer once the server's event for it arrives,
// and does not mark the channel unread.
func TestOwnCommandShownOnce(t *testing.T) {
t.Parallel()
tests := []struct {
name string
send func(app *App)
event api.Message
}{
{
name: "message",
send: func(app *App) { app.handleInput(testText) },
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
},
{
name: "join",
send: func(app *App) { app.cmdJoin(testChannel) },
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
},
{
name: "part",
send: func(app *App) { app.cmdPart(testChannel) },
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// The server accepts every command and answers nothing.
server := httptest.NewServer(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {},
))
t.Cleanup(server.Close)
app := &App{
ui: NewUI(),
client: api.NewClient(server.URL),
nick: testNick,
target: testChannel,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testChannel)
test.send(app)
app.handleServerMessage(&test.event)
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != 0 {
t.Fatalf("got %d lines and %d unread, want 1 and 0",
lines, unread)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to.
func TestOwnEventFromOtherClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
event api.Message
buffer string
}{
{
name: "channel message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: testChannel,
},
{
name: "direct message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testOtherNick,
Body: []string{testText},
},
buffer: testOtherNick,
},
{
name: "notice",
event: api.Message{
Command: irc.CmdNotice, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: "(status)",
},
{
name: "join",
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
buffer: testChannel,
},
{
name: "part",
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
buffer: testChannel,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&test.event)
lines, _ := bufferState(app.ui, test.buffer)
if lines != 1 {
t.Fatalf("got %d lines in %s, want 1", lines, test.buffer)
}
})
}
}
// bufferState returns the number of lines in the named
// buffer and how many of them are unread.
func bufferState(ui *UI, name string) (int, int) {
var lines, unread int
ui.app.QueueUpdate(func() {
buf := ui.getOrCreateBuffer(name)
lines = len(buf.Lines)
unread = buf.Unread
})
return lines, unread
}
// runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) {
t.Helper()
ui.app.SetScreen(tcell.NewSimulationScreen(""))
done := make(chan error)
go func() { done <- ui.Run() }()
t.Cleanup(func() {
ui.Stop()
err := <-done
if err != nil {
t.Errorf("run ui: %v", err)
}
})
}
-13
View File
@@ -95,19 +95,6 @@ func (ui *UI) AddLine(bufferName, line string) {
}) })
} }
// AddLineAsRead adds a line to the specified buffer, as
// AddLine does, but does not mark the buffer unread.
func (ui *UI) AddLineAsRead(bufferName, line string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(bufferName)
buf.Lines = append(buf.Lines, line)
if ui.buffers[ui.currentBuffer] == buf {
_, _ = fmt.Fprintln(ui.messages, line)
}
})
}
// AddStatus adds a line to the status buffer. // AddStatus adds a line to the status buffer.
func (ui *UI) AddStatus(line string) { func (ui *UI) AddStatus(line string) {
ts := time.Now().Format("15:04") ts := time.Now().Format("15:04")
+8 -30
View File
@@ -264,25 +264,6 @@ func (database *Database) GetSessionByNick(
return sessionID, nil return sessionID, nil
} }
// GetSessionNick returns the nick a session holds.
func (database *Database) GetSessionNick(
ctx context.Context,
sessionID int64,
) (string, error) {
var nick string
err := database.conn.QueryRowContext(
ctx,
"SELECT nick FROM sessions WHERE id = ?",
sessionID,
).Scan(&nick)
if err != nil {
return "", fmt.Errorf("get session nick: %w", err)
}
return nick, nil
}
// SessionHostInfo holds the username, hostname, and IP // SessionHostInfo holds the username, hostname, and IP
// for a session. // for a session.
type SessionHostInfo struct { type SessionHostInfo struct {
@@ -1227,12 +1208,11 @@ func insertMessageInTx(
return dbID, msgUUID, nil return dbID, msgUUID, nil
} }
// EnqueueToSession adds a message to the queues of all // EnqueueToSession adds a message to all clients of a
// clients of a session except client skipClientID, or of all // session's queues.
// of them when skipClientID is 0.
func (database *Database) EnqueueToSession( func (database *Database) EnqueueToSession(
ctx context.Context, ctx context.Context,
sessionID, messageID, skipClientID int64, sessionID, messageID int64,
) error { ) error {
transaction, err := database.conn.BeginTx(ctx, nil) transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -1241,9 +1221,7 @@ func (database *Database) EnqueueToSession(
defer func() { _ = transaction.Rollback() }() defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx( err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID)
ctx, transaction, sessionID, messageID, skipClientID,
)
if err != nil { if err != nil {
return err return err
} }
@@ -1261,15 +1239,15 @@ func (database *Database) EnqueueToSession(
func enqueueToSessionInTx( func enqueueToSessionInTx(
ctx context.Context, ctx context.Context,
transaction *sql.Tx, transaction *sql.Tx,
sessionID, messageID, skipClientID int64, sessionID, messageID int64,
) error { ) error {
_, err := transaction.ExecContext(ctx, _, err := transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues `INSERT OR IGNORE INTO client_queues
(client_id, message_id, created_at) (client_id, message_id, created_at)
SELECT c.id, ?, ? SELECT c.id, ?, ?
FROM clients c FROM clients c
WHERE c.session_id = ? AND c.id != ?`, WHERE c.session_id = ?`,
messageID, time.Now(), sessionID, skipClientID) messageID, time.Now(), sessionID)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"enqueue to session: %w", err, "enqueue to session: %w", err,
@@ -1695,7 +1673,7 @@ func quitSessionInTx(
for _, peerID := range peerIDs { for _, peerID := range peerIDs {
err = enqueueToSessionInTx( err = enqueueToSessionInTx(
ctx, transaction, peerID, messageID, 0, ctx, transaction, peerID, messageID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
+1 -1
View File
@@ -675,7 +675,7 @@ func TestPollMessages(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.EnqueueToSession(ctx, sid, dbID, 0) err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+37 -68
View File
@@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
if !hdlr.checkHashcash( if !hdlr.validateHashcash(
writer, request, payload.Hashcash, writer, request, payload.Hashcash,
) { ) {
return return
@@ -232,15 +232,14 @@ func (hdlr *Handlers) handleCreateSession(
} }
hdlr.executeCreateSession( hdlr.executeCreateSession(
writer, request, writer, request, payload.Nick, username,
payload.Nick, username, payload.Hashcash,
) )
} }
func (hdlr *Handlers) executeCreateSession( func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
nick, username, stamp string, nick, username string,
) { ) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
@@ -248,17 +247,12 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP, request.Context(), remoteIP,
) )
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), request.Context(),
nick, username, hostname, remoteIP, nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
writer, request, err, writer, request, err,
) )
@@ -279,10 +273,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated) }, http.StatusCreated)
} }
// checkHashcash checks a hashcash stamp if required, // validateHashcash validates a hashcash stamp if required.
// without spending it. Returns false if the check failed // Returns false if validation failed and a response was
// and a response was already sent. // already sent.
func (hdlr *Handlers) checkHashcash( func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
stamp string, stamp string,
@@ -301,31 +295,7 @@ func (hdlr *Handlers) checkHashcash(
return false return false
} }
err := hdlr.hashcashVal.Check( err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if Claim refused the stamp, normally because
// it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
err := hdlr.hashcashVal.Claim(
stamp, hdlr.params.Config.HashcashBits, stamp, hdlr.params.Config.HashcashBits,
) )
if err != nil { if err != nil {
@@ -813,14 +783,7 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
timeout = maxLongPollTimeout timeout = maxLongPollTimeout
} }
// Register with the broker before reading the queue. msgs, lastQID, err := hdlr.params.Database.PollMessages(
// Notify wakes only the channels registered at that
// moment, so a message queued after the read below
// must find this one there already.
waitCh := hdlr.broker.Wait(sessionID)
defer hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.pollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -848,22 +811,19 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll( hdlr.longPoll(
writer, request, writer, request,
waitCh, clientID, afterID, timeout, sessionID, clientID, afterID, timeout,
) )
} }
} }
// longPoll waits up to timeout seconds for the broker to
// signal waitCh, then answers with the messages queued for
// clientID after afterID. It answers nothing if the client
// goes away first.
func (hdlr *Handlers) longPoll( func (hdlr *Handlers) longPoll(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
waitCh <-chan struct{}, sessionID, clientID, afterID int64,
clientID, afterID int64,
timeout int, timeout int,
) { ) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer( timer := time.NewTimer(
time.Duration(timeout) * time.Second, time.Duration(timeout) * time.Second,
) )
@@ -874,10 +834,14 @@ func (hdlr *Handlers) longPoll(
case <-waitCh: case <-waitCh:
case <-timer.C: case <-timer.C:
case <-request.Context().Done(): case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return return
} }
msgs, lastQID, err := hdlr.pollMessages( hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -1257,11 +1221,9 @@ func (hdlr *Handlers) handleChannelMsg(
) { ) {
ctx := request.Context() ctx := request.Context()
// Delegate validation + fan-out to service layer. The // Delegate validation + fan-out to service layer.
// fan-out includes the sender's own session, so all dbID, uuid, err := hdlr.svc.SendChannelMessage(
// their clients see the message in long-poll responses. ctx, sessionID, nick,
uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, 0, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1271,6 +1233,13 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
// HTTP echo: enqueue to sender so all their clients
// see the message in long-poll responses.
_ = hdlr.params.Database.EnqueueToSession(
ctx, sessionID, dbID,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": uuid, statusKey: "sent"}, map[string]string{"id": uuid, statusKey: "sent"},
http.StatusOK) http.StatusOK)
@@ -1285,7 +1254,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage, meta json.RawMessage,
) { ) {
result, err := hdlr.svc.SendDirectMessage( result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1356,7 +1325,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) { ) {
result, err := hdlr.svc.JoinChannel( result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, suppliedKey, channel, suppliedKey,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1540,7 +1509,7 @@ func (hdlr *Handlers) handlePart(
} }
err := hdlr.svc.PartChannel( err := hdlr.svc.PartChannel(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, reason, nick, channel, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1611,7 +1580,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string, nick, newNick string,
) { ) {
err := hdlr.svc.ChangeNick( err := hdlr.svc.ChangeNick(
request.Context(), sessionID, 0, nick, newNick, request.Context(), sessionID, nick, newNick,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
writer, request, writer, request,
@@ -1666,7 +1635,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ") topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic( err := hdlr.svc.SetTopic(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, topic, nick, channel, topic,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -2152,7 +2121,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeText, modeText,
) )
@@ -2198,7 +2167,7 @@ func (hdlr *Handlers) setChannelFlag(
} }
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeStr, modeStr,
) )
@@ -3645,7 +3614,7 @@ func (hdlr *Handlers) handleKick(
} }
err := hdlr.svc.KickUser( err := hdlr.svc.KickUser(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -3718,7 +3687,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession( enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID, 0, request.Context(), sid, dbID,
) )
if enqErr != nil { if enqErr != nil {
hdlr.log.Error("enqueue failed", hdlr.log.Error("enqueue failed",
+70 -536
View File
@@ -29,7 +29,6 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"modernc.org/sqlite" "modernc.org/sqlite"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
@@ -140,7 +139,6 @@ type testServer struct {
fxApp *fxtest.App fxApp *fxtest.App
broker *broker.Broker broker *broker.Broker
database *db.Database database *db.Database
handlers *handlers.Handlers
} }
func newTestServer( func newTestServer(
@@ -171,7 +169,6 @@ func newTestServerWith(
srv *server.Server srv *server.Server
brk *broker.Broker brk *broker.Broker
database *db.Database database *db.Database
hdlr *handlers.Handlers
) )
app := fxtest.New(t, app := fxtest.New(t,
@@ -212,7 +209,7 @@ func newTestServerWith(
newTestHandlers, newTestHandlers,
newTestServerFx, newTestServerFx,
), ),
fx.Populate(&srv, &brk, &database, &hdlr), fx.Populate(&srv, &brk, &database),
) )
app.RequireStart() app.RequireStart()
@@ -230,7 +227,6 @@ func newTestServerWith(
fxApp: app, fxApp: app,
broker: brk, broker: brk,
database: database, database: database,
handlers: hdlr,
} }
} }
@@ -2557,364 +2553,6 @@ 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) { func TestEphemeralChannelCleanup(t *testing.T) {
const channel = "#ephemeral" const channel = "#ephemeral"
@@ -3267,73 +2905,84 @@ func TestPassCommand(t *testing.T) {
} }
} }
// The shortest and longest password PASS accepts; bcrypt func TestPassCommandShortPassword(t *testing.T) {
// cannot hash more than 72 bytes. tserver := newTestServer(t)
const ( token := tserver.createSession("shortpw")
minPasswordBytes = 8
maxPasswordBytes = 72
)
// TestPassCommandLength checks that PASS stores a password of // Drain initial messages.
// 8 to 72 bytes and refuses any other with 461. The limit _, lastID := tserver.pollMessages(token, 0)
// counts bytes: "é" is two bytes in UTF-8, so 72 of them are
// refused. // Try short password — should fail.
func TestPassCommandLength(t *testing.T) { status, _ := tserver.sendCommand(
cases := []struct { token,
name string map[string]any{
password string commandKey: irc.CmdPass,
stored bool bodyKey: []string{"short"},
}{ },
{"empty", "", false}, )
{"7 bytes", strings.Repeat("a", minPasswordBytes-1), false}, if status != http.StatusOK {
{"8 bytes", strings.Repeat("a", minPasswordBytes), true}, t.Fatalf("expected 200, got %d", status)
{"72 bytes", strings.Repeat("a", maxPasswordBytes), true},
{"73 bytes", strings.Repeat("a", maxPasswordBytes+1), false},
{"72 characters", strings.Repeat("é", maxPasswordBytes), false},
} }
for _, tc := range cases { msgs, _ := tserver.pollMessages(token, lastID)
t.Run(tc.name, func(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("pwlength")
_, lastID := tserver.pollMessages(token, 0) if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
status, result := tserver.sendCommand( // maxPasswordBytes is the longest password bcrypt can hash.
token, const maxPasswordBytes = 72
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 { // TestPassCommandLongPassword checks that a password over
msgs, _ := tserver.pollMessages(token, lastID) // maxPasswordBytes is refused like a short one, and that one
if !findNumeric(msgs, "461") { // of exactly maxPasswordBytes is stored.
t.Fatalf( func TestPassCommandLongPassword(t *testing.T) {
"expected ERR_NEEDMOREPARAMS (461), got %v", tserver := newTestServer(t)
msgs, token := tserver.createSession("longpw")
)
}
return // Drain initial messages.
} _, lastID := tserver.pollMessages(token, 0)
if result[statusKey] != "ok" { status, _ := tserver.sendCommand(
t.Fatalf("expected ok, got %v", result) token,
} map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{
strings.Repeat("a", maxPasswordBytes+1),
},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
postJSONExpectStatus( msgs, _ := tserver.pollMessages(token, lastID)
t, tserver, "/api/v1/login",
map[string]string{ if !findNumeric(msgs, "461") {
nickKey: "pwlength", passwordKey: tc.password, t.Fatalf(
}, "expected ERR_NEEDMOREPARAMS (461), got %v",
http.StatusOK, msgs,
) )
}) }
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{
strings.Repeat("a", maxPasswordBytes),
},
},
)
if status != http.StatusOK || result[statusKey] != "ok" {
t.Fatalf(
"expected 200 and ok, got %d: %v",
status, result,
)
} }
} }
@@ -4476,121 +4125,6 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
} }
} }
// postSessionWithStamp posts a session request for nick and
// username carrying stamp and returns the response status.
// An empty username defaults to the nick.
func postSessionWithStamp(
t *testing.T,
tserver *testServer,
nick, username, stamp string,
) int {
t.Helper()
body, err := json.Marshal(map[string]string{
nickKey: nick, "username": username, "pow_token": stamp,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
resp, err := doRequest(
t, http.MethodPost, tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
// sessionCount returns the number of sessions in tserver's
// database.
func sessionCount(t *testing.T, tserver *testServer) int64 {
t.Helper()
count, err := tserver.database.GetUserCount(t.Context())
if err != nil {
t.Fatalf("count sessions: %v", err)
}
return count
}
// TestRefusedSessionLeavesStampUnspent checks that a
// session request is refused with 402 for an invalid stamp
// before its nick is looked at, that a request refused with
// 400 or 409 does not spend its stamp, so the same stamp
// then creates a session, that the stamp stays spent after
// that, that a spent stamp with an invalid nick is refused
// for its nick, and that no refused request creates a
// session.
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
const (
bits = 2
badNick = "bad nick"
)
tserver := newTestServerWith(t, bits, "", "")
// A server with no SERVER_NAME takes stamps for "neoirc".
status := postSessionWithStamp(
t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"),
)
if status != http.StatusCreated {
t.Fatalf("create taken: want 201, got %d", status)
}
stamp := api.MintHashcash(bits, "neoirc")
otherServerStamp := api.MintHashcash(bits, "elsewhere")
steps := []struct {
nick, username, stamp string
want int
}{
{badNick, "", otherServerStamp, http.StatusPaymentRequired},
{badNick, "", stamp, http.StatusBadRequest},
{"fresh", "bad username", stamp, http.StatusBadRequest},
{"taken", "", stamp, http.StatusConflict},
{"fresh", "", stamp, http.StatusCreated},
{"another", "", stamp, http.StatusPaymentRequired},
// Refused as already spent, the stamp is not given back.
{"later", "", stamp, http.StatusPaymentRequired},
// Whether the stamp is spent is looked at after the nick.
{badNick, "", stamp, http.StatusBadRequest},
// The refused request for "another" left the nick free.
{"another", "", api.MintHashcash(bits, "neoirc"), http.StatusCreated},
}
sessions := sessionCount(t, tserver)
for _, step := range steps {
status = postSessionWithStamp(
t, tserver, step.nick, step.username, step.stamp,
)
if status != step.want {
t.Fatalf(
"nick %q, username %q: want %d, got %d",
step.nick, step.username, step.want, status,
)
}
if status == http.StatusCreated {
sessions++
}
count := sessionCount(t, tserver)
if count != sessions {
t.Fatalf(
"nick %q, username %q: want %d sessions, got %d",
step.nick, step.username, sessions, count,
)
}
}
}
func TestNamesShowsHostmask(t *testing.T) { func TestNamesShowsHostmask(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
-19
View File
@@ -1,19 +0,0 @@
package handlers
import (
"context"
"sneak.berlin/go/neoirc/internal/db"
)
// SetPollMessages makes HandleGetMessages read the client
// output queue with poll in place of Database.PollMessages.
func (hdlr *Handlers) SetPollMessages(
poll func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error),
) {
hdlr.pollMessages = poll
}
-11
View File
@@ -64,16 +64,6 @@ type Handlers struct {
loginLimiter *ratelimit.Limiter loginLimiter *ratelimit.Limiter
stats *stats.Tracker stats *stats.Tracker
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
// pollMessages is Database.PollMessages, with which
// HandleGetMessages reads the client output queue. Tests
// replace it to act just after a read, or to make a read
// fail.
pollMessages func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error)
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -105,7 +95,6 @@ func New(
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
loginLimiter: ratelimit.New(loginRate, loginBurst), loginLimiter: ratelimit.New(loginRate, loginBurst),
stats: params.Stats, stats: params.Stats,
pollMessages: params.Database.PollMessages,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
+9 -53
View File
@@ -77,13 +77,9 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Check returns nil if requiredBits is not positive, or if // Validate checks a hashcash stamp. It returns nil if the
// the stamp is a well-formed, recently dated proof of work // stamp is valid and has not been seen before.
// for this resource with at least requiredBits. It does not func (v *Validator) Validate(
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other
// checks.
func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
) error { ) error {
@@ -91,49 +87,9 @@ func (v *Validator) Check(
return nil return nil
} }
_, err := v.check(stamp, requiredBits)
return err
}
// Claim checks the stamp as Check does and, when
// requiredBits is positive, records it as spent until it
// expires. It returns an error if the stamp is already
// spent. Release gives a claimed stamp back.
func (v *Validator) Claim(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
stampTime, err := v.check(stamp, requiredBits)
if err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
// Release forgets a claimed stamp so that it can be claimed
// again, for a request that failed after its Claim.
// Releasing a stamp that is not claimed does nothing.
func (v *Validator) Release(stamp string) {
v.mu.Lock()
defer v.mu.Unlock()
delete(v.spent, stamp)
}
// check returns the stamp's date if Check accepts it.
func (v *Validator) check(
stamp string,
requiredBits int,
) (time.Time, error) {
parts := strings.Split(stamp, ":") parts := strings.Split(stamp, ":")
if len(parts) != stampFields { if len(parts) != stampFields {
return time.Time{}, fmt.Errorf( return fmt.Errorf(
"%w: expected %d, got %d", "%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts), errInvalidFields, stampFields, len(parts),
) )
@@ -148,25 +104,25 @@ func (v *Validator) check(
version, bitsStr, resource, requiredBits, version, bitsStr, resource, requiredBits,
) )
if err != nil { if err != nil {
return time.Time{}, err return err
} }
stampTime, err := parseStampDate(dateStr) stampTime, err := parseStampDate(dateStr)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateTime(stampTime) err = validateTime(stampTime)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateProof(stamp, requiredBits) err = validateProof(stamp, requiredBits)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
return stampTime, nil return v.checkAndRecordStamp(stamp, stampTime)
} }
func (v *Validator) validateHeader( func (v *Validator) validateHeader(
+14 -76
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("valid stamp rejected: %v", err) t.Fatalf("valid stamp rejected: %v", err)
} }
@@ -99,74 +99,17 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("first use failed: %v", err) t.Fatalf("first use failed: %v", err)
} }
err = validator.Claim(stamp, testBits) err = validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("replay not detected") t.Fatal("replay not detected")
} }
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once, and that
// Check still accepts it once it is spent.
func TestCheckDoesNotSpendStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
for range 2 {
err := validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after check failed: %v", err)
}
err = validator.Claim(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
err = validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("spent stamp rejected by Check: %v", err)
}
}
// TestReleasedStampCanBeClaimedAgain checks that Release
// gives a claimed stamp back.
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("first claim failed: %v", err)
}
validator.Release(stamp)
err = validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after release failed: %v", err)
}
}
func TestResourceMismatch(t *testing.T) { func TestResourceMismatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -175,7 +118,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(), t, testBits, "wrong-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected resource mismatch error") t.Fatal("expected resource mismatch error")
} }
@@ -186,7 +129,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Claim( err := validator.Validate(
"not:a:valid:stamp", testBits, "not:a:valid:stamp", testBits,
) )
if err == nil { if err == nil {
@@ -203,7 +146,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource", testBits, todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad version error") t.Fatal("expected bad version error")
} }
@@ -219,7 +162,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource", todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected insufficient bits error") t.Fatal("expected insufficient bits error")
} }
@@ -235,7 +178,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate, t, testBits, "test-resource", oldDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected expired stamp error") t.Fatal("expected expired stamp error")
} }
@@ -246,14 +189,9 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Check("garbage", 0) err := validator.Validate("garbage", 0)
if err != nil { if err != nil {
t.Fatalf("zero bits should skip the check: %v", err) t.Fatalf("zero bits should skip: %v", err)
}
err = validator.Claim("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the claim: %v", err)
} }
} }
@@ -266,7 +204,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate, t, testBits, "test-resource", longDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("long date stamp rejected: %v", err) t.Fatalf("long date stamp rejected: %v", err)
} }
@@ -281,7 +219,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource", testBits, "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad date error") t.Fatal("expected bad date error")
} }
@@ -297,7 +235,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("unique stamp rejected: %v", err) t.Fatalf("unique stamp rejected: %v", err)
} }
@@ -313,7 +251,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(), t, 4, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"higher-difficulty stamp rejected: %v", "higher-difficulty stamp rejected: %v",
+21 -21
View File
@@ -81,10 +81,10 @@ func (c *Conn) handleNick(
} }
oldMask := c.hostmask() oldMask := c.hostmask()
oldNick := c.currentNick() oldNick := c.nick
err := c.svc.ChangeNick( err := c.svc.ChangeNick(
ctx, c.sessionID, c.clientID, oldNick, newNick, ctx, c.sessionID, oldNick, newNick,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -92,10 +92,9 @@ func (c *Conn) handleNick(
return return
} }
// Take the nick the session holds, not newNick: another c.mu.Lock()
// client of the session may have changed it again since c.nick = newNick
// ChangeNick stored newNick. c.mu.Unlock()
c.loadSessionNick(ctx)
// Echo NICK change to the client on wire. // Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick)) c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -129,8 +128,8 @@ func (c *Conn) handlePrivmsg(
body, _ := json.Marshal([]string{text}) //nolint:errchkjson body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
_, err := c.svc.SendChannelMessage( _, _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -138,7 +137,7 @@ func (c *Conn) handlePrivmsg(
} }
} else { } else {
result, err := c.svc.SendDirectMessage( result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -200,7 +199,7 @@ func (c *Conn) joinChannel(
channel, key string, channel, key string,
) { ) {
result, err := c.svc.JoinChannel( result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, key, ctx, c.sessionID, c.nick, channel, key,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -338,7 +337,7 @@ func (c *Conn) partChannel(
channel, reason string, channel, reason string,
) { ) {
err := c.svc.PartChannel( err := c.svc.PartChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason, ctx, c.sessionID, c.nick, channel, reason,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -402,7 +401,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1] newTopic := msg.Params[1]
err := c.svc.SetTopic( err := c.svc.SetTopic(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic, ctx, c.sessionID, c.nick, channel, newTopic,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -709,7 +708,7 @@ func (c *Conn) applyChannelModes(
c.send(FormatMessage(c.hostmask(), "MODE", params...)) c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.svc.BroadcastMode( c.svc.BroadcastMode(
ctx, c.clientID, c.currentNick(), channel, chID, modeReply, ctx, c.nick, channel, chID, modeReply,
) )
} }
} }
@@ -1186,7 +1185,7 @@ func (c *Conn) handleKick(
} }
err := c.svc.KickUser( err := c.svc.KickUser(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if err != nil { if err != nil {
@@ -1226,7 +1225,7 @@ func (c *Conn) handlePassPostReg(
return return
} }
c.sendFromServer("NOTICE", c.currentNick(), c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+ "Password set. You can reconnect using "+
"PASS <password> with your nick.", "PASS <password> with your nick.",
) )
@@ -1250,7 +1249,7 @@ func (c *Conn) handleInvite(
channel := msg.Params[1] channel := msg.Params[1]
targetID, err := c.svc.InviteUser( targetID, err := c.svc.InviteUser(
ctx, c.sessionID, c.currentNick(), targetNick, channel, ctx, c.sessionID, c.nick, targetNick, channel,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -1262,13 +1261,14 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel, irc.RplInviting, targetNick, channel,
) )
// Queue the INVITE to the target with the body the HTTP // Send INVITE notice to target via service fan-out.
// API uses. body, _ := json.Marshal( //nolint:errchkjson
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson []string{"You have been invited to " + channel},
)
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.currentNick(), targetNick, targetID, 0, ctx, "INVITE", c.nick, targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, 0, nil, body, nil, []int64{targetID},
) )
} }
+8 -30
View File
@@ -63,7 +63,6 @@ type Conn struct {
registered bool registered bool
gotNick bool gotNick bool
gotUser bool gotUser bool
gotPass bool
passWord string passWord string
lastQueueID int64 lastQueueID int64
@@ -313,22 +312,6 @@ func (c *Conn) currentNick() string {
return c.nick return c.nick
} }
// loadSessionNick sets c.nick to the nick the connection's
// session holds in the database, read by session ID. The
// connection's own NICK and the relay passing on a NICK line
// both call it, and it reads and sets the nick under c.mu, so
// whichever runs last leaves the connection on its session's
// nick. If the read fails, c.nick is left as it is.
func (c *Conn) loadSessionNick(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
if err == nil {
c.nick = nick
}
}
// send writes a formatted IRC line to the connection. // send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) { func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line) c.sendWithin(writeTimeout, line)
@@ -361,7 +344,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType, code irc.IRCMessageType,
params ...string, params ...string,
) { ) {
nick := c.currentNick() nick := c.nick
if nick == "" { if nick == "" {
nick = "*" nick = "*"
} }
@@ -385,11 +368,9 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask // hostmask returns the client's full hostmask
// (nick!user@host). // (nick!user@host).
func (c *Conn) hostmask() string { func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username user := c.username
if user == "" { if user == "" {
user = nick user = c.nick
} }
host := c.hostname host := c.hostname
@@ -397,7 +378,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP host = c.remoteIP
} }
return nick + "!" + user + "@" + host return c.nick + "!" + user + "@" + host
} }
// handleMessage dispatches a parsed IRC message using // handleMessage dispatches a parsed IRC message using
@@ -456,7 +437,6 @@ func (c *Conn) handlePreRegistration(
} }
c.passWord = msg.Params[0] c.passWord = msg.Params[0]
c.gotPass = true
case irc.CmdNick: case irc.CmdNick:
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
@@ -576,12 +556,10 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.deliverLusers(ctx) c.deliverLusers(ctx)
c.deliverMOTD() c.deliverMOTD()
// If PASS was given before registration, store its // If PASS was provided before registration, set the
// password now, after the welcome burst, so that a // session password now. A failure to store it is only
// NOTICE refusing it reaches a registered client. A // logged, as it is for PASS after registration.
// failure to store it is only logged, as it is for PASS if c.passWord != "" && c.checkPasswordLength(c.passWord) {
// after registration.
if c.gotPass && c.checkPasswordLength(c.passWord) {
_ = c.setPassword(ctx, c.passWord) _ = c.setPassword(ctx, c.passWord)
} }
@@ -675,7 +653,7 @@ func (c *Conn) checkPasswordLength(pw string) bool {
return true return true
} }
c.sendFromServer("NOTICE", c.currentNick(), c.sendFromServer("NOTICE", c.nick,
"Password must be 8 to 72 bytes", "Password must be 8 to 72 bytes",
) )
-5
View File
@@ -71,11 +71,6 @@ func (s *Server) Listener() net.Listener {
return s.listener return s.listener
} }
// Service returns the service the server's connections use.
func (s *Server) Service() *service.Service {
return s.svc
}
// PingTimes returns how long the server's connections wait // PingTimes returns how long the server's connections wait
// with nothing read before they are sent a PING, and after it // with nothing read before they are sent a PING, and after it
// before they are closed. // before they are closed.
-36
View File
@@ -1373,42 +1373,6 @@ func TestIntegrationInviteUserOnChannel(t *testing.T) {
) )
} }
// TestIntegrationInviteLine checks that an IRC listener user
// invited by an IRC listener user, and then over the HTTP
// API, gets an INVITE line from the inviter for the channel
// each time.
func TestIntegrationInviteLine(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#inv")
bob := env.dial(t)
bob.register("bob")
alice.sendAndExpect("INVITE bob #inv", " 341 ")
got := bob.readLine()
want := ":alice!alice@* INVITE bob #inv"
if got != want {
t.Errorf("from the IRC listener: got %q, want %q", got, want)
}
// The HTTP API queues an INVITE with the channel as its body.
env.queueMessage(t, "bob", "INVITE", "carol", "bob", []string{"#inv"})
got = bob.readLine()
want = ":carol!carol@* INVITE bob #inv"
if got != want {
t.Errorf("over the HTTP API: got %q, want %q", got, want)
}
}
// TestIntegrationModeSecretList checks that LIST shows a +s // TestIntegrationModeSecretList checks that LIST shows a +s
// channel only to its members. // channel only to its members.
func TestIntegrationModeSecretList(t *testing.T) { func TestIntegrationModeSecretList(t *testing.T) {
+50 -27
View File
@@ -27,22 +27,17 @@ func (c *Conn) relayMessages(ctx context.Context) {
default: default:
} }
// Register with the broker before draining, so that a
// message queued after the drain has read the queue
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. // Drain any available messages.
delivered := c.drainQueue(ctx) delivered := c.drainQueue(ctx)
if delivered { if delivered {
c.brk.Remove(c.sessionID, waitCh)
// Tight loop while there are messages. // Tight loop while there are messages.
continue continue
} }
// Wait for notification or timeout. // Wait for notification or timeout.
waitCh := c.brk.Wait(c.sessionID)
select { select {
case <-waitCh: case <-waitCh:
// New message notification — loop back. // New message notification — loop back.
@@ -92,7 +87,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
// //
//nolint:cyclop // dispatch table //nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage( func (c *Conn) deliverIRCMessage(
ctx context.Context, _ context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
) { ) {
command := msg.Command command := msg.Command
@@ -120,7 +115,7 @@ func (c *Conn) deliverIRCMessage(
case command == irc.CmdPart: case command == irc.CmdPart:
c.deliverPart(msg, text) c.deliverPart(msg, text)
case command == irc.CmdNick: case command == irc.CmdNick:
c.deliverNickChange(ctx, msg, text) c.deliverNickChange(msg, text)
case command == irc.CmdQuit: case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text) c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic: case command == irc.CmdTopic:
@@ -198,6 +193,15 @@ func (c *Conn) deliverTextMessage(
from := msg.From from := msg.From
target := msg.To target := msg.To
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own messages back.
if from == nick {
return
}
prefix := from prefix := from
if !strings.Contains(prefix, "!") { if !strings.Contains(prefix, "!") {
prefix = from + "!" + from + "@*" prefix = from + "!" + from + "@*"
@@ -210,6 +214,16 @@ func (c *Conn) deliverTextMessage(
// deliverJoin sends a JOIN notification. // deliverJoin sends a JOIN notification.
func (c *Conn) deliverJoin(msg *db.IRCMessage) { func (c *Conn) deliverJoin(msg *db.IRCMessage) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own JOINs (we already sent them
// during joinChannel).
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -218,6 +232,14 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification. // deliverPart sends a PART notification.
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -230,17 +252,18 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
} }
} }
// deliverNickChange sends a NICK change notification. The // deliverNickChange sends a NICK change notification.
// change may be its own session's, made from another client,
// so the connection first takes the nick its session holds
// now. It does not go by the line's old nick: the connection
// may have taken that nick after another user gave it up.
func (c *Conn) deliverNickChange( func (c *Conn) deliverNickChange(
ctx context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
newNick string, newNick string,
) { ) {
c.loadSessionNick(ctx) c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
@@ -252,6 +275,14 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage, msg *db.IRCMessage,
text string, text string,
) { ) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
if text != "" { if text != "" {
@@ -304,24 +335,16 @@ func (c *Conn) deliverKickMsg(
} }
} }
// deliverInviteMsg sends an INVITE line from the inviter // deliverInviteMsg sends an INVITE notification.
// for the channel named in text, or nothing when text names
// no channel.
func (c *Conn) deliverInviteMsg( func (c *Conn) deliverInviteMsg(
msg *db.IRCMessage, _ *db.IRCMessage,
text string, text string,
) { ) {
if text == "" {
return
}
c.mu.Lock() c.mu.Lock()
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
prefix := msg.From + "!" + msg.From + "@*" c.sendFromServer("NOTICE", nick, text)
c.send(FormatMessage(prefix, "INVITE", nick, text))
} }
// deliverWallops sends a WALLOPS notification. // deliverWallops sends a WALLOPS notification.
+14 -440
View File
@@ -1,7 +1,6 @@
package ircserver_test package ircserver_test
import ( import (
"context"
"encoding/json" "encoding/json"
"slices" "slices"
"strings" "strings"
@@ -9,7 +8,6 @@ import (
"time" "time"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
) )
// queueMessage queues a message for the user with nick // queueMessage queues a message for the user with nick
@@ -41,7 +39,7 @@ func (env *testEnv) queueMessage(
t.Fatalf("get session of %s: %v", recipient, err) t.Fatalf("get session of %s: %v", recipient, err)
} }
err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0) err = env.database.EnqueueToSession(ctx, sessionID, msgID)
if err != nil { if err != nil {
t.Fatalf("enqueue: %v", err) t.Fatalf("enqueue: %v", err)
} }
@@ -49,13 +47,14 @@ func (env *testEnv) queueMessage(
env.brk.Notify(sessionID) env.brk.Notify(sessionID)
} }
// TestRelayKeepsOneWaitChannel checks that an IRC listener // TestRelayKeepsOneWaitChannel checks that an idle IRC
// connection holds at most one broker wait channel across // listener connection holds at most one broker wait channel
// several relay ticks, both while idle and after the relay // across several relay ticks, and none once it closes.
// has delivered it a message, and none once it closes.
func TestRelayKeepsOneWaitChannel(t *testing.T) { func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel() t.Parallel()
const idleTicks = 5
env := newTestEnv(t) env := newTestEnv(t)
client := env.dial(t) client := env.dial(t)
@@ -66,17 +65,15 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Fatalf("get session of idle: %v", err) t.Fatalf("get session of idle: %v", err)
} }
env.checkOneWaitChannel(t, sessionID) for tick := range idleTicks {
time.Sleep(ircserver.PollInterval)
env.queueMessage( got := env.brk.ListenerCount(sessionID)
t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"}, if got > 1 {
) t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
client.readUntil(func(l string) bool { }
return strings.Contains(l, "hello") }
})
env.checkOneWaitChannel(t, sessionID)
err = client.conn.Close() err = client.conn.Close()
if err != nil { if err != nil {
@@ -95,28 +92,6 @@ func TestRelayKeepsOneWaitChannel(t *testing.T) {
} }
} }
// checkOneWaitChannel fails the test if the session
// sessionID holds more than one broker wait channel at any of
// several relay ticks.
func (env *testEnv) checkOneWaitChannel(
t *testing.T,
sessionID int64,
) {
t.Helper()
const ticks = 5
for tick := range ticks {
time.Sleep(ircserver.PollInterval)
got := env.brk.ListenerCount(sessionID)
if got > 1 {
t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
}
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG // TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC // or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line. // listener user as its own line.
@@ -213,38 +188,6 @@ func TestRelayModeParameters(t *testing.T) {
} }
} }
// TestRelayInviteWithoutChannel checks that an INVITE whose
// body names no channel sends the invited IRC listener user
// no INVITE line.
func TestRelayInviteWithoutChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
// A nil body is stored as JSON null, which is not an array.
for _, body := range [][]string{nil, {}, {""}} {
env.queueMessage(t, "bob", "INVITE", "carol", "bob", body)
}
// The relay sends a user's queue in order, so this arrives
// after anything sent for the INVITEs above.
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
for _, line := range lines {
if strings.Contains(line, " INVITE ") {
t.Errorf("got %q, want no INVITE line", line)
}
}
}
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored // TestRelayLineBreaks checks that a CR, LF or NUL in a stored
// body line, key or mask never reaches an IRC listener user // body line, key or mask never reaches an IRC listener user
// as a line break, so no extra line arrives, and that a body // as a line break, so no extra line arrives, and that a body
@@ -326,372 +269,3 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
} }
} }
} }
// TestRelayOwnChangeOnce checks that an IRC listener user who
// sets a topic, changes a channel mode or kicks gets one TOPIC,
// MODE or KICK line for it, and so does the other member.
func TestRelayOwnChangeOnce(t *testing.T) {
t.Parallel()
for _, step := range []struct {
command, line string
}{
{"TOPIC", "TOPIC #dup :new topic"},
{"MODE", "MODE #dup +m"},
{"KICK", "KICK #dup carl :bye"},
} {
t.Run(step.command, func(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#dup")
carl := env.dial(t)
carl.register("carl")
carl.joinAndDrain("#dup")
alice.send(step.line)
// The reply to alice's next command comes after her
// command has finished, so her own copy comes before it.
beforePong := alice.sendAndExpect("PING :done", "PONG")
// The relay sends a user's queue in order, so a message
// queued now arrives after any relayed copy.
for nick, client := range map[string]*testClient{
"alice": alice, "carl": carl,
} {
env.queueMessage(
t, nick, "PRIVMSG", "dave", nick,
[]string{"after the change"},
)
lines := client.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :after the change")
})
if client == alice {
lines = slices.Concat(beforePong, lines)
}
var changes []string
for _, line := range lines {
if strings.Contains(line, " "+step.command+" #dup ") {
changes = append(changes, line)
}
}
if len(changes) != 1 {
t.Errorf("%s got %q, want one %s line",
nick, changes, step.command)
}
}
})
}
}
// TestRelayOwnSessionOnce checks that a command a user makes
// from one client of their session reaches each of the
// session's clients once: an IRC listener connection and an
// HTTP API client. The connection that sent a command gets
// only its own echo, and none for PRIVMSG and NOTICE, which
// IRC does not echo.
//
//nolint:funlen // table-driven test
func TestRelayOwnSessionOnce(t *testing.T) {
t.Parallel()
hello := json.RawMessage(`["hello"]`)
for _, step := range []struct {
// line is the command as alice sends it on the IRC
// listener.
line string
// api makes the same command for her session the way
// the HTTP API does.
api func(
ctx context.Context, svc *service.Service, sessionID int64,
) error
// echoes is how many copies alice's connection gets of
// line when it sends line itself.
echoes int
}{
{"PRIVMSG #chan :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendChannelMessage(
ctx, sessionID, 0, "alice", "PRIVMSG", "#chan", hello, nil,
)
return err
}, 0},
{"NOTICE bob :hello", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.SendDirectMessage(
ctx, sessionID, 0, "alice", "NOTICE", "bob", hello, nil,
)
return err
}, 0},
{"JOIN #other", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
_, err := svc.JoinChannel(ctx, sessionID, 0, "alice", "#other", "")
return err
}, 1},
{"PART #chan :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.PartChannel(ctx, sessionID, 0, "alice", "#chan", "bye")
}, 1},
{"NICK alice2", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.ChangeNick(ctx, sessionID, 0, "alice", "alice2")
}, 1},
{"TOPIC #chan :new topic", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.SetTopic(
ctx, sessionID, 0, "alice", "#chan", "new topic",
)
}, 1},
{"MODE #chan +m", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
chID, err := svc.ValidateChannelOp(ctx, sessionID, "#chan")
if err != nil {
return err
}
err = svc.SetChannelFlag(ctx, chID, 'm', true)
if err != nil {
return err
}
svc.BroadcastMode(ctx, 0, "alice", "#chan", chID, "+m")
return nil
}, 1},
{"KICK #chan bob :bye", func(
ctx context.Context, svc *service.Service, sessionID int64,
) error {
return svc.KickUser(
ctx, sessionID, 0, "alice", "#chan", "bob", "bye",
)
}, 1},
} {
command := strings.Fields(step.line)[0]
t.Run(command+" from IRC", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, nil)
if ircCopies != step.echoes || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want %d and 1",
ircCopies, httpCopies, step.echoes)
}
})
t.Run(command+" from the HTTP API", func(t *testing.T) {
t.Parallel()
ircCopies, httpCopies := ownSessionCopies(t, step.line, step.api)
if ircCopies != 1 || httpCopies != 1 {
t.Errorf("connection got %d, HTTP client got %d, want 1 and 1",
ircCopies, httpCopies)
}
})
}
}
// ownSessionCopies sets up alice with aliceWithHTTPClient. It
// makes the command line from her connection, or with api from
// the HTTP API client when api is not nil, and returns how many
// copies of it her connection and her HTTP API client got.
func ownSessionCopies(
t *testing.T,
line string,
api func(ctx context.Context, svc *service.Service, sessionID int64) error,
) (int, int) {
t.Helper()
ctx := t.Context()
env := newTestEnv(t)
alice, _, sessionID, httpClientID := aliceWithHTTPClient(t, env)
var lines []string
if api == nil {
alice.send(line)
// Her echo comes before the reply to her next command.
lines = alice.sendAndExpect("PING :done", "PONG")
} else {
err := api(ctx, env.srv.Service(), sessionID)
if err != nil {
t.Fatalf("%s over the HTTP API: %v", line, err)
}
}
msgs, _, err := env.database.PollMessages(ctx, httpClientID, 0, 0)
if err != nil {
t.Fatalf("poll the HTTP API client: %v", err)
}
httpCopies := 0
for _, msg := range msgs {
if msg.Command == strings.Fields(line)[0] {
httpCopies++
}
}
// The relay sends a queue in order, so this arrives after any
// copy. It is queued by session, as NICK changes her nick.
endID, _, err := env.database.InsertMessage(
ctx, "PRIVMSG", "dave", "alice", 0, 0,
nil, json.RawMessage(`["the end"]`), nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
err = env.database.EnqueueToSession(ctx, sessionID, endID, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
lines = append(lines, alice.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})...)
// A copy starts with the command and its first parameter.
start := " " + strings.Join(strings.Fields(line)[:2], " ")
ircCopies := strings.Count(strings.Join(lines, "\n"), start)
return ircCopies, httpCopies
}
// aliceWithHTTPClient connects alice to the IRC listener and
// has her join #chan, which bob is on too, then adds an HTTP API
// client to her session with the password her connection set,
// as POST /api/v1/login does. It returns her connection, bob's
// connection, her session and her HTTP API client.
func aliceWithHTTPClient(
t *testing.T, env *testEnv,
) (*testClient, *testClient, int64, int64) {
t.Helper()
const password = "alicepass"
alice := env.dial(t)
alice.send("PASS " + password)
alice.register("alice")
// Registration stores the password before the JOIN runs.
alice.joinAndDrain("#chan")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#chan")
sessionID, httpClientID, _, err := env.database.LoginUser(
t.Context(), "alice", password, "127.0.0.1", "localhost",
)
if err != nil {
t.Fatalf("log in as alice: %v", err)
}
return alice, bob, sessionID, httpClientID
}
// TestRelayNickChangeFromHTTPAPI checks that once alice's nick
// is changed from her HTTP API client, her IRC listener
// connection uses the new nick: what she then sends from it
// reaches bob under the new nick.
func TestRelayNickChangeFromHTTPAPI(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice, bob, sessionID, _ := aliceWithHTTPClient(t, env)
bob.joinAndDrain("#other")
// As the HTTP API does.
err := env.srv.Service().ChangeNick(
t.Context(), sessionID, 0, "alice", "alice2",
)
if err != nil {
t.Fatalf("change nick over the HTTP API: %v", err)
}
// Her connection has taken the new nick once it has sent her
// the change.
const change = ":alice!alice@* NICK alice2"
alice.readUntil(func(l string) bool { return l == change })
bob.readUntil(func(l string) bool { return l == change })
for _, step := range []struct {
line, want string
}{
{"PRIVMSG #chan :hi there", ":alice2!alice2@* PRIVMSG #chan :hi there"},
{"JOIN #other", ":alice2!alice2@* JOIN #other"},
{"NICK alice3", ":alice2!alice2@* NICK alice3"},
} {
alice.send(step.line)
got := bob.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// TestRelayNickChangeKeepsTakenNick checks that an IRC
// listener connection which takes a nick another user has
// given up keeps it when that user's change reaches it
// afterwards: what it then sends goes out under the nick its
// session holds.
func TestRelayNickChangeKeepsTakenNick(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("NICK bob2", " NICK bob2")
alice := env.dial(t)
alice.register("alice")
alice.sendAndExpect("NICK bob", " NICK bob")
alice.joinAndDrain("#chan")
bob.joinAndDrain("#chan")
// Bob's change reaches alice only now, as it does when she
// takes his old nick before her connection has passed the
// change on. She holds bob now, so it is queued to bob.
env.queueMessage(t, "bob", "NICK", "bob", "", []string{"bob2"})
alice.readUntil(func(l string) bool {
return l == ":bob!bob@* NICK bob2"
})
alice.send("PRIVMSG #chan :hi there")
const want = ":bob!bob@* PRIVMSG #chan :hi there"
got := bob.readLine()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
+59 -95
View File
@@ -645,6 +645,8 @@ func TestNewPingTimes(t *testing.T) {
func TestIdleCleanupKeepsActiveUser(t *testing.T) { func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel() t.Parallel()
const idleTimeout = 500 * time.Millisecond
env := newTestEnv(t) env := newTestEnv(t)
active := env.dial(t) active := env.dial(t)
@@ -653,17 +655,13 @@ func TestIdleCleanupKeepsActiveUser(t *testing.T) {
idle := env.dial(t) idle := env.dial(t)
idle.register("idle") idle.register("idle")
// Both users were last seen before cutoff, and only active time.Sleep(idleTimeout)
// sends a command after it, so the result does not depend
// on how long the PONG takes to arrive.
cutoff := time.Now()
active.sendAndExpect("PING :still here", "PONG") active.sendAndExpect("PING :still here", "PONG")
// The idle cleanup in internal/handlers removes exactly // The idle cleanup in internal/handlers removes exactly
// the users this returns. // the users this returns.
stale, err := env.database.GetStaleOrphanSessions( stale, err := env.database.GetStaleOrphanSessions(
t.Context(), cutoff, t.Context(), time.Now().Add(-idleTimeout),
) )
if err != nil { if err != nil {
t.Fatalf("get stale sessions: %v", err) t.Fatalf("get stale sessions: %v", err)
@@ -1160,91 +1158,47 @@ func TestHandlePassPostRegistration(t *testing.T) {
) )
} }
// The shortest and longest password PASS accepts; bcrypt // maxPasswordBytes is the longest password bcrypt can hash.
// cannot hash more than 72 bytes. const maxPasswordBytes = 72
const (
minPasswordBytes = 8
maxPasswordBytes = 72
)
// passwordLengthNotice is the NOTICE text for a password that // passwordLengthNotice is the NOTICE text for a password that
// is too short or too long. // is too short or too long.
const passwordLengthNotice = "Password must be 8 to 72 bytes" const passwordLengthNotice = "Password must be 8 to 72 bytes"
// passwordCase is a password a PASS test sends, and whether // assertCanLogin fails the test if nick cannot log in with
// the server should store it. // password.
type passwordCase struct { func assertCanLogin(t *testing.T, env *testEnv, nick, password string) {
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() t.Helper()
_, _, token, err := env.database.LoginUser( _, _, token, err := env.database.LoginUser(
t.Context(), nick, password, "127.0.0.1", "localhost", t.Context(), nick, password, "127.0.0.1", "localhost",
) )
if err != nil || token == "" {
loggedIn := err == nil && token != "" t.Errorf("log in as %s: token %q, %v", nick, token, err)
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 // TestPassPostRegistrationLength checks that a PASS over
// registration stores and confirms a password of 8 to 72 // maxPasswordBytes after registration is refused with a
// bytes, and refuses any other with a NOTICE. // NOTICE, and that one of exactly maxPasswordBytes is stored
// and confirmed.
func TestPassPostRegistrationLength(t *testing.T) { func TestPassPostRegistrationLength(t *testing.T) {
t.Parallel() t.Parallel()
env := newTestEnv(t) env := newTestEnv(t)
client := env.dial(t)
for i, tc := range passwordCases() { client.register("longpass")
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("postpass%d", i) client.sendAndExpect(
client := env.dial(t) "PASS :"+strings.Repeat("a", maxPasswordBytes+1),
passwordLengthNotice,
)
client.register(nick) longest := strings.Repeat("a", maxPasswordBytes)
client.send("PASS :" + tc.password) client.sendAndExpect("PASS :"+longest, "Password set")
// The server answers commands in order, so its assertCanLogin(t, env, "longpass", longest)
// 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 // TestPassPostRegistrationNotStored checks that PASS is not
@@ -1279,46 +1233,56 @@ func TestPassPostRegistrationNotStored(t *testing.T) {
} }
// TestPassBeforeRegistrationLength checks that a PASS before // TestPassBeforeRegistrationLength checks that a PASS before
// registration stores a password of 8 to 72 bytes, and that // registration that is too short or too long is refused with
// any other is refused with a NOTICE once registration // a NOTICE once registration completes.
// completes.
func TestPassBeforeRegistrationLength(t *testing.T) { func TestPassBeforeRegistrationLength(t *testing.T) {
t.Parallel() t.Parallel()
cases := []struct {
nick, password string
}{
{"preshort", "short"},
{"prelong", strings.Repeat("a", maxPasswordBytes+1)},
}
env := newTestEnv(t) env := newTestEnv(t)
for i, tc := range passwordCases() { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.nick, func(t *testing.T) {
t.Parallel() t.Parallel()
nick := fmt.Sprintf("prepass%d", i)
client := env.dial(t) client := env.dial(t)
client.send("PASS :" + tc.password) client.send("PASS :" + tc.password)
client.register(nick) client.register(tc.nick)
// The password is checked after the welcome client.readUntil(func(line string) bool {
// burst, which register reads; the PONG comes return strings.Contains(line, passwordLengthNotice)
// 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)
}) })
} }
} }
// TestPassBeforeRegistrationStored checks that a PASS of
// exactly maxPasswordBytes before registration is stored.
func TestPassBeforeRegistrationStored(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
longest := strings.Repeat("a", maxPasswordBytes)
client.send("PASS :" + longest)
client.register("prestored")
// The password is stored after the welcome burst; the
// PONG comes after that.
client.sendAndExpect("PING :after", "PONG")
assertCanLogin(t, env, "prestored", longest)
}
func TestPreRegistrationNotRegistered(t *testing.T) { func TestPreRegistrationNotRegistered(t *testing.T) {
t.Parallel() t.Parallel()
+77 -74
View File
@@ -312,7 +312,7 @@ func (s *Service) SendWallops(
_, _, err = s.FanOut( _, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*", 0, 0, ctx, irc.CmdWallops, nick, "*", 0, 0,
nil, body, nil, recipients, 0, nil, body, nil, recipients,
) )
if err != nil { if err != nil {
return fmt.Errorf("wallops: %w", err) return fmt.Errorf("wallops: %w", err)
@@ -347,19 +347,12 @@ type DirectMsgResult struct {
// session IDs, notifying each via the broker. toSessionID // session IDs, notifying each via the broker. toSessionID
// and toChannelID are the recipient InsertMessage records // and toChannelID are the recipient InsertMessage records
// for history, 0 when there is none. // for history, 0 when there is none.
//
// skipClientID is the client whose queue the message is
// not added to: the IRC listener connection that sent the
// command, which has already shown it to its user. The HTTP
// API passes 0, as its clients see their own commands
// through their queues.
func (s *Service) FanOut( func (s *Service) FanOut(
ctx context.Context, ctx context.Context,
command, from, to string, command, from, to string,
toSessionID, toChannelID int64, toSessionID, toChannelID int64,
params, body, meta json.RawMessage, params, body, meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
skipClientID int64,
) (int64, string, error) { ) (int64, string, error) {
dbID, msgUUID, err := s.db.InsertMessage( dbID, msgUUID, err := s.db.InsertMessage(
ctx, command, from, to, ctx, command, from, to,
@@ -370,40 +363,66 @@ func (s *Service) FanOut(
} }
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID) _ = s.db.EnqueueToSession(ctx, sid, dbID)
s.broker.Notify(sid) s.broker.Notify(sid)
} }
return dbID, msgUUID, nil return dbID, msgUUID, nil
} }
// SendChannelMessage checks membership, bans and // excludeSession returns a copy of ids without the given
// moderation, then the hashcash stamp of a PRIVMSG to a +H // session.
// channel, then fans out a message to all channel members, func excludeSession(
// the sender's session included, skipping client ids []int64,
// skipClientID as FanOut does. The stamp is checked after exclude int64,
// the other checks so that a message they refuse does not ) []int64 {
// spend it. Returns the message UUID and any error. out := make([]int64, 0, len(ids))
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
// SendChannelMessage validates the hashcash stamp of a
// PRIVMSG to a +H channel, membership and moderation, then
// fans out a message to all channel members except the
// sender. Returns the database row ID, message UUID, and
// any error. The dbID lets callers enqueue the same
// message to the sender when echo is needed (HTTP
// transport).
func (s *Service) SendChannelMessage( func (s *Service) SendChannelMessage(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, command, channel string, nick, command, channel string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (string, error) { ) (int64, string, error) {
noSuchChannel := &IRCError{ noSuchChannel := &IRCError{
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel, irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
} }
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil { if err != nil {
return "", noSuchChannel return 0, "", noSuchChannel
}
if command == irc.CmdPrivmsg {
err = s.checkChannelHashcash(
ctx, chID, channel, body, meta,
)
if err != nil {
return 0, "", err
}
} }
isMember, _ := s.db.IsChannelMember( isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if !isMember { if !isMember {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel", "Cannot send to channel",
@@ -415,7 +434,7 @@ func (s *Service) SendChannelMessage(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if banErr == nil && isBanned { if banErr == nil && isBanned {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+b)", "Cannot send to channel (+b)",
@@ -432,7 +451,7 @@ func (s *Service) SendChannelMessage(
) )
if !isOp && !isVoiced { if !isOp && !isVoiced {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+m)", "Cannot send to channel (+m)",
@@ -440,40 +459,31 @@ func (s *Service) SendChannelMessage(
} }
} }
if command == irc.CmdPrivmsg {
err = s.checkChannelHashcash(
ctx, chID, channel, body, meta,
)
if err != nil {
return "", err
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
_, uuid, fanErr := s.FanOut( dbID, uuid, fanErr := s.FanOut(
ctx, command, nick, channel, 0, chID, ctx, command, nick, channel, 0, chID,
nil, body, meta, memberIDs, skipClientID, nil, body, meta, recipients,
) )
// The channel was deleted after the lookup above. // The channel was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) { if db.IsForeignKeyConstraintError(fanErr) {
return "", noSuchChannel return 0, "", noSuchChannel
} }
if fanErr != nil { if fanErr != nil {
return "", fanErr return 0, "", fanErr
} }
return uuid, nil return dbID, uuid, nil
} }
// SendDirectMessage validates the target and sends a // SendDirectMessage validates the target and sends a
// direct message to the target and the sender's session, // direct message, returning the message UUID and any away
// skipping client skipClientID as FanOut does, and returns // message set on the target.
// the message UUID and any away message set on the target.
func (s *Service) SendDirectMessage( func (s *Service) SendDirectMessage(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, command, target string, nick, command, target string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (*DirectMsgResult, error) { ) (*DirectMsgResult, error) {
@@ -495,7 +505,7 @@ func (s *Service) SendDirectMessage(
_, uuid, fanErr := s.FanOut( _, uuid, fanErr := s.FanOut(
ctx, command, nick, target, targetSID, 0, ctx, command, nick, target, targetSID, 0,
nil, body, meta, recipients, skipClientID, nil, body, meta, recipients,
) )
// The target's session was deleted after the lookup above. // The target's session was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) { if db.IsForeignKeyConstraintError(fanErr) {
@@ -511,12 +521,11 @@ func (s *Service) SendDirectMessage(
// JoinChannel creates or joins a channel, making the // JoinChannel creates or joins a channel, making the
// first joiner the operator. Fans out the JOIN to all // first joiner the operator. Fans out the JOIN to all
// channel members, skipping client skipClientID as FanOut // channel members. A channel name that validChannelRe
// does. A channel name that validChannelRe does not match // does not match gets ERR_NOSUCHCHANNEL.
// gets ERR_NOSUCHCHANNEL.
func (s *Service) JoinChannel( func (s *Service) JoinChannel(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) (*JoinResult, error) { ) (*JoinResult, error) {
if !validChannelRe.MatchString(channel) { if !validChannelRe.MatchString(channel) {
@@ -539,7 +548,7 @@ func (s *Service) JoinChannel(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdJoin, nick, channel, 0, chID, ctx, irc.CmdJoin, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
return &JoinResult{ return &JoinResult{
@@ -548,12 +557,12 @@ func (s *Service) JoinChannel(
}, nil }, nil
} }
// PartChannel validates membership, broadcasts PART to all // PartChannel validates membership, broadcasts PART to
// channel members, skipping client skipClientID as FanOut // remaining members, removes the user, and cleans up empty
// does, removes the user, and cleans up empty channels. // channels.
func (s *Service) PartChannel( func (s *Service) PartChannel(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, reason string, nick, channel, reason string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -577,11 +586,12 @@ func (s *Service) PartChannel(
} }
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdPart, nick, channel, 0, chID, ctx, irc.CmdPart, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, recipients,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec
@@ -590,11 +600,10 @@ func (s *Service) PartChannel(
} }
// SetTopic validates membership and topic-lock, sets the // SetTopic validates membership and topic-lock, sets the
// topic, and broadcasts the change to all channel members, // topic, and broadcasts the change.
// skipping client skipClientID as FanOut does.
func (s *Service) SetTopic( func (s *Service) SetTopic(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, topic string, nick, channel, topic string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -643,19 +652,18 @@ func (s *Service) SetTopic(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdTopic, nick, channel, 0, chID, ctx, irc.CmdTopic, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
return nil return nil
} }
// KickUser validates operator status and target // KickUser validates operator status and target
// membership, broadcasts the KICK to all channel members, // membership, broadcasts the KICK, removes the target,
// skipping client skipClientID as FanOut does, removes the // and cleans up empty channels.
// target, and cleans up empty channels.
func (s *Service) KickUser( func (s *Service) KickUser(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, targetNick, reason string, nick, channel, targetNick, reason string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -708,7 +716,7 @@ func (s *Service) KickUser(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdKick, nick, channel, 0, chID, ctx, irc.CmdKick, nick, channel, 0, chID,
params, body, nil, memberIDs, skipClientID, params, body, nil, memberIDs,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec
@@ -789,11 +797,10 @@ func (s *Service) InviteUser(
} }
// ChangeNick changes a user's nickname and broadcasts the // ChangeNick changes a user's nickname and broadcasts the
// change to the user's own session and all users sharing // change to all users sharing channels.
// channels, skipping client skipClientID as FanOut does.
func (s *Service) ChangeNick( func (s *Service) ChangeNick(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
oldNick, newNick string, oldNick, newNick string,
) error { ) error {
err := s.db.ChangeNick(ctx, sessionID, newNick) err := s.db.ChangeNick(ctx, sessionID, newNick)
@@ -814,9 +821,7 @@ func (s *Service) ChangeNick(
} }
} }
s.broadcastNickChange( s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
ctx, sessionID, skipClientID, oldNick, newNick,
)
return nil return nil
} }
@@ -1036,10 +1041,9 @@ func (s *Service) SetChannelFlag(
} }
// BroadcastMode fans out a MODE change to all channel // BroadcastMode fans out a MODE change to all channel
// members, skipping client skipClientID as FanOut does. // members.
func (s *Service) BroadcastMode( func (s *Service) BroadcastMode(
ctx context.Context, ctx context.Context,
skipClientID int64,
nick, channel string, nick, channel string,
chID int64, chID int64,
modeText string, modeText string,
@@ -1049,7 +1053,7 @@ func (s *Service) BroadcastMode(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdMode, nick, channel, 0, chID, ctx, irc.CmdMode, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
} }
@@ -1301,12 +1305,11 @@ func (s *Service) checkChannelHashcash(
return nil return nil
} }
// broadcastNickChange notifies the user's own session, // broadcastNickChange notifies channel peers of a nick
// except client skipClientID, and channel peers of a nick
// change. // change.
func (s *Service) broadcastNickChange( func (s *Service) broadcastNickChange(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
oldNick, newNick string, oldNick, newNick string,
) { ) {
channels, err := s.db.GetSessionChannels( channels, err := s.db.GetSessionChannels(
@@ -1328,7 +1331,7 @@ func (s *Service) broadcastNickChange(
} }
// Notify the user themselves (for multi-client sync). // Notify the user themselves (for multi-client sync).
_ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID) _ = s.db.EnqueueToSession(ctx, sessionID, dbID)
s.broker.Notify(sessionID) s.broker.Notify(sessionID)
notified[sessionID] = true notified[sessionID] = true
@@ -1347,7 +1350,7 @@ func (s *Service) broadcastNickChange(
notified[mid] = true notified[mid] = true
_ = s.db.EnqueueToSession(ctx, mid, dbID, 0) _ = s.db.EnqueueToSession(ctx, mid, dbID)
s.broker.Notify(mid) s.broker.Notify(mid)
} }
} }
+51 -130
View File
@@ -24,7 +24,6 @@ import (
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
@@ -168,7 +167,7 @@ func TestFanOut(t *testing.T) {
dbID, uuid, err := env.svc.FanOut( dbID, uuid, err := env.svc.FanOut(
ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0, ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
nil, body, nil, nil, body, nil,
[]int64{sid1, sid2}, 0, []int64{sid1, sid2},
) )
if err != nil { if err != nil {
t.Fatalf("FanOut: %v", err) t.Fatalf("FanOut: %v", err)
@@ -190,7 +189,7 @@ func TestJoinChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel( result, err := env.svc.JoinChannel(
ctx, sid, 0, "alice", "#general", "", ctx, sid, "alice", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel: %v", err) t.Fatalf("JoinChannel: %v", err)
@@ -208,7 +207,7 @@ func TestJoinChannel(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel( result2, err := env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#general", "", ctx, sid2, "bob", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel bob: %v", err) t.Fatalf("JoinChannel bob: %v", err)
@@ -230,14 +229,14 @@ func TestPartChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, sid, 0, "alice", "#general", "", ctx, sid, "alice", "#general", "",
) )
if err != nil { if err != nil {
t.Fatalf("JoinChannel: %v", err) t.Fatalf("JoinChannel: %v", err)
} }
err = env.svc.PartChannel( err = env.svc.PartChannel(
ctx, sid, 0, "alice", "#general", "bye", ctx, sid, "alice", "#general", "bye",
) )
if err != nil { if err != nil {
t.Fatalf("PartChannel: %v", err) t.Fatalf("PartChannel: %v", err)
@@ -245,7 +244,7 @@ func TestPartChannel(t *testing.T) {
// Parting a non-existent channel returns error. // Parting a non-existent channel returns error.
err = env.svc.PartChannel( err = env.svc.PartChannel(
ctx, sid, 0, "alice", "#nonexistent", "", ctx, sid, "alice", "#nonexistent", "",
) )
if err == nil { if err == nil {
t.Error("expected error for non-existent channel") t.Error("expected error for non-existent channel")
@@ -309,7 +308,7 @@ func joinConcurrently(
joins[i] = func() error { joins[i] = func() error {
result, err := env.svc.JoinChannel( result, err := env.svc.JoinChannel(
ctx, sessionID, 0, nick, channel, "", ctx, sessionID, nick, channel, "",
) )
results[i] = result results[i] = result
@@ -335,7 +334,7 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
opSID := createSession(ctx, t, env.db, opNick) opSID := createSession(ctx, t, env.db, opNick)
created, err := env.svc.JoinChannel( created, err := env.svc.JoinChannel(
ctx, opSID, 0, opNick, channel, "", ctx, opSID, opNick, channel, "",
) )
if err != nil { if err != nil {
t.Fatalf("join %s: %v", opNick, err) t.Fatalf("join %s: %v", opNick, err)
@@ -448,7 +447,7 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick) joinerSID := createSession(ctx, t, env.db, joinerNick)
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, leaverSID, 0, leaverNick, channel, "", ctx, leaverSID, leaverNick, channel, "",
) )
if err != nil { if err != nil {
t.Fatalf("join %s: %v", leaverNick, err) t.Fatalf("join %s: %v", leaverNick, err)
@@ -457,12 +456,12 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
errs := runConcurrently( errs := runConcurrently(
func() error { func() error {
return env.svc.PartChannel( return env.svc.PartChannel(
ctx, leaverSID, 0, leaverNick, channel, "", ctx, leaverSID, leaverNick, channel, "",
) )
}, },
func() error { func() error {
_, joinErr := env.svc.JoinChannel( _, joinErr := env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "", ctx, joinerSID, joinerNick, channel, "",
) )
return joinErr return joinErr
@@ -513,7 +512,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick) joinerSID := createSession(ctx, t, env.db, joinerNick)
created, err := env.svc.JoinChannel( created, err := env.svc.JoinChannel(
ctx, leaverSID, 0, leaverNick, channel, "", ctx, leaverSID, leaverNick, channel, "",
) )
if err != nil { if err != nil {
t.Fatalf("join %s: %v", leaverNick, err) t.Fatalf("join %s: %v", leaverNick, err)
@@ -545,7 +544,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
) )
joined, joinErr = env.svc.JoinChannel( joined, joinErr = env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "", ctx, joinerSID, joinerNick, channel, "",
) )
if !errors.As(joinErr, &ircErr) || if !errors.As(joinErr, &ircErr) ||
ircErr.Code != irc.ErrBadChannelKey { ircErr.Code != irc.ErrBadChannelKey {
@@ -592,7 +591,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
quitterSID := createSession(ctx, t, env.db, nick) quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} { for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "") _, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", channel, err) t.Fatalf("join %s: %v", channel, err)
} }
@@ -602,7 +601,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member) memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick) memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "") _, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", memberNick, err) t.Fatalf("join %s: %v", memberNick, err)
} }
@@ -617,7 +616,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
t.Fatalf("create session %s: %v", watcherNick, err) t.Fatalf("create session %s: %v", watcherNick, err)
} }
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "") _, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "")
if err != nil { if err != nil {
t.Fatalf("join %s: %v", watcherNick, err) t.Fatalf("join %s: %v", watcherNick, err)
} }
@@ -678,7 +677,7 @@ func takeNickAndJoin(
return err return err
} }
_, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "") _, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "")
return err return err
} }
@@ -710,7 +709,7 @@ func TestJoinUsesUpInvite(t *testing.T) {
opSID := createSession(ctx, t, env.db, "op") opSID := createSession(ctx, t, env.db, "op")
guestSID := createSession(ctx, t, env.db, "guest") guestSID := createSession(ctx, t, env.db, "guest")
created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil { if err != nil {
t.Fatalf("join op: %v", err) t.Fatalf("join op: %v", err)
} }
@@ -725,17 +724,17 @@ func TestJoinUsesUpInvite(t *testing.T) {
t.Fatalf("invite guest: %v", err) t.Fatalf("invite guest: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
if err != nil { if err != nil {
t.Fatalf("invited join: %v", err) t.Fatalf("invited join: %v", err)
} }
err = env.svc.PartChannel(ctx, guestSID, 0, "guest", channel, "") err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "")
if err != nil { if err != nil {
t.Fatalf("part guest: %v", err) t.Fatalf("part guest: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan { if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan {
@@ -754,7 +753,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
opSID := createSession(ctx, t, env.db, "op") opSID := createSession(ctx, t, env.db, "op")
created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil { if err != nil {
t.Fatalf("join op: %v", err) t.Fatalf("join op: %v", err)
} }
@@ -773,7 +772,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
t.Fatalf("create session guest: %v", err) t.Fatalf("create session guest: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan { if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan {
@@ -793,17 +792,17 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
aliceSID := createSession(ctx, t, env.db, "alice") aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob") bobSID := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "") _, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "") _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
} }
err = env.svc.PartChannel(ctx, aliceSID, 0, "alice", channel, "") err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "")
if err != nil { if err != nil {
t.Fatalf("part alice: %v", err) t.Fatalf("part alice: %v", err)
} }
@@ -818,7 +817,7 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
t.Fatal("bob is not on the channel after alice parted") t.Fatal("bob is not on the channel after alice parted")
} }
err = env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "") err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
if err != nil { if err != nil {
t.Fatalf("part bob: %v", err) t.Fatalf("part bob: %v", err)
} }
@@ -839,12 +838,12 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, 0, "alice", channel, "") _, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "")
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
err = env.svc.KickUser(ctx, sid, 0, "alice", channel, "alice", "") err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "")
if err != nil { if err != nil {
t.Fatalf("kick alice: %v", err) t.Fatalf("kick alice: %v", err)
} }
@@ -863,14 +862,14 @@ func TestSendChannelMessage(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#chat", "", ctx, sid1, "alice", "#chat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#chat", "", ctx, sid2, "bob", "#chat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -878,14 +877,18 @@ func TestSendChannelMessage(t *testing.T) {
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
uuid, err := env.svc.SendChannelMessage( dbID, uuid, err := env.svc.SendChannelMessage(
ctx, sid1, 0, "alice", ctx, sid1, "alice",
irc.CmdPrivmsg, "#chat", body, nil, irc.CmdPrivmsg, "#chat", body, nil,
) )
if err != nil { if err != nil {
t.Fatalf("SendChannelMessage: %v", err) t.Fatalf("SendChannelMessage: %v", err)
} }
if dbID == 0 {
t.Error("expected non-zero dbID")
}
if uuid == "" { if uuid == "" {
t.Error("expected non-empty UUID") t.Error("expected non-empty UUID")
} }
@@ -893,8 +896,8 @@ func TestSendChannelMessage(t *testing.T) {
// Non-member cannot send. // Non-member cannot send.
sid3 := createSession(ctx, t, env.db, "charlie") sid3 := createSession(ctx, t, env.db, "charlie")
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid3, 0, "charlie", ctx, sid3, "charlie",
irc.CmdPrivmsg, "#chat", body, nil, irc.CmdPrivmsg, "#chat", body, nil,
) )
if err == nil { if err == nil {
@@ -910,14 +913,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel( _, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#room", "", ctx, sid1, "alice", "#room", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#room", "", ctx, sid2, "bob", "#room", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -959,7 +962,7 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendDirectMessage( _, err = env.svc.SendDirectMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil, ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil,
) )
var ircErr *service.IRCError var ircErr *service.IRCError
@@ -979,7 +982,7 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice") sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, 0, "alice", "#gone", "") _, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "")
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
@@ -996,8 +999,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil, ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
) )
var ircErr *service.IRCError var ircErr *service.IRCError
@@ -1015,14 +1018,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob") sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel( result, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#modchat", "", ctx, sid1, "alice", "#modchat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join alice: %v", err) t.Fatalf("join alice: %v", err)
} }
_, err = env.svc.JoinChannel( _, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#modchat", "", ctx, sid2, "bob", "#modchat", "",
) )
if err != nil { if err != nil {
t.Fatalf("join bob: %v", err) t.Fatalf("join bob: %v", err)
@@ -1035,8 +1038,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
// Bob (non-op, non-voiced) should fail to send. // Bob (non-op, non-voiced) should fail to send.
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid2, 0, "bob", ctx, sid2, "bob",
irc.CmdPrivmsg, "#modchat", body, nil, irc.CmdPrivmsg, "#modchat", body, nil,
) )
if err == nil { if err == nil {
@@ -1044,8 +1047,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
} }
// Alice (operator) should succeed. // Alice (operator) should succeed.
_, err = env.svc.SendChannelMessage( _, _, err = env.svc.SendChannelMessage(
ctx, sid1, 0, "alice", ctx, sid1, "alice",
irc.CmdPrivmsg, "#modchat", body, nil, irc.CmdPrivmsg, "#modchat", body, nil,
) )
if err != nil { if err != nil {
@@ -1053,88 +1056,6 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
} }
} }
// TestRefusedChannelMessageLeavesStampUnspent checks that a
// PRIVMSG to a +H channel refused because the sender is not
// a member, is banned, or has no voice on a +m channel does
// not spend its hashcash stamp: the operator can then send
// the same message with it.
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
const (
channel = "#paid"
bits = 2
)
refusals := []struct {
name string
// refuse stops bob from sending to the channel.
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
}{
{"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error {
return env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "")
}},
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
}},
{"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.svc.SetChannelFlag(ctx, chID, 'm', true)
}},
}
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
for _, refusal := range refusals {
t.Run(refusal.name, func(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob")
joined, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "")
if err != nil {
t.Fatalf("join bob: %v", err)
}
err = env.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits)
if err != nil {
t.Fatalf("set +H: %v", err)
}
err = refusal.refuse(ctx, env, joined.ChannelID, bobSID)
if err != nil {
t.Fatalf("refuse bob: %v", err)
}
stamp := hashcash.MintChannelStamp(
bits, channel, hashcash.BodyHash(body),
)
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
_, err = env.svc.SendChannelMessage(
ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta,
)
var ircErr *service.IRCError
if !errors.As(err, &ircErr) ||
ircErr.Code != irc.ErrCannotSendToChan {
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
}
_, err = env.svc.SendChannelMessage(
ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta,
)
if err != nil {
t.Fatalf("alice with the same stamp: %v", err)
}
})
}
}
func TestQueryUserMode(t *testing.T) { func TestQueryUserMode(t *testing.T) {
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()
+63 -114
View File
@@ -58,13 +58,13 @@ async function mintHashcash(bits, resource) {
} }
} }
// Requests are authenticated by the auth cookie, which the browser
// sends on its own.
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(opts.headers || {}), ...(opts.headers || {}),
}; };
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts; const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
@@ -122,11 +122,12 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name; if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
// Resume the session if the browser still holds a valid auth const saved = localStorage.getItem("neoirc_token");
// cookie; a 401 leaves the login form up. if (saved) {
api("/state?initChannelState=1") api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => {}); .catch(() => localStorage.removeItem("neoirc_token"));
}
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -151,6 +152,7 @@ function LoginScreen({ onLogin }) {
method: "POST", method: "POST",
body: JSON.stringify(reqBody), body: JSON.stringify(reqBody),
}); });
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);
} catch (err) { } catch (err) {
setError(err.data?.error || "Connection failed"); setError(err.data?.error || "Connection failed");
@@ -225,9 +227,10 @@ function UserList({ members, onNickClick }) {
const regular = []; const regular = [];
for (const m of members) { for (const m of members) {
if (m.isOperator) { const mode = m.mode || "";
if (mode === "o") {
ops.push(m); ops.push(m);
} else if (m.isVoiced) { } else if (mode === "v") {
voiced.push(m); voiced.push(m);
} else { } else {
regular.push(m); regular.push(m);
@@ -300,14 +303,11 @@ function App() {
nickRef.current = nick; nickRef.current = nick;
}, [nick]); }, [nick]);
// Persist joined channels, once logged in: before then only the // Persist joined channels.
// Server tab exists, and writing would erase the saved channels
// that onLogin rejoins.
useEffect(() => { useEffect(() => {
if (!loggedIn) return;
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name); const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels)); localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [loggedIn, tabs]); }, [tabs]);
// Clear unread on tab switch. // Clear unread on tab switch.
useEffect(() => { useEffect(() => {
@@ -366,9 +366,7 @@ function App() {
command: msg.command, command: msg.command,
}; };
// A numeric reply carries its number in `code` and its name in switch (msg.command) {
// `command`; other messages have no `code`.
switch (msg.code || msg.command) {
case "PRIVMSG": case "PRIVMSG":
case "NOTICE": { case "NOTICE": {
let text = body; let text = body;
@@ -466,18 +464,8 @@ function App() {
break; break;
} }
case "MODE": { case "353": {
// The body is the change, with its value where it has one: // RPL_NAMREPLY — parse mode prefixes from names list.
// ["+o bob"], ["+m"], ["+k", "secretpass"].
const modes = Array.isArray(msg.body) ? msg.body.join(" ") : "";
const text = `${msg.from} sets mode ${modes} on ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
break;
}
case 353: {
// RPL_NAMREPLY — each name is a hostmask (nick!user@host),
// prefixed with @ for an operator or + for a voiced member.
if ( if (
Array.isArray(msg.params) && Array.isArray(msg.params) &&
msg.params.length >= 2 && msg.params.length >= 2 &&
@@ -489,18 +477,21 @@ function App() {
: String(msg.body); : String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean); const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => { const parsed = names.map((n) => {
const isOperator = n.startsWith("@"); if (n.startsWith("@")) {
const isVoiced = n.startsWith("+"); return { nick: n.slice(1), mode: "o" };
const hostmask = isOperator || isVoiced ? n.slice(1) : n; }
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
return { nick: hostmask.split("!")[0], isOperator, isVoiced }; return { nick: n, mode: "" };
}); });
setMembers((prev) => ({ ...prev, [channel]: parsed })); setMembers((prev) => ({ ...prev, [channel]: parsed }));
} }
break; break;
} }
case 332: { case "332": {
// RPL_TOPIC — set topic from server. // RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0]; const channel = msg.params[0];
@@ -512,7 +503,7 @@ function App() {
break; break;
} }
case 322: { case "322": {
// RPL_LIST — channel, member count, topic. // RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0]; const chName = msg.params[0];
@@ -527,7 +518,7 @@ function App() {
break; break;
} }
case 323: case "323":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of channel list", text: body || "End of channel list",
@@ -535,7 +526,7 @@ function App() {
}); });
break; break;
case 352: { case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags. // RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) { if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0]; const whoCh = msg.params[0];
@@ -550,7 +541,7 @@ function App() {
break; break;
} }
case 315: case "315":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHO list", text: body || "End of /WHO list",
@@ -558,20 +549,20 @@ function App() {
}); });
break; break;
case 311: { case "311": {
// RPL_WHOISUSER — params nick, user, host, *; body realname. // RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const [wiNick, wiUser, wiHost] = msg.params; const wiNick = msg.params[0];
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, text: `${wiNick} (${body})`,
system: true, system: true,
}); });
} }
break; break;
} }
case 312: { case "312": {
// RPL_WHOISSERVER — nick, server, server info. // RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -585,33 +576,7 @@ function App() {
break; break;
} }
case 313: { case "319": {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage("Server", {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
});
}
break;
}
case 317: {
// RPL_WHOISIDLE — nick, idle seconds, signon time (Unix).
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage("Server", {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
});
}
break;
}
case 319: {
// RPL_WHOISCHANNELS — nick, channels. // RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -624,7 +589,7 @@ function App() {
break; break;
} }
case 318: case "318":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHOIS list", text: body || "End of /WHOIS list",
@@ -632,21 +597,9 @@ function App() {
}); });
break; break;
case 324: { case "375":
// RPL_CHANNELMODEIS — channel, mode string. case "372":
if (Array.isArray(msg.params) && msg.params.length >= 2) { case "376":
addMessage("Server", {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
});
}
break;
}
case 375:
case 372:
case 376:
addMessage("Server", { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
break; break;
@@ -684,14 +637,6 @@ function App() {
} catch (err) { } catch (err) {
if (!alive) break; if (!alive) break;
if (err.name === "AbortError") continue; if (err.name === "AbortError") continue;
// 401: the session is gone (QUIT from another client, or
// expiry). Reloading drops this page's state and brings
// back the login screen.
if (err.status === 401) {
window.location.reload();
return;
}
setConnected(false); setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
} }
@@ -771,11 +716,6 @@ function App() {
const onLogin = useCallback( const onLogin = useCallback(
async (userNick, isResumed) => { async (userNick, isResumed) => {
// Read before setLoggedIn(true): from then on the effect under
// "Persist joined channels" writes the open channel tabs over it.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
@@ -800,6 +740,9 @@ function App() {
} }
// Fresh session — join any previously saved channels. // Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) { for (const ch of saved) {
try { try {
await api("/messages", { await api("/messages", {
@@ -1029,22 +972,27 @@ function App() {
break; break;
} }
// With no arguments MODE is a query. A change is one sign and
// letter, then its value where it takes one: +m, +o nick.
const modeArgs = parts.slice(1); const modeArgs = parts.slice(1);
const body = if (modeArgs.length > 0) {
modeArgs.length > 0 try {
? { command: "MODE", to: tab.name, body: modeArgs } await api("/messages", {
: { command: "MODE", to: tab.name }; method: "POST",
try { body: JSON.stringify({
await api("/messages", { command: "MODE",
method: "POST", to: tab.name,
body: JSON.stringify(body), params: modeArgs,
}); }),
} catch (err) { });
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage( addSystemMessage(
"Server", "Server",
`MODE failed: ${err.data?.error || "error"}`, "Usage: /mode <+/-mode> [params]",
); );
} }
@@ -1061,6 +1009,7 @@ function App() {
} catch (e) { } catch (e) {
// Ignore. // Ignore.
} }
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels"); localStorage.removeItem("neoirc_channels");
window.location.reload(); window.location.reload();
@@ -1179,7 +1128,7 @@ function App() {
" /me action — Send an action", " /me action — Send an action",
" /nick newnick — Change your nickname", " /nick newnick — Change your nickname",
" /topic [text] — View or set channel topic", " /topic [text] — View or set channel topic",
" /mode [+/-flag [arg]] — View or set channel modes", " /mode +/-flags — Set channel modes",
" /motd — Display the message of the day", " /motd — Display the message of the day",
" /list — List all channels", " /list — List all channels",
" /who [#channel] — List users in a channel", " /who [#channel] — List users in a channel",