Author SHA1 Message Date
sneak eb67f2b417 Make the internal/handlers tests fast (closes #113)
check / check (push) Failing after 4s
-race also turns on Go's unsafe-pointer checks (checkptr) in every
package. modernc.org/sqlite is SQLite machine-translated from C into Go,
and every SQL statement the tests run passes through those checks
thousands of times; they were half the CPU time of the internal/handlers
tests. The test phase now turns them off for the modernc.org packages
only. Race detection is unchanged everywhere, and the pointer checks stay
on for this repository's code and every other dependency.

Model: opus-5-5
2026-10-06 19:19:15 +00:00
50 changed files with 2406 additions and 10896 deletions
+7 -10
View File
@@ -1,15 +1,9 @@
# Web build stage — compile SPA from source
# node:22-alpine, 2026-03-09
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
# yarn 1.22.22, 2026-10-06
# The version script/bootstrap pins, checked against the sha512 of its
# npm package. corepack enable replaces the yarn the image ships with
# corepack's own, which runs the version activated here.
RUN corepack enable \
&& corepack prepare yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e --activate
WORKDIR /web
COPY web/package.json web/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/src/ src/
COPY web/build.sh build.sh
RUN sh build.sh
@@ -38,9 +32,12 @@ RUN go mod download
COPY . .
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
# -p 4 because test runs on a shared build host cap their parallelism.
RUN go test -p 4 -timeout 90s -race -cover ./... || \
# -race also turns on Go's unsafe-pointer checks (checkptr) in every
# package. In modernc.org, SQLite machine-translated from C, they doubled
# the test time, so they are off there and stay on everywhere else.
RUN go test -p 4 -timeout 90s -race -gcflags='modernc.org/...=-d=checkptr=0' -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -p 4 -timeout 90s -race -v ./...; exit 1; }
go test -p 4 -timeout 90s -race -gcflags='modernc.org/...=-d=checkptr=0' -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
+472 -825
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -47,16 +47,6 @@ func (b *Broker) Notify(userID int64) {
}
}
// ListenerCount returns how many channels from Wait are still
// registered for the user: those that neither Notify nor Remove
// has taken out.
func (b *Broker) ListenerCount(userID int64) int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.listeners[userID])
}
// Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove(
userID int64,
-33
View File
@@ -59,39 +59,6 @@ func TestRemove(t *testing.T) {
}
}
func TestListenerCount(t *testing.T) {
t.Parallel()
brk := broker.New()
wantCount := func(step string, userID int64, want int) {
t.Helper()
got := brk.ListenerCount(userID)
if got != want {
t.Errorf("%s: user %d: got %d, want %d",
step, userID, got, want)
}
}
wantCount("before Wait", 1, 0)
waitCh := brk.Wait(1)
brk.Wait(1)
brk.Wait(2)
wantCount("after two Waits", 1, 2)
brk.Remove(1, waitCh)
wantCount("after Remove", 1, 1)
brk.Notify(1)
wantCount("after Notify", 1, 0)
wantCount("after Notify", 2, 1)
}
func TestMultipleWaiters(t *testing.T) {
t.Parallel()
+2 -2
View File
@@ -250,7 +250,7 @@ func (client *Client) ListChannels() (
// GetMembers returns members of a channel.
func (client *Client) GetMembers(
channel string,
) ([]Member, error) {
) ([]string, error) {
name := strings.TrimPrefix(channel, "#")
data, err := client.do(
@@ -263,7 +263,7 @@ func (client *Client) GetMembers(
return nil, err
}
var members []Member
var members []string
err = json.Unmarshal(data, &members)
if err != nil {
+7 -366
View File
@@ -2,28 +2,18 @@ package neoircapi_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"slices"
"strconv"
"strings"
"testing"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/internal/hashcash"
)
const (
cookieValue = "opaque-value"
testNick = "alice"
testChannel = "#general"
)
const cookieValue = "opaque-value"
// newSessionServer starts a plain-HTTP server that, like
// neoircd, sets a Secure auth cookie when a session is
@@ -34,16 +24,10 @@ func newSessionServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux()
// Without hashcash the server sends no hashcash_bits.
mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, `{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42
}`)
_, _ = io.WriteString(writer, `{}`)
})
mux.HandleFunc("POST /api/v1/session", func(
@@ -72,14 +56,9 @@ func newSessionServer(t *testing.T) *httptest.Server {
return
}
_, _ = io.WriteString(writer, `{
"id": 1,
"nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}`)
_, _ = io.WriteString(
writer, `{"id":1,"nick":"alice","channels":[]}`,
)
})
server := httptest.NewServer(mux)
@@ -88,344 +67,6 @@ func newSessionServer(t *testing.T) *httptest.Server {
return server
}
// newClientForPath starts a server that answers GET path with
// body, and returns a client for it.
func newClientForPath(t *testing.T, path, body string) *api.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET "+path, func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, body)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return api.NewClient(server.URL)
}
func TestGetStateDecodesFields(t *testing.T) {
t.Parallel()
client := api.NewClient(newSessionServer(t).URL)
_, err := client.CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
state, err := client.GetState()
if err != nil {
t.Fatalf("state: %v", err)
}
want := api.StateResponse{
ID: 1,
Nick: testNick,
Channels: []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: ""},
},
}
if !reflect.DeepEqual(*state, want) {
t.Fatalf("state: got %+v, want %+v", *state, want)
}
}
func TestGetServerInfoDecodesFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/server", `{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": 20
}`)
info, err := client.GetServerInfo()
if err != nil {
t.Fatalf("server info: %v", err)
}
want := api.ServerInfo{
Name: "My NeoIRC Server",
MOTD: "Welcome! Be nice.",
Version: "0.1.0",
HashcashBits: 20,
}
if *info != want {
t.Fatalf("server info: got %+v, want %+v", *info, want)
}
}
// TestCreateSessionSendsNickAndStamp serves a server that, like
// neoircd with hashcash on, advertises hashcash_bits and creates
// the session only for a pow_token the server's validator in
// internal/hashcash accepts.
func TestCreateSessionSendsNickAndStamp(t *testing.T) {
t.Parallel()
const (
serverName = "My NeoIRC Server"
stampBits = 8
sessionID = 7
)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = fmt.Fprintf(writer, `{
"name": %q,
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": %d
}`, serverName, stampBits)
})
validator := hashcash.NewValidator(serverName)
mux.HandleFunc("POST /api/v1/session", func(
writer http.ResponseWriter, request *http.Request,
) {
// Decoded with the field names handleCreateSession uses.
var payload struct {
Nick string `json:"nick"`
Hashcash string `json:"pow_token"` //nolint:tagliatelle
}
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
err = validator.Claim(payload.Hashcash, stampBits)
if err != nil {
writer.WriteHeader(http.StatusPaymentRequired)
return
}
writer.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(writer).Encode(map[string]any{
"id": sessionID,
"nick": payload.Nick,
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
session, err := api.NewClient(server.URL).CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
want := api.SessionResponse{ID: sessionID, Nick: testNick}
if *session != want {
t.Fatalf("session: got %+v, want %+v", *session, want)
}
}
func TestListChannelsDecodesNamesAndTopics(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels", `[
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": "Development discussion"}
]`)
channels, err := client.ListChannels()
if err != nil {
t.Fatalf("list channels: %v", err)
}
want := []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: "Development discussion"},
}
if !slices.Equal(channels, want) {
t.Fatalf("channels: got %+v, want %+v", channels, want)
}
}
func TestGetMembersDecodesMemberObjects(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels/general/members", `[
{
"id": 1, "nick": "alice", "username": "alice",
"hostname": "host.example.com",
"isOperator": true, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 2, "nick": "bob", "username": "bob",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": true,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 3, "nick": "Carol", "username": "carol",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
}
]`)
members, err := client.GetMembers(testChannel)
if err != nil {
t.Fatalf("get members: %v", err)
}
want := []api.Member{
{Nick: testNick, IsOperator: true, IsVoiced: false},
{Nick: "bob", IsOperator: false, IsVoiced: true},
{Nick: "Carol", IsOperator: false, IsVoiced: false},
}
if !slices.Equal(members, want) {
t.Fatalf("members: got %+v, want %+v", members, want)
}
}
// TestPollMessagesAdvancesCursor serves the queue as the server
// does: entries after the cursor, and last_id as the new cursor.
func TestPollMessagesAdvancesCursor(t *testing.T) {
t.Parallel()
const lastQueueID = 42
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/messages", func(
writer http.ResponseWriter, request *http.Request,
) {
after := request.URL.Query().Get("after")
if after == strconv.Itoa(lastQueueID) {
_, _ = io.WriteString(
writer, `{"messages": [], "last_id": 42}`,
)
return
}
_, _ = io.WriteString(writer, `{
"messages": [{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello world"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {}
}],
"last_id": 42
}`)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := api.NewClient(server.URL)
first, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("first poll: %v", err)
}
if first.LastID != lastQueueID || len(first.Messages) != 1 {
t.Fatalf(
"first poll: got last ID %d and %d messages, "+
"want %d and 1",
first.LastID, len(first.Messages), lastQueueID,
)
}
second, err := client.PollMessages(first.LastID, 0)
if err != nil {
t.Fatalf("second poll: %v", err)
}
if len(second.Messages) != 0 {
t.Fatalf(
"second poll: got %d messages again, want none",
len(second.Messages),
)
}
}
// TestPollMessagesDecodesMessageFields serves a relayed PRIVMSG
// with the sender's meta and a numeric, as the server queues
// them.
func TestPollMessagesDecodesMessageFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/messages", `{
"messages": [
{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["signed message"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {"sig": "base64...", "alg": "ed25519"}
},
{
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
"command": "RPL_LUSEROP",
"code": 252,
"from": "neoirc",
"to": "alice",
"params": ["0"],
"body": ["operator(s) online"],
"ts": "2026-02-10T20:00:02Z",
"meta": {}
}
],
"last_id": 42
}`)
result, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
want := []api.Message{
{
ID: "b7c8210f-849c-4b90-9ee8-d99c8889358e",
Command: "PRIVMSG",
From: testNick,
To: testChannel,
Body: []any{"signed message"},
TS: "2026-02-10T20:00:01.234567891Z",
Meta: map[string]any{"sig": "base64...", "alg": "ed25519"},
},
{
ID: "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
Command: "RPL_LUSEROP",
From: "neoirc",
To: testNick,
Params: []string{"0"},
Body: []any{"operator(s) online"},
TS: "2026-02-10T20:00:02Z",
Meta: map[string]any{},
},
}
if !reflect.DeepEqual(result.Messages, want) {
t.Fatalf("messages: got %+v, want %+v", result.Messages, want)
}
}
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
t.Parallel()
@@ -444,7 +85,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
"http://" + net.JoinHostPort(host, serverURL.Port()),
)
_, err := client.CreateSession(testNick)
_, err := client.CreateSession("alice")
if err != nil {
t.Fatalf("create session: %v", err)
}
@@ -477,7 +118,7 @@ func TestClientWithholdsCookieOverPlainHTTPFromOtherHosts(t *testing.T) {
},
}
_, err := client.CreateSession(testNick)
_, err := client.CreateSession("alice")
if err != nil {
t.Fatalf("create session: %v", err)
}
+9 -16
View File
@@ -16,9 +16,9 @@ type SessionResponse struct {
// StateResponse is the response from GET /api/v1/state.
type StateResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Channels []Channel `json:"channels"`
ID int64 `json:"id"`
Nick string `json:"nick"`
Channels []string `json:"channels"`
}
// Message represents a neoirc message envelope.
@@ -53,19 +53,12 @@ func (m *Message) BodyLines() []string {
}
}
// Channel is a channel as GET /api/v1/channels and
// GET /api/v1/state describe it.
// Channel represents a channel in the list response.
type Channel struct {
Name string `json:"name"`
Topic string `json:"topic"`
}
// Member is one entry of the response from
// GET /api/v1/channels/{name}/members.
type Member struct {
Nick string `json:"nick"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
Name string `json:"name"`
Topic string `json:"topic"`
Members int `json:"members"`
CreatedAt string `json:"createdAt"`
}
// ServerInfo is the response from GET /api/v1/server.
@@ -79,7 +72,7 @@ type ServerInfo struct {
// MessagesResponse wraps polling results.
type MessagesResponse struct {
Messages []Message `json:"messages"`
LastID int64 `json:"last_id"` //nolint:tagliatelle
LastID int64 `json:"lastId"`
}
// PollResult wraps the poll response including the cursor.
+59 -47
View File
@@ -96,7 +96,20 @@ func (a *App) handleInput(text string) {
a.ui.AddStatus(
"[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) {
@@ -288,6 +301,9 @@ func (a *App) cmdJoin(channel string) {
a.mu.Unlock()
a.ui.SwitchToBuffer(channel)
a.ui.AddLine(channel,
"[yellow]*** Joined "+channel,
)
a.ui.SetStatus(nick, channel, "connected")
}
@@ -322,6 +338,10 @@ func (a *App) cmdPart(channel string) {
return
}
a.ui.AddLine(channel,
"[yellow]*** Left "+channel,
)
a.mu.Lock()
if a.target == channel {
a.target = ""
@@ -348,6 +368,7 @@ func (a *App) cmdMsg(args string) {
a.mu.Lock()
connected := a.connected
nick := a.nick
a.mu.Unlock()
if !connected {
@@ -365,7 +386,16 @@ func (a *App) cmdMsg(args string) {
a.ui.AddStatus(fmt.Sprintf(
"[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) {
@@ -404,9 +434,16 @@ func (a *App) cmdTopic(args string) {
return
}
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" {
a.ui.AddStatus("[red]Usage: /topic <text>")
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
To: target,
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic query failed: %v", err,
))
}
return
}
@@ -450,32 +487,12 @@ func (a *App) cmdNames() {
return
}
nicks := make([]string, 0, len(members))
for _, member := range members {
nicks = append(nicks, memberPrefix(member)+member.Nick)
}
a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s",
target, strings.Join(nicks, " "),
target, strings.Join(members, " "),
))
}
// memberPrefix returns "@" for an operator, "+" for a voiced
// member, and "" for anyone else.
func memberPrefix(member api.Member) string {
if member.IsOperator {
return "@"
}
if member.IsVoiced {
return "+"
}
return ""
}
func (a *App) cmdList() {
a.mu.Lock()
connected := a.connected
@@ -500,7 +517,8 @@ func (a *App) cmdList() {
for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf(
" %s %s", ch.Name, ch.Topic,
" %s (%d members) %s",
ch.Name, ch.Members, ch.Topic,
))
}
@@ -658,7 +676,7 @@ func (a *App) cmdHelp() {
" /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window",
" /topic <text> — Set topic",
" /topic [text] — View/set topic",
" /names — List channel members",
" /list — List channels",
" /who [#channel] — List users in channel",
@@ -727,7 +745,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp)
case irc.CmdPart:
a.handlePartEvent(msg, timestamp, myNick)
a.handlePartEvent(msg, timestamp)
case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp)
case irc.CmdNick:
@@ -755,10 +773,12 @@ func (a *App) handlePrivmsgEvent(
lines := msg.BodyLines()
text := strings.Join(lines, " ")
// A direct message shows in the buffer of the other user:
// the recipient of one the user sent, else the sender.
if msg.From == myNick {
return
}
target := msg.To
if !strings.HasPrefix(target, "#") && msg.From != myNick {
if !strings.HasPrefix(target, "#") {
target = msg.From
}
@@ -781,11 +801,8 @@ func (a *App) handleJoinEvent(
))
}
// handlePartEvent shows a part in the channel's buffer. The
// user's own part does not mark the channel unread: the user
// has just left it.
func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string,
msg *api.Message, timestamp string,
) {
if msg.To == "" {
return
@@ -794,22 +811,17 @@ func (a *App) handlePartEvent(
lines := msg.BodyLines()
reason := strings.Join(lines, " ")
line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
)
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(
-269
View File
@@ -1,269 +0,0 @@
package cli
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
// The user's nick, a channel it is in, another user's nick and
// a message text, for the tests below.
const (
testNick = "erin"
testChannel = "#chan"
testOtherNick = "bob"
testText = "hello"
)
func TestMemberPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
member api.Member
want string
}{
{
name: "operator",
member: api.Member{Nick: "alice", IsOperator: true},
want: "@",
},
{
name: "voiced member",
member: api.Member{Nick: "bob", IsVoiced: true},
want: "+",
},
{
name: "voiced operator",
member: api.Member{
Nick: "Carol", IsOperator: true, IsVoiced: true,
},
want: "@",
},
{
name: "plain member",
member: api.Member{Nick: "dave"},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := memberPrefix(test.member)
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except that the
// user's own part does not mark it unread.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantUnread int
}{
{name: "own part", from: testNick, wantUnread: 0},
{name: "other member", from: testOtherNick, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: testChannel,
})
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want 1 and %d",
lines, unread, test.wantUnread)
}
})
}
}
// TestOwnCommandShownOnce checks that a message, join or
// part made from neoirc-cli shows as one line in the
// channel's buffer once the server's event for it arrives,
// and does not mark the channel unread.
func TestOwnCommandShownOnce(t *testing.T) {
t.Parallel()
tests := []struct {
name string
send func(app *App)
event api.Message
}{
{
name: "message",
send: func(app *App) { app.handleInput(testText) },
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
},
{
name: "join",
send: func(app *App) { app.cmdJoin(testChannel) },
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
},
{
name: "part",
send: func(app *App) { app.cmdPart(testChannel) },
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// The server accepts every command 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.
func (ui *UI) AddStatus(line string) {
ts := time.Now().Format("15:04")
+1 -15
View File
@@ -4,7 +4,6 @@ package config
import (
"errors"
"log/slog"
"os"
"github.com/spf13/viper"
"go.uber.org/fx"
@@ -22,12 +21,6 @@ const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.`
// DefaultDBURL is the database URL used when DBURL is not set. Its
// _pragma parameters, which the SQLite driver runs on every connection
// it opens, set WAL mode and a 5000 ms busy timeout.
const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
// Params defines the dependencies for creating a Config.
type Params struct {
fx.In
@@ -78,7 +71,7 @@ func New(
viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080")
viper.SetDefault("DBURL", DefaultDBURL)
viper.SetDefault("DBURL", "file:///var/lib/neoirc/state.db?_journal_mode=WAL")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
@@ -130,13 +123,6 @@ func New(
params: &params,
}
// Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
if set {
cfg.IRCListenAddr = ircListenAddr
}
if cfg.Debug {
params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get()
-61
View File
@@ -1,61 +0,0 @@
package config_test
import (
"os"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
func newConfig(t *testing.T) *config.Config {
t.Helper()
globs := &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
log, err := logger.New(nil, logger.Params{Globals: globs})
if err != nil {
t.Fatal(err)
}
cfg, err := config.New(
nil, config.Params{Globals: globs, Logger: log},
)
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "")
err := os.Unsetenv("IRC_LISTEN_ADDR")
if err != nil {
t.Fatal(err)
}
const want = ":6667"
cfg := newConfig(t)
if cfg.IRCListenAddr != want {
t.Errorf("IRCListenAddr = %q, want %q", cfg.IRCListenAddr, want)
}
}
func TestIRCListenAddrEmptyTurnsListenerOff(t *testing.T) {
t.Setenv("IRC_LISTEN_ADDR", "")
cfg := newConfig(t)
if cfg.IRCListenAddr != "" {
t.Errorf("IRCListenAddr = %q, want empty", cfg.IRCListenAddr)
}
}
+8 -1
View File
@@ -87,7 +87,7 @@ func (database *Database) GetDB() *sql.DB {
func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL
if dbURL == "" {
dbURL = config.DefaultDBURL
dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
}
database.log.Info(
@@ -116,6 +116,13 @@ func (database *Database) connect(ctx context.Context) error {
return fmt.Errorf("enable foreign keys: %w", err)
}
_, err = database.conn.ExecContext(
ctx, "PRAGMA busy_timeout = 5000",
)
if err != nil {
return fmt.Errorf("set busy timeout: %w", err)
}
return database.runMigrations(ctx)
}
-75
View File
@@ -1,75 +0,0 @@
package db_test
import (
"path/filepath"
"strings"
"testing"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
const wantBusyTimeoutMS = 5000
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
// with the default URL's parameters the way the server does,
// and checks the journal mode and busy timeout it ends up with.
func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
t.Parallel()
_, params, _ := strings.Cut(config.DefaultDBURL, "?")
dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") +
"?" + params
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatal(err)
}
lifecycle.RequireStart()
t.Cleanup(lifecycle.RequireStop)
var journalMode string
err = database.GetDB().QueryRowContext(
t.Context(), "PRAGMA journal_mode",
).Scan(&journalMode)
if err != nil {
t.Fatal(err)
}
if journalMode != "wal" {
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
}
var busyTimeout int
err = database.GetDB().QueryRowContext(
t.Context(), "PRAGMA busy_timeout",
).Scan(&busyTimeout)
if err != nil {
t.Fatal(err)
}
if busyTimeout != wantBusyTimeoutMS {
t.Errorf(
"busy_timeout = %d, want %d",
busyTimeout, wantBusyTimeoutMS,
)
}
}
-11
View File
@@ -17,14 +17,3 @@ func IsUniqueConstraintError(err error) bool {
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}
// IsForeignKeyConstraintError reports whether err is a
// SQLite foreign-key constraint violation.
func IsForeignKeyConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
}
-14
View File
@@ -58,17 +58,3 @@ func (database *Database) Close() error {
return nil
}
// ExecForTest runs a raw statement, such as a trigger that
// makes a write fail.
func (database *Database) ExecForTest(
ctx context.Context,
query string,
) error {
_, err := database.conn.ExecContext(ctx, query)
if err != nil {
return fmt.Errorf("exec for test: %w", err)
}
return nil
}
+194 -720
View File
File diff suppressed because it is too large Load Diff
+39 -330
View File
@@ -2,7 +2,6 @@ package db_test
import (
"encoding/json"
"slices"
"testing"
"sneak.berlin/go/neoirc/internal/db"
@@ -412,7 +411,7 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid)
err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
@@ -423,10 +422,7 @@ func TestJoinAndPart(t *testing.T) {
}
}
// TestPartChannelAndDeleteIfEmpty checks that a part keeps
// a channel another session is still on and deletes the
// channel when its last member parts.
func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
func TestDeleteChannelIfEmpty(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
@@ -439,34 +435,22 @@ func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Fatal(err)
}
nicks := []string{"temp1", "temp2"}
sids := make([]int64, 0, len(nicks))
for _, nick := range nicks {
sid, _, _, createErr := database.CreateSession(ctx, nick, "", "", "")
if createErr != nil {
t.Fatal(createErr)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
sids = append(sids, sid)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[0])
sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
if err != nil {
t.Fatal(err)
}
_, err = database.GetChannelByName(ctx, "#empty")
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatalf("channel deleted while a member is on it: %v", err)
t.Fatal(err)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[1])
err = database.PartChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, chID)
if err != nil {
t.Fatal(err)
}
@@ -635,7 +619,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
)
if err != nil {
t.Fatal(err)
@@ -669,13 +653,13 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
)
if err != nil {
t.Fatal(err)
}
err = database.EnqueueToSession(ctx, sid, dbID, 0)
err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil {
t.Fatal(err)
}
@@ -722,16 +706,11 @@ func TestGetHistory(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#hist")
if err != nil {
t.Fatal(err)
}
const msgCount = 10
for range msgCount {
_, _, err = database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist", 0, chID,
_, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist",
nil, json.RawMessage(`["msg"]`), nil,
)
if err != nil {
@@ -742,7 +721,7 @@ func TestGetHistory(t *testing.T) {
const histLimit = 5
msgs, err := database.GetHistory(
ctx, 0, chID, "", histLimit,
ctx, "#hist", 0, histLimit,
)
if err != nil {
t.Fatal(err)
@@ -758,209 +737,44 @@ func TestGetHistory(t *testing.T) {
}
}
// TestGetHistoryReusedNames checks that history follows the
// session and the channel row, not the nick or the channel
// name: a session that takes a released nick, and a channel
// created again under a deleted one's name, start with none.
func TestGetHistoryReusedNames(t *testing.T) {
func TestDeleteSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
historyLen := func(toSessionID, toChannelID int64) int {
t.Helper()
msgs, err := database.GetHistory(
ctx, toSessionID, toChannelID, "", 0,
)
if err != nil {
t.Fatal(err)
}
return len(msgs)
}
oldSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
sid, _, _, err := database.CreateSession(
ctx, "deleteme", "", "", "",
)
if err != nil {
t.Fatal(err)
}
oldChID, err := database.GetOrCreateChannel(ctx, "#reused")
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, oldChID, oldSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["hi"]`)
_, _, err = database.InsertMessage(
ctx, "PRIVMSG", "sender", "reused", oldSID, 0,
nil, body, nil,
chID, err := database.GetOrCreateChannel(
ctx, "#delchan",
)
if err != nil {
t.Fatal(err)
}
_, _, err = database.InsertMessage(
ctx, "PRIVMSG", "sender", "#reused", 0, oldChID,
nil, body, nil,
)
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
if historyLen(oldSID, 0) != 1 || historyLen(0, oldChID) != 1 {
t.Fatal("expected one DM and one channel message")
}
// The session is the channel's only member, so quitting
// deletes the channel too.
_, err = database.QuitSession(ctx, oldSID, "reused", nil)
err = database.DeleteSession(ctx, sid)
if err != nil {
t.Fatal(err)
}
newSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
)
if err != nil {
t.Fatal(err)
}
newChID, err := database.GetOrCreateChannel(ctx, "#reused")
if err != nil {
t.Fatal(err)
}
if historyLen(newSID, 0) != 0 || historyLen(0, newChID) != 0 {
t.Fatal("expected no history for the names' new holders")
}
}
// TestQuitSession checks that QuitSession deletes the
// session, keeps a channel another session is still on,
// deletes a channel it leaves with no members, and queues
// one QUIT to the other session; and that a second call for
// the same session changes nothing.
func TestQuitSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, sharedID, _ := createSessionWithChannels(
t, database, "deleteme", "#shared", "#alone",
)
peerSID, peerClientID, _, err := database.CreateSession(
ctx, "peer", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.JoinChannel(ctx, sharedID, peerSID)
if err != nil {
t.Fatal(err)
}
body := json.RawMessage(`["bye"]`)
peerIDs, err := database.QuitSession(ctx, sid, "deleteme", body)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(peerIDs, []int64{peerSID}) {
t.Fatalf("got peers %v, want [%d]", peerIDs, peerSID)
}
_, err = database.GetSessionByNick(ctx, "deleteme")
if err == nil {
t.Fatal("session should be deleted")
}
ids, _ := database.GetChannelMemberIDs(ctx, sharedID)
if !slices.Equal(ids, []int64{peerSID}) {
t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID)
}
_, err = database.GetChannelByName(ctx, "#alone")
if err == nil {
t.Fatal("expected the emptied channel to be deleted")
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
peerIDs, err = database.QuitSession(ctx, sid, "deleteme", body)
if err != nil || len(peerIDs) != 0 {
t.Fatalf("second quit: got %v, %v; want none", peerIDs, err)
}
checkOneQuitQueued(t, database, peerClientID, "deleteme")
}
// checkOneQuitQueued fails the test unless the queue of the
// client clientID holds one message, a QUIT from nick.
func checkOneQuitQueued(
t *testing.T,
database *db.Database,
clientID int64,
nick string,
) {
t.Helper()
msgs, _, err := database.PollMessages(t.Context(), clientID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || msgs[0].Command != "QUIT" || msgs[0].From != nick {
t.Fatalf("queue: got %v, want one QUIT from %s", msgs, nick)
}
}
// TestLogoutClientFailureKeepsClient checks that when the
// session of a logout's last client cannot be deleted,
// LogoutClient fails and the client stays logged in.
func TestLogoutClientFailureKeepsClient(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sid, clientID, token, err := database.CreateSession(
ctx, "stuck", "", "", "",
)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(ctx,
`CREATE TRIGGER keep_sessions
BEFORE DELETE ON sessions
BEGIN
SELECT RAISE(ABORT, 'sessions are kept');
END`)
if err != nil {
t.Fatal(err)
}
_, err = database.LogoutClient(ctx, sid, clientID, "stuck", nil)
if err == nil {
t.Fatal("logout succeeded although the session was kept")
}
_, _, nick, err := database.GetSessionByToken(ctx, token)
if err != nil || nick != "stuck" {
t.Fatalf("client gone after a failed logout: %q, %v", nick, err)
ids, _ := database.GetChannelMemberIDs(ctx, chID)
if len(ids) != 0 {
t.Fatal("expected no members after deletion")
}
}
@@ -1049,7 +863,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", 0, 0, nil, body, nil,
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
)
if err != nil {
t.Fatal(err)
@@ -1439,6 +1253,17 @@ func TestChannelInviteCRUD(t *testing.T) {
if !has {
t.Fatal("should have invite")
}
// Clear invite.
err = database.ClearChannelInvite(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
has, _ = database.HasChannelInvite(ctx, chID, sid)
if has {
t.Fatal("invite should be cleared")
}
}
func TestChannelSecret(t *testing.T) {
@@ -1666,119 +1491,3 @@ func TestChannelUserLimit(t *testing.T) {
t.Fatalf("expected 0, got %d", limit)
}
}
// TestSetSessionUserModesIsAtomic makes the is_oper write
// fail after the is_wallops write has run, and checks that
// the wallops change was rolled back.
func TestSetSessionUserModesIsAtomic(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "alice", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.ExecForTest(ctx,
`CREATE TRIGGER reject_oper
BEFORE UPDATE OF is_oper ON sessions
BEGIN SELECT RAISE(ABORT, 'oper write rejected');
END`,
)
if err != nil {
t.Fatal(err)
}
wallops := true
oper := false
err = database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper,
)
if err == nil {
t.Fatal("expected the rejected oper write to fail")
}
gotWallops, err := database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if gotWallops {
t.Error("wallops was stored although the change failed")
}
}
// TestSetSessionUserModesAppliesBoth checks that both flags
// are written, and that a nil flag is left as it is.
func TestSetSessionUserModesAppliesBoth(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "alice", "", "", "",
)
if err != nil {
t.Fatal(err)
}
err = database.SetSessionOper(ctx, sessionID, true)
if err != nil {
t.Fatal(err)
}
wallops := true
oper := false
err = database.SetSessionUserModes(
ctx, sessionID, &wallops, &oper,
)
if err != nil {
t.Fatal(err)
}
gotWallops, err := database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
gotOper, err := database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if !gotWallops || gotOper {
t.Errorf(
"want wallops=true oper=false, got %v/%v",
gotWallops, gotOper,
)
}
err = database.SetSessionUserModes(
ctx, sessionID, nil, nil,
)
if err != nil {
t.Fatal(err)
}
gotWallops, err = database.IsSessionWallops(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if !gotWallops {
t.Error("a nil wallops flag cleared wallops")
}
}
+2 -9
View File
@@ -10,7 +10,6 @@ CREATE TABLE IF NOT EXISTS sessions (
hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0,
is_wallops INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '',
@@ -85,25 +84,19 @@ CREATE TABLE IF NOT EXISTS channel_members (
UNIQUE(channel_id, session_id)
);
-- Messages: IRC envelope format. to_session_id is the session a DM
-- or INVITE was sent to, to_channel_id the channel a channel message
-- was sent to. History is selected by these ids because nicks and
-- channel names are reused once released, and ids are not.
-- Messages: IRC envelope format
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '',
msg_to TEXT NOT NULL DEFAULT '',
to_session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL,
to_channel_id INTEGER REFERENCES channels(id) ON DELETE SET NULL,
params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_messages_to_session ON messages(to_session_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_to_channel ON messages(to_channel_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL)
+413 -158
View File
@@ -2,7 +2,6 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -15,10 +14,24 @@ import (
"github.com/go-chi/chi/v5"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
var (
errHashcashRequired = errors.New("hashcash required")
errHashcashReused = errors.New("hashcash reused")
)
var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
)
var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
var validUsernameRe = regexp.MustCompile(
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
)
@@ -199,7 +212,7 @@ func (hdlr *Handlers) handleCreateSession(
return
}
if !hdlr.checkHashcash(
if !hdlr.validateHashcash(
writer, request, payload.Hashcash,
) {
return
@@ -207,7 +220,7 @@ func (hdlr *Handlers) handleCreateSession(
payload.Nick = strings.TrimSpace(payload.Nick)
if !service.ValidNick(payload.Nick) {
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
@@ -232,15 +245,14 @@ func (hdlr *Handlers) handleCreateSession(
}
hdlr.executeCreateSession(
writer, request,
payload.Nick, username, payload.Hashcash,
writer, request, payload.Nick, username,
)
}
func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter,
request *http.Request,
nick, username, stamp string,
nick, username string,
) {
remoteIP := clientIP(request)
@@ -248,17 +260,12 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP,
)
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession(
request.Context(),
nick, username, hostname, remoteIP,
)
if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError(
writer, request, err,
)
@@ -279,10 +286,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated)
}
// checkHashcash checks a hashcash stamp if required,
// without spending it. Returns false if the check failed
// and a response was already sent.
func (hdlr *Handlers) checkHashcash(
// validateHashcash validates a hashcash stamp if required.
// Returns false if validation failed and a response was
// already sent.
func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
@@ -301,31 +308,7 @@ func (hdlr *Handlers) checkHashcash(
return false
}
err := hdlr.hashcashVal.Check(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if Claim refused the stamp, normally because
// it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
err := hdlr.hashcashVal.Claim(
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
@@ -596,7 +579,7 @@ func (hdlr *Handlers) enqueueNumeric(
}
dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, hdlr.serverName(), nick, 0, 0,
ctx, command, hdlr.serverName(), nick,
paramsJSON, json.RawMessage(body), nil,
)
if insertErr != nil {
@@ -682,7 +665,7 @@ func (hdlr *Handlers) initChannelState(
dbID, _, insErr := hdlr.params.Database.
InsertMessage(
ctx, "JOIN", nick, chanInfo.Name,
0, chanInfo.ID, nil, nil, nil,
nil, nil, nil,
)
if insErr != nil {
hdlr.log.Error(
@@ -813,14 +796,7 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
timeout = maxLongPollTimeout
}
// Register with the broker before reading the queue.
// Notify wakes only the channels registered at that
// moment, so a message queued after the read below
// must find this one there already.
waitCh := hdlr.broker.Wait(sessionID)
defer hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.pollMessages(
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID,
afterID, pollMessageLimit,
)
@@ -848,22 +824,19 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll(
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(
writer http.ResponseWriter,
request *http.Request,
waitCh <-chan struct{},
clientID, afterID int64,
sessionID, clientID, afterID int64,
timeout int,
) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer(
time.Duration(timeout) * time.Second,
)
@@ -874,10 +847,14 @@ func (hdlr *Handlers) longPoll(
case <-waitCh:
case <-timer.C:
case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return
}
msgs, lastQID, err := hdlr.pollMessages(
hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID,
afterID, pollMessageLimit,
)
@@ -1038,10 +1015,10 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
hdlr.dispatchOperCommand(
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, command, bodyLines,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdMotd, irc.CmdPing:
hdlr.dispatchInfoCommand(
@@ -1098,27 +1075,6 @@ func (hdlr *Handlers) dispatchQueryCommand(
writer, request,
sessionID, clientID, nick,
)
case irc.CmdUserhost:
hdlr.handleUserhost(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdVersion:
hdlr.handleVersion(
writer, request, sessionID, clientID, nick,
)
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request, sessionID, clientID, nick,
)
case irc.CmdInfo:
hdlr.handleInfo(
writer, request, sessionID, clientID, nick,
)
case irc.CmdTime:
hdlr.handleTime(
writer, request, sessionID, clientID, nick,
)
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
@@ -1257,11 +1213,26 @@ func (hdlr *Handlers) handleChannelMsg(
) {
ctx := request.Context()
// Delegate validation + fan-out to service layer. The
// fan-out includes the sender's own session, so all
// their clients see the message in long-poll responses.
uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, 0, nick,
// Hashcash validation is HTTP-specific; needs chID.
isNotice := command == irc.CmdNotice
if !isNotice {
chID, chErr := hdlr.params.Database.
GetChannelByName(ctx, target)
if chErr == nil {
hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID,
writer, nick, target, body, meta, chID,
)
if hashcashErr != nil {
return
}
}
}
// Delegate validation + fan-out to service layer.
dbID, uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, nick,
command, target, body, meta,
)
if hdlr.handleServiceError(
@@ -1271,11 +1242,172 @@ func (hdlr *Handlers) handleChannelMsg(
return
}
// HTTP echo: enqueue to sender so all their clients
// see the message in long-poll responses.
_ = hdlr.params.Database.EnqueueToSession(
ctx, sessionID, dbID,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"id": uuid, statusKey: "sent"},
http.StatusOK)
}
// validateChannelHashcash checks whether the channel
// requires hashcash proof-of-work for messages and
// validates the stamp from the message meta field.
// Returns nil on success or if the channel has no
// hashcash requirement. On failure, it sends the
// appropriate IRC error and returns a non-nil error.
func (hdlr *Handlers) validateChannelHashcash(
request *http.Request,
clientID, sessionID int64,
writer http.ResponseWriter,
nick, target string,
body json.RawMessage,
meta json.RawMessage,
chID int64,
) error {
ctx := request.Context()
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
ctx, chID,
)
if bitsErr != nil {
hdlr.log.Error(
"get channel hashcash bits", "error", bitsErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
}
if bits <= 0 {
return nil
}
stamp := hdlr.extractHashcashFromMeta(meta)
if stamp == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Channel requires hashcash proof-of-work",
)
return errHashcashRequired
}
return hdlr.verifyChannelStamp(
request, writer,
clientID, sessionID,
nick, target, body, stamp, bits,
)
}
// verifyChannelStamp validates a channel hashcash stamp
// and checks for replay attacks.
func (hdlr *Handlers) verifyChannelStamp(
request *http.Request,
writer http.ResponseWriter,
clientID, sessionID int64,
nick, target string,
body json.RawMessage,
stamp string,
bits int,
) error {
ctx := request.Context()
bodyHashStr := hashcash.BodyHash(body)
valErr := hdlr.channelHashcash.ValidateStamp(
stamp, bits, target, bodyHashStr,
)
if valErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Invalid hashcash: "+valErr.Error(),
)
return fmt.Errorf("channel hashcash: %w", valErr)
}
stampKey := hashcash.StampHash(stamp)
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
ctx, stampKey,
)
if spentErr != nil {
hdlr.log.Error(
"check spent hashcash", "error", spentErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("check spent hashcash: %w", spentErr)
}
if spent {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Hashcash stamp already used",
)
return errHashcashReused
}
recordErr := hdlr.params.Database.RecordSpentHashcash(
ctx, stampKey,
)
if recordErr != nil {
hdlr.log.Error(
"record spent hashcash", "error", recordErr,
)
}
return nil
}
// extractHashcashFromMeta parses the meta JSON and
// returns the hashcash stamp string, or empty string
// if not present.
func (hdlr *Handlers) extractHashcashFromMeta(
meta json.RawMessage,
) string {
if len(meta) == 0 {
return ""
}
var metaMap map[string]json.RawMessage
err := json.Unmarshal(meta, &metaMap)
if err != nil {
return ""
}
raw, ok := metaMap["hashcash"]
if !ok {
return ""
}
var stamp string
err = json.Unmarshal(raw, &stamp)
if err != nil {
return ""
}
return stamp
}
func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter,
request *http.Request,
@@ -1285,7 +1417,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage,
) {
result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, 0, nick,
request.Context(), sessionID, nick,
command, target, body, meta,
)
if hdlr.handleServiceError(
@@ -1335,6 +1467,16 @@ func (hdlr *Handlers) handleJoin(
channel = "#" + channel
}
if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
return
}
// Extract key from body lines (JOIN #channel key).
var suppliedKey string
@@ -1356,7 +1498,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string,
) {
result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, 0, nick,
request.Context(), sessionID, nick,
channel, suppliedKey,
)
if hdlr.handleServiceError(
@@ -1540,7 +1682,7 @@ func (hdlr *Handlers) handlePart(
}
err := hdlr.svc.PartChannel(
request.Context(), sessionID, 0,
request.Context(), sessionID,
nick, channel, reason,
)
if hdlr.handleServiceError(
@@ -1578,7 +1720,7 @@ func (hdlr *Handlers) handleNick(
newNick := strings.TrimSpace(lines[0])
if !service.ValidNick(newNick) {
if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrErroneusNickname, nick, []string{newNick},
@@ -1611,7 +1753,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string,
) {
err := hdlr.svc.ChangeNick(
request.Context(), sessionID, 0, nick, newNick,
request.Context(), sessionID, nick, newNick,
)
if hdlr.handleServiceError(
writer, request,
@@ -1666,7 +1808,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic(
request.Context(), sessionID, 0,
request.Context(), sessionID,
nick, channel, topic,
)
if hdlr.handleServiceError(
@@ -1788,9 +1930,8 @@ func (hdlr *Handlers) handleQuit(
http.StatusOK)
}
// handleMode handles the MODE command: a target that starts
// with # is a channel, handled by handleChannelMode, and any
// other target a nick, handled by handleUserMode.
// handleMode handles the MODE command for channels and
// users. Currently supports query-only (no mode changes).
func (hdlr *Handlers) handleMode(
writer http.ResponseWriter,
request *http.Request,
@@ -1810,11 +1951,15 @@ func (hdlr *Handlers) handleMode(
channel := target
if !strings.HasPrefix(channel, "#") {
hdlr.handleUserMode(
writer, request,
sessionID, clientID, nick, target,
bodyLines,
// User mode query — return empty modes.
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplUmodeIs, nick, nil, "+",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
return
}
@@ -2152,7 +2297,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID,
request.Context(), nick, channel, chID,
modeText,
)
@@ -2198,7 +2343,7 @@ func (hdlr *Handlers) setChannelFlag(
}
hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID,
request.Context(), nick, channel, chID,
modeStr,
)
@@ -2435,7 +2580,7 @@ func (hdlr *Handlers) executeBanChange(
)
if mErr == nil {
_ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID,
request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs,
)
}
@@ -2553,7 +2698,7 @@ func (hdlr *Handlers) setChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"+k", key})
if mErr == nil {
_ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID,
request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs,
)
}
@@ -2602,7 +2747,7 @@ func (hdlr *Handlers) clearChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"-k", "*"})
if mErr == nil {
_ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID,
request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs,
)
}
@@ -2677,7 +2822,7 @@ func (hdlr *Handlers) setChannelLimitMode(
)
if mErr == nil {
_ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID,
request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs,
)
}
@@ -2727,7 +2872,7 @@ func (hdlr *Handlers) clearChannelLimitMode(
modeBody, mErr := json.Marshal([]string{"-l"})
if mErr == nil {
_ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID,
request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs,
)
}
@@ -2764,15 +2909,123 @@ func (hdlr *Handlers) handleInvite(
channel = "#" + channel
}
chID, targetSID, ok := hdlr.validateInvite(
writer, request,
sessionID, clientID, nick,
targetNick, channel,
)
if !ok {
return
}
hdlr.executeInvite(
writer, request,
sessionID, clientID, nick,
targetNick, channel, chID, targetSID,
)
}
// validateInvite checks channel, membership, permissions,
// and target for an INVITE command.
func (hdlr *Handlers) validateInvite(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, targetNick, channel string,
) (int64, int64, bool) {
ctx := request.Context()
targetSID, err := hdlr.svc.InviteUser(
ctx, sessionID, nick, targetNick, channel,
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if hdlr.handleServiceError(
writer, request,
clientID, sessionID, nick, err,
) {
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchChannel, nick,
[]string{channel}, "No such channel",
)
return 0, 0, false
}
isMember, memErr := hdlr.params.Database.IsChannelMember(
ctx, chID, sessionID,
)
if memErr != nil || !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNotOnChannel, nick,
[]string{channel},
"You're not on that channel",
)
return 0, 0, false
}
isInviteOnly, ioErr := hdlr.params.Database.
IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
if !hdlr.requireChannelOp(
writer, request,
sessionID, clientID, nick, channel, chID,
) {
return 0, 0, false
}
}
targetSID, nickErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if nickErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return 0, 0, false
}
alreadyIn, aiErr := hdlr.params.Database.
IsChannelMember(ctx, chID, targetSID)
if aiErr == nil && alreadyIn {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUserOnChannel, nick,
[]string{targetNick, channel},
"is already on channel",
)
return 0, 0, false
}
return chID, targetSID, true
}
// executeInvite records the invite and sends
// notifications.
func (hdlr *Handlers) executeInvite(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, targetNick, channel string,
chID, targetSID int64,
) {
ctx := request.Context()
invErr := hdlr.params.Database.AddChannelInvite(
ctx, chID, targetSID, nick,
)
if invErr != nil {
hdlr.log.Error(
"add invite failed", "error", invErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
@@ -2786,7 +3039,7 @@ func (hdlr *Handlers) handleInvite(
if mErr == nil {
_ = hdlr.fanOutSilent(
request, irc.CmdInvite, nick, targetNick,
targetSID, 0, json.RawMessage(invBody),
json.RawMessage(invBody),
[]int64{targetSID},
)
}
@@ -3281,13 +3534,16 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
return
}
toSessionID, toChannelID, ok := hdlr.canAccessHistory(
if !hdlr.canAccessHistory(
writer, request, sessionID, nick, target,
)
if !ok {
) {
return
}
beforeID, _ := strconv.ParseInt(
request.URL.Query().Get("before"), 10, 64,
)
limit, _ := strconv.Atoi(
request.URL.Query().Get("limit"),
)
@@ -3296,19 +3552,8 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
}
msgs, err := hdlr.params.Database.GetHistory(
request.Context(), toSessionID, toChannelID,
request.URL.Query().Get("before"), limit,
request.Context(), target, beforeID, limit,
)
if errors.Is(err, sql.ErrNoRows) {
hdlr.respondError(
writer, request,
"unknown before id",
http.StatusBadRequest,
)
return
}
if err != nil {
hdlr.log.Error(
"get history failed", "error", err,
@@ -3329,23 +3574,17 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
}
// canAccessHistory verifies the user can read history
// for the given target (channel or DM participant) and
// returns the session id and channel id to pass to
// GetHistory: the user's own session for its nick, or the
// channel's id, with 0 for the other. On false it has
// written the error response.
// for the given target (channel or DM participant).
func (hdlr *Handlers) canAccessHistory(
writer http.ResponseWriter,
request *http.Request,
sessionID int64,
nick, target string,
) (int64, int64, bool) {
) bool {
if strings.HasPrefix(target, "#") {
chID, ok := hdlr.canAccessChannelHistory(
return hdlr.canAccessChannelHistory(
writer, request, sessionID, target,
)
return 0, chID, ok
}
// DM history: only allow if the target is the
@@ -3357,20 +3596,18 @@ func (hdlr *Handlers) canAccessHistory(
http.StatusForbidden,
)
return 0, 0, false
return false
}
return sessionID, 0, true
return true
}
// canAccessChannelHistory returns the id of the channel
// named target if the user is a member of it.
func (hdlr *Handlers) canAccessChannelHistory(
writer http.ResponseWriter,
request *http.Request,
sessionID int64,
target string,
) (int64, bool) {
) bool {
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target,
)
@@ -3381,7 +3618,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusNotFound,
)
return 0, false
return false
}
isMember, err := hdlr.params.Database.IsChannelMember(
@@ -3397,7 +3634,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusInternalServerError,
)
return 0, false
return false
}
if !isMember {
@@ -3407,10 +3644,10 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusForbidden,
)
return 0, false
return false
}
return chID, true
return true
}
// HandleLogout deletes the authenticated client's token
@@ -3426,11 +3663,15 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return
}
err := hdlr.svc.Logout(
request.Context(), sessionID, clientID, nick,
ctx := request.Context()
err := hdlr.params.Database.DeleteClient(
ctx, clientID,
)
if err != nil {
hdlr.log.Error("logout failed", "error", err)
hdlr.log.Error(
"delete client failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
@@ -3440,6 +3681,23 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return
}
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.clearAuthCookie(writer)
hdlr.respondJSON(writer, request,
@@ -3645,7 +3903,7 @@ func (hdlr *Handlers) handleKick(
}
err := hdlr.svc.KickUser(
request.Context(), sessionID, 0, nick,
request.Context(), sessionID, nick,
channel, targetNick, reason,
)
if hdlr.handleServiceError(
@@ -3698,19 +3956,17 @@ func (hdlr *Handlers) deliverWhoisIdle(
}
// fanOut inserts a message and enqueues it to all given
// sessions. toSessionID and toChannelID are the recipient
// InsertMessage records for history, 0 when there is none.
// sessions.
func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target,
toSessionID, toChannelID, nil, body, meta,
nil, body, meta,
)
if err != nil {
return "", fmt.Errorf("insert message: %w", err)
@@ -3718,7 +3974,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID, 0,
request.Context(), sid, dbID,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
@@ -3735,13 +3991,12 @@ func (hdlr *Handlers) fanOut(
func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target,
toSessionID, toChannelID, body, nil, sessionIDs,
body, nil, sessionIDs,
)
return err
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -8,12 +8,7 @@ import (
"sneak.berlin/go/neoirc/pkg/irc"
)
// A session password is 8 to 72 bytes; bcrypt cannot hash
// a longer one.
const (
minPasswordLength = 8
maxPasswordLength = 72
)
const minPasswordLength = 8
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
@@ -154,13 +149,12 @@ func (hdlr *Handlers) handlePass(
password := lines[0]
if len(password) < minPasswordLength ||
len(password) > maxPasswordLength {
if len(password) < minPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be 8 to 72 bytes",
"Password must be at least 8 characters",
)
return
-19
View File
@@ -1,19 +0,0 @@
package handlers
import (
"context"
"sneak.berlin/go/neoirc/internal/db"
)
// SetPollMessages makes HandleGetMessages read the client
// output queue with poll in place of Database.PollMessages.
func (hdlr *Handlers) SetPollMessages(
poll func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error),
) {
hdlr.pollMessages = poll
}
+19 -28
View File
@@ -55,25 +55,16 @@ const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling.
type Handlers struct {
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
svc *service.Service
hashcashVal *hashcash.Validator
loginLimiter *ratelimit.Limiter
stats *stats.Tracker
cancelCleanup context.CancelFunc
// pollMessages is Database.PollMessages, with which
// HandleGetMessages reads the client output queue. Tests
// replace it to act just after a read, or to make a read
// fail.
pollMessages func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error)
params *Params
log *slog.Logger
hc *healthcheck.Healthcheck
broker *broker.Broker
svc *service.Service
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
loginLimiter *ratelimit.Limiter
stats *stats.Tracker
cancelCleanup context.CancelFunc
}
// New creates a new Handlers instance.
@@ -97,15 +88,15 @@ func New(
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: params.Broker,
svc: params.Service,
hashcashVal: hashcash.NewValidator(resource),
loginLimiter: ratelimit.New(loginRate, loginBurst),
stats: params.Stats,
pollMessages: params.Database.PollMessages,
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: params.Broker,
svc: params.Service,
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
loginLimiter: ratelimit.New(loginRate, loginBurst),
stats: params.Stats,
}
lifecycle.Append(fx.Hook{
-304
View File
@@ -1,304 +0,0 @@
package handlers
import (
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/pkg/irc"
)
// dispatchOperCommand routes the server operator commands
// OPER, KILL and WALLOPS to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKill:
hdlr.handleKill(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdWallops:
hdlr.handleWallops(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// handleUserhost handles USERHOST: user@host for each of
// up to five nicks given in the body.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
reply, err := hdlr.svc.UserhostReply(
request.Context(), lines, hdlr.serverName(),
)
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.enqueueNumeric(
request.Context(), clientID,
irc.RplUserHost, nick, nil, reply,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleVersion handles VERSION.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID, irc.RplVersion, nick,
[]string{
hdlr.svc.ServerVersion() + ".",
hdlr.serverName(),
},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleAdmin handles ADMIN.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
location1, location2, email := hdlr.svc.AdminLines(srvName)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName}, "Administrative info",
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil, location1,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil, location2,
)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil, email,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleInfo handles INFO.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
for _, line := range hdlr.svc.InfoLines() {
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil, line,
)
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleTime handles TIME: the server's local time.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID, irc.RplTime, nick,
[]string{hdlr.serverName()},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleKill handles KILL: the body is the target nick and
// an optional reason.
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
targetNick := ""
if len(lines) > 0 {
targetNick = strings.TrimSpace(lines[0])
}
if targetNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
reason := ""
if len(lines) > 1 {
reason = lines[1]
}
err := hdlr.svc.KillUser(
request.Context(), sessionID, nick,
targetNick, reason,
)
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleWallops handles WALLOPS: the body is the message.
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
err := hdlr.svc.SendWallops(
request.Context(), sessionID, nick,
strings.Join(lines, " "),
)
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
// handleUserMode handles MODE for a nick: a query when the
// body is empty, otherwise a change by the mode string in
// the body. Only the caller's own nick is allowed.
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
if target != nick {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
ctx := request.Context()
var (
modes string
err error
)
lines := bodyLines()
if len(lines) > 0 {
modes, err = hdlr.svc.ApplyUserMode(
ctx, sessionID, lines[0],
)
} else {
modes, err = hdlr.svc.QueryUserMode(ctx, sessionID)
}
if hdlr.handleServiceError(
writer, request, clientID, sessionID, nick, err,
) {
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil, modes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{statusKey: "ok"},
http.StatusOK)
}
-337
View File
@@ -1,337 +0,0 @@
//nolint:paralleltest // global viper, as in api_test.go
package handlers_test
import (
"net/http"
"strings"
"testing"
"sneak.berlin/go/neoirc/pkg/irc"
)
// Each test starts one server and reuses it for all its
// checks, and makes as few requests as it can: servers,
// sessions and requests are what makes this package's tests
// slow.
// sendAndPoll sends cmd from the session token and returns
// the messages after lastID that the session has been sent,
// and the ID of the last of them.
func sendAndPoll(
tserver *testServer,
token string,
lastID int64,
cmd map[string]any,
) ([]map[string]any, int64) {
tserver.t.Helper()
tserver.sendCommand(token, cmd)
return tserver.pollMessages(token, lastID)
}
// newOperSession creates a session for nick, makes it a
// server operator, and returns its token and the ID of the
// last message it has been sent.
func newOperSession(
tserver *testServer, nick string,
) (string, int64) {
tserver.t.Helper()
token := tserver.createSession(nick)
_, lastID := sendAndPoll(tserver, token, 0, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName, testOperPassword},
})
return token, lastID
}
// numericBody returns the first body line of the message in
// msgs with the given numeric, and fails the test if there
// is none.
func numericBody(
t *testing.T, msgs []map[string]any, numeric string,
) string {
t.Helper()
msg := findNumericWithParams(msgs, numeric)
if msg == nil {
t.Fatalf("expected numeric %s, got %v", numeric, msgs)
}
lines, _ := msg[bodyKey].([]any)
if len(lines) == 0 {
return ""
}
line, _ := lines[0].(string)
return line
}
func TestUserhost(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
tserver.createSession("bob")
_, lastID := tserver.pollMessages(token, 0)
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost,
bodyKey: []string{"alice", "bob"},
})
body := numericBody(t, msgs, "302")
if !strings.HasPrefix(body, "alice=+alice@") ||
!strings.Contains(body, " bob=+bob@") {
t.Errorf("expected alice and bob, got %q", body)
}
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost,
bodyKey: []string{"ghost"},
})
body = numericBody(t, msgs, "302")
if body != "" {
t.Errorf("expected an empty reply for ghost, got %q", body)
}
msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost,
})
if !findNumeric(msgs, "461") {
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
}
}
func TestVersionAdminInfoTime(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("frank")
_, lastID := tserver.pollMessages(token, 0)
msgs, lastID := sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdVersion,
})
params := getNumericParams(findNumericWithParams(msgs, "351"))
if len(params) == 0 || params[0] != "neoirc-test-test." {
t.Errorf("expected RPL_VERSION neoirc-test-test., got %v", msgs)
}
for _, check := range []struct {
command string
numerics []string
}{
{irc.CmdAdmin, []string{"256", "257", "258", "259"}},
{irc.CmdInfo, []string{"371", "374"}},
{irc.CmdTime, []string{"391"}},
} {
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: check.command,
})
for _, numeric := range check.numerics {
if !findNumeric(msgs, numeric) {
t.Errorf(
"%s: expected %s, got %v",
check.command, numeric, msgs,
)
}
}
}
}
func TestKill(t *testing.T) {
tserver := newTestServerWithOper(t)
victimToken := tserver.createSession("victim")
observerToken := tserver.createSession("observer")
notOperToken := tserver.createSession("notoper")
operToken, operLastID := newOperSession(tserver, "killer")
for _, token := range []string{victimToken, observerToken} {
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
}
_, notOperLastID := tserver.pollMessages(notOperToken, 0)
msgs, _ := sendAndPoll(tserver, notOperToken, notOperLastID, map[string]any{
commandKey: irc.CmdKill,
bodyKey: []string{"victim"},
})
if !findNumeric(msgs, "481") {
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
}
for _, check := range []struct {
numeric string
body []string
}{
{"461", []string{}},
{"401", []string{"ghost"}},
{"483", []string{"killer"}},
} {
msgs, operLastID = sendAndPoll(tserver, operToken, operLastID, map[string]any{
commandKey: irc.CmdKill,
bodyKey: check.body,
})
if !findNumeric(msgs, check.numeric) {
t.Errorf(
"KILL %v: expected %s, got %v",
check.body, check.numeric, msgs,
)
}
}
_, observerLastID := tserver.pollMessages(observerToken, 0)
status, result := tserver.sendCommand(operToken, map[string]any{
commandKey: irc.CmdKill,
bodyKey: []string{"victim", "go away"},
})
if status != http.StatusOK || result[statusKey] != "ok" {
t.Fatalf("expected 200 and status ok, got %d: %v", status, result)
}
msgs, _ = tserver.pollMessages(observerToken, observerLastID)
if !findMessage(msgs, irc.CmdQuit, "victim") {
t.Errorf("expected the observer to see QUIT, got %v", msgs)
}
status, _ = tserver.getState(victimToken)
if status != http.StatusUnauthorized {
t.Errorf("expected the victim's session gone, got %d", status)
}
}
func TestWallops(t *testing.T) {
tserver := newTestServerWithOper(t)
receiverToken := tserver.createSession("receiver")
plainToken := tserver.createSession("plain")
operToken, operLastID := newOperSession(tserver, "walloper")
_, receiverLastID := sendAndPoll(tserver, receiverToken, 0, map[string]any{
commandKey: modeCmd,
toKey: "receiver",
bodyKey: []string{"+w"},
})
_, plainLastID := tserver.pollMessages(plainToken, 0)
msgs, plainLastID := sendAndPoll(tserver, plainToken, plainLastID, map[string]any{
commandKey: irc.CmdWallops,
bodyKey: []string{"not allowed"},
})
if !findNumeric(msgs, "481") {
t.Errorf("expected ERR_NOPRIVILEGES (481), got %v", msgs)
}
msgs, _ = sendAndPoll(tserver, operToken, operLastID, map[string]any{
commandKey: irc.CmdWallops,
})
if !findNumeric(msgs, "461") {
t.Errorf("expected ERR_NEEDMOREPARAMS (461), got %v", msgs)
}
tserver.sendCommand(operToken, map[string]any{
commandKey: irc.CmdWallops,
bodyKey: []string{"server going down"},
})
msgs, _ = tserver.pollMessages(receiverToken, receiverLastID)
if !findMessage(msgs, irc.CmdWallops, "walloper") {
t.Errorf("expected WALLOPS for the +w user, got %v", msgs)
}
msgs, _ = tserver.pollMessages(plainToken, plainLastID)
if findMessage(msgs, irc.CmdWallops, "walloper") {
t.Errorf("WALLOPS reached a user without +w: %v", msgs)
}
}
func TestUserMode(t *testing.T) {
const (
nick = "alice"
other = "other"
)
tserver := newTestServerWithOper(t)
token := tserver.createSession(nick)
otherToken := tserver.createSession(other)
operToken, operLastID := newOperSession(tserver, "deoper")
// mode sends MODE for target from token, with modeStr
// unless it is empty, and returns the session's next
// messages.
mode := func(
token string, lastID int64, target, modeStr string,
) ([]map[string]any, int64) {
cmd := map[string]any{commandKey: modeCmd, toKey: target}
if modeStr != "" {
cmd[bodyKey] = []string{modeStr}
}
return sendAndPoll(tserver, token, lastID, cmd)
}
_, otherLastID := mode(otherToken, 0, other, "+w")
_, lastID := tserver.pollMessages(token, 0)
for _, check := range []struct {
target, modeStr, numeric, modes string
}{
{nick, "+w", "221", "+w"},
{"Alice", "", "502", ""},
{"Alice", "-w", "502", ""},
{nick, "", "221", "+w"},
{nick, "-w", "221", "+"},
{nick, "+z", "501", ""},
{nick, "+o", "501", ""},
{other, "", "502", ""},
{other, "-w", "502", ""},
} {
var msgs []map[string]any
msgs, lastID = mode(token, lastID, check.target, check.modeStr)
if check.numeric != "221" {
if !findNumeric(msgs, check.numeric) ||
findNumeric(msgs, "221") {
t.Errorf(
"MODE %s %s: expected only %s, got %v",
check.target, check.modeStr,
check.numeric, msgs,
)
}
continue
}
got := numericBody(t, msgs, "221")
if got != check.modes {
t.Errorf(
"MODE %s %s: expected %q, got %q",
check.target, check.modeStr, check.modes, got,
)
}
}
msgs, _ := mode(otherToken, otherLastID, other, "")
if got := numericBody(t, msgs, "221"); got != "+w" {
t.Errorf("%s's modes changed to %q", other, got)
}
msgs, _ = mode(operToken, operLastID, "deoper", "-o")
if got := numericBody(t, msgs, "221"); got != "+" {
t.Errorf("after -o: expected +, got %q", got)
}
}
+9 -53
View File
@@ -77,13 +77,9 @@ func NewValidator(resource string) *Validator {
return validator
}
// Check returns nil if requiredBits is not positive, or if
// the stamp is a well-formed, recently dated proof of work
// for this resource with at least requiredBits. It does not
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other
// checks.
func (v *Validator) Check(
// Validate checks a hashcash stamp. It returns nil if the
// stamp is valid and has not been seen before.
func (v *Validator) Validate(
stamp string,
requiredBits int,
) error {
@@ -91,49 +87,9 @@ func (v *Validator) Check(
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, ":")
if len(parts) != stampFields {
return time.Time{}, fmt.Errorf(
return fmt.Errorf(
"%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts),
)
@@ -148,25 +104,25 @@ func (v *Validator) check(
version, bitsStr, resource, requiredBits,
)
if err != nil {
return time.Time{}, err
return err
}
stampTime, err := parseStampDate(dateStr)
if err != nil {
return time.Time{}, err
return err
}
err = validateTime(stampTime)
if err != nil {
return time.Time{}, err
return err
}
err = validateProof(stamp, requiredBits)
if err != nil {
return time.Time{}, err
return err
}
return stampTime, nil
return v.checkAndRecordStamp(stamp, stampTime)
}
func (v *Validator) validateHeader(
+14 -76
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
@@ -99,74 +99,17 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("first use failed: %v", err)
}
err = validator.Claim(stamp, testBits)
err = validator.Validate(stamp, testBits)
if err == nil {
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) {
t.Parallel()
@@ -175,7 +118,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected resource mismatch error")
}
@@ -186,7 +129,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource")
err := validator.Claim(
err := validator.Validate(
"not:a:valid:stamp", testBits,
)
if err == nil {
@@ -203,7 +146,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource",
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad version error")
}
@@ -219,7 +162,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource",
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected insufficient bits error")
}
@@ -235,7 +178,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate,
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected expired stamp error")
}
@@ -246,14 +189,9 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource")
err := validator.Check("garbage", 0)
err := validator.Validate("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the check: %v", err)
}
err = validator.Claim("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the claim: %v", err)
t.Fatalf("zero bits should skip: %v", err)
}
}
@@ -266,7 +204,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate,
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("long date stamp rejected: %v", err)
}
@@ -281,7 +219,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource",
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err == nil {
t.Fatal("expected bad date error")
}
@@ -297,7 +235,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf("unique stamp rejected: %v", err)
}
@@ -313,7 +251,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
err := validator.Validate(stamp, testBits)
if err != nil {
t.Fatalf(
"higher-difficulty stamp rejected: %v",
+107 -200
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"slices"
"strconv"
"strings"
"time"
@@ -14,7 +13,7 @@ import (
)
// sendIRCError maps a service.IRCError to an IRC numeric
// reply on the wire, and logs any other error.
// reply on the wire.
func (c *Conn) sendIRCError(err error) {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
@@ -22,11 +21,7 @@ func (c *Conn) sendIRCError(err error) {
args = append(args, ircErr.Params...)
args = append(args, ircErr.Message)
c.sendNumeric(ircErr.Code, args...)
return
}
c.log.Error("command failed", "error", err)
}
// handleCAP silently acknowledges CAP negotiation.
@@ -71,20 +66,15 @@ func (c *Conn) handleNick(
}
newNick := msg.Params[0]
if !service.ValidNick(newNick) {
c.sendNumeric(
irc.ErrErroneusNickname,
newNick, "Erroneous nickname",
)
return
if len(newNick) > maxNickLen {
newNick = newNick[:maxNickLen]
}
oldMask := c.hostmask()
oldNick := c.currentNick()
oldNick := c.nick
err := c.svc.ChangeNick(
ctx, c.sessionID, c.clientID, oldNick, newNick,
ctx, c.sessionID, oldNick, newNick,
)
if err != nil {
c.sendIRCError(err)
@@ -92,10 +82,9 @@ func (c *Conn) handleNick(
return
}
// Take the nick the session holds, not newNick: another
// client of the session may have changed it again since
// ChangeNick stored newNick.
c.loadSessionNick(ctx)
c.mu.Lock()
c.nick = newNick
c.mu.Unlock()
// Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -129,8 +118,8 @@ func (c *Conn) handlePrivmsg(
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") {
_, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.clientID, c.currentNick(),
_, _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.nick,
msg.Command, target, body, nil,
)
if err != nil {
@@ -138,7 +127,7 @@ func (c *Conn) handlePrivmsg(
}
} else {
result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.clientID, c.currentNick(),
ctx, c.sessionID, c.nick,
msg.Command, target, body, nil,
)
if err != nil {
@@ -156,9 +145,7 @@ func (c *Conn) handlePrivmsg(
}
// handleJoin joins one or more channels via the shared
// service layer. The optional second parameter holds the
// channel keys, comma-separated in the order of the
// channels.
// service layer.
func (c *Conn) handleJoin(
ctx context.Context,
msg *Message,
@@ -172,24 +159,14 @@ func (c *Conn) handleJoin(
return
}
var keys []string
if len(msg.Params) > 1 {
keys = strings.Split(msg.Params[1], ",")
}
for idx, chanName := range strings.Split(msg.Params[0], ",") {
for chanName := range strings.SplitSeq(msg.Params[0], ",") {
chanName = strings.TrimSpace(chanName)
if !strings.HasPrefix(chanName, "#") {
chanName = "#" + chanName
}
key := ""
if idx < len(keys) {
key = keys[idx]
}
c.joinChannel(ctx, chanName, key)
c.joinChannel(ctx, chanName)
}
}
@@ -197,10 +174,10 @@ func (c *Conn) handleJoin(
// and delivers topic/names on the wire.
func (c *Conn) joinChannel(
ctx context.Context,
channel, key string,
channel string,
) {
result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, key,
ctx, c.sessionID, c.nick, channel, "",
)
if err != nil {
c.sendIRCError(err)
@@ -338,7 +315,7 @@ func (c *Conn) partChannel(
channel, reason string,
) {
err := c.svc.PartChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason,
ctx, c.sessionID, c.nick, channel, reason,
)
if err != nil {
c.sendIRCError(err)
@@ -368,10 +345,7 @@ func (c *Conn) handleQuit(msg *Message) {
c.send("ERROR :Closing Link: " + c.hostname +
" (Quit: " + reason + ")")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
}
// handleTopic gets or sets a channel topic via the shared
@@ -402,7 +376,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1]
err := c.svc.SetTopic(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic,
ctx, c.sessionID, c.nick, channel, newTopic,
)
if err != nil {
c.sendIRCError(err)
@@ -453,7 +427,7 @@ func (c *Conn) handleMode(
if strings.HasPrefix(target, "#") {
c.handleChannelMode(ctx, msg)
} else {
c.handleUserMode(ctx, msg)
c.handleUserMode(msg)
}
}
@@ -702,26 +676,21 @@ func (c *Conn) applyChannelModes(
if applied.Len() > 0 {
modeReply := applied.String() + appliedArgs.String()
params := slices.Concat(
[]string{channel}, strings.Fields(modeReply),
)
c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.send(FormatMessage(
c.hostmask(), "MODE", channel, modeReply,
))
c.svc.BroadcastMode(
ctx, c.clientID, c.currentNick(), channel, chID, modeReply,
ctx, c.nick, channel, chID, modeReply,
)
}
}
// handleUserMode handles MODE for a nick: a query without a
// mode string, otherwise a change. Only the client's own
// nick is allowed.
func (c *Conn) handleUserMode(
ctx context.Context,
msg *Message,
) {
if msg.Params[0] != c.currentNick() {
// handleUserMode handles MODE for users.
func (c *Conn) handleUserMode(msg *Message) {
target := msg.Params[0]
if !strings.EqualFold(target, c.nick) {
c.sendNumeric(
irc.ErrUsersDoNotMatch,
"Can't change mode for other users",
@@ -730,26 +699,8 @@ func (c *Conn) handleUserMode(
return
}
var (
modes string
err error
)
if len(msg.Params) == 1 {
modes, err = c.svc.QueryUserMode(ctx, c.sessionID)
} else {
modes, err = c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
)
}
if err != nil {
c.sendIRCError(err)
return
}
c.sendNumeric(irc.RplUmodeIs, modes)
// We don't support user modes beyond the basics.
c.sendNumeric(irc.RplUmodeIs, "+")
}
// handleNames replies with channel member list.
@@ -781,11 +732,10 @@ func (c *Conn) handleNames(
c.deliverNames(ctx, channel, chID)
}
// handleList sends the channel list, leaving out +s
// channels the user is not on.
// handleList sends the channel list.
func (c *Conn) handleList(ctx context.Context) {
channels, err := c.database.ListAllChannelsWithCountsFiltered(
ctx, c.sessionID,
channels, err := c.database.ListAllChannelsWithCounts(
ctx,
)
if err != nil {
c.sendNumeric(
@@ -905,15 +855,14 @@ func (c *Conn) whoisOper(
}
}
// whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out
// +s channels the querier is not on.
// whoisChannels sends 319 RPL_WHOISCHANNELS.
func (c *Conn) whoisChannels(
ctx context.Context,
target string,
targetID int64,
) {
userChannels, _ := c.database.GetSessionChannelsFiltered(
ctx, targetID, c.sessionID,
userChannels, _ := c.database.GetSessionChannels(
ctx, targetID,
)
if len(userChannels) == 0 {
return
@@ -1186,7 +1135,7 @@ func (c *Conn) handleKick(
}
err := c.svc.KickUser(
ctx, c.sessionID, c.clientID, c.currentNick(),
ctx, c.sessionID, c.nick,
channel, targetNick, reason,
)
if err != nil {
@@ -1217,16 +1166,17 @@ func (c *Conn) handlePassPostReg(
}
password := msg.Params[0]
if !c.checkPasswordLength(password) {
if len(password) < minPasswordLen {
c.sendFromServer("NOTICE", c.nick,
"Password must be at least 8 characters",
)
return
}
err := c.setPassword(ctx, password)
if err != nil {
return
}
c.setPassword(ctx, password)
c.sendFromServer("NOTICE", c.currentNick(),
c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+
"PASS <password> with your nick.",
)
@@ -1249,11 +1199,36 @@ func (c *Conn) handleInvite(
targetNick := msg.Params[0]
channel := msg.Params[1]
targetID, err := c.svc.InviteUser(
ctx, c.sessionID, c.currentNick(), targetNick, channel,
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrNotOnChannel,
channel, "You're not on that channel",
)
return
}
targetID, err := c.database.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
c.sendIRCError(err)
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick",
)
return
}
@@ -1262,126 +1237,58 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel,
)
// Queue the INVITE to the target with the body the HTTP
// API uses.
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
// Send INVITE notice to target via service fan-out.
body, _ := json.Marshal( //nolint:errchkjson
[]string{"You have been invited to " + channel},
)
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.currentNick(), targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, 0,
ctx, "INVITE", c.nick, targetNick,
nil, body, nil, []int64{targetID},
)
}
// handleUserhost replies with user@host for each of up to
// five nicks.
// handleUserhost replies with USERHOST info.
func (c *Conn) handleUserhost(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
irc.CmdUserhost, "Not enough parameters",
return
}
replies := make([]string, 0, len(msg.Params))
for _, nick := range msg.Params {
sid, err := c.database.GetSessionByNick(ctx, nick)
if err != nil {
continue
}
hostInfo, _ := c.database.GetSessionHostInfo(
ctx, sid,
)
return
}
host := "*"
if hostInfo != nil {
host = hostInfo.Hostname
}
reply, err := c.svc.UserhostReply(
ctx, msg.Params, c.serverSfx,
)
if err != nil {
c.sendIRCError(err)
isOper, _ := c.database.IsSessionOper(ctx, sid)
return
}
operStar := ""
if isOper {
operStar = "*"
}
c.sendNumeric(irc.RplUserHost, reply)
}
// handleVersion replies with the server version.
func (c *Conn) handleVersion() {
c.sendNumeric(
irc.RplVersion,
c.svc.ServerVersion()+".", c.serverSfx, "",
)
}
// handleAdmin replies with the server's admin info.
func (c *Conn) handleAdmin() {
location1, location2, email := c.svc.AdminLines(c.serverSfx)
c.sendNumeric(
irc.RplAdminMe, c.serverSfx, "Administrative info",
)
c.sendNumeric(irc.RplAdminLoc1, location1)
c.sendNumeric(irc.RplAdminLoc2, location2)
c.sendNumeric(irc.RplAdminEmail, email)
}
// handleInfo replies with the server's software info.
func (c *Conn) handleInfo() {
for _, line := range c.svc.InfoLines() {
c.sendNumeric(irc.RplInfo, line)
}
c.sendNumeric(irc.RplEndOfInfo, "End of /INFO list")
}
// handleTime replies with the server's local time.
func (c *Conn) handleTime() {
c.sendNumeric(
irc.RplTime,
c.serverSfx, time.Now().Format(time.RFC1123),
)
}
// handleKill handles KILL <nick> [<reason>].
func (c *Conn) handleKill(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
irc.CmdKill, "Not enough parameters",
replies = append(
replies,
nick+operStar+"=+"+nick+"@"+host,
)
return
}
reason := ""
if len(msg.Params) > 1 {
reason = msg.Params[1]
}
err := c.svc.KillUser(
ctx, c.sessionID, c.currentNick(),
msg.Params[0], reason,
c.sendNumeric(
irc.RplUserHost,
strings.Join(replies, " "),
)
if err != nil {
c.sendIRCError(err)
}
}
// handleWallops handles WALLOPS <message>.
func (c *Conn) handleWallops(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
irc.CmdWallops, "Not enough parameters",
)
return
}
err := c.svc.SendWallops(
ctx, c.sessionID, c.currentNick(), msg.Params[0],
)
if err != nil {
c.sendIRCError(err)
}
}
+27 -223
View File
@@ -20,14 +20,14 @@ import (
const (
maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second
killWriteWindow = 2 * time.Second
dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8
maxPasswordLen = 72 // bcrypt cannot hash a longer one
maxHashcashBits = 40
)
@@ -46,11 +46,6 @@ type Conn struct {
serverSfx string
commands map[string]cmdHandler
// writeMu serializes writes to conn, which come from
// serve(), from the relay goroutine and, for KILL, from
// Disconnect.
writeMu sync.Mutex
mu sync.Mutex
nick string
username string
@@ -63,15 +58,14 @@ type Conn struct {
registered bool
gotNick bool
gotUser bool
gotPass bool
passWord string
lastQueueID int64
closed bool
killed bool
}
func newConn(
ctx context.Context,
tcpConn net.Conn,
log *slog.Logger,
database *db.Database,
@@ -95,6 +89,7 @@ func newConn(
svc: svc,
serverSfx: srvName,
remoteIP: host,
hostname: resolveHost(ctx, host),
}
conn.commands = conn.buildCommandMap()
@@ -102,57 +97,6 @@ func newConn(
return conn
}
// Disconnect ends the connection for an operator KILL from
// either transport: the victim is sent KILL and ERROR, then
// its socket is closed, which ends serve() and with it the
// relay goroutine. It is called from the killer's goroutine
// and does not wait for the writes, so a victim that has
// stopped reading cannot stall the killer.
func (c *Conn) Disconnect(reason string) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
c.closed = true
c.killed = true
nick := c.nick
host := c.hostname
c.mu.Unlock()
if nick == "" {
nick = "*"
}
go func() {
c.notifyKilledAndClose(nick, host, reason)
}()
}
// notifyKilledAndClose sends a killed victim the KILL and
// ERROR lines, each bounded by killWriteWindow, and then
// closes its socket.
func (c *Conn) notifyKilledAndClose(
nick, host, reason string,
) {
defer func() { _ = c.conn.Close() }()
c.sendWithin(
killWriteWindow,
FormatMessage(
c.serverSfx, irc.CmdKill, nick, reason,
),
)
c.sendWithin(
killWriteWindow,
"ERROR :Closing Link: "+host+
" ("+reason+")",
)
}
// buildCommandMap returns a map from IRC command strings
// to handler functions.
func (c *Conn) buildCommandMap() map[string]cmdHandler {
@@ -185,13 +129,7 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
"CAP": func(_ context.Context, msg *Message) {
c.handleCAP(msg)
},
irc.CmdUserhost: c.handleUserhost,
irc.CmdVersion: func(context.Context, *Message) { c.handleVersion() },
irc.CmdAdmin: func(context.Context, *Message) { c.handleAdmin() },
irc.CmdInfo: func(context.Context, *Message) { c.handleInfo() },
irc.CmdTime: func(context.Context, *Message) { c.handleTime() },
irc.CmdKill: c.handleKill,
irc.CmdWallops: c.handleWallops,
"USERHOST": c.handleUserhost,
}
}
@@ -212,42 +150,18 @@ func resolveHost(ctx context.Context, addr string) string {
}
// serve is the main loop for a single IRC client connection.
// It first sets the client's hostname to what resolve returns
// for the client's address.
// Cancelling ctx when it returns stops the relay goroutine.
// When nothing has been read for interval the client is sent
// a PING, and when nothing is read within deadline after that
// the connection is closed.
func (c *Conn) serve(
ctx context.Context,
resolve func(ctx context.Context, addr string) string,
interval, deadline time.Duration,
) {
func (c *Conn) serve(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Stopping the server cancels ctx, and the cleanup must
// still end the session then.
defer c.cleanup(context.WithoutCancel(ctx))
// The hostname is looked up here, on this connection's
// goroutine, and not when the connection is accepted, so
// that a slow lookup holds up only this client.
c.hostname = resolve(ctx, c.remoteIP)
ping := time.AfterFunc(interval, func() {
c.send("PING :" + c.serverSfx)
})
defer ping.Stop()
defer c.cleanup(ctx)
scanner := bufio.NewScanner(c.conn)
scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
for {
// Every line read starts both waits again.
ping.Reset(interval)
_ = c.conn.SetReadDeadline(
time.Now().Add(interval + deadline),
time.Now().Add(readTimeout),
)
if !scanner.Scan() {
@@ -266,7 +180,7 @@ func (c *Conn) serve(
c.handleMessage(ctx, msg)
if c.isClosed() {
if c.closed {
return
}
}
@@ -275,82 +189,24 @@ func (c *Conn) serve(
func (c *Conn) cleanup(ctx context.Context) {
c.mu.Lock()
wasRegistered := c.registered
wasKilled := c.killed
sessID := c.sessionID
nick := c.nick
c.closed = true
c.mu.Unlock()
if wasRegistered && sessID > 0 {
c.svc.UnregisterWireConn(sessID, c)
// KILL has already sent the QUIT and deleted the
// session.
if !wasKilled {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
c.conn.Close() //nolint:errcheck,gosec
}
// isClosed reports whether QUIT or KILL has ended the
// connection.
func (c *Conn) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
// currentNick returns c.nick, read under c.mu.
func (c *Conn) currentNick() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.nick
}
// loadSessionNick sets c.nick to the nick the connection's
// session holds in the database, read by session ID. The
// connection's own NICK and the relay passing on a NICK line
// both call it, and it reads and sets the nick under c.mu, so
// whichever runs last leaves the connection on its session's
// nick. If the read fails, c.nick is left as it is.
func (c *Conn) loadSessionNick(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
if err == nil {
c.nick = nick
}
}
// send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line)
}
// sendWithin writes a formatted IRC line to the connection,
// giving up after timeout. CR, LF and NUL are removed first,
// as FormatMessage does for each parameter, so that a line
// built without it cannot end early either.
func (c *Conn) sendWithin(
timeout time.Duration,
line string,
) {
line = strings.ReplaceAll(line, "\r", "")
line = strings.ReplaceAll(line, "\n", "")
line = strings.ReplaceAll(line, "\x00", "")
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.conn.SetWriteDeadline(
time.Now().Add(timeout),
time.Now().Add(writeTimeout),
)
_, _ = fmt.Fprintf(c.conn, "%s\r\n", line)
@@ -361,7 +217,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType,
params ...string,
) {
nick := c.currentNick()
nick := c.nick
if nick == "" {
nick = "*"
}
@@ -385,11 +241,9 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask
// (nick!user@host).
func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username
if user == "" {
user = nick
user = c.nick
}
host := c.hostname
@@ -397,7 +251,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP
}
return nick + "!" + user + "@" + host
return c.nick + "!" + user + "@" + host
}
// handleMessage dispatches a parsed IRC message using
@@ -407,24 +261,13 @@ func (c *Conn) handleMessage(
msg *Message,
) {
// Before registration, only NICK, USER, PASS, PING,
// PONG, QUIT, and CAP are accepted.
// QUIT, and CAP are accepted.
if !c.registered {
c.handlePreRegistration(ctx, msg)
return
}
// Every command is activity, so the idle cleanup must not
// remove this user.
err := c.database.UpdateLastSeen(
ctx, c.sessionID, c.clientID,
)
if err != nil {
c.log.Error(
"failed to update last_seen", "error", err,
)
}
handler, ok := c.commands[msg.Command]
if !ok {
c.sendNumeric(
@@ -456,7 +299,6 @@ func (c *Conn) handlePreRegistration(
}
c.passWord = msg.Params[0]
c.gotPass = true
case irc.CmdNick:
if len(msg.Params) < 1 {
c.sendNumeric(
@@ -467,16 +309,11 @@ func (c *Conn) handlePreRegistration(
return
}
if !service.ValidNick(msg.Params[0]) {
c.sendNumeric(
irc.ErrErroneusNickname,
msg.Params[0], "Erroneous nickname",
)
return
c.nick = msg.Params[0]
if len(c.nick) > maxNickLen {
c.nick = c.nick[:maxNickLen]
}
c.nick = msg.Params[0]
c.gotNick = true
case irc.CmdUser:
if len(msg.Params) < 4 { //nolint:mnd
@@ -494,10 +331,6 @@ func (c *Conn) handlePreRegistration(
case irc.CmdPing:
c.handlePing(msg)
return
case irc.CmdPong:
// The answer to serve's PING, which unregistered
// clients are sent too.
return
case irc.CmdQuit:
c.handleQuit(msg)
@@ -554,10 +387,7 @@ func (c *Conn) completeRegistration(ctx context.Context) {
"failed to create session", "error", err,
)
c.send("ERROR :Internal server error")
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return
}
@@ -568,23 +398,17 @@ func (c *Conn) completeRegistration(ctx context.Context) {
c.registered = true
c.mu.Unlock()
// So that KILL, from either transport, can close it.
c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
c.setPassword(ctx, c.passWord)
}
// Send welcome burst.
c.deliverWelcome()
c.deliverLusers(ctx)
c.deliverMOTD()
// If PASS was given before registration, store its
// password now, after the welcome burst, so that a
// NOTICE refusing it reaches a registered client. A
// failure to store it is only logged, as it is for PASS
// after registration.
if c.gotPass && c.checkPasswordLength(c.passWord) {
_ = c.setPassword(ctx, c.passWord)
}
// Start the message relay goroutine.
go c.relayMessages(ctx)
}
@@ -667,33 +491,13 @@ func (c *Conn) deliverMOTD() {
)
}
// checkPasswordLength reports whether pw is 8 to 72 bytes
// long, and sends the client a NOTICE saying so when it is
// not.
func (c *Conn) checkPasswordLength(pw string) bool {
if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen {
return true
}
c.sendFromServer("NOTICE", c.currentNick(),
"Password must be 8 to 72 bytes",
)
return false
}
// setPassword sets a bcrypt password on the session, and
// logs and returns the error when it was not stored.
func (c *Conn) setPassword(ctx context.Context, pw string) error {
// setPassword sets a bcrypt password on the session.
func (c *Conn) setPassword(ctx context.Context, pw string) {
// Use the database's auth module to hash and store.
err := c.database.SetPassword(ctx, c.sessionID, pw)
if err != nil {
c.log.Error(
"failed to set password", "error", err,
)
return err
}
return nil
}
-148
View File
@@ -1,148 +0,0 @@
package ircserver_test
import (
"bufio"
"log/slog"
"net"
"os"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/ircserver"
)
// newKillVictim returns a Conn for the nick victim whose
// socket is one end of a net.Pipe, and the other end, from
// which the test reads what the victim is sent. A write to
// the pipe blocks until the other end reads.
func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
t.Helper()
serverSide, clientSide := net.Pipe()
t.Cleanup(func() { _ = clientSide.Close() })
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError},
))
cfg := &config.Config{ServerName: testServerName}
return ircserver.NewTestConn(
log, cfg, serverSide, "victim",
), clientSide
}
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
// that Disconnect returns although the victim never reads,
// so a KILL cannot stall the operator who sent it. The
// limit is well below one KillWriteWindow, so a Disconnect
// that waits on any write to the victim fails.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T,
) {
t.Parallel()
victim, _ := newKillVictim(t)
returned := make(chan struct{})
go func() {
victim.Disconnect("killed by oper")
close(returned)
}()
select {
case <-returned:
case <-time.After(ircserver.KillWriteWindow / 4):
t.Fatal("Disconnect waited for the victim to read")
}
}
// TestDisconnectNotifiesAndClosesVictim checks that a
// victim that reads is sent KILL and ERROR, and that its
// connection is then closed.
func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Parallel()
victim, clientSide := newKillVictim(t)
received := make(chan []string, 1)
go func() {
var lines []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
received <- lines
}()
victim.Disconnect("killed by oper")
var lines []string
select {
case lines = <-received:
case <-time.After(testTimeout):
t.Fatal("the victim's connection was not closed")
}
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "KILL victim :killed by oper") {
t.Errorf("missing KILL line, got: %q", joined)
}
if !strings.Contains(joined, "ERROR :Closing Link:") {
t.Errorf("missing ERROR line, got: %q", joined)
}
}
// TestDisconnectReasonCannotStartLine checks that CR, LF and
// NUL in a KILL reason are removed from both the KILL line
// and the ERROR line, so the victim gets exactly those two
// lines. The ERROR line is not built with FormatMessage, so
// only the removal in sendWithin protects it.
func TestDisconnectReasonCannotStartLine(t *testing.T) {
t.Parallel()
victim, clientSide := newKillVictim(t)
received := make(chan []string, 1)
go func() {
var lines []string
scanner := bufio.NewScanner(clientSide)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
received <- lines
}()
victim.Disconnect("bye\r\nPRIVMSG victim :forged\x00")
var lines []string
select {
case lines = <-received:
case <-time.After(testTimeout):
t.Fatal("the victim's connection was not closed")
}
// The test connection has no host name, hence the two
// spaces after "Closing Link:".
want := []string{
":" + testServerName +
" KILL victim :byePRIVMSG victim :forged",
"ERROR :Closing Link: (byePRIVMSG victim :forged)",
}
if !slices.Equal(lines, want) {
t.Errorf("got lines %q, want %q", lines, want)
}
}
+2 -71
View File
@@ -4,36 +4,23 @@ import (
"context"
"log/slog"
"net"
"time"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/service"
)
// NewTestServer creates a Server suitable for testing, whose
// connections use interval and deadline in place of
// pingInterval and pongDeadline, and resolve in place of
// resolveHost.
// NewTestServer creates a Server suitable for testing.
// The caller must call Stop() when finished.
func NewTestServer(
log *slog.Logger,
cfg *config.Config,
database *db.Database,
brk *broker.Broker,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *Server {
globs := &globals.Globals{
Appname: "neoirc",
Version: "test",
StartTime: time.Now(),
}
svc := service.NewTestService(
database, brk, cfg, globs, log,
database, brk, cfg, log,
)
return &Server{ //nolint:exhaustruct
@@ -43,19 +30,9 @@ func NewTestServer(
brk: brk,
svc: svc,
conns: make(map[*Conn]struct{}),
pingInterval: interval,
pongDeadline: deadline,
resolveHost: resolve,
}
}
// ResolveHost is the reverse DNS lookup that a server made by
// New does for each client.
func ResolveHost(ctx context.Context, addr string) string {
return resolveHost(ctx, addr)
}
// Start exposes the unexported start method for tests.
func (s *Server) Start(addr string) error {
return s.start(context.Background(), addr)
@@ -70,49 +47,3 @@ func (s *Server) Stop() {
func (s *Server) Listener() net.Listener {
return s.listener
}
// Service returns the service the server's connections use.
func (s *Server) Service() *service.Service {
return s.svc
}
// PingTimes returns how long the server's connections wait
// with nothing read before they are sent a PING, and after it
// before they are closed.
func (s *Server) PingTimes() (time.Duration, time.Duration) {
return s.pingInterval, s.pongDeadline
}
// KillWriteWindow is how long Disconnect allows each write
// to a killed victim.
const KillWriteWindow = killWriteWindow
// PollInterval is how often the relay checks the client
// output queue when no broker notification arrives.
const PollInterval = pollInterval
// PingInterval is how long the server waits with nothing read
// from a connection before it sends a PING.
const PingInterval = pingInterval
// PongDeadline is how long the server waits after the PING
// for a line before it closes the connection.
const PongDeadline = pongDeadline
// StopTimeout is the longest Stop waits for the connections'
// cleanup.
const StopTimeout = stopTimeout
// NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it.
func NewTestConn(
log *slog.Logger,
cfg *config.Config,
tcpConn net.Conn,
nick string,
) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil)
conn.nick = nick
return conn
}
-642
View File
@@ -4,8 +4,6 @@ import (
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/config"
)
// TestIntegrationTwoClients is a comprehensive integration
@@ -764,373 +762,6 @@ func TestIntegrationTwoClients(t *testing.T) {
)
}
// ── Tier 3 Utility Command Integration Tests ──────────
// TestIntegrationUserhost verifies the USERHOST command
// returns user@host info for connected nicks.
func TestIntegrationUserhost(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
bob.send("AWAY :lunch")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 306 ")
})
for _, step := range []struct {
line, want string
}{
{"USERHOST alice", " 302 alice alice=+alice@"},
{"USERHOST alice bob", " :alice=+alice@"},
{"USERHOST alice bob", " bob=-bob@"},
{"USERHOST nobody", " 302 alice :"},
{"USERHOST", " 461 alice USERHOST :"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ") ||
strings.Contains(l, " 461 ")
})
assertContains(t, reply, step.want, step.line)
}
}
// TestIntegrationVersion verifies the VERSION command
// returns the server version string.
func TestIntegrationVersion(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("VERSION")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 351 ")
})
assertContains(
t, aliceReply, " 351 ",
"RPL_VERSION",
)
assertContains(
t, aliceReply, "neoirc",
"VERSION reply contains server name",
)
}
// TestIntegrationAdmin verifies the ADMIN command returns
// server admin info (256–259 numerics).
func TestIntegrationAdmin(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("ADMIN")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 259 ")
})
assertContains(
t, aliceReply, " 256 ",
"RPL_ADMINME",
)
assertContains(
t, aliceReply, " 257 ",
"RPL_ADMINLOC1",
)
assertContains(
t, aliceReply, " 258 ",
"RPL_ADMINLOC2",
)
assertContains(
t, aliceReply, " 259 ",
"RPL_ADMINEMAIL",
)
}
// TestIntegrationInfo verifies the INFO command returns
// server information (371/374 numerics).
func TestIntegrationInfo(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("INFO")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 374 ")
})
assertContains(
t, aliceReply, " 371 ",
"RPL_INFO",
)
assertContains(
t, aliceReply, " 374 ",
"RPL_ENDOFINFO",
)
assertContains(
t, aliceReply, "neoirc",
"INFO reply mentions server name",
)
}
// TestIntegrationTime verifies the TIME command returns
// the server time (391 numeric).
func TestIntegrationTime(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("TIME")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 391 ")
})
assertContains(
t, aliceReply, " 391 ",
"RPL_TIME",
)
assertContains(
t, aliceReply, testServerName,
"TIME reply includes server name",
)
}
// becomeOper sends OPER with newTestEnvWithOper's
// credentials and waits for RPL_YOUREOPER.
func (tc *testClient) becomeOper() {
tc.t.Helper()
tc.send("OPER testoper testpass")
tc.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
}
// TestIntegrationKillRefused covers the KILL errors: not an
// operator, no such nick, and killing yourself.
func TestIntegrationKillRefused(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
for _, step := range []struct {
line, numeric string
}{
{"KILL bob :nope", " 481 "},
{"OPER testoper testpass", " 381 "},
{"KILL nobody123 :gone", " 401 "},
{"KILL alice :me", " 483 "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.numeric)
})
assertContains(t, reply, step.numeric, step.line)
}
}
// TestIntegrationKill checks that the victim of a KILL is
// told why, is disconnected, and is gone from its channels.
func TestIntegrationKill(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#killtest")
bob.joinAndDrain("#killtest")
// Drain alice's view of bob's join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
alice.becomeOper()
alice.send("KILL bob :bad behavior")
bobLines := bob.readUntilClosed()
assertContains(
t, bobLines, "KILL",
"victim receives KILL before disconnect",
)
assertContains(
t, bobLines, "ERROR :Closing Link",
"victim receives ERROR before disconnect",
)
assertContains(
t, bobLines, "bad behavior",
"KILL reason delivered to victim",
)
// alice should see bob's QUIT relay.
aliceSeesQuit := alice.readUntil(func(l string) bool {
return strings.Contains(l, "QUIT") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceSeesQuit, "Killed",
"KILL reason in QUIT message",
)
// bob must be gone from the channel member list.
alice.send("NAMES #killtest")
aliceNames := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, aliceNames, "alice",
"alice still in NAMES after killing bob",
)
assertNotContains(
t, aliceNames, "bob",
"killed user must not appear in NAMES",
)
// ...nor from WHO.
alice.send("WHO #killtest")
aliceWho := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 315 ")
})
assertNotContains(
t, aliceWho, "bob",
"killed user must not appear in WHO",
)
}
// TestIntegrationWallops verifies the WALLOPS command:
// oper can broadcast to +w users.
func TestIntegrationWallops(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
// Non-oper WALLOPS should fail.
alice.send("WALLOPS :test broadcast")
aliceWallopsFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceWallopsFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper WALLOPS",
)
alice.becomeOper()
// bob sets +w to receive wallops.
bob.send("MODE bob +w")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ")
})
// alice sends WALLOPS.
alice.send("WALLOPS :important announcement")
// bob (who has +w) should receive it.
bobWallops := bob.readUntil(func(l string) bool {
return strings.Contains(
l, "important announcement",
)
})
assertContains(
t, bobWallops, "important announcement",
"bob receives WALLOPS message",
)
assertContains(
t, bobWallops, "WALLOPS",
"message is WALLOPS command",
)
}
// TestIntegrationUserMode covers MODE for a nick: changes
// and queries of your own modes, refusal for another nick,
// even one that differs from yours only in letter case, and
// a rejected mode string changing nothing.
func TestIntegrationUserMode(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
const (
notYours = " 502 alice :Can't change mode for other users"
unknown = " 501 alice :Unknown MODE flag"
)
for _, step := range []struct {
line, want string
}{
{"MODE alice +w", " 221 alice +w"},
{"MODE Alice", notYours},
{"MODE Alice -w", notYours},
{"MODE bob", notYours},
{"MODE bob -w", notYours},
{"MODE alice xw", unknown},
{"MODE alice -w+z", unknown},
{"MODE alice", " 221 alice +w"},
{"MODE alice +w-w", " 221 alice +"},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ") ||
strings.Contains(l, " 501 ") ||
strings.Contains(l, " 502 ")
})
last := reply[len(reply)-1]
if !strings.HasSuffix(last, step.want) {
t.Errorf("%s: want %q, got %q", step.line, step.want, last)
}
}
}
// TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is
// reflected in MODE queries.
@@ -1241,252 +872,6 @@ func TestIntegrationModeModerated(t *testing.T) {
)
}
// TestIntegrationModeKey checks that JOIN takes the key of
// a +k channel as its second parameter, and that with
// several channels each key goes to the channel in the
// same position.
func TestIntegrationModeKey(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#keyed")
alice.joinAndDrain("#vault")
// The IRC listener cannot set +k, so the test sets the
// keys directly.
for channel, key := range map[string]string{
"#keyed": "sesame",
"#vault": "barley",
} {
chID, err := env.database.GetChannelByName(
t.Context(), channel,
)
if err != nil {
t.Fatalf("get channel %s: %v", channel, err)
}
err = env.database.SetChannelKey(t.Context(), chID, key)
if err != nil {
t.Fatalf("set key of %s: %v", channel, err)
}
}
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #keyed", " 475 ")
bob.sendAndExpect("JOIN #keyed wrong", " 475 ")
lines := bob.sendAndExpect("JOIN #keyed sesame", " 366 ")
assertContains(
t, lines, "JOIN #keyed", "bob joins with the key",
)
carol := env.dial(t)
carol.register("carol")
lines = carol.sendAndExpect(
"JOIN #keyed,#vault sesame,barley", " 366 carol #vault ",
)
assertContains(t, lines, "JOIN #keyed", "carol joins #keyed")
assertContains(t, lines, "JOIN #vault", "carol joins #vault")
assertNotContains(t, lines, " 475 ", "carol's JOIN")
}
// TestIntegrationModeInviteOnly checks that INVITE lets a
// user into a +i channel.
func TestIntegrationModeInviteOnly(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("JOIN #invonly", " 473 ")
alice.sendAndExpect("INVITE bob #invonly", " 341 ")
lines := bob.sendAndExpect("JOIN #invonly", " 366 ")
assertContains(
t, lines, "JOIN #invonly", "bob joins once invited",
)
}
// TestIntegrationModeInviteOnlyNeedsOperator checks that on
// a +i channel an INVITE from a member who is not a channel
// operator gets 482 and does not let the invited user in.
func TestIntegrationModeInviteOnlyNeedsOperator(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#invonly")
// Bob joins before +i is set, so he is a member but
// not an operator.
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#invonly")
alice.sendAndExpect("MODE #invonly +i", "+i")
carol := env.dial(t)
carol.register("carol")
lines := bob.sendAndExpect("INVITE carol #invonly", " 482 ")
assertContains(
t, lines, " 482 bob #invonly ", "bob's INVITE refused",
)
carol.sendAndExpect("JOIN #invonly", " 473 ")
}
// TestIntegrationInviteUserOnChannel checks that inviting a
// user who is already on the channel gets 443.
func TestIntegrationInviteUserOnChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#room")
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#room")
lines := alice.sendAndExpect("INVITE bob #room", " 443 ")
assertContains(
t, lines, " 443 alice bob #room ", "alice's INVITE refused",
)
}
// TestIntegrationInviteLine checks that an IRC listener user
// invited by an IRC listener user, and then over the HTTP
// API, gets an INVITE line from the inviter for the channel
// each time.
func TestIntegrationInviteLine(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#inv")
bob := env.dial(t)
bob.register("bob")
alice.sendAndExpect("INVITE bob #inv", " 341 ")
got := bob.readLine()
want := ":alice!alice@* INVITE bob #inv"
if got != want {
t.Errorf("from the IRC listener: got %q, want %q", got, want)
}
// The HTTP API queues an INVITE with the channel as its body.
env.queueMessage(t, "bob", "INVITE", "carol", "bob", []string{"#inv"})
got = bob.readLine()
want = ":carol!carol@* INVITE bob #inv"
if got != want {
t.Errorf("over the HTTP API: got %q, want %q", got, want)
}
}
// TestIntegrationModeSecretList checks that LIST shows a +s
// channel only to its members.
func TestIntegrationModeSecretList(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobList := bob.sendAndExpect("LIST", " 323 ")
assertContains(
t, bobList, " 322 bob #visible ", "bob's LIST",
)
assertNotContains(t, bobList, "#hidden", "bob's LIST")
aliceList := alice.sendAndExpect("LIST", " 323 ")
assertContains(
t, aliceList, " 322 alice #hidden ", "alice's LIST",
)
}
// TestIntegrationModeSecretWhois checks that WHOIS names a
// +s channel only to users on it.
func TestIntegrationModeSecretWhois(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#hidden")
alice.joinAndDrain("#visible")
alice.sendAndExpect("MODE #hidden +s", "+s")
bob := env.dial(t)
bob.register("bob")
bobWhois := bob.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, bobWhois, "@#visible", "bob's WHOIS")
assertNotContains(t, bobWhois, "#hidden", "bob's WHOIS")
carol := env.dial(t)
carol.register("carol")
carol.joinAndDrain("#hidden")
carolWhois := carol.sendAndExpect("WHOIS alice", " 318 ")
assertContains(t, carolWhois, "@#hidden", "carol's WHOIS")
}
// TestIntegrationModeHashcash checks that PRIVMSG to a +H
// channel is refused, since an IRC client cannot attach a
// hashcash stamp.
func TestIntegrationModeHashcash(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#stamped")
alice.sendAndExpect("MODE #stamped +H 2", "+H")
lines := alice.sendAndExpect(
"PRIVMSG #stamped :hello", " 404 ",
)
assertContains(
t, lines, " 404 alice #stamped :Channel requires hashcash",
"PRIVMSG refused",
)
}
// TestIntegrationThirdClientObserver verifies that a third
// client observing the same channel receives messages from
// the other two.
@@ -1530,30 +915,3 @@ func TestIntegrationThirdClientObserver(t *testing.T) {
"carol receives trio message",
)
}
// TestIntegrationDefaultServerNameFallback checks that with
// SERVER_NAME unset, as it is by default, VERSION, ADMIN and
// TIME name the server "neoirc".
func TestIntegrationDefaultServerNameFallback(t *testing.T) {
t.Parallel()
env := newTestEnvWithConfig(t, &config.Config{})
alice := env.dial(t)
alice.register("alice")
for _, step := range []struct {
line, lastNumeric, want string
}{
{"VERSION", " 351 ", " 351 alice neoirc-test. neoirc "},
{"ADMIN", " 259 ", " 256 alice neoirc "},
{"TIME", " 391 ", " 391 alice neoirc "},
} {
alice.send(step.line)
reply := alice.readUntil(func(l string) bool {
return strings.Contains(l, step.lastNumeric)
})
assertContains(t, reply, step.want, step.line)
}
}
+1 -8
View File
@@ -90,10 +90,7 @@ func ParseMessage(line string) *Message {
}
// FormatMessage formats an IRC message into wire protocol
// format (without the trailing CR-LF). CR, LF and NUL are
// removed from each parameter, because on the wire they would
// end the line early; this happens before deciding whether
// the last parameter needs a leading ':'.
// format (without the trailing CR-LF).
func FormatMessage(
prefix, command string,
params ...string,
@@ -109,10 +106,6 @@ func FormatMessage(
buf.WriteString(command)
for i, param := range params {
param = strings.ReplaceAll(param, "\r", "")
param = strings.ReplaceAll(param, "\n", "")
param = strings.ReplaceAll(param, "\x00", "")
buf.WriteByte(' ')
isLast := i == len(params)-1
+61 -71
View File
@@ -3,7 +3,6 @@ package ircserver
import (
"context"
"encoding/json"
"slices"
"strings"
"time"
@@ -27,30 +26,22 @@ func (c *Conn) relayMessages(ctx context.Context) {
default:
}
// Register with the broker before draining, so that a
// message queued after the drain has read the queue
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages.
delivered := c.drainQueue(ctx)
if delivered {
c.brk.Remove(c.sessionID, waitCh)
// Tight loop while there are messages.
continue
}
// Wait for notification or timeout.
waitCh := c.brk.Wait(c.sessionID)
select {
case <-waitCh:
// New message notification — loop back.
case <-ticker.C:
// Periodic check. Nothing used waitCh, so take it
// out of the broker before the next pass adds
// another.
c.brk.Remove(c.sessionID, waitCh)
// Periodic check.
case <-ctx.Done():
c.brk.Remove(c.sessionID, waitCh)
@@ -92,7 +83,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
//
//nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage(
ctx context.Context,
_ context.Context,
msg *db.IRCMessage,
) {
command := msg.Command
@@ -114,13 +105,13 @@ func (c *Conn) deliverIRCMessage(
case isNumeric(command):
c.deliverNumeric(msg, text)
case command == irc.CmdPrivmsg || command == irc.CmdNotice:
c.deliverTextMessage(msg, command, bodyLines)
c.deliverTextMessage(msg, command, text)
case command == irc.CmdJoin:
c.deliverJoin(msg)
case command == irc.CmdPart:
c.deliverPart(msg, text)
case command == irc.CmdNick:
c.deliverNickChange(ctx, msg, text)
c.deliverNickChange(msg, text)
case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic:
@@ -129,10 +120,8 @@ func (c *Conn) deliverIRCMessage(
c.deliverKickMsg(msg, text)
case command == "INVITE":
c.deliverInviteMsg(msg, text)
case command == irc.CmdWallops:
c.deliverWallops(msg, text)
case command == irc.CmdMode:
c.deliverMode(msg, bodyLines)
c.deliverMode(msg, text)
case command == irc.CmdPing:
// Server-originated PING — reply with PONG.
c.sendFromServer("PING", c.serverSfx)
@@ -188,28 +177,43 @@ func (c *Conn) deliverNumeric(
c.send(FormatMessage(from, msg.Command, allParams...))
}
// deliverTextMessage sends PRIVMSG or NOTICE, one line for
// each line of the body.
// deliverTextMessage sends PRIVMSG or NOTICE.
func (c *Conn) deliverTextMessage(
msg *db.IRCMessage,
command string,
bodyLines []string,
command, text string,
) {
from := msg.From
target := msg.To
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own messages back.
if strings.EqualFold(from, nick) {
return
}
prefix := from
if !strings.Contains(prefix, "!") {
prefix = from + "!" + from + "@*"
}
for _, line := range bodyLines {
c.send(FormatMessage(prefix, command, target, line))
}
c.send(FormatMessage(prefix, command, target, text))
}
// deliverJoin sends a JOIN notification.
func (c *Conn) deliverJoin(msg *db.IRCMessage) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own JOINs (we already sent them
// during joinChannel).
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To
@@ -218,6 +222,14 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification.
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To
@@ -230,17 +242,18 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
}
}
// deliverNickChange sends a NICK change notification. The
// change may be its own session's, made from another client,
// so the connection first takes the nick its session holds
// now. It does not go by the line's old nick: the connection
// may have taken that nick after another user gave it up.
// deliverNickChange sends a NICK change notification.
func (c *Conn) deliverNickChange(
ctx context.Context,
msg *db.IRCMessage,
newNick string,
) {
c.loadSessionNick(ctx)
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*"
@@ -252,6 +265,14 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage,
text string,
) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*"
if text != "" {
@@ -304,58 +325,27 @@ func (c *Conn) deliverKickMsg(
}
}
// deliverInviteMsg sends an INVITE line from the inviter
// for the channel named in text, or nothing when text names
// no channel.
// deliverInviteMsg sends an INVITE notification.
func (c *Conn) deliverInviteMsg(
msg *db.IRCMessage,
_ *db.IRCMessage,
text string,
) {
if text == "" {
return
}
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
prefix := msg.From + "!" + msg.From + "@*"
c.send(FormatMessage(prefix, "INVITE", nick, text))
c.sendFromServer("NOTICE", nick, text)
}
// deliverWallops sends a WALLOPS notification.
func (c *Conn) deliverWallops(
// deliverMode sends a MODE change notification.
func (c *Conn) deliverMode(
msg *db.IRCMessage,
text string,
) {
prefix := msg.From + "!" + msg.From + "@*"
target := msg.To
c.send(FormatMessage(
prefix, irc.CmdWallops, text,
))
}
// deliverMode sends a MODE change notification. The first
// body line is the change, possibly followed by its values
// separated by spaces ("+o bob"); any further lines are
// values ("+k", "secret"). Each goes out as its own
// parameter, as IRC sends them.
func (c *Conn) deliverMode(
msg *db.IRCMessage,
bodyLines []string,
) {
if len(bodyLines) == 0 || bodyLines[0] == "" {
return
if text != "" {
c.send(FormatMessage(prefix, "MODE", target, text))
}
prefix := msg.From + "!" + msg.From + "@*"
params := slices.Concat(
[]string{msg.To},
strings.Fields(bodyLines[0]),
bodyLines[1:],
)
c.send(FormatMessage(prefix, "MODE", params...))
}
-697
View File
@@ -1,697 +0,0 @@
package ircserver_test
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
)
// queueMessage queues a message for the user with nick
// recipient the way the HTTP API sends one: stored once, then
// queued to the recipient's session.
func (env *testEnv) queueMessage(
t *testing.T,
recipient, command, from, to string,
body []string,
) {
t.Helper()
ctx := t.Context()
bodyJSON, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
msgID, _, err := env.database.InsertMessage(
ctx, command, from, to, 0, 0, nil, bodyJSON, nil,
)
if err != nil {
t.Fatalf("insert message: %v", err)
}
sessionID, err := env.database.GetSessionByNick(ctx, recipient)
if err != nil {
t.Fatalf("get session of %s: %v", recipient, err)
}
err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
env.brk.Notify(sessionID)
}
// TestRelayKeepsOneWaitChannel checks that an IRC listener
// connection holds at most one broker wait channel across
// several relay ticks, both while idle and after the relay
// has delivered it a message, and none once it closes.
func TestRelayKeepsOneWaitChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("idle")
sessionID, err := env.database.GetSessionByNick(t.Context(), "idle")
if err != nil {
t.Fatalf("get session of idle: %v", err)
}
env.checkOneWaitChannel(t, sessionID)
env.queueMessage(
t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"},
)
client.readUntil(func(l string) bool {
return strings.Contains(l, "hello")
})
env.checkOneWaitChannel(t, sessionID)
err = client.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
deadline := time.Now().Add(testTimeout)
for env.brk.ListenerCount(sessionID) != 0 {
if time.Now().After(deadline) {
t.Fatalf("after close: got %d wait channels, want 0",
env.brk.ListenerCount(sessionID))
}
time.Sleep(relayCheckStep)
}
}
// checkOneWaitChannel fails the test if the session
// sessionID holds more than one broker wait channel at any of
// several relay ticks.
func (env *testEnv) checkOneWaitChannel(
t *testing.T,
sessionID int64,
) {
t.Helper()
const ticks = 5
for tick := range ticks {
time.Sleep(ircserver.PollInterval)
got := env.brk.ListenerCount(sessionID)
if got > 1 {
t.Fatalf("tick %d: got %d wait channels, want at most 1",
tick+1, got)
}
}
}
// TestRelayMultiLineBody checks that each line of a PRIVMSG
// or NOTICE body sent over the HTTP API reaches an IRC
// listener user as its own line.
func TestRelayMultiLineBody(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, command := range []string{"PRIVMSG", "NOTICE"} {
env.queueMessage(
t, "alice", command, "carol", "alice",
[]string{"first line", "second line"},
)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "second line")
})
want := []string{
":carol!carol@* " + command + " alice :first line",
":carol!carol@* " + command + " alice :second line",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("%s: got %q, want %q", command, got, want)
}
}
}
// TestRelayModeParameters checks that a MODE change reaches
// IRC listener members with its values as separate
// parameters, both for the bodies the HTTP API stores and
// for a change made on the IRC listener.
func TestRelayModeParameters(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
for _, step := range []struct {
body []string
want string
}{
{[]string{"+k", "secret"}, "+k secret"},
{[]string{"-k", "*"}, "-k *"},
{[]string{"+l", "50"}, "+l 50"},
{[]string{"-l"}, "-l"},
{[]string{"+b", "*!*@*.example.com"}, "+b *!*@*.example.com"},
{[]string{"-b", "*!*@*.example.com"}, "-b *!*@*.example.com"},
{[]string{"+o bob"}, "+o bob"},
{[]string{"+m"}, "+m"},
} {
env.queueMessage(t, "alice", "MODE", "op", "#modes", step.body)
lines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
want := ":op!op@* MODE #modes " + step.want
if got != want {
t.Errorf("body %q: got %q, want %q", step.body, got, want)
}
}
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#modes")
bob.joinAndDrain("#modes")
alice.send("MODE #modes +v bob")
const change = " MODE #modes +v bob"
for name, client := range map[string]*testClient{
"alice": alice, "bob": bob,
} {
lines := client.readUntil(func(l string) bool {
return strings.Contains(l, " MODE ")
})
got := lines[len(lines)-1]
if !strings.HasSuffix(got, change) {
t.Errorf("%s: got %q, want it to end in %q", name, got, change)
}
}
}
// TestRelayInviteWithoutChannel checks that an INVITE whose
// body names no channel sends the invited IRC listener user
// no INVITE line.
func TestRelayInviteWithoutChannel(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
// A nil body is stored as JSON null, which is not an array.
for _, body := range [][]string{nil, {}, {""}} {
env.queueMessage(t, "bob", "INVITE", "carol", "bob", body)
}
// The relay sends a user's queue in order, so this arrives
// after anything sent for the INVITEs above.
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
for _, line := range lines {
if strings.Contains(line, " INVITE ") {
t.Errorf("got %q, want no INVITE line", line)
}
}
}
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored
// body line, key or mask never reaches an IRC listener user
// as a line break, so no extra line arrives, and that a body
// line keeps its parameter and leading ':' once they are
// removed.
func TestRelayLineBreaks(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob", []string{
"one\r\n:admin!a@b PRIVMSG bob :hi",
"two\n:admin!a@b NOTICE bob :hi\x00",
})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+k", "key\r\nERROR :Closing link"})
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
[]string{"+b", "*!*@*\r\n:admin!a@b KICK #breaks bob"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"\r\n", "\x00:hi"})
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
[]string{"the end"})
lines := bob.readUntil(func(l string) bool {
return strings.HasSuffix(l, " :the end")
})
want := []string{
":carol!carol@* PRIVMSG bob :one:admin!a@b PRIVMSG bob :hi",
":carol!carol@* PRIVMSG bob :two:admin!a@b NOTICE bob :hi",
":op!op@* MODE #breaks +k :keyERROR :Closing link",
":op!op@* MODE #breaks +b :*!*@*:admin!a@b KICK #breaks bob",
":carol!carol@* PRIVMSG bob :",
":carol!carol@* PRIVMSG bob ::hi",
":carol!carol@* PRIVMSG bob :the end",
}
got := lines[max(0, len(lines)-len(want)):]
if !slices.Equal(got, want) {
t.Errorf("got %q, want %q", got, want)
}
}
// TestRelayNickDiffersOnlyInCase checks that an IRC listener
// user alice receives a JOIN, PRIVMSG, PART, NICK and QUIT
// from Alice in a channel they share: nicks are
// case-sensitive, so Alice is someone else.
func TestRelayNickDiffersOnlyInCase(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#case")
capitalAlice := env.dial(t)
capitalAlice.register("Alice")
for _, step := range []struct {
line, want string
}{
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"PRIVMSG #case :hi there", ":Alice!Alice@* PRIVMSG #case :hi there"},
{"PART #case :see you", ":Alice!Alice@* PART #case :see you"},
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} {
capitalAlice.send(step.line)
got := alice.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// 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)
}
}
+3 -62
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"net"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
@@ -16,10 +15,6 @@ import (
"sneak.berlin/go/neoirc/internal/service"
)
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC
// Server.
type Params struct {
@@ -43,19 +38,6 @@ type Server struct {
mu sync.Mutex
conns map[*Conn]struct{}
cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the
// same names, except in tests, which shorten them.
pingInterval time.Duration
pongDeadline time.Duration
// resolveHost is the function of the same name, except in
// tests, which replace it with one that blocks.
resolveHost func(ctx context.Context, addr string) string
}
// New creates a new IRC Server and registers its lifecycle
@@ -75,11 +57,6 @@ func New(
listener: nil,
cancel: nil,
mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
resolveHost: resolveHost,
}
listenAddr := params.Config.IRCListenAddr
@@ -126,10 +103,7 @@ func (s *Server) start(ctx context.Context, addr string) error {
return nil
}
// stop shuts down the listener and all connections, then
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
// stop shuts down the listener and all connections.
func (s *Server) stop() {
if s.cancel != nil {
s.cancel()
@@ -144,22 +118,6 @@ func (s *Server) stop() {
c.conn.Close() //nolint:errcheck,gosec
}
s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
)
}
}
// acceptLoop accepts new connections.
@@ -180,39 +138,22 @@ func (s *Server) acceptLoop(ctx context.Context) {
}
client := newConn(
tcpConn, s.log,
ctx, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc,
)
s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock()
go func() {
defer s.serving.Done()
defer func() {
s.mu.Lock()
delete(s.conns, client)
s.mu.Unlock()
}()
client.serve(
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
client.serve(ctx)
}()
}
}
+16 -760
View File
@@ -2,27 +2,20 @@ package ircserver_test
import (
"bufio"
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"log/slog"
"net"
"os"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
_ "modernc.org/sqlite"
)
@@ -43,75 +36,12 @@ type testEnv struct {
srv *ircserver.Server
}
// testServerName is the SERVER_NAME of newTestEnv.
const testServerName = "test.irc"
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithConfig(t, &config.Config{
ServerName: testServerName,
MOTD: "Welcome to test IRC",
})
}
// newTestEnvWithOper is newTestEnv with the operator name
// testoper and password testpass.
func newTestEnvWithOper(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithConfig(t, &config.Config{
ServerName: testServerName,
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
})
}
// newTestEnvWithConfig starts an IRC server with cfg on a
// fresh database.
func newTestEnvWithConfig(
t *testing.T,
cfg *config.Config,
) *testEnv {
t.Helper()
return newTestEnvWithPingTimes(
t, cfg, ircserver.PingInterval, ircserver.PongDeadline,
)
}
// newTestEnvWithPingTimes is newTestEnvWithConfig, except
// that the server sends a connection a PING after interval
// with nothing read, and closes it when nothing is read
// within deadline after that.
func newTestEnvWithPingTimes(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
) *testEnv {
t.Helper()
return newTestEnvWithResolver(
t, cfg, interval, deadline, ircserver.ResolveHost,
)
}
// newTestEnvWithResolver is newTestEnvWithPingTimes, except
// that the server looks up a client's hostname with resolve.
func newTestEnvWithResolver(
t *testing.T,
cfg *config.Config,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *testEnv {
t.Helper()
// A random name, so that no other test environment, not
// even an earlier run of the same test, can share it.
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared",
rand.Text(),
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
)
conn, err := sql.Open("sqlite", dsn)
@@ -119,13 +49,6 @@ func newTestEnvWithResolver(
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() {
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
@@ -144,6 +67,11 @@ func newTestEnvWithResolver(
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
}
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
@@ -163,18 +91,21 @@ func newTestEnvWithResolver(
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
srv := ircserver.NewTestServer(
log, cfg, database, brk, interval, deadline, resolve,
)
srv := ircserver.NewTestServer(log, cfg, database, brk)
err = srv.Start(addr)
if err != nil {
t.Fatalf("start irc server: %v", err)
}
// Cleanups run last registered first, so the server stops
// before its database is closed.
t.Cleanup(srv.Stop)
t.Cleanup(func() {
srv.Stop()
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{
database: database,
@@ -213,34 +144,6 @@ func (env *testEnv) dial(t *testing.T) *testClient {
}
}
// The ping interval and PONG deadline for
// newTestEnvWithShortPings. They differ, so that a test can
// tell them apart; the PONG deadline is how long a test
// client has to answer a PING.
const (
testPingInterval = time.Second
testPongDeadline = 300 * time.Millisecond
)
// newTestEnvWithShortPings starts an IRC server that uses
// testPingInterval and testPongDeadline in place of
// pingInterval and pongDeadline.
func newTestEnvWithShortPings(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWithPingTimes(
t,
&config.Config{ServerName: testServerName},
testPingInterval, testPongDeadline,
)
}
// isServerPing reports whether line is the PING the test
// server sends a quiet client.
func isServerPing(line string) bool {
return line == "PING :"+testServerName
}
// testClient wraps a raw TCP connection with helpers.
type testClient struct {
t *testing.T
@@ -312,51 +215,6 @@ func (tc *testClient) register(nick string) []string {
})
}
// readUntilClosed returns the lines received until the
// server closes the connection, and fails the test if it is
// still open after testTimeout.
func (tc *testClient) readUntilClosed() []string {
tc.t.Helper()
_ = tc.conn.SetReadDeadline(
time.Now().Add(testTimeout),
)
var lines []string
for tc.scanner.Scan() {
lines = append(lines, tc.scanner.Text())
}
err := tc.scanner.Err()
if err != nil {
tc.t.Fatalf(
"connection not closed: %v (lines: %v)",
err, lines,
)
}
return lines
}
// readServerPing reads until the PING the test server sends a
// quiet client, and fails the test if it came sooner than
// testPingInterval after lastSent, a time just before the
// client sent its last line.
func (tc *testClient) readServerPing(lastSent time.Time) {
tc.t.Helper()
tc.readUntil(isServerPing)
waited := time.Since(lastSent)
if waited < testPingInterval {
tc.t.Errorf(
"PING came %v after the client's last line, want at least %v",
waited, testPingInterval,
)
}
}
// assertContains checks that at least one line matches the
// given substring.
func assertContains(
@@ -375,27 +233,6 @@ func assertContains(
t.Errorf("did not find %q in output: %s", substr, description)
}
// assertNotContains checks that no line matches the given
// substring.
func assertNotContains(
t *testing.T,
lines []string,
substr, description string,
) {
t.Helper()
for _, line := range lines {
if strings.Contains(line, substr) {
t.Errorf(
"unexpectedly found %q in output: %s",
substr, description,
)
return
}
}
}
// joinAndDrain joins a channel and reads until
// RPL_ENDOFNAMES.
func (tc *testClient) joinAndDrain(channel string) {
@@ -432,63 +269,6 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME")
}
// TestRegistrationWhileAnotherLookupIsPending checks that a
// client is accepted and registered while the hostname lookup
// of an earlier client has not returned, and that it
// registers with the hostname its own lookup returned.
func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
t.Parallel()
const lookedUpHost = "client.example"
var lookups atomic.Int32
firstLookupStarted := make(chan struct{})
// The first lookup returns only when the server stops.
resolve := func(ctx context.Context, _ string) string {
if lookups.Add(1) == 1 {
close(firstLookupStarted)
<-ctx.Done()
}
return lookedUpHost
}
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve,
)
env.dial(t)
select {
case <-firstLookupStarted:
case <-time.After(testTimeout):
t.Fatal("the first client's lookup did not start")
}
lines := env.dial(t).register("second")
assertContains(
t, lines, "second!second@"+lookedUpHost,
"RPL_WELCOME with the looked-up hostname",
)
}
// TestEachTestEnvHasItsOwnDatabase checks that a test
// environment starts on an empty database while another one
// is still open.
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
t.Parallel()
first := newTestEnv(t)
first.dial(t).register("samenick")
second := newTestEnv(t)
lines := second.dial(t).register("samenick")
assertContains(t, lines, " 001 ", "RPL_WELCOME")
}
func TestWelcomeContainsNick(t *testing.T) {
t.Parallel()
@@ -516,164 +296,6 @@ func TestPingPong(t *testing.T) {
assertContains(t, lines, "PONG", "PONG response")
}
// TestQuietClientAnsweringPingStaysConnected checks that a
// registered client that sends nothing is sent a PING once
// the ping interval has passed since its last line, and stays
// connected as long as it answers each one, whether with a
// PONG or with another line.
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
t.Parallel()
tests := []struct {
name string
answer string
}{
{name: "PONG", answer: "PONG :" + testServerName},
{name: "PING of its own", answer: "PING :answer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("answers")
// The second PING comes later than a client that
// answers none stays connected.
for range 2 {
client.readServerPing(lastSent)
lastSent = time.Now()
client.send(tt.answer)
}
client.sendAndExpect("PING :still here", "still here")
})
}
}
// TestQuietClientNotAnsweringPingIsClosed checks that a
// registered client that does not answer the PING is
// disconnected once the PONG deadline has passed after it.
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
t.Parallel()
// latest leaves room for a loaded host, but is sooner than
// a read deadline of two ping intervals would close it.
const (
earliest = testPingInterval + testPongDeadline
latest = 2 * testPingInterval
)
env := newTestEnvWithShortPings(t)
client := env.dial(t)
lastSent := time.Now()
client.register("silent")
client.readServerPing(lastSent)
client.readUntilClosed()
waited := time.Since(lastSent)
if waited < earliest || waited >= latest {
t.Errorf(
"closed %v after the client's last line, want %v to %v",
waited, earliest, latest,
)
}
}
// TestUnregisteredClientAnswersPing checks that a PONG before
// registration, the answer to the PING unregistered clients
// are sent too, gets no ERR_NOTREGISTERED.
func TestUnregisteredClientAnswersPing(t *testing.T) {
t.Parallel()
env := newTestEnvWithShortPings(t)
client := env.dial(t)
client.readUntil(isServerPing)
client.send("PONG :" + testServerName)
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(t, lines, " 451 ", "ERR_NOTREGISTERED")
}
// TestNewPingTimes checks that a server made by New sends a
// PING after 90 seconds with nothing read, and closes the
// connection 30 seconds after that, as README.md states.
func TestNewPingTimes(t *testing.T) {
t.Parallel()
const (
wantInterval = 90 * time.Second
wantDeadline = 30 * time.Second
)
log, err := logger.New(nil, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
// With no IRC_LISTEN_ADDR, New starts no listener and
// uses nothing else from its params.
srv := ircserver.New(fxtest.NewLifecycle(t), ircserver.Params{
Logger: log,
Config: &config.Config{},
})
interval, deadline := srv.PingTimes()
if interval != wantInterval || deadline != wantDeadline {
t.Errorf(
"PING after %v and close %v later, want %v and %v",
interval, deadline, wantInterval, wantDeadline,
)
}
}
// TestIdleCleanupKeepsActiveUser checks that a user who
// sends commands is kept by the idle cleanup, and a user who
// sends nothing is not.
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
active := env.dial(t)
active.register("active")
idle := env.dial(t)
idle.register("idle")
// Both users were last seen before cutoff, and only active
// sends a command after it, so the result does not depend
// on how long the PONG takes to arrive.
cutoff := time.Now()
active.sendAndExpect("PING :still here", "PONG")
// The idle cleanup in internal/handlers removes exactly
// the users this returns.
stale, err := env.database.GetStaleOrphanSessions(
t.Context(), cutoff,
)
if err != nil {
t.Fatalf("get stale sessions: %v", err)
}
if len(stale) != 1 || stale[0].Nick != "idle" {
t.Errorf("cleanup removes %v, want only idle", stale)
}
}
func TestJoinChannel(t *testing.T) {
t.Parallel()
@@ -691,37 +313,6 @@ func TestJoinChannel(t *testing.T) {
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
}
// TestJoinInvalidChannelName checks that JOIN refuses a name
// that is not # followed by 1 to 63 letters, digits, _ or -
// with ERR_NOSUCHCHANNEL, and creates no such channel.
func TestJoinInvalidChannelName(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("joiner")
for _, channel := range []string{
"#bad.name",
"#",
"#" + strings.Repeat("a", 64),
} {
lines := client.sendAndExpect("JOIN "+channel, " 403 ")
assertContains(
t, lines,
" 403 joiner "+channel+" :No such channel",
channel,
)
_, err := env.database.GetChannelByName(t.Context(), channel)
if err == nil {
t.Errorf("channel %q was created", channel)
}
}
}
func TestPrivmsgBetweenClients(t *testing.T) {
t.Parallel()
@@ -772,68 +363,6 @@ func TestDuplicateNick(t *testing.T) {
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE")
}
// TestNickRefused checks that NICK, before and after
// registration, answers a nick the HTTP API refuses with
// ERR_ERRONEUSNICKNAME and keeps the nick as it was, and
// that another member of the channel sees no line from it.
func TestNickRefused(t *testing.T) {
t.Parallel()
// Each is what follows "NICK " on the wire.
refused := []string{
":bob PRIVMSG #inj :hi", // contains spaces
":", // empty
strings.Repeat("a", 33), // one character too long
"bob!x",
"bob@x",
"#bob",
}
env := newTestEnv(t)
// Before registration: the 432 is addressed to the nick
// sent first, and USER registers with that nick.
early := env.dial(t)
early.send("NICK early")
for _, param := range refused {
lines := early.sendAndExpect("NICK "+param, " 432 ")
assertContains(t, lines, " 432 early ", "before: "+param)
}
welcome := early.sendAndExpect("USER early 0 * :Test", " 001 ")
assertContains(t, welcome, " 001 early ", "registered as early")
// After registration.
bob := env.dial(t)
bob.register("bob")
bob.joinAndDrain("#inj")
mallory := env.dial(t)
mallory.register("mallory")
mallory.joinAndDrain("#inj")
bob.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "mallory")
})
for _, param := range refused {
lines := mallory.sendAndExpect("NICK "+param, " 432 ")
assertContains(t, lines, " 432 mallory ", "after: "+param)
}
mallory.send("PRIVMSG #inj :still mallory")
bobLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, "still mallory")
})
assertNotContains(t, bobLines, " NICK ", "bob sees no NICK")
assertContains(
t, bobLines, ":mallory!", "bob sees mallory's message",
)
}
func TestListChannels(t *testing.T) {
t.Parallel()
@@ -875,120 +404,6 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason")
}
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
// its channel membership are gone from the database, and a
// channel peer has been sent the QUIT.
func TestStopEndsSessions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("shutirc")
client.joinAndDrain("#shut")
ctx := t.Context()
// A peer on the HTTP API, whose session outlives the stop.
peerID, peerClientID, _, err := env.database.CreateSession(
ctx, "peer", "peer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
env.srv.Stop()
_, err = env.database.GetSessionByNick(ctx, "shutirc")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("look up shutirc after stop: %v, want no rows", err)
}
clients, err := env.database.GetClientCount(ctx)
if err != nil {
t.Fatalf("count clients: %v", err)
}
if clients != 1 {
t.Errorf("%d clients after stop, want only the peer's", clients)
}
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
if err != nil {
t.Fatalf("get members: %v", err)
}
if len(members) != 1 || members[0] != peerID {
t.Errorf(
"members of #shut after stop: %v, want only the peer %d",
members, peerID,
)
}
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == "QUIT" && msg.From == "shutirc" {
return
}
}
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
if err != nil {
t.Fatalf("take the database connection: %v", err)
}
stopped := make(chan struct{})
go func() {
env.srv.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
if err != nil {
t.Errorf("release the database connection: %v", err)
}
}
func TestTopicSetAndGet(t *testing.T) {
t.Parallel()
@@ -1160,165 +575,6 @@ func TestHandlePassPostRegistration(t *testing.T) {
)
}
// The shortest and longest password PASS accepts; bcrypt
// cannot hash more than 72 bytes.
const (
minPasswordBytes = 8
maxPasswordBytes = 72
)
// passwordLengthNotice is the NOTICE text for a password that
// is too short or too long.
const passwordLengthNotice = "Password must be 8 to 72 bytes"
// passwordCase is a password a PASS test sends, and whether
// the server should store it.
type passwordCase struct {
name string
password string
stored bool
}
// passwordCases returns the passwords the PASS length tests
// send: one of 8 to 72 bytes is stored and any other is
// refused. The limit counts bytes: "é" is two bytes in UTF-8,
// so 72 of them are refused.
func passwordCases() []passwordCase {
return []passwordCase{
{"empty", "", false},
{"7 bytes", strings.Repeat("a", minPasswordBytes-1), false},
{"8 bytes", strings.Repeat("a", minPasswordBytes), true},
{"72 bytes", strings.Repeat("a", maxPasswordBytes), true},
{"73 bytes", strings.Repeat("a", maxPasswordBytes+1), false},
{"72 characters", strings.Repeat("é", maxPasswordBytes), false},
}
}
// assertStored fails the test unless nick can log in with
// password exactly when stored is true.
func assertStored(
t *testing.T, env *testEnv, nick, password string, stored bool,
) {
t.Helper()
_, _, token, err := env.database.LoginUser(
t.Context(), nick, password, "127.0.0.1", "localhost",
)
loggedIn := err == nil && token != ""
if loggedIn != stored {
t.Errorf(
"log in as %s: logged in %v, want %v (error: %v)",
nick, loggedIn, stored, err,
)
}
}
// TestPassPostRegistrationLength checks that PASS after
// registration stores and confirms a password of 8 to 72
// bytes, and refuses any other with a NOTICE.
func TestPassPostRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("postpass%d", i)
client := env.dial(t)
client.register(nick)
client.send("PASS :" + tc.password)
// The server answers commands in order, so its
// reply to PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
reply := passwordLengthNotice
if tc.stored {
reply = "Password set"
}
assertContains(t, lines, reply, "reply to PASS")
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
// TestPassPostRegistrationNotStored checks that PASS is not
// confirmed when the password could not be stored.
func TestPassPostRegistrationNotStored(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("failpass")
_, err := env.database.GetDB().ExecContext(t.Context(),
`CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
client.send("PASS :mypassword123")
// The server answers commands in order, so anything it
// sent about PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(
t, lines, "Password set",
"confirmation of a password that was not stored",
)
}
// TestPassBeforeRegistrationLength checks that a PASS before
// registration stores a password of 8 to 72 bytes, and that
// any other is refused with a NOTICE once registration
// completes.
func TestPassBeforeRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
for i, tc := range passwordCases() {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
nick := fmt.Sprintf("prepass%d", i)
client := env.dial(t)
client.send("PASS :" + tc.password)
client.register(nick)
// The password is checked after the welcome
// burst, which register reads; the PONG comes
// after that.
lines := client.sendAndExpect("PING :after", "PONG")
if tc.stored {
assertNotContains(
t, lines, passwordLengthNotice,
"refusal of a valid password",
)
} else {
assertContains(
t, lines, passwordLengthNotice,
"refusal after registration",
)
}
assertStored(t, env, nick, tc.password, tc.stored)
})
}
}
func TestPreRegistrationNotRegistered(t *testing.T) {
t.Parallel()
-22
View File
@@ -1,22 +0,0 @@
package server
import (
"net"
"github.com/go-chi/chi/v5"
)
// NewTestSPAServer creates a Server whose router serves only
// the embedded web client.
func NewTestSPAServer() *Server {
srv := &Server{router: chi.NewRouter()}
srv.setupSPA()
return srv
}
// ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
srv.serveUntilShutdown(listener)
}
+1 -4
View File
@@ -3,7 +3,6 @@ package server
import (
"io/fs"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/web"
@@ -138,10 +137,8 @@ func (srv *Server) setupSPA() {
return
}
// The path is empty, not "/", for a request line such
// as "GET http://host HTTP/1.1".
fileData, readErr := readFS.ReadFile(
strings.TrimPrefix(request.URL.Path, "/"),
request.URL.Path[1:],
)
if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile(
-41
View File
@@ -1,41 +0,0 @@
package server_test
import (
"io/fs"
"net/http"
"net/http/httptest"
"testing"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/web"
)
// An absolute-form request line with no path, such as
// "GET http://example.com HTTP/1.1", leaves URL.Path empty.
func TestSPAServesIndexForEmptyPath(t *testing.T) {
t.Parallel()
request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "http://example.com", nil,
)
if request.URL.Path != "" {
t.Fatalf("URL.Path is %q, want it empty", request.URL.Path)
}
recorder := httptest.NewRecorder()
server.NewTestSPAServer().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status %d, want %d", recorder.Code, http.StatusOK)
}
indexHTML, err := fs.ReadFile(web.Dist, "dist/index.html")
if err != nil {
t.Fatal(err)
}
if recorder.Body.String() != string(indexHTML) {
t.Errorf("body is not the index page")
}
}
+50 -47
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
@@ -40,15 +39,17 @@ type Params struct {
Config *config.Config
Middleware *middleware.Middleware
Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
}
// Server is the main HTTP server.
// It manages routing, middleware, and lifecycle.
type Server struct {
startupTime time.Time
exitCode int
sentryEnabled bool
log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
cancelFunc context.CancelFunc
httpServer *http.Server
router *chi.Mux
params Params
@@ -68,7 +69,7 @@ func New(
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
OnStart: func(_ context.Context) error {
srv.startupTime = time.Now()
// Build the router before OnStart returns, so srv can
@@ -77,14 +78,13 @@ func New(
srv.enableSentry()
srv.SetupRoutes()
// A closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
// The start hook's context ends when the hook
// returns; serving must outlive it.
go srv.serve() //nolint:contextcheck,gosec // G118
return srv.startHTTP(ctx)
return nil
},
OnStop: func(ctx context.Context) error {
srv.cleanShutdown(ctx)
OnStop: func(_ context.Context) error {
return nil
},
})
@@ -129,15 +129,44 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true
}
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go srv.serveUntilShutdown()
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up")
}
// cleanShutdown stops the HTTP server, waiting up to
// shutdownTimeout for the requests in progress to finish.
func (srv *Server) cleanShutdown(ctx context.Context) {
func (srv *Server) cleanShutdown() {
srv.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
ctx, shutdownTimeout,
context.Background(), shutdownTimeout,
)
err := srv.httpServer.Shutdown(ctxShutdown)
@@ -162,23 +191,11 @@ func (srv *Server) configure() {
// Server configuration placeholder.
}
// startHTTP binds the HTTP listen address, so that a port
// that is taken fails startup, then serves the HTTP server
// from a new goroutine. srv.httpServer is set before that
// goroutine starts and never changes, so it and cleanShutdown
// read it without a lock.
func (srv *Server) startHTTP(ctx context.Context) error {
func (srv *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port,
)
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(ctx, "tcp", listenAddr)
if err != nil {
return fmt.Errorf("http listen: %w", err)
}
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr,
ReadTimeout: httpReadTimeout,
@@ -187,30 +204,16 @@ func (srv *Server) startHTTP(ctx context.Context) error {
Handler: srv,
}
go func() {
srv.serveUntilShutdown(listener)
}()
return nil
}
// serveUntilShutdown serves the HTTP server on listener until
// cleanShutdown stops it. If serving fails before then, it
// stops the app with exit code 1.
func (srv *Server) serveUntilShutdown(listener net.Listener) {
srv.log.Info(
"http begin listen", "listenaddr", srv.httpServer.Addr,
"http begin listen", "listenaddr", listenAddr,
)
err := srv.httpServer.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
return
}
err := srv.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
srv.log.Error("listen error", "error", err)
srv.log.Error("listen error", "error", err)
err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1))
if err != nil {
srv.log.Error("shutdown failed", "error", err)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}
}
-358
View File
@@ -1,358 +0,0 @@
// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package server_test
import (
"crypto/rand"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
const (
testTimeout = 5 * time.Second
// checkStep is how often waitUntil checks again.
checkStep = 10 * time.Millisecond
// stopHoldTime is how long a test waits to see that the
// app has not stopped.
stopHoldTime = 200 * time.Millisecond
)
// TestStopWaitsForRequestInProgress checks that stopping the
// app, which runs the stop hook server.New registers, waits
// for a long poll the HTTP server is serving to finish, and
// that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) {
port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "",
)
if err != nil {
t.Fatalf("create session: %v", err)
}
waitUntil(t, "the HTTP server to listen", func() bool {
return canConnect(t, addr)
})
statuses := make(chan int, 1)
go func() {
statuses <- getAuthenticated(
t, "http://"+addr+"/api/v1/messages?timeout=30", token,
)
}()
waitUntil(t, "the long poll to wait for a message", func() bool {
return brk.ListenerCount(sessionID) == 1
})
var stopErr error
stopped := make(chan struct{})
go func() {
stopErr = app.Stop(t.Context())
close(stopped)
}()
select {
case <-stopped:
t.Error("the app stopped while a request was in progress")
case <-time.After(stopHoldTime):
}
brk.Notify(sessionID)
status := <-statuses
if status != http.StatusOK {
t.Errorf("status %d, want %d", status, http.StatusOK)
}
select {
case <-stopped:
if stopErr != nil {
t.Errorf("stop: %v", stopErr)
}
case <-time.After(testTimeout):
t.Fatal("the app did not stop after the request finished")
}
}
// TestStartFailsWhenHTTPPortIsTaken checks that starting the
// app, which runs the start hook server.New registers,
// returns an error naming the HTTP listen address when
// something else is already listening on its port.
func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
var listenConfig net.ListenConfig
taken, err := listenConfig.Listen(t.Context(), "tcp", ":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = taken.Close() })
tcpAddr, ok := taken.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", taken.Addr())
}
app, _, _ := newApp(t, tcpAddr.Port)
err = app.Start(t.Context())
if err == nil {
app.RequireStop()
t.Fatal("the app started while its HTTP port was taken")
}
listenAddr := fmt.Sprintf(":%d", tcpAddr.Port)
if !strings.Contains(err.Error(), listenAddr) {
t.Errorf("start error %q does not name %s", err, listenAddr)
}
}
// TestServeFailureStopsApp checks that when serving HTTP fails
// after the start hook has bound the listen address, the
// server stops the app with exit code 1.
func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
if shutdown.ExitCode != 1 {
t.Errorf("exit code %d, want 1", shutdown.ExitCode)
}
default:
t.Error("the server did not stop the app")
}
}
// TestServeAfterStopDoesNotStopApp checks that serving HTTP
// after the app has stopped, which ends at once because the
// stop hook has shut the HTTP server down, does not stop the
// app. Serving ends the same way on every normal stop.
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
app.RequireStop()
srv.ServeUntilShutdown(closedListener(t))
select {
case shutdown := <-shutdowns:
t.Errorf(
"the server stopped the app with exit code %d",
shutdown.ExitCode,
)
default:
}
}
// startApp starts the app newApp builds, with the HTTP server
// listening on port.
func startApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
app, brk, database := newApp(t, port, options...)
app.RequireStart()
// Stops the app if the test ends before stopping it.
t.Cleanup(app.RequireStop)
return app, brk, database
}
// newApp builds, without starting it, an app that runs the
// server with the dependencies cmd/neoircd gives it, on a
// fresh in-memory database, with the HTTP port set to port.
// options are added to the app, for a test that needs more
// from it than the broker and the database.
func newApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
var (
brk *broker.Broker
database *db.Database
)
app := fxtest.New(t,
fx.Supply(&globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}),
fx.Provide(
broker.New,
config.New,
db.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
server.New,
service.New,
stats.New,
),
fx.Decorate(func(cfg *config.Config) *config.Config {
// A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.Port = port
return cfg
}),
fx.Invoke(func(*server.Server) {}),
fx.Populate(&brk, &database),
fx.Options(options...),
)
return app, brk, database
}
// freePort returns a TCP port that nothing was listening on a
// moment ago.
func freePort(t *testing.T) int {
t.Helper()
listener := closedListener(t)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
return tcpAddr.Port
}
// closedListener returns a TCP listener that has already been
// closed, so that accepting a connection on it fails.
func closedListener(t *testing.T) net.Listener {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(
t.Context(), "tcp", "127.0.0.1:0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
return listener
}
// canConnect reports whether addr accepts a TCP connection.
func canConnect(t *testing.T, addr string) bool {
t.Helper()
var dialer net.Dialer
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// waitUntil calls done every checkStep until it returns true,
// and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for !done() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(checkStep)
}
}
// getAuthenticated sends a GET request to url as the session
// whose auth token is token, and returns the response status,
// or 0 if there was no response.
func getAuthenticated(t *testing.T, url, token string) int {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), http.MethodGet, url, nil,
)
if err != nil {
t.Errorf("new request: %v", err)
return 0
}
request.Header.Set("Cookie", "neoirc_auth="+token)
client := http.Client{Timeout: testTimeout}
response, err := client.Do(request)
if err != nil {
t.Errorf("get %s: %v", url, err)
return 0
}
_ = response.Body.Close()
return response.StatusCode
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -29
View File
@@ -2,33 +2,26 @@ package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdAdmin = "ADMIN"
CmdAway = "AWAY"
CmdInfo = "INFO"
CmdInvite = "INVITE"
CmdJoin = "JOIN"
CmdKick = "KICK"
CmdKill = "KILL"
CmdList = "LIST"
CmdLusers = "LUSERS"
CmdMode = "MODE"
CmdMotd = "MOTD"
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdOper = "OPER"
CmdPass = "PASS"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT"
CmdTime = "TIME"
CmdTopic = "TOPIC"
CmdUser = "USER"
CmdUserhost = "USERHOST"
CmdVersion = "VERSION"
CmdWallops = "WALLOPS"
CmdWho = "WHO"
CmdWhois = "WHOIS"
CmdAway = "AWAY"
CmdInvite = "INVITE"
CmdJoin = "JOIN"
CmdKick = "KICK"
CmdList = "LIST"
CmdLusers = "LUSERS"
CmdMode = "MODE"
CmdMotd = "MOTD"
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdOper = "OPER"
CmdPass = "PASS"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT"
CmdTopic = "TOPIC"
CmdUser = "USER"
CmdWho = "WHO"
CmdWhois = "WHOIS"
)
+11 -5
View File
@@ -2,16 +2,22 @@
set -e
cd "$(dirname "$0")"
# The esbuild that yarn.lock pins, which yarn install puts in node_modules
if [ ! -x node_modules/.bin/esbuild ]; then
echo "esbuild not found. Install it: yarn install --frozen-lockfile"
exit 1
# Install esbuild if not present
if ! command -v esbuild >/dev/null 2>&1; then
if command -v npx >/dev/null 2>&1; then
NPX="npx"
else
echo "esbuild not found. Install it: npm install -g esbuild"
exit 1
fi
else
NPX=""
fi
mkdir -p dist
# Build JS bundle — preact must be bundled (no CDN/external loader)
node_modules/.bin/esbuild src/app.jsx \
${NPX:+$NPX} esbuild src/app.jsx \
--bundle \
--minify \
--format=esm \
+513
View File
@@ -0,0 +1,513 @@
{
"name": "web",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"preact": "^10.28.3"
},
"devDependencies": {
"esbuild": "^0.27.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/preact": {
"version": "10.28.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
}
}
}
+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 = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = {
"Content-Type": "application/json",
...(opts.headers || {}),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
@@ -122,11 +122,12 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name;
})
.catch(() => {});
// Resume the session if the browser still holds a valid auth
// cookie; a 401 leaves the login form up.
api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true))
.catch(() => {});
const saved = localStorage.getItem("neoirc_token");
if (saved) {
api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token"));
}
inputRef.current?.focus();
}, []);
@@ -151,6 +152,7 @@ function LoginScreen({ onLogin }) {
method: "POST",
body: JSON.stringify(reqBody),
});
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick);
} catch (err) {
setError(err.data?.error || "Connection failed");
@@ -225,9 +227,10 @@ function UserList({ members, onNickClick }) {
const regular = [];
for (const m of members) {
if (m.isOperator) {
const mode = m.mode || "";
if (mode === "o") {
ops.push(m);
} else if (m.isVoiced) {
} else if (mode === "v") {
voiced.push(m);
} else {
regular.push(m);
@@ -300,14 +303,11 @@ function App() {
nickRef.current = nick;
}, [nick]);
// Persist joined channels, once logged in: before then only the
// Server tab exists, and writing would erase the saved channels
// that onLogin rejoins.
// Persist joined channels.
useEffect(() => {
if (!loggedIn) return;
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [loggedIn, tabs]);
}, [tabs]);
// Clear unread on tab switch.
useEffect(() => {
@@ -366,9 +366,7 @@ function App() {
command: msg.command,
};
// A numeric reply carries its number in `code` and its name in
// `command`; other messages have no `code`.
switch (msg.code || msg.command) {
switch (msg.command) {
case "PRIVMSG":
case "NOTICE": {
let text = body;
@@ -466,18 +464,8 @@ function App() {
break;
}
case "MODE": {
// The body is the change, with its value where it has one:
// ["+o bob"], ["+m"], ["+k", "secretpass"].
const modes = Array.isArray(msg.body) ? msg.body.join(" ") : "";
const text = `${msg.from} sets mode ${modes} on ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
break;
}
case 353: {
// RPL_NAMREPLY — each name is a hostmask (nick!user@host),
// prefixed with @ for an operator or + for a voiced member.
case "353": {
// RPL_NAMREPLY — parse mode prefixes from names list.
if (
Array.isArray(msg.params) &&
msg.params.length >= 2 &&
@@ -489,18 +477,21 @@ function App() {
: String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => {
const isOperator = n.startsWith("@");
const isVoiced = n.startsWith("+");
const hostmask = isOperator || isVoiced ? n.slice(1) : n;
if (n.startsWith("@")) {
return { nick: n.slice(1), mode: "o" };
}
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 }));
}
break;
}
case 332: {
case "332": {
// RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0];
@@ -512,7 +503,7 @@ function App() {
break;
}
case 322: {
case "322": {
// RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0];
@@ -527,7 +518,7 @@ function App() {
break;
}
case 323:
case "323":
addMessage("Server", {
...base,
text: body || "End of channel list",
@@ -535,7 +526,7 @@ function App() {
});
break;
case 352: {
case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0];
@@ -550,7 +541,7 @@ function App() {
break;
}
case 315:
case "315":
addMessage("Server", {
...base,
text: body || "End of /WHO list",
@@ -558,20 +549,20 @@ function App() {
});
break;
case 311: {
// RPL_WHOISUSER — params nick, user, host, *; body realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, wiUser, wiHost] = msg.params;
case "311": {
// RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`,
text: `${wiNick} (${body})`,
system: true,
});
}
break;
}
case 312: {
case "312": {
// RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0];
@@ -585,33 +576,7 @@ function App() {
break;
}
case 313: {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage("Server", {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
});
}
break;
}
case 317: {
// RPL_WHOISIDLE — nick, idle seconds, signon time (Unix).
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage("Server", {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
});
}
break;
}
case 319: {
case "319": {
// RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
@@ -624,7 +589,7 @@ function App() {
break;
}
case 318:
case "318":
addMessage("Server", {
...base,
text: body || "End of /WHOIS list",
@@ -632,21 +597,9 @@ function App() {
});
break;
case 324: {
// RPL_CHANNELMODEIS — channel, mode string.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
addMessage("Server", {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
});
}
break;
}
case 375:
case 372:
case 376:
case "375":
case "372":
case "376":
addMessage("Server", { ...base, text: body, system: true });
break;
@@ -684,14 +637,6 @@ function App() {
} catch (err) {
if (!alive) break;
if (err.name === "AbortError") continue;
// 401: the session is gone (QUIT from another client, or
// expiry). Reloading drops this page's state and brings
// back the login screen.
if (err.status === 401) {
window.location.reload();
return;
}
setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
}
@@ -771,11 +716,6 @@ function App() {
const onLogin = useCallback(
async (userNick, isResumed) => {
// Read before setLoggedIn(true): from then on the effect under
// "Persist joined channels" writes the open channel tabs over it.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
setNick(userNick);
setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`);
@@ -800,6 +740,9 @@ function App() {
}
// Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) {
try {
await api("/messages", {
@@ -1029,22 +972,27 @@ function App() {
break;
}
// With no arguments MODE is a query. A change is one sign and
// letter, then its value where it takes one: +m, +o nick.
const modeArgs = parts.slice(1);
const body =
modeArgs.length > 0
? { command: "MODE", to: tab.name, body: modeArgs }
: { command: "MODE", to: tab.name };
try {
await api("/messages", {
method: "POST",
body: JSON.stringify(body),
});
} catch (err) {
if (modeArgs.length > 0) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({
command: "MODE",
to: tab.name,
params: modeArgs,
}),
});
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(
"Server",
`MODE failed: ${err.data?.error || "error"}`,
"Usage: /mode <+/-mode> [params]",
);
}
@@ -1061,6 +1009,7 @@ function App() {
} catch (e) {
// Ignore.
}
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels");
window.location.reload();
@@ -1179,7 +1128,7 @@ function App() {
" /me action — Send an action",
" /nick newnick — Change your nickname",
" /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",
" /list — List all channels",
" /who [#channel] — List users in a channel",
-170
View File
@@ -1,170 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@esbuild/aix-ppc64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==
"@esbuild/android-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8"
integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==
"@esbuild/android-arm@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b"
integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==
"@esbuild/android-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac"
integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==
"@esbuild/darwin-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd"
integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==
"@esbuild/darwin-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a"
integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==
"@esbuild/freebsd-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b"
integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==
"@esbuild/freebsd-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead"
integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==
"@esbuild/linux-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6"
integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==
"@esbuild/linux-arm@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11"
integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==
"@esbuild/linux-ia32@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29"
integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==
"@esbuild/linux-loong64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed"
integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==
"@esbuild/linux-mips64el@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1"
integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==
"@esbuild/linux-ppc64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78"
integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==
"@esbuild/linux-riscv64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d"
integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==
"@esbuild/linux-s390x@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d"
integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==
"@esbuild/linux-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5"
integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==
"@esbuild/netbsd-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7"
integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==
"@esbuild/netbsd-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b"
integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==
"@esbuild/openbsd-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5"
integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==
"@esbuild/openbsd-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b"
integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==
"@esbuild/openharmony-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e"
integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==
"@esbuild/sunos-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537"
integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==
"@esbuild/win32-arm64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e"
integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==
"@esbuild/win32-ia32@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c"
integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==
"@esbuild/win32-x64@0.27.3":
version "0.27.3"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
esbuild@^0.27.3:
version "0.27.3"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8"
integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==
optionalDependencies:
"@esbuild/aix-ppc64" "0.27.3"
"@esbuild/android-arm" "0.27.3"
"@esbuild/android-arm64" "0.27.3"
"@esbuild/android-x64" "0.27.3"
"@esbuild/darwin-arm64" "0.27.3"
"@esbuild/darwin-x64" "0.27.3"
"@esbuild/freebsd-arm64" "0.27.3"
"@esbuild/freebsd-x64" "0.27.3"
"@esbuild/linux-arm" "0.27.3"
"@esbuild/linux-arm64" "0.27.3"
"@esbuild/linux-ia32" "0.27.3"
"@esbuild/linux-loong64" "0.27.3"
"@esbuild/linux-mips64el" "0.27.3"
"@esbuild/linux-ppc64" "0.27.3"
"@esbuild/linux-riscv64" "0.27.3"
"@esbuild/linux-s390x" "0.27.3"
"@esbuild/linux-x64" "0.27.3"
"@esbuild/netbsd-arm64" "0.27.3"
"@esbuild/netbsd-x64" "0.27.3"
"@esbuild/openbsd-arm64" "0.27.3"
"@esbuild/openbsd-x64" "0.27.3"
"@esbuild/openharmony-arm64" "0.27.3"
"@esbuild/sunos-x64" "0.27.3"
"@esbuild/win32-arm64" "0.27.3"
"@esbuild/win32-ia32" "0.27.3"
"@esbuild/win32-x64" "0.27.3"
preact@^10.28.3:
version "10.28.3"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.3.tgz#3c2171526b3e29628ad1a6c56a9e3ca867bbdee8"
integrity sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==