1 Commits
Author SHA1 Message Date
clawbot a7faabb68f Add the tier 3 utility IRC commands (closes #87)
check / check (push) Canceled after 0s
USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS work on the
HTTP API and on the IRC listener. The USERHOST reply, the VERSION and
INFO text, KILL and WALLOPS are each one service function that both
transports call. User mode +w selects who receives WALLOPS. MODE on
your own nick queries or changes your user modes through one parser
on both transports: the whole mode string is checked before anything
is stored, and both flags are stored in one transaction. KILL closes
the victim's IRC connection after sending it KILL and ERROR. The
sessions table gains is_wallops in 001_initial.sql, as the pre-1.0
rule in REPO_POLICIES.md asks.

Model: opus-5-5
2026-10-08 01:13:36 +00:00
43 changed files with 1770 additions and 8023 deletions
+459 -782
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). // Remove removes a specific wait channel (for cleanup on timeout).
func (b *Broker) Remove( func (b *Broker) Remove(
userID int64, 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) { func TestMultipleWaiters(t *testing.T) {
t.Parallel() t.Parallel()
+2 -2
View File
@@ -250,7 +250,7 @@ func (client *Client) ListChannels() (
// GetMembers returns members of a channel. // GetMembers returns members of a channel.
func (client *Client) GetMembers( func (client *Client) GetMembers(
channel string, channel string,
) ([]Member, error) { ) ([]string, error) {
name := strings.TrimPrefix(channel, "#") name := strings.TrimPrefix(channel, "#")
data, err := client.do( data, err := client.do(
@@ -263,7 +263,7 @@ func (client *Client) GetMembers(
return nil, err return nil, err
} }
var members []Member var members []string
err = json.Unmarshal(data, &members) err = json.Unmarshal(data, &members)
if err != nil { if err != nil {
+7 -366
View File
@@ -2,28 +2,18 @@ package neoircapi_test
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"reflect"
"slices"
"strconv"
"strings" "strings"
"testing" "testing"
api "sneak.berlin/go/neoirc/internal/cli/api" api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/internal/hashcash"
) )
const ( const cookieValue = "opaque-value"
cookieValue = "opaque-value"
testNick = "alice"
testChannel = "#general"
)
// newSessionServer starts a plain-HTTP server that, like // newSessionServer starts a plain-HTTP server that, like
// neoircd, sets a Secure auth cookie when a session is // neoircd, sets a Secure auth cookie when a session is
@@ -34,16 +24,10 @@ func newSessionServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
// Without hashcash the server sends no hashcash_bits.
mux.HandleFunc("GET /api/v1/server", func( mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request, writer http.ResponseWriter, _ *http.Request,
) { ) {
_, _ = io.WriteString(writer, `{ _, _ = io.WriteString(writer, `{}`)
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42
}`)
}) })
mux.HandleFunc("POST /api/v1/session", func( mux.HandleFunc("POST /api/v1/session", func(
@@ -72,14 +56,9 @@ func newSessionServer(t *testing.T) *httptest.Server {
return return
} }
_, _ = io.WriteString(writer, `{ _, _ = io.WriteString(
"id": 1, writer, `{"id":1,"nick":"alice","channels":[]}`,
"nick": "alice", )
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}`)
}) })
server := httptest.NewServer(mux) server := httptest.NewServer(mux)
@@ -88,344 +67,6 @@ func newSessionServer(t *testing.T) *httptest.Server {
return server return server
} }
// newClientForPath starts a server that answers GET path with
// body, and returns a client for it.
func newClientForPath(t *testing.T, path, body string) *api.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET "+path, func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, body)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return api.NewClient(server.URL)
}
func TestGetStateDecodesFields(t *testing.T) {
t.Parallel()
client := api.NewClient(newSessionServer(t).URL)
_, err := client.CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
state, err := client.GetState()
if err != nil {
t.Fatalf("state: %v", err)
}
want := api.StateResponse{
ID: 1,
Nick: testNick,
Channels: []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: ""},
},
}
if !reflect.DeepEqual(*state, want) {
t.Fatalf("state: got %+v, want %+v", *state, want)
}
}
func TestGetServerInfoDecodesFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/server", `{
"name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": 20
}`)
info, err := client.GetServerInfo()
if err != nil {
t.Fatalf("server info: %v", err)
}
want := api.ServerInfo{
Name: "My NeoIRC Server",
MOTD: "Welcome! Be nice.",
Version: "0.1.0",
HashcashBits: 20,
}
if *info != want {
t.Fatalf("server info: got %+v, want %+v", *info, want)
}
}
// TestCreateSessionSendsNickAndStamp serves a server that, like
// neoircd with hashcash on, advertises hashcash_bits and creates
// the session only for a pow_token the server's validator in
// internal/hashcash accepts.
func TestCreateSessionSendsNickAndStamp(t *testing.T) {
t.Parallel()
const (
serverName = "My NeoIRC Server"
stampBits = 8
sessionID = 7
)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/server", func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = fmt.Fprintf(writer, `{
"name": %q,
"version": "0.1.0",
"motd": "Welcome! Be nice.",
"users": 42,
"hashcash_bits": %d
}`, serverName, stampBits)
})
validator := hashcash.NewValidator(serverName)
mux.HandleFunc("POST /api/v1/session", func(
writer http.ResponseWriter, request *http.Request,
) {
// Decoded with the field names handleCreateSession uses.
var payload struct {
Nick string `json:"nick"`
Hashcash string `json:"pow_token"` //nolint:tagliatelle
}
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
return
}
err = validator.Claim(payload.Hashcash, stampBits)
if err != nil {
writer.WriteHeader(http.StatusPaymentRequired)
return
}
writer.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(writer).Encode(map[string]any{
"id": sessionID,
"nick": payload.Nick,
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
session, err := api.NewClient(server.URL).CreateSession(testNick)
if err != nil {
t.Fatalf("create session: %v", err)
}
want := api.SessionResponse{ID: sessionID, Nick: testNick}
if *session != want {
t.Fatalf("session: got %+v, want %+v", *session, want)
}
}
func TestListChannelsDecodesNamesAndTopics(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels", `[
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": "Development discussion"}
]`)
channels, err := client.ListChannels()
if err != nil {
t.Fatalf("list channels: %v", err)
}
want := []api.Channel{
{Name: testChannel, Topic: "Welcome!"},
{Name: "#dev", Topic: "Development discussion"},
}
if !slices.Equal(channels, want) {
t.Fatalf("channels: got %+v, want %+v", channels, want)
}
}
func TestGetMembersDecodesMemberObjects(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels/general/members", `[
{
"id": 1, "nick": "alice", "username": "alice",
"hostname": "host.example.com",
"isOperator": true, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 2, "nick": "bob", "username": "bob",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": true,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 3, "nick": "Carol", "username": "carol",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
}
]`)
members, err := client.GetMembers(testChannel)
if err != nil {
t.Fatalf("get members: %v", err)
}
want := []api.Member{
{Nick: testNick, IsOperator: true, IsVoiced: false},
{Nick: "bob", IsOperator: false, IsVoiced: true},
{Nick: "Carol", IsOperator: false, IsVoiced: false},
}
if !slices.Equal(members, want) {
t.Fatalf("members: got %+v, want %+v", members, want)
}
}
// TestPollMessagesAdvancesCursor serves the queue as the server
// does: entries after the cursor, and last_id as the new cursor.
func TestPollMessagesAdvancesCursor(t *testing.T) {
t.Parallel()
const lastQueueID = 42
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/messages", func(
writer http.ResponseWriter, request *http.Request,
) {
after := request.URL.Query().Get("after")
if after == strconv.Itoa(lastQueueID) {
_, _ = io.WriteString(
writer, `{"messages": [], "last_id": 42}`,
)
return
}
_, _ = io.WriteString(writer, `{
"messages": [{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello world"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {}
}],
"last_id": 42
}`)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := api.NewClient(server.URL)
first, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("first poll: %v", err)
}
if first.LastID != lastQueueID || len(first.Messages) != 1 {
t.Fatalf(
"first poll: got last ID %d and %d messages, "+
"want %d and 1",
first.LastID, len(first.Messages), lastQueueID,
)
}
second, err := client.PollMessages(first.LastID, 0)
if err != nil {
t.Fatalf("second poll: %v", err)
}
if len(second.Messages) != 0 {
t.Fatalf(
"second poll: got %d messages again, want none",
len(second.Messages),
)
}
}
// TestPollMessagesDecodesMessageFields serves a relayed PRIVMSG
// with the sender's meta and a numeric, as the server queues
// them.
func TestPollMessagesDecodesMessageFields(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/messages", `{
"messages": [
{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["signed message"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {"sig": "base64...", "alg": "ed25519"}
},
{
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
"command": "RPL_LUSEROP",
"code": 252,
"from": "neoirc",
"to": "alice",
"params": ["0"],
"body": ["operator(s) online"],
"ts": "2026-02-10T20:00:02Z",
"meta": {}
}
],
"last_id": 42
}`)
result, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
want := []api.Message{
{
ID: "b7c8210f-849c-4b90-9ee8-d99c8889358e",
Command: "PRIVMSG",
From: testNick,
To: testChannel,
Body: []any{"signed message"},
TS: "2026-02-10T20:00:01.234567891Z",
Meta: map[string]any{"sig": "base64...", "alg": "ed25519"},
},
{
ID: "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
Command: "RPL_LUSEROP",
From: "neoirc",
To: testNick,
Params: []string{"0"},
Body: []any{"operator(s) online"},
TS: "2026-02-10T20:00:02Z",
Meta: map[string]any{},
},
}
if !reflect.DeepEqual(result.Messages, want) {
t.Fatalf("messages: got %+v, want %+v", result.Messages, want)
}
}
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) { func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -444,7 +85,7 @@ func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
"http://" + net.JoinHostPort(host, serverURL.Port()), "http://" + net.JoinHostPort(host, serverURL.Port()),
) )
_, err := client.CreateSession(testNick) _, err := client.CreateSession("alice")
if err != nil { if err != nil {
t.Fatalf("create session: %v", err) 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 { if err != nil {
t.Fatalf("create session: %v", err) 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. // StateResponse is the response from GET /api/v1/state.
type StateResponse struct { type StateResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Nick string `json:"nick"` Nick string `json:"nick"`
Channels []Channel `json:"channels"` Channels []string `json:"channels"`
} }
// Message represents a neoirc message envelope. // 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 // Channel represents a channel in the list response.
// GET /api/v1/state describe it.
type Channel struct { type Channel struct {
Name string `json:"name"` Name string `json:"name"`
Topic string `json:"topic"` Topic string `json:"topic"`
} Members int `json:"members"`
CreatedAt string `json:"createdAt"`
// Member is one entry of the response from
// GET /api/v1/channels/{name}/members.
type Member struct {
Nick string `json:"nick"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
} }
// ServerInfo is the response from GET /api/v1/server. // ServerInfo is the response from GET /api/v1/server.
@@ -79,7 +72,7 @@ type ServerInfo struct {
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.
type MessagesResponse struct { type MessagesResponse struct {
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
LastID int64 `json:"last_id"` //nolint:tagliatelle LastID int64 `json:"lastId"`
} }
// PollResult wraps the poll response including the cursor. // PollResult wraps the poll response including the cursor.
+16 -32
View File
@@ -434,9 +434,16 @@ func (a *App) cmdTopic(args string) {
return return
} }
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" { 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 return
} }
@@ -480,32 +487,12 @@ func (a *App) cmdNames() {
return return
} }
nicks := make([]string, 0, len(members))
for _, member := range members {
nicks = append(nicks, memberPrefix(member)+member.Nick)
}
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s", "[cyan]*** Members of %s: %s",
target, strings.Join(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() { func (a *App) cmdList() {
a.mu.Lock() a.mu.Lock()
connected := a.connected connected := a.connected
@@ -530,7 +517,8 @@ func (a *App) cmdList() {
for _, ch := range channels { for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
" %s %s", ch.Name, ch.Topic, " %s (%d members) %s",
ch.Name, ch.Members, ch.Topic,
)) ))
} }
@@ -688,7 +676,7 @@ func (a *App) cmdHelp() {
" /part [#chan] — Leave channel", " /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM", " /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window", " /query <nick> — Open DM window",
" /topic <text> — Set topic", " /topic [text] — View/set topic",
" /names — List channel members", " /names — List channel members",
" /list — List channels", " /list — List channels",
" /who [#channel] — List users in channel", " /who [#channel] — List users in channel",
@@ -757,7 +745,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin: case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp) a.handleJoinEvent(msg, timestamp)
case irc.CmdPart: case irc.CmdPart:
a.handlePartEvent(msg, timestamp, myNick) a.handlePartEvent(msg, timestamp)
case irc.CmdQuit: case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp) a.handleQuitEvent(msg, timestamp)
case irc.CmdNick: case irc.CmdNick:
@@ -813,14 +801,10 @@ func (a *App) handleJoinEvent(
)) ))
} }
// handlePartEvent shows another user's part. The server
// also sends the user's own part back to it, which cmdPart
// has already shown; showing it again would add a second
// line and mark the channel just left as unread.
func (a *App) handlePartEvent( func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string, msg *api.Message, timestamp string,
) { ) {
if msg.To == "" || msg.From == myNick { if msg.To == "" {
return return
} }
-116
View File
@@ -1,116 +0,0 @@
package cli
import (
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestMemberPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
member api.Member
want string
}{
{
name: "operator",
member: api.Member{Nick: "alice", IsOperator: true},
want: "@",
},
{
name: "voiced member",
member: api.Member{Nick: "bob", IsVoiced: true},
want: "+",
},
{
name: "voiced operator",
member: api.Member{
Nick: "Carol", IsOperator: true, IsVoiced: true,
},
want: "@",
},
{
name: "plain member",
member: api.Member{Nick: "dave"},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := memberPrefix(test.member)
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except for the
// user's own part, which cmdPart has already shown.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantLines int
wantUnread int
}{
{name: "own part", from: "erin", wantLines: 0, wantUnread: 0},
{name: "other member", from: "bob", wantLines: 1, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: "erin"}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: "#chan",
})
var lines, unread int
app.ui.app.QueueUpdate(func() {
buf := app.ui.getOrCreateBuffer("#chan")
lines = len(buf.Lines)
unread = buf.Unread
})
if lines != test.wantLines || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want %d and %d",
lines, unread, test.wantLines, test.wantUnread)
}
})
}
}
// runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) {
t.Helper()
ui.app.SetScreen(tcell.NewSimulationScreen(""))
done := make(chan error)
go func() { done <- ui.Run() }()
t.Cleanup(func() {
ui.Stop()
err := <-done
if err != nil {
t.Errorf("run ui: %v", err)
}
})
}
+1 -15
View File
@@ -4,7 +4,6 @@ package config
import ( import (
"errors" "errors"
"log/slog" "log/slog"
"os"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -22,12 +21,6 @@ const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
Welcome to NeoIRC — IRC semantics over HTTP. Welcome to NeoIRC — IRC semantics over HTTP.
Type /help for available commands.` 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. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -78,7 +71,7 @@ func New(
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "false")
viper.SetDefault("MAINTENANCE_MODE", "false") viper.SetDefault("MAINTENANCE_MODE", "false")
viper.SetDefault("PORT", "8080") 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("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "") viper.SetDefault("METRICS_PASSWORD", "")
@@ -130,13 +123,6 @@ func New(
params: &params, 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 { if cfg.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get() 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 { func (database *Database) connect(ctx context.Context) error {
dbURL := database.params.Config.DBURL dbURL := database.params.Config.DBURL
if dbURL == "" { if dbURL == "" {
dbURL = config.DefaultDBURL dbURL = "file:///var/lib/neoirc/state.db?_journal_mode=WAL&_busy_timeout=5000"
} }
database.log.Info( database.log.Info(
@@ -116,6 +116,13 @@ func (database *Database) connect(ctx context.Context) error {
return fmt.Errorf("enable foreign keys: %w", err) 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) 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 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
}
+194 -550
View File
@@ -9,7 +9,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -205,42 +204,21 @@ func (database *Database) GetSessionByToken(
) )
} }
_ = database.UpdateLastSeen(ctx, sessionID, clientID)
return sessionID, clientID, nick, nil
}
// UpdateLastSeen sets last_seen to now on a session and on
// one of its clients, so that the idle cleanup keeps them.
func (database *Database) UpdateLastSeen(
ctx context.Context,
sessionID, clientID int64,
) error {
now := time.Now() now := time.Now()
_, err := database.conn.ExecContext( _, _ = database.conn.ExecContext(
ctx, ctx,
"UPDATE sessions SET last_seen = ? WHERE id = ?", "UPDATE sessions SET last_seen = ? WHERE id = ?",
now, sessionID, now, sessionID,
) )
if err != nil {
return fmt.Errorf(
"update session last_seen: %w", err,
)
}
_, err = database.conn.ExecContext( _, _ = database.conn.ExecContext(
ctx, ctx,
"UPDATE clients SET last_seen = ? WHERE id = ?", "UPDATE clients SET last_seen = ? WHERE id = ?",
now, clientID, now, clientID,
) )
if err != nil {
return fmt.Errorf(
"update client last_seen: %w", err,
)
}
return nil return sessionID, clientID, nick, nil
} }
// GetSessionByNick returns session id for a given nick. // GetSessionByNick returns session id for a given nick.
@@ -264,25 +242,6 @@ func (database *Database) GetSessionByNick(
return sessionID, nil return sessionID, nil
} }
// GetSessionNick returns the nick a session holds.
func (database *Database) GetSessionNick(
ctx context.Context,
sessionID int64,
) (string, error) {
var nick string
err := database.conn.QueryRowContext(
ctx,
"SELECT nick FROM sessions WHERE id = ?",
sessionID,
).Scan(&nick)
if err != nil {
return "", fmt.Errorf("get session nick: %w", err)
}
return nick, nil
}
// SessionHostInfo holds the username, hostname, and IP // SessionHostInfo holds the username, hostname, and IP
// for a session. // for a session.
type SessionHostInfo struct { type SessionHostInfo struct {
@@ -481,207 +440,46 @@ func (database *Database) JoinChannel(
return nil return nil
} }
// Errors that CreateOrJoinChannel refuses a join with. // JoinChannelAsOperator adds a session to a channel with
var ( // operator status. Used when a user creates a new channel.
ErrBannedFromChannel = errors.New("banned from channel") func (database *Database) JoinChannelAsOperator(
ErrInviteOnlyChannel = errors.New("channel is invite-only")
ErrBadChannelKey = errors.New("wrong channel key")
ErrChannelIsFull = errors.New("channel is full")
)
// CreateOrJoinChannel adds a session to the channel called
// name, creating the channel if it does not exist. The
// session is made operator if the channel has no members;
// otherwise checkJoinRestrictions may refuse it. A join
// uses up the session's invite to the channel. It returns
// the channel ID and whether the session was made operator.
// All of it is one transaction, and the database has one
// connection, so every other statement waits until the
// join is decided.
func (database *Database) CreateOrJoinChannel(
ctx context.Context, ctx context.Context,
name string, channelID, sessionID int64,
sessionID int64, ) error {
suppliedKey string, _, err := database.conn.ExecContext(ctx,
) (int64, bool, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, false, fmt.Errorf("begin join: %w", err)
}
defer func() { _ = transaction.Rollback() }()
now := time.Now()
_, err = transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO channels
(name, created_at, updated_at)
VALUES (?, ?, ?)`,
name, now, now)
if err != nil {
return 0, false, fmt.Errorf("create channel: %w", err)
}
var channelID, memberCount int64
err = transaction.QueryRowContext(ctx,
`SELECT id,
(SELECT COUNT(*) FROM channel_members
WHERE channel_id = channels.id)
FROM channels WHERE name = ?`,
name,
).Scan(&channelID, &memberCount)
if err != nil {
return 0, false, fmt.Errorf("get channel: %w", err)
}
isOperator := memberCount == 0
if !isOperator {
err = checkJoinRestrictions(
ctx, transaction, channelID, sessionID,
suppliedKey, memberCount,
)
if err != nil {
return 0, false, err
}
}
_, err = transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO channel_members `INSERT OR IGNORE INTO channel_members
(channel_id, session_id, is_operator, joined_at) (channel_id, session_id, is_operator, joined_at)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, 1, ?)`,
channelID, sessionID, isOperator, now) channelID, sessionID, time.Now())
if err != nil { if err != nil {
return 0, false, fmt.Errorf("join channel: %w", err) return fmt.Errorf(
} "join channel as operator: %w", err,
)
_, err = transaction.ExecContext(ctx,
`DELETE FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return 0, false, fmt.Errorf("clear invite: %w", err)
}
err = transaction.Commit()
if err != nil {
return 0, false, fmt.Errorf("commit join: %w", err)
}
return channelID, isOperator, nil
}
// checkJoinRestrictions returns the error that refuses a
// session's join of a channel with memberCount members:
// ErrBannedFromChannel when a ban matches its hostmask,
// ErrInviteOnlyChannel when the channel is +i and the
// session has no invite, ErrBadChannelKey when suppliedKey
// is not the +k key, or ErrChannelIsFull when the channel
// has at least its +l number of members. It returns nil
// when none applies.
func checkJoinRestrictions(
ctx context.Context,
transaction *sql.Tx,
channelID, sessionID int64,
suppliedKey string,
memberCount int64,
) error {
banned, err := isSessionBannedInTx(
ctx, transaction, channelID, sessionID,
)
if err != nil {
return err
}
if banned {
return ErrBannedFromChannel
}
var (
inviteOnly, invited bool
key string
userLimit int64
)
err = transaction.QueryRowContext(ctx,
`SELECT is_invite_only, channel_key, user_limit,
EXISTS (SELECT 1 FROM channel_invites
WHERE channel_id = channels.id
AND session_id = ?)
FROM channels WHERE id = ?`,
sessionID, channelID,
).Scan(&inviteOnly, &key, &userLimit, &invited)
if err != nil {
return fmt.Errorf("get channel modes: %w", err)
}
switch {
case inviteOnly && !invited:
return ErrInviteOnlyChannel
case key != "" && suppliedKey != key:
return ErrBadChannelKey
case userLimit > 0 && memberCount >= userLimit:
return ErrChannelIsFull
} }
return nil return nil
} }
// isSessionBannedInTx is IsSessionBanned, reading through // CountChannelMembers returns the number of members in
// transaction. // a channel.
func isSessionBannedInTx( func (database *Database) CountChannelMembers(
ctx context.Context, ctx context.Context,
transaction *sql.Tx, channelID int64,
channelID, sessionID int64, ) (int64, error) {
) (bool, error) { var count int64
var nick, username, hostname string
err := transaction.QueryRowContext(ctx, err := database.conn.QueryRowContext(ctx,
`SELECT nick, username, hostname `SELECT COUNT(*) FROM channel_members
FROM sessions WHERE id = ?`,
sessionID,
).Scan(&nick, &username, &hostname)
if err != nil {
return false, fmt.Errorf(
"get session hostmask: %w", err,
)
}
hostmask := FormatHostmask(nick, username, hostname)
rows, err := transaction.QueryContext(ctx,
`SELECT mask FROM channel_bans
WHERE channel_id = ?`, WHERE channel_id = ?`,
channelID) channelID,
).Scan(&count)
if err != nil { if err != nil {
return false, fmt.Errorf("list channel bans: %w", err) return 0, fmt.Errorf(
} "count channel members: %w", err,
defer func() { _ = rows.Close() }()
for rows.Next() {
var mask string
err = rows.Scan(&mask)
if err != nil {
return false, fmt.Errorf(
"scan channel ban: %w", err,
)
}
if MatchBanMask(mask, hostmask) {
return true, nil
}
}
err = rows.Err()
if err != nil {
return false, fmt.Errorf(
"iterate channel bans: %w", err,
) )
} }
return false, nil return count, nil
} }
// IsChannelOperator checks if a session has operator // IsChannelOperator checks if a session has operator
@@ -873,44 +671,12 @@ func (database *Database) SetChannelTopicLocked(
return nil return nil
} }
// PartChannelAndDeleteIfEmpty removes a session from a // PartChannel removes a session from a channel.
// channel and deletes the channel if that left it with no func (database *Database) PartChannel(
// members, in one transaction, so that no JOIN lands
// between the two.
func (database *Database) PartChannelAndDeleteIfEmpty(
ctx context.Context, ctx context.Context,
channelID, sessionID int64, channelID, sessionID int64,
) error { ) error {
transaction, err := database.conn.BeginTx(ctx, nil) _, err := database.conn.ExecContext(ctx,
if err != nil {
return fmt.Errorf("begin part: %w", err)
}
defer func() { _ = transaction.Rollback() }()
err = partChannelAndDeleteIfEmptyInTx(
ctx, transaction, channelID, sessionID,
)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit part: %w", err)
}
return nil
}
// partChannelAndDeleteIfEmptyInTx is
// PartChannelAndDeleteIfEmpty, writing through transaction.
func partChannelAndDeleteIfEmptyInTx(
ctx context.Context,
transaction *sql.Tx,
channelID, sessionID int64,
) error {
_, err := transaction.ExecContext(ctx,
`DELETE FROM channel_members `DELETE FROM channel_members
WHERE channel_id = ? AND session_id = ?`, WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID) channelID, sessionID)
@@ -918,7 +684,15 @@ func partChannelAndDeleteIfEmptyInTx(
return fmt.Errorf("part channel: %w", err) return fmt.Errorf("part channel: %w", err)
} }
_, err = transaction.ExecContext(ctx, return nil
}
// DeleteChannelIfEmpty removes a channel with no members.
func (database *Database) DeleteChannelIfEmpty(
ctx context.Context,
channelID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channels WHERE id = ? `DELETE FROM channels WHERE id = ?
AND NOT EXISTS AND NOT EXISTS
(SELECT 1 FROM channel_members (SELECT 1 FROM channel_members
@@ -1145,48 +919,9 @@ func (database *Database) GetSessionChannelIDs(
} }
// InsertMessage stores a message and returns its DB ID. // InsertMessage stores a message and returns its DB ID.
// toSessionID is the session a DM or INVITE is sent to and
// toChannelID the channel a channel message is sent to;
// GetHistory selects by them. Pass 0 for one that does not
// apply, which stores NULL.
func (database *Database) InsertMessage( func (database *Database) InsertMessage(
ctx context.Context, ctx context.Context,
command, from, target string, command, from, target string,
toSessionID, toChannelID int64,
params json.RawMessage,
body json.RawMessage,
meta json.RawMessage,
) (int64, string, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, "", fmt.Errorf("begin insert message: %w", err)
}
defer func() { _ = transaction.Rollback() }()
dbID, msgUUID, err := insertMessageInTx(
ctx, transaction, command, from, target,
toSessionID, toChannelID, params, body, meta,
)
if err != nil {
return 0, "", err
}
err = transaction.Commit()
if err != nil {
return 0, "", fmt.Errorf("commit insert message: %w", err)
}
return dbID, msgUUID, nil
}
// insertMessageInTx is InsertMessage, writing through
// transaction.
func insertMessageInTx(
ctx context.Context,
transaction *sql.Tx,
command, from, target string,
toSessionID, toChannelID int64,
params json.RawMessage, params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
@@ -1206,15 +941,12 @@ func insertMessageInTx(
meta = json.RawMessage("{}") meta = json.RawMessage("{}")
} }
res, err := transaction.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO messages `INSERT INTO messages
(uuid, command, msg_from, msg_to, (uuid, command, msg_from, msg_to,
to_session_id, to_channel_id,
params, body, meta, created_at) params, body, meta, created_at)
VALUES (?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0), VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
?, ?, ?, ?)`,
msgUUID, command, from, target, msgUUID, command, from, target,
toSessionID, toChannelID,
string(params), string(body), string(meta), now) string(params), string(body), string(meta), now)
if err != nil { if err != nil {
return 0, "", fmt.Errorf( return 0, "", fmt.Errorf(
@@ -1227,49 +959,19 @@ func insertMessageInTx(
return dbID, msgUUID, nil return dbID, msgUUID, nil
} }
// EnqueueToSession adds a message to the queues of all // EnqueueToSession adds a message to all clients of a
// clients of a session except client skipClientID, or of all // session's queues.
// of them when skipClientID is 0.
func (database *Database) EnqueueToSession( func (database *Database) EnqueueToSession(
ctx context.Context, ctx context.Context,
sessionID, messageID, skipClientID int64, sessionID, messageID int64,
) error { ) error {
transaction, err := database.conn.BeginTx(ctx, nil) _, err := database.conn.ExecContext(ctx,
if err != nil {
return fmt.Errorf("begin enqueue: %w", err)
}
defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx(
ctx, transaction, sessionID, messageID, skipClientID,
)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("commit enqueue: %w", err)
}
return nil
}
// enqueueToSessionInTx is EnqueueToSession, writing through
// transaction.
func enqueueToSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID, messageID, skipClientID int64,
) error {
_, err := transaction.ExecContext(ctx,
`INSERT OR IGNORE INTO client_queues `INSERT OR IGNORE INTO client_queues
(client_id, message_id, created_at) (client_id, message_id, created_at)
SELECT c.id, ?, ? SELECT c.id, ?, ?
FROM clients c FROM clients c
WHERE c.session_id = ? AND c.id != ?`, WHERE c.session_id = ?`,
messageID, time.Now(), sessionID, skipClientID) messageID, time.Now(), sessionID)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"enqueue to session: %w", err, "enqueue to session: %w", err,
@@ -1335,41 +1037,19 @@ func (database *Database) PollMessages(
return msgs, lastQID, nil return msgs, lastQID, nil
} }
// GetHistory returns the PRIVMSG history of one recipient: // GetHistory returns message history for a target.
// the DMs sent to session toSessionID, or the messages sent
// to channel toChannelID. Pass 0 for the other. A non-empty
// before is the id of a PRIVMSG in that history, and only
// messages older than it are returned; an id with no such
// message gives an error wrapping sql.ErrNoRows.
func (database *Database) GetHistory( func (database *Database) GetHistory(
ctx context.Context, ctx context.Context,
toSessionID, toChannelID int64, target string,
before string, beforeID int64,
limit int, limit int,
) ([]IRCMessage, error) { ) ([]IRCMessage, error) {
if limit <= 0 { if limit <= 0 {
limit = defaultHistLimit limit = defaultHistLimit
} }
// With no before, every message is older than this.
beforeID := int64(math.MaxInt64)
if before != "" {
err := database.conn.QueryRowContext(ctx,
`SELECT id FROM messages
WHERE uuid = ? AND command = 'PRIVMSG'
AND (to_session_id = ? OR to_channel_id = ?)`,
before, toSessionID, toChannelID,
).Scan(&beforeID)
if err != nil {
return nil, fmt.Errorf(
"find message %q: %w", before, err,
)
}
}
rows, err := database.queryHistory( rows, err := database.queryHistory(
ctx, toSessionID, toChannelID, beforeID, limit, ctx, target, beforeID, limit,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1389,23 +1069,21 @@ func (database *Database) GetHistory(
return msgs, nil return msgs, nil
} }
// queryHistory selects by to_session_id when toSessionID is
// set and by to_channel_id otherwise, so that the column's
// (column, id) index serves the order and the limit.
func (database *Database) queryHistory( func (database *Database) queryHistory(
ctx context.Context, ctx context.Context,
toSessionID, toChannelID, beforeID int64, target string,
beforeID int64,
limit int, limit int,
) (*sql.Rows, error) { ) (*sql.Rows, error) {
if toSessionID != 0 { if beforeID > 0 {
rows, err := database.conn.QueryContext(ctx, rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, params, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE to_session_id = ? AND id < ? WHERE msg_to = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`, ORDER BY id DESC LIMIT ?`,
toSessionID, beforeID, limit) target, beforeID, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"query history: %w", err, "query history: %w", err,
@@ -1419,10 +1097,10 @@ func (database *Database) queryHistory(
`SELECT id, uuid, command, msg_from, `SELECT id, uuid, command, msg_from,
msg_to, params, body, meta, created_at msg_to, params, body, meta, created_at
FROM messages FROM messages
WHERE to_channel_id = ? AND id < ? WHERE msg_to = ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`, ORDER BY id DESC LIMIT ?`,
toChannelID, beforeID, limit) target, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("query history: %w", err) return nil, fmt.Errorf("query history: %w", err)
} }
@@ -1534,175 +1212,38 @@ func (database *Database) SetTopic(
return nil return nil
} }
// QuitSession ends a session in one transaction: it parts // DeleteSession removes a session and all its data.
// the session from its channels, deleting each channel this func (database *Database) DeleteSession(
// leaves with no members, deletes the session with its
// clients, and stores a QUIT from nick with body and queues
// it to the other sessions that were on those channels. It
// returns those sessions, for the caller to wake once the
// QUIT is committed. Of several calls for one session at
// once, only the first finds the session; the others change
// nothing and return none.
func (database *Database) QuitSession(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick string, ) error {
body json.RawMessage, _, err := database.conn.ExecContext(
) ([]int64, error) { ctx,
transaction, err := database.conn.BeginTx(ctx, nil) "DELETE FROM sessions WHERE id = ?",
if err != nil {
return nil, fmt.Errorf("begin quit: %w", err)
}
defer func() { _ = transaction.Rollback() }()
peerIDs, err := quitSessionInTx(
ctx, transaction, sessionID, nick, body,
)
if err != nil {
return nil, err
}
err = transaction.Commit()
if err != nil {
return nil, fmt.Errorf("commit quit: %w", err)
}
return peerIDs, nil
}
// LogoutClient deletes a client of a session and, if that
// leaves the session with no clients, does what QuitSession
// does, in one transaction. It returns what QuitSession
// returns, or none if the session keeps a client.
func (database *Database) LogoutClient(
ctx context.Context,
sessionID, clientID int64,
nick string,
body json.RawMessage,
) ([]int64, error) {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin logout: %w", err)
}
defer func() { _ = transaction.Rollback() }()
_, err = transaction.ExecContext(ctx,
"DELETE FROM clients WHERE id = ?", clientID)
if err != nil {
return nil, fmt.Errorf("delete client: %w", err)
}
var remaining int64
err = transaction.QueryRowContext(ctx,
"SELECT COUNT(*) FROM clients WHERE session_id = ?",
sessionID, sessionID,
).Scan(&remaining)
if err != nil {
return nil, fmt.Errorf("count clients: %w", err)
}
var peerIDs []int64
if remaining == 0 {
peerIDs, err = quitSessionInTx(
ctx, transaction, sessionID, nick, body,
)
if err != nil {
return nil, err
}
}
err = transaction.Commit()
if err != nil {
return nil, fmt.Errorf("commit logout: %w", err)
}
return peerIDs, nil
}
// quitSessionInTx is QuitSession, writing through
// transaction.
func quitSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID int64,
nick string,
body json.RawMessage,
) ([]int64, error) {
rows, err := transaction.QueryContext(ctx,
`SELECT DISTINCT session_id FROM channel_members
WHERE session_id != ? AND channel_id IN
(SELECT channel_id FROM channel_members
WHERE session_id = ?)`,
sessionID, sessionID)
if err != nil {
return nil, fmt.Errorf("get channel peers: %w", err)
}
peerIDs, err := scanInt64s(rows)
if err != nil {
return nil, err
}
rows, err = transaction.QueryContext(ctx,
`SELECT channel_id FROM channel_members
WHERE session_id = ?`, sessionID)
if err != nil {
return nil, fmt.Errorf(
"get session channel ids: %w", err,
)
}
channelIDs, err := scanInt64s(rows)
if err != nil {
return nil, err
}
for _, channelID := range channelIDs {
err = partChannelAndDeleteIfEmptyInTx(
ctx, transaction, channelID, sessionID,
)
if err != nil {
return nil, err
}
}
result, err := transaction.ExecContext(ctx,
"DELETE FROM sessions WHERE id = ?", sessionID)
if err != nil {
return nil, fmt.Errorf("delete session: %w", err)
}
deletedRows, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("delete session: %w", err)
}
if deletedRows == 0 || len(peerIDs) == 0 {
return nil, nil
}
messageID, _, err := insertMessageInTx(
ctx, transaction, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil,
) )
if err != nil { if err != nil {
return nil, err return fmt.Errorf("delete session: %w", err)
} }
for _, peerID := range peerIDs { return nil
err = enqueueToSessionInTx( }
ctx, transaction, peerID, messageID, 0,
) // DeleteClient removes a single client record by ID.
if err != nil { func (database *Database) DeleteClient(
return nil, err ctx context.Context,
} clientID int64,
) error {
_, err := database.conn.ExecContext(
ctx,
"DELETE FROM clients WHERE id = ?",
clientID,
)
if err != nil {
return fmt.Errorf("delete client: %w", err)
} }
return peerIDs, nil return nil
} }
// GetUserCount returns the number of active users. // GetUserCount returns the number of active users.
@@ -1744,6 +1285,29 @@ func (database *Database) GetOperCount(
return count, nil return count, nil
} }
// ClientCountForSession returns the number of clients
// belonging to a session.
func (database *Database) ClientCountForSession(
ctx context.Context,
sessionID int64,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
`SELECT COUNT(*) FROM clients
WHERE session_id = ?`,
sessionID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"client count for session: %w", err,
)
}
return count, nil
}
// DeleteStaleUsers removes clients not seen since the // DeleteStaleUsers removes clients not seen since the
// cutoff and cleans up orphaned users (sessions). // cutoff and cleans up orphaned users (sessions).
func (database *Database) DeleteStaleUsers( func (database *Database) DeleteStaleUsers(
@@ -1880,6 +1444,57 @@ type ChannelInfoFull struct {
MemberCount int64 `json:"memberCount"` MemberCount int64 `json:"memberCount"`
} }
// ListAllChannelsWithCounts returns every channel
// with its member count.
func (database *Database) ListAllChannelsWithCounts(
ctx context.Context,
) ([]ChannelInfoFull, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic,
COUNT(cm.session_id) AS member_count
FROM channels c
LEFT JOIN channel_members cm
ON cm.channel_id = c.id
GROUP BY c.id
ORDER BY c.name`)
if err != nil {
return nil, fmt.Errorf(
"list channels with counts: %w", err,
)
}
defer func() { _ = rows.Close() }()
var out []ChannelInfoFull
for rows.Next() {
var chanInfo ChannelInfoFull
err = rows.Scan(
&chanInfo.ID, &chanInfo.Name,
&chanInfo.Topic, &chanInfo.MemberCount,
)
if err != nil {
return nil, fmt.Errorf(
"scan channel full: %w", err,
)
}
out = append(out, chanInfo)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
if out == nil {
out = []ChannelInfoFull{}
}
return out, nil
}
// GetChannelCreatedAt returns the creation time of a // GetChannelCreatedAt returns the creation time of a
// channel. // channel.
func (database *Database) GetChannelCreatedAt( func (database *Database) GetChannelCreatedAt(
@@ -2165,33 +1780,45 @@ func (database *Database) SetChannelHashcashBits(
} }
// RecordSpentHashcash stores a spent hashcash stamp hash // RecordSpentHashcash stores a spent hashcash stamp hash
// for replay prevention. It returns false when the hash was // for replay prevention.
// already stored. The one INSERT is both the check and the
// record, so of several concurrent callers with the same
// hash exactly one gets true.
func (database *Database) RecordSpentHashcash( func (database *Database) RecordSpentHashcash(
ctx context.Context, ctx context.Context,
stampHash string, stampHash string,
) (bool, error) { ) error {
res, err := database.conn.ExecContext(ctx, _, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO spent_hashcash `INSERT OR IGNORE INTO spent_hashcash
(stamp_hash, created_at) (stamp_hash, created_at)
VALUES (?, ?)`, VALUES (?, ?)`,
stampHash, time.Now()) stampHash, time.Now())
if err != nil { if err != nil {
return false, fmt.Errorf( return fmt.Errorf(
"record spent hashcash: %w", err, "record spent hashcash: %w", err,
) )
} }
inserted, err := res.RowsAffected() return nil
}
// IsHashcashSpent checks whether a hashcash stamp hash
// has already been used.
func (database *Database) IsHashcashSpent(
ctx context.Context,
stampHash string,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM spent_hashcash
WHERE stamp_hash = ?`,
stampHash,
).Scan(&count)
if err != nil { if err != nil {
return false, fmt.Errorf( return false, fmt.Errorf(
"record spent hashcash: %w", err, "check spent hashcash: %w", err,
) )
} }
return inserted > 0, nil return count > 0, nil
} }
// PruneSpentHashcash deletes spent hashcash tokens older // PruneSpentHashcash deletes spent hashcash tokens older
@@ -2482,6 +2109,23 @@ func (database *Database) HasChannelInvite(
return count > 0, nil return count > 0, nil
} }
// ClearChannelInvite removes a session's invite to a
// channel (called after successful JOIN).
func (database *Database) ClearChannelInvite(
ctx context.Context,
channelID, sessionID int64,
) error {
_, err := database.conn.ExecContext(ctx,
`DELETE FROM channel_invites
WHERE channel_id = ? AND session_id = ?`,
channelID, sessionID)
if err != nil {
return fmt.Errorf("clear invite: %w", err)
}
return nil
}
// --- Tier 2: Secret (+s) --- // --- Tier 2: Secret (+s) ---
// IsChannelSecret checks if a channel has +s mode. // IsChannelSecret checks if a channel has +s mode.
+39 -214
View File
@@ -2,7 +2,6 @@ package db_test
import ( import (
"encoding/json" "encoding/json"
"slices"
"testing" "testing"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
@@ -412,7 +411,7 @@ func TestJoinAndPart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sid) err = database.PartChannel(ctx, chID, sid)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -423,10 +422,7 @@ func TestJoinAndPart(t *testing.T) {
} }
} }
// TestPartChannelAndDeleteIfEmpty checks that a part keeps func TestDeleteChannelIfEmpty(t *testing.T) {
// a channel another session is still on and deletes the
// channel when its last member parts.
func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
@@ -439,34 +435,22 @@ func TestPartChannelAndDeleteIfEmpty(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
nicks := []string{"temp1", "temp2"} sid, _, _, err := database.CreateSession(ctx, "temp", "", "", "")
sids := make([]int64, 0, len(nicks))
for _, nick := range nicks {
sid, _, _, createErr := database.CreateSession(ctx, nick, "", "", "")
if createErr != nil {
t.Fatal(createErr)
}
err = database.JoinChannel(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
sids = append(sids, sid)
}
err = database.PartChannelAndDeleteIfEmpty(ctx, chID, sids[0])
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err = database.GetChannelByName(ctx, "#empty") err = database.JoinChannel(ctx, chID, sid)
if err != nil { 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -635,7 +619,7 @@ func TestInsertMessage(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, msgUUID, err := database.InsertMessage( dbID, msgUUID, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -669,13 +653,13 @@ func TestPollMessages(t *testing.T) {
body := json.RawMessage(`["hello"]`) body := json.RawMessage(`["hello"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil, ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = database.EnqueueToSession(ctx, sid, dbID, 0) err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -722,16 +706,11 @@ func TestGetHistory(t *testing.T) {
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
chID, err := database.GetOrCreateChannel(ctx, "#hist")
if err != nil {
t.Fatal(err)
}
const msgCount = 10 const msgCount = 10
for range msgCount { for range msgCount {
_, _, err = database.InsertMessage( _, _, err := database.InsertMessage(
ctx, "PRIVMSG", "user", "#hist", 0, chID, ctx, "PRIVMSG", "user", "#hist",
nil, json.RawMessage(`["msg"]`), nil, nil, json.RawMessage(`["msg"]`), nil,
) )
if err != nil { if err != nil {
@@ -742,7 +721,7 @@ func TestGetHistory(t *testing.T) {
const histLimit = 5 const histLimit = 5
msgs, err := database.GetHistory( msgs, err := database.GetHistory(
ctx, 0, chID, "", histLimit, ctx, "#hist", 0, histLimit,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -758,209 +737,44 @@ func TestGetHistory(t *testing.T) {
} }
} }
// TestGetHistoryReusedNames checks that history follows the func TestDeleteSession(t *testing.T) {
// 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) {
t.Parallel() t.Parallel()
database := setupTestDB(t) database := setupTestDB(t)
ctx := t.Context() ctx := t.Context()
historyLen := func(toSessionID, toChannelID int64) int { sid, _, _, err := database.CreateSession(
t.Helper() ctx, "deleteme", "", "", "",
msgs, err := database.GetHistory(
ctx, toSessionID, toChannelID, "", 0,
)
if err != nil {
t.Fatal(err)
}
return len(msgs)
}
oldSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
oldChID, err := database.GetOrCreateChannel(ctx, "#reused") chID, err := database.GetOrCreateChannel(
if err != nil { ctx, "#delchan",
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,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, _, err = database.InsertMessage( err = database.JoinChannel(ctx, chID, sid)
ctx, "PRIVMSG", "sender", "#reused", 0, oldChID,
nil, body, nil,
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if historyLen(oldSID, 0) != 1 || historyLen(0, oldChID) != 1 { err = database.DeleteSession(ctx, sid)
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)
if err != nil { if err != nil {
t.Fatal(err) 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") _, err = database.GetSessionByNick(ctx, "deleteme")
if err == nil { if err == nil {
t.Fatal("session should be deleted") t.Fatal("session should be deleted")
} }
ids, _ := database.GetChannelMemberIDs(ctx, sharedID) ids, _ := database.GetChannelMemberIDs(ctx, chID)
if !slices.Equal(ids, []int64{peerSID}) { if len(ids) != 0 {
t.Fatalf("#shared members: got %v, want [%d]", ids, peerSID) t.Fatal("expected no members after deletion")
}
_, 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)
} }
} }
@@ -1049,7 +863,7 @@ func TestEnqueueToClient(t *testing.T) {
body := json.RawMessage(`["test"]`) body := json.RawMessage(`["test"]`)
dbID, _, err := database.InsertMessage( dbID, _, err := database.InsertMessage(
ctx, "PRIVMSG", "sender", "#ch", 0, 0, nil, body, nil, ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1439,6 +1253,17 @@ func TestChannelInviteCRUD(t *testing.T) {
if !has { if !has {
t.Fatal("should have invite") t.Fatal("should have invite")
} }
// Clear invite.
err = database.ClearChannelInvite(ctx, chID, sid)
if err != nil {
t.Fatal(err)
}
has, _ = database.HasChannelInvite(ctx, chID, sid)
if has {
t.Fatal("invite should be cleared")
}
} }
func TestChannelSecret(t *testing.T) { func TestChannelSecret(t *testing.T) {
+2 -8
View File
@@ -85,25 +85,19 @@ CREATE TABLE IF NOT EXISTS channel_members (
UNIQUE(channel_id, session_id) UNIQUE(channel_id, session_id)
); );
-- Messages: IRC envelope format. to_session_id is the session a DM -- Messages: IRC envelope format
-- 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.
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE, uuid TEXT NOT NULL UNIQUE,
command TEXT NOT NULL DEFAULT 'PRIVMSG', command TEXT NOT NULL DEFAULT 'PRIVMSG',
msg_from TEXT NOT NULL DEFAULT '', msg_from TEXT NOT NULL DEFAULT '',
msg_to 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 '[]', params TEXT NOT NULL DEFAULT '[]',
body TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]',
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_to_channel ON messages(to_channel_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL) -- Spent hashcash tokens for replay prevention (1-year TTL)
+402 -130
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -15,10 +14,24 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "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( var validUsernameRe = regexp.MustCompile(
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`, `^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
) )
@@ -199,7 +212,7 @@ func (hdlr *Handlers) handleCreateSession(
return return
} }
if !hdlr.checkHashcash( if !hdlr.validateHashcash(
writer, request, payload.Hashcash, writer, request, payload.Hashcash,
) { ) {
return return
@@ -207,7 +220,7 @@ func (hdlr *Handlers) handleCreateSession(
payload.Nick = strings.TrimSpace(payload.Nick) payload.Nick = strings.TrimSpace(payload.Nick)
if !service.ValidNick(payload.Nick) { if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"invalid nick format", "invalid nick format",
@@ -232,15 +245,14 @@ func (hdlr *Handlers) handleCreateSession(
} }
hdlr.executeCreateSession( hdlr.executeCreateSession(
writer, request, writer, request, payload.Nick, username,
payload.Nick, username, payload.Hashcash,
) )
} }
func (hdlr *Handlers) executeCreateSession( func (hdlr *Handlers) executeCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
nick, username, stamp string, nick, username string,
) { ) {
remoteIP := clientIP(request) remoteIP := clientIP(request)
@@ -248,17 +260,12 @@ func (hdlr *Handlers) executeCreateSession(
request.Context(), remoteIP, request.Context(), remoteIP,
) )
if !hdlr.claimHashcash(writer, request, stamp) {
return
}
sessionID, clientID, token, err := sessionID, clientID, token, err :=
hdlr.params.Database.CreateSession( hdlr.params.Database.CreateSession(
request.Context(), request.Context(),
nick, username, hostname, remoteIP, nick, username, hostname, remoteIP,
) )
if err != nil { if err != nil {
hdlr.hashcashVal.Release(stamp)
hdlr.handleCreateSessionError( hdlr.handleCreateSessionError(
writer, request, err, writer, request, err,
) )
@@ -279,10 +286,10 @@ func (hdlr *Handlers) executeCreateSession(
}, http.StatusCreated) }, http.StatusCreated)
} }
// checkHashcash checks a hashcash stamp if required, // validateHashcash validates a hashcash stamp if required.
// without spending it. Returns false if the check failed // Returns false if validation failed and a response was
// and a response was already sent. // already sent.
func (hdlr *Handlers) checkHashcash( func (hdlr *Handlers) validateHashcash(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
stamp string, stamp string,
@@ -301,31 +308,7 @@ func (hdlr *Handlers) checkHashcash(
return false return false
} }
err := hdlr.hashcashVal.Check( err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return false
}
return true
}
// claimHashcash spends a stamp that checkHashcash accepted.
// Returns false if Claim refused the stamp, normally because
// it was already spent, and a response was already sent.
func (hdlr *Handlers) claimHashcash(
writer http.ResponseWriter,
request *http.Request,
stamp string,
) bool {
err := hdlr.hashcashVal.Claim(
stamp, hdlr.params.Config.HashcashBits, stamp, hdlr.params.Config.HashcashBits,
) )
if err != nil { if err != nil {
@@ -596,7 +579,7 @@ func (hdlr *Handlers) enqueueNumeric(
} }
dbID, _, insertErr := hdlr.params.Database.InsertMessage( dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, hdlr.serverName(), nick, 0, 0, ctx, command, hdlr.serverName(), nick,
paramsJSON, json.RawMessage(body), nil, paramsJSON, json.RawMessage(body), nil,
) )
if insertErr != nil { if insertErr != nil {
@@ -682,7 +665,7 @@ func (hdlr *Handlers) initChannelState(
dbID, _, insErr := hdlr.params.Database. dbID, _, insErr := hdlr.params.Database.
InsertMessage( InsertMessage(
ctx, "JOIN", nick, chanInfo.Name, ctx, "JOIN", nick, chanInfo.Name,
0, chanInfo.ID, nil, nil, nil, nil, nil, nil,
) )
if insErr != nil { if insErr != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -813,14 +796,7 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
timeout = maxLongPollTimeout timeout = maxLongPollTimeout
} }
// Register with the broker before reading the queue. msgs, lastQID, err := hdlr.params.Database.PollMessages(
// Notify wakes only the channels registered at that
// moment, so a message queued after the read below
// must find this one there already.
waitCh := hdlr.broker.Wait(sessionID)
defer hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.pollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -848,22 +824,19 @@ func (hdlr *Handlers) HandleGetMessages() http.HandlerFunc {
hdlr.longPoll( hdlr.longPoll(
writer, request, writer, request,
waitCh, clientID, afterID, timeout, sessionID, clientID, afterID, timeout,
) )
} }
} }
// longPoll waits up to timeout seconds for the broker to
// signal waitCh, then answers with the messages queued for
// clientID after afterID. It answers nothing if the client
// goes away first.
func (hdlr *Handlers) longPoll( func (hdlr *Handlers) longPoll(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
waitCh <-chan struct{}, sessionID, clientID, afterID int64,
clientID, afterID int64,
timeout int, timeout int,
) { ) {
waitCh := hdlr.broker.Wait(sessionID)
timer := time.NewTimer( timer := time.NewTimer(
time.Duration(timeout) * time.Second, time.Duration(timeout) * time.Second,
) )
@@ -874,10 +847,14 @@ func (hdlr *Handlers) longPoll(
case <-waitCh: case <-waitCh:
case <-timer.C: case <-timer.C:
case <-request.Context().Done(): case <-request.Context().Done():
hdlr.broker.Remove(sessionID, waitCh)
return return
} }
msgs, lastQID, err := hdlr.pollMessages( hdlr.broker.Remove(sessionID, waitCh)
msgs, lastQID, err := hdlr.params.Database.PollMessages(
request.Context(), clientID, request.Context(), clientID,
afterID, pollMessageLimit, afterID, pollMessageLimit,
) )
@@ -1257,11 +1234,26 @@ func (hdlr *Handlers) handleChannelMsg(
) { ) {
ctx := request.Context() ctx := request.Context()
// Delegate validation + fan-out to service layer. The // Hashcash validation is HTTP-specific; needs chID.
// fan-out includes the sender's own session, so all isNotice := command == irc.CmdNotice
// their clients see the message in long-poll responses.
uuid, err := hdlr.svc.SendChannelMessage( if !isNotice {
ctx, sessionID, 0, nick, 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, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1271,11 +1263,172 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
// HTTP echo: enqueue to sender so all their clients
// see the message in long-poll responses.
_ = hdlr.params.Database.EnqueueToSession(
ctx, sessionID, dbID,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"id": uuid, statusKey: "sent"}, map[string]string{"id": uuid, statusKey: "sent"},
http.StatusOK) http.StatusOK)
} }
// 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( func (hdlr *Handlers) handleDirectMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1285,7 +1438,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage, meta json.RawMessage,
) { ) {
result, err := hdlr.svc.SendDirectMessage( result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
command, target, body, meta, command, target, body, meta,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1335,6 +1488,16 @@ func (hdlr *Handlers) handleJoin(
channel = "#" + channel 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). // Extract key from body lines (JOIN #channel key).
var suppliedKey string var suppliedKey string
@@ -1356,7 +1519,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) { ) {
result, err := hdlr.svc.JoinChannel( result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, suppliedKey, channel, suppliedKey,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1540,7 +1703,7 @@ func (hdlr *Handlers) handlePart(
} }
err := hdlr.svc.PartChannel( err := hdlr.svc.PartChannel(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, reason, nick, channel, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1578,7 +1741,7 @@ func (hdlr *Handlers) handleNick(
newNick := strings.TrimSpace(lines[0]) newNick := strings.TrimSpace(lines[0])
if !service.ValidNick(newNick) { if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrErroneusNickname, nick, []string{newNick}, irc.ErrErroneusNickname, nick, []string{newNick},
@@ -1611,7 +1774,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string, nick, newNick string,
) { ) {
err := hdlr.svc.ChangeNick( err := hdlr.svc.ChangeNick(
request.Context(), sessionID, 0, nick, newNick, request.Context(), sessionID, nick, newNick,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
writer, request, writer, request,
@@ -1666,7 +1829,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ") topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic( err := hdlr.svc.SetTopic(
request.Context(), sessionID, 0, request.Context(), sessionID,
nick, channel, topic, nick, channel, topic,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -1788,9 +1951,8 @@ func (hdlr *Handlers) handleQuit(
http.StatusOK) http.StatusOK)
} }
// handleMode handles the MODE command: a target that starts // handleMode handles the MODE command for channels and
// with # is a channel, handled by handleChannelMode, and any // users. Currently supports query-only (no mode changes).
// other target a nick, handled by handleUserMode.
func (hdlr *Handlers) handleMode( func (hdlr *Handlers) handleMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -2152,7 +2314,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeText, modeText,
) )
@@ -2198,7 +2360,7 @@ func (hdlr *Handlers) setChannelFlag(
} }
hdlr.svc.BroadcastMode( hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID, request.Context(), nick, channel, chID,
modeStr, modeStr,
) )
@@ -2435,7 +2597,7 @@ func (hdlr *Handlers) executeBanChange(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2553,7 +2715,7 @@ func (hdlr *Handlers) setChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"+k", key}) modeBody, mErr := json.Marshal([]string{"+k", key})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2602,7 +2764,7 @@ func (hdlr *Handlers) clearChannelKeyMode(
modeBody, mErr := json.Marshal([]string{"-k", "*"}) modeBody, mErr := json.Marshal([]string{"-k", "*"})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2677,7 +2839,7 @@ func (hdlr *Handlers) setChannelLimitMode(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2727,7 +2889,7 @@ func (hdlr *Handlers) clearChannelLimitMode(
modeBody, mErr := json.Marshal([]string{"-l"}) modeBody, mErr := json.Marshal([]string{"-l"})
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, 0, chID, request, irc.CmdMode, nick, channel,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2764,15 +2926,123 @@ func (hdlr *Handlers) handleInvite(
channel = "#" + channel 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() ctx := request.Context()
targetSID, err := hdlr.svc.InviteUser( chID, err := hdlr.params.Database.GetChannelByName(
ctx, sessionID, nick, targetNick, channel, ctx, channel,
) )
if hdlr.handleServiceError( if err != nil {
writer, request, hdlr.respondIRCError(
clientID, sessionID, nick, err, 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 return
} }
@@ -2786,7 +3056,7 @@ func (hdlr *Handlers) handleInvite(
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdInvite, nick, targetNick, request, irc.CmdInvite, nick, targetNick,
targetSID, 0, json.RawMessage(invBody), json.RawMessage(invBody),
[]int64{targetSID}, []int64{targetSID},
) )
} }
@@ -3281,13 +3551,16 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
return return
} }
toSessionID, toChannelID, ok := hdlr.canAccessHistory( if !hdlr.canAccessHistory(
writer, request, sessionID, nick, target, writer, request, sessionID, nick, target,
) ) {
if !ok {
return return
} }
beforeID, _ := strconv.ParseInt(
request.URL.Query().Get("before"), 10, 64,
)
limit, _ := strconv.Atoi( limit, _ := strconv.Atoi(
request.URL.Query().Get("limit"), request.URL.Query().Get("limit"),
) )
@@ -3296,19 +3569,8 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
} }
msgs, err := hdlr.params.Database.GetHistory( msgs, err := hdlr.params.Database.GetHistory(
request.Context(), toSessionID, toChannelID, request.Context(), target, beforeID, limit,
request.URL.Query().Get("before"), limit,
) )
if errors.Is(err, sql.ErrNoRows) {
hdlr.respondError(
writer, request,
"unknown before id",
http.StatusBadRequest,
)
return
}
if err != nil { if err != nil {
hdlr.log.Error( hdlr.log.Error(
"get history failed", "error", err, "get history failed", "error", err,
@@ -3329,23 +3591,17 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
} }
// canAccessHistory verifies the user can read history // canAccessHistory verifies the user can read history
// for the given target (channel or DM participant) and // for the given target (channel or DM participant).
// 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.
func (hdlr *Handlers) canAccessHistory( func (hdlr *Handlers) canAccessHistory(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID int64,
nick, target string, nick, target string,
) (int64, int64, bool) { ) bool {
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
chID, ok := hdlr.canAccessChannelHistory( return hdlr.canAccessChannelHistory(
writer, request, sessionID, target, writer, request, sessionID, target,
) )
return 0, chID, ok
} }
// DM history: only allow if the target is the // DM history: only allow if the target is the
@@ -3357,20 +3613,18 @@ func (hdlr *Handlers) canAccessHistory(
http.StatusForbidden, 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( func (hdlr *Handlers) canAccessChannelHistory(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
sessionID int64, sessionID int64,
target string, target string,
) (int64, bool) { ) bool {
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target, request.Context(), target,
) )
@@ -3381,7 +3635,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusNotFound, http.StatusNotFound,
) )
return 0, false return false
} }
isMember, err := hdlr.params.Database.IsChannelMember( isMember, err := hdlr.params.Database.IsChannelMember(
@@ -3397,7 +3651,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusInternalServerError, http.StatusInternalServerError,
) )
return 0, false return false
} }
if !isMember { if !isMember {
@@ -3407,10 +3661,10 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusForbidden, http.StatusForbidden,
) )
return 0, false return false
} }
return chID, true return true
} }
// HandleLogout deletes the authenticated client's token // HandleLogout deletes the authenticated client's token
@@ -3426,11 +3680,15 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
err := hdlr.svc.Logout( ctx := request.Context()
request.Context(), sessionID, clientID, nick,
err := hdlr.params.Database.DeleteClient(
ctx, clientID,
) )
if err != nil { if err != nil {
hdlr.log.Error("logout failed", "error", err) hdlr.log.Error(
"delete client failed", "error", err,
)
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"internal error", "internal error",
@@ -3440,6 +3698,23 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
return return
} }
// If no clients remain, clean up the user fully:
// part all channels (notifying members) and
// delete the session.
remaining, err := hdlr.params.Database.
ClientCountForSession(ctx, sessionID)
if err != nil {
hdlr.log.Error(
"client count check failed", "error", err,
)
}
if remaining == 0 {
hdlr.cleanupUser(
ctx, sessionID, nick,
)
}
hdlr.clearAuthCookie(writer) hdlr.clearAuthCookie(writer)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -3645,7 +3920,7 @@ func (hdlr *Handlers) handleKick(
} }
err := hdlr.svc.KickUser( err := hdlr.svc.KickUser(
request.Context(), sessionID, 0, nick, request.Context(), sessionID, nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if hdlr.handleServiceError( if hdlr.handleServiceError(
@@ -3698,19 +3973,17 @@ func (hdlr *Handlers) deliverWhoisIdle(
} }
// fanOut inserts a message and enqueues it to all given // fanOut inserts a message and enqueues it to all given
// sessions. toSessionID and toChannelID are the recipient // sessions.
// InsertMessage records for history, 0 when there is none.
func (hdlr *Handlers) fanOut( func (hdlr *Handlers) fanOut(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, request.Context(), command, from, target,
toSessionID, toChannelID, nil, body, meta, nil, body, meta,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -3718,7 +3991,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession( enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID, 0, request.Context(), sid, dbID,
) )
if enqErr != nil { if enqErr != nil {
hdlr.log.Error("enqueue failed", hdlr.log.Error("enqueue failed",
@@ -3735,13 +4008,12 @@ func (hdlr *Handlers) fanOut(
func (hdlr *Handlers) fanOutSilent( func (hdlr *Handlers) fanOutSilent(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
toSessionID, toChannelID int64,
body json.RawMessage, body json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) error { ) error {
_, err := hdlr.fanOut( _, err := hdlr.fanOut(
request, command, from, target, request, command, from, target,
toSessionID, toChannelID, body, nil, sessionIDs, body, nil, sessionIDs,
) )
return err 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" "sneak.berlin/go/neoirc/pkg/irc"
) )
// A session password is 8 to 72 bytes; bcrypt cannot hash const minPasswordLength = 8
// a longer one.
const (
minPasswordLength = 8
maxPasswordLength = 72
)
// HandleLogin authenticates a user with nick and password. // HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc { func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
@@ -154,13 +149,12 @@ func (hdlr *Handlers) handlePass(
password := lines[0] password := lines[0]
if len(password) < minPasswordLength || if len(password) < minPasswordLength {
len(password) > maxPasswordLength {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass}, []string{irc.CmdPass},
"Password must be 8 to 72 bytes", "Password must be at least 8 characters",
) )
return 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. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
params *Params params *Params
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
svc *service.Service svc *service.Service
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
loginLimiter *ratelimit.Limiter channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker loginLimiter *ratelimit.Limiter
cancelCleanup context.CancelFunc stats *stats.Tracker
cancelCleanup context.CancelFunc
// pollMessages is Database.PollMessages, with which
// HandleGetMessages reads the client output queue. Tests
// replace it to act just after a read, or to make a read
// fail.
pollMessages func(
ctx context.Context,
clientID, afterQueueID int64,
limit int,
) ([]db.IRCMessage, int64, error)
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -97,15 +88,15 @@ func New(
} }
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: params.Broker, broker: params.Broker,
svc: params.Service, svc: params.Service,
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
loginLimiter: ratelimit.New(loginRate, loginBurst), channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats, loginLimiter: ratelimit.New(loginRate, loginBurst),
pollMessages: params.Database.PollMessages, stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
+10 -7
View File
@@ -106,20 +106,22 @@ func (hdlr *Handlers) handleAdmin(
) { ) {
ctx := request.Context() ctx := request.Context()
srvName := hdlr.serverName() srvName := hdlr.serverName()
location1, location2, email := hdlr.svc.AdminLines(srvName)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick, ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName}, "Administrative info", []string{srvName}, "Administrative info",
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil, location1, ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil, location2, ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil, email, ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -197,7 +199,7 @@ func (hdlr *Handlers) handleKill(
return return
} }
reason := "" reason := "KILLed"
if len(lines) > 1 { if len(lines) > 1 {
reason = lines[1] reason = lines[1]
} }
@@ -254,7 +256,8 @@ func (hdlr *Handlers) handleWallops(
// handleUserMode handles MODE for a nick: a query when the // handleUserMode handles MODE for a nick: a query when the
// body is empty, otherwise a change by the mode string in // body is empty, otherwise a change by the mode string in
// the body. Only the caller's own nick is allowed. // the body. Only the caller's own nick, in any letter case,
// is allowed.
func (hdlr *Handlers) handleUserMode( func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -262,7 +265,7 @@ func (hdlr *Handlers) handleUserMode(
nick, target string, nick, target string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target != nick { if !strings.EqualFold(target, nick) {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil, irc.ErrUsersDoNotMatch, nick, nil,
+3 -16
View File
@@ -90,16 +90,6 @@ func TestUserhost(t *testing.T) {
t.Errorf("expected alice and bob, got %q", body) 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{ msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost, commandKey: irc.CmdUserhost,
}) })
@@ -197,8 +187,8 @@ func TestKill(t *testing.T) {
commandKey: irc.CmdKill, commandKey: irc.CmdKill,
bodyKey: []string{"victim", "go away"}, bodyKey: []string{"victim", "go away"},
}) })
if status != http.StatusOK || result[statusKey] != "ok" { if status != http.StatusOK {
t.Fatalf("expected 200 and status ok, got %d: %v", status, result) t.Fatalf("expected 200, got %d: %v", status, result)
} }
msgs, _ = tserver.pollMessages(observerToken, observerLastID) msgs, _ = tserver.pollMessages(observerToken, observerLastID)
@@ -290,12 +280,9 @@ func TestUserMode(t *testing.T) {
target, modeStr, numeric, modes string target, modeStr, numeric, modes string
}{ }{
{nick, "+w", "221", "+w"}, {nick, "+w", "221", "+w"},
{"Alice", "", "502", ""},
{"Alice", "-w", "502", ""},
{nick, "", "221", "+w"}, {nick, "", "221", "+w"},
{nick, "-w", "221", "+"}, {strings.ToUpper(nick), "-w", "221", "+"},
{nick, "+z", "501", ""}, {nick, "+z", "501", ""},
{nick, "+o", "501", ""},
{other, "", "502", ""}, {other, "", "502", ""},
{other, "-w", "502", ""}, {other, "-w", "502", ""},
} { } {
+9 -53
View File
@@ -77,13 +77,9 @@ func NewValidator(resource string) *Validator {
return validator return validator
} }
// Check returns nil if requiredBits is not positive, or if // Validate checks a hashcash stamp. It returns nil if the
// the stamp is a well-formed, recently dated proof of work // stamp is valid and has not been seen before.
// for this resource with at least requiredBits. It does not func (v *Validator) Validate(
// look at or change the set of spent stamps, so a request
// can check its stamp first and Claim it after its other
// checks.
func (v *Validator) Check(
stamp string, stamp string,
requiredBits int, requiredBits int,
) error { ) error {
@@ -91,49 +87,9 @@ func (v *Validator) Check(
return nil return nil
} }
_, err := v.check(stamp, requiredBits)
return err
}
// Claim checks the stamp as Check does and, when
// requiredBits is positive, records it as spent until it
// expires. It returns an error if the stamp is already
// spent. Release gives a claimed stamp back.
func (v *Validator) Claim(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
stampTime, err := v.check(stamp, requiredBits)
if err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
// Release forgets a claimed stamp so that it can be claimed
// again, for a request that failed after its Claim.
// Releasing a stamp that is not claimed does nothing.
func (v *Validator) Release(stamp string) {
v.mu.Lock()
defer v.mu.Unlock()
delete(v.spent, stamp)
}
// check returns the stamp's date if Check accepts it.
func (v *Validator) check(
stamp string,
requiredBits int,
) (time.Time, error) {
parts := strings.Split(stamp, ":") parts := strings.Split(stamp, ":")
if len(parts) != stampFields { if len(parts) != stampFields {
return time.Time{}, fmt.Errorf( return fmt.Errorf(
"%w: expected %d, got %d", "%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts), errInvalidFields, stampFields, len(parts),
) )
@@ -148,25 +104,25 @@ func (v *Validator) check(
version, bitsStr, resource, requiredBits, version, bitsStr, resource, requiredBits,
) )
if err != nil { if err != nil {
return time.Time{}, err return err
} }
stampTime, err := parseStampDate(dateStr) stampTime, err := parseStampDate(dateStr)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateTime(stampTime) err = validateTime(stampTime)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
err = validateProof(stamp, requiredBits) err = validateProof(stamp, requiredBits)
if err != nil { if err != nil {
return time.Time{}, err return err
} }
return stampTime, nil return v.checkAndRecordStamp(stamp, stampTime)
} }
func (v *Validator) validateHeader( func (v *Validator) validateHeader(
+14 -76
View File
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("valid stamp rejected: %v", err) t.Fatalf("valid stamp rejected: %v", err)
} }
@@ -99,74 +99,17 @@ func TestReplayDetection(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("first use failed: %v", err) t.Fatalf("first use failed: %v", err)
} }
err = validator.Claim(stamp, testBits) err = validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("replay not detected") t.Fatal("replay not detected")
} }
} }
// TestCheckDoesNotSpendStamp checks that a stamp Check
// accepts can still be claimed, and only once, and that
// Check still accepts it once it is spent.
func TestCheckDoesNotSpendStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
for range 2 {
err := validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("valid stamp rejected: %v", err)
}
}
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after check failed: %v", err)
}
err = validator.Claim(stamp, testBits)
if err == nil {
t.Fatal("replay not detected")
}
err = validator.Check(stamp, testBits)
if err != nil {
t.Fatalf("spent stamp rejected by Check: %v", err)
}
}
// TestReleasedStampCanBeClaimedAgain checks that Release
// gives a claimed stamp back.
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
t.Parallel()
validator := hashcash.NewValidator("test-resource")
stamp := mintStampWithDate(
t, testBits, "test-resource", todayDate(),
)
err := validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("first claim failed: %v", err)
}
validator.Release(stamp)
err = validator.Claim(stamp, testBits)
if err != nil {
t.Fatalf("claim after release failed: %v", err)
}
}
func TestResourceMismatch(t *testing.T) { func TestResourceMismatch(t *testing.T) {
t.Parallel() t.Parallel()
@@ -175,7 +118,7 @@ func TestResourceMismatch(t *testing.T) {
t, testBits, "wrong-resource", todayDate(), t, testBits, "wrong-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected resource mismatch error") t.Fatal("expected resource mismatch error")
} }
@@ -186,7 +129,7 @@ func TestInvalidStampFormat(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Claim( err := validator.Validate(
"not:a:valid:stamp", testBits, "not:a:valid:stamp", testBits,
) )
if err == nil { if err == nil {
@@ -203,7 +146,7 @@ func TestBadVersion(t *testing.T) {
testBits, todayDate(), "test-resource", testBits, todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad version error") t.Fatal("expected bad version error")
} }
@@ -219,7 +162,7 @@ func TestInsufficientDifficulty(t *testing.T) {
todayDate(), "test-resource", todayDate(), "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected insufficient bits error") t.Fatal("expected insufficient bits error")
} }
@@ -235,7 +178,7 @@ func TestExpiredStamp(t *testing.T) {
t, testBits, "test-resource", oldDate, t, testBits, "test-resource", oldDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected expired stamp error") t.Fatal("expected expired stamp error")
} }
@@ -246,14 +189,9 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
validator := hashcash.NewValidator("test-resource") validator := hashcash.NewValidator("test-resource")
err := validator.Check("garbage", 0) err := validator.Validate("garbage", 0)
if err != nil { if err != nil {
t.Fatalf("zero bits should skip the check: %v", err) t.Fatalf("zero bits should skip: %v", err)
}
err = validator.Claim("garbage", 0)
if err != nil {
t.Fatalf("zero bits should skip the claim: %v", err)
} }
} }
@@ -266,7 +204,7 @@ func TestLongDateFormat(t *testing.T) {
t, testBits, "test-resource", longDate, t, testBits, "test-resource", longDate,
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("long date stamp rejected: %v", err) t.Fatalf("long date stamp rejected: %v", err)
} }
@@ -281,7 +219,7 @@ func TestBadDateFormat(t *testing.T) {
testBits, "test-resource", testBits, "test-resource",
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err == nil { if err == nil {
t.Fatal("expected bad date error") t.Fatal("expected bad date error")
} }
@@ -297,7 +235,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
t, testBits, "test-resource", todayDate(), t, testBits, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf("unique stamp rejected: %v", err) t.Fatalf("unique stamp rejected: %v", err)
} }
@@ -313,7 +251,7 @@ func TestHigherBitsStillValid(t *testing.T) {
t, 4, "test-resource", todayDate(), t, 4, "test-resource", todayDate(),
) )
err := validator.Claim(stamp, testBits) err := validator.Validate(stamp, testBits)
if err != nil { if err != nil {
t.Fatalf( t.Fatalf(
"higher-difficulty stamp rejected: %v", "higher-difficulty stamp rejected: %v",
+75 -73
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -71,20 +70,15 @@ func (c *Conn) handleNick(
} }
newNick := msg.Params[0] newNick := msg.Params[0]
if !service.ValidNick(newNick) { if len(newNick) > maxNickLen {
c.sendNumeric( newNick = newNick[:maxNickLen]
irc.ErrErroneusNickname,
newNick, "Erroneous nickname",
)
return
} }
oldMask := c.hostmask() oldMask := c.hostmask()
oldNick := c.currentNick() oldNick := c.nick
err := c.svc.ChangeNick( err := c.svc.ChangeNick(
ctx, c.sessionID, c.clientID, oldNick, newNick, ctx, c.sessionID, oldNick, newNick,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -92,10 +86,9 @@ func (c *Conn) handleNick(
return return
} }
// Take the nick the session holds, not newNick: another c.mu.Lock()
// client of the session may have changed it again since c.nick = newNick
// ChangeNick stored newNick. c.mu.Unlock()
c.loadSessionNick(ctx)
// Echo NICK change to the client on wire. // Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick)) c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -129,8 +122,8 @@ func (c *Conn) handlePrivmsg(
body, _ := json.Marshal([]string{text}) //nolint:errchkjson body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
_, err := c.svc.SendChannelMessage( _, _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -138,7 +131,7 @@ func (c *Conn) handlePrivmsg(
} }
} else { } else {
result, err := c.svc.SendDirectMessage( result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
msg.Command, target, body, nil, msg.Command, target, body, nil,
) )
if err != nil { if err != nil {
@@ -156,9 +149,7 @@ func (c *Conn) handlePrivmsg(
} }
// handleJoin joins one or more channels via the shared // handleJoin joins one or more channels via the shared
// service layer. The optional second parameter holds the // service layer.
// channel keys, comma-separated in the order of the
// channels.
func (c *Conn) handleJoin( func (c *Conn) handleJoin(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
@@ -172,24 +163,14 @@ func (c *Conn) handleJoin(
return return
} }
var keys []string for chanName := range strings.SplitSeq(msg.Params[0], ",") {
if len(msg.Params) > 1 {
keys = strings.Split(msg.Params[1], ",")
}
for idx, chanName := range strings.Split(msg.Params[0], ",") {
chanName = strings.TrimSpace(chanName) chanName = strings.TrimSpace(chanName)
if !strings.HasPrefix(chanName, "#") { if !strings.HasPrefix(chanName, "#") {
chanName = "#" + chanName chanName = "#" + chanName
} }
key := "" c.joinChannel(ctx, chanName)
if idx < len(keys) {
key = keys[idx]
}
c.joinChannel(ctx, chanName, key)
} }
} }
@@ -197,10 +178,10 @@ func (c *Conn) handleJoin(
// and delivers topic/names on the wire. // and delivers topic/names on the wire.
func (c *Conn) joinChannel( func (c *Conn) joinChannel(
ctx context.Context, ctx context.Context,
channel, key string, channel string,
) { ) {
result, err := c.svc.JoinChannel( result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, key, ctx, c.sessionID, c.nick, channel, "",
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -338,7 +319,7 @@ func (c *Conn) partChannel(
channel, reason string, channel, reason string,
) { ) {
err := c.svc.PartChannel( err := c.svc.PartChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason, ctx, c.sessionID, c.nick, channel, reason,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -402,7 +383,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1] newTopic := msg.Params[1]
err := c.svc.SetTopic( err := c.svc.SetTopic(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic, ctx, c.sessionID, c.nick, channel, newTopic,
) )
if err != nil { if err != nil {
c.sendIRCError(err) c.sendIRCError(err)
@@ -702,26 +683,24 @@ func (c *Conn) applyChannelModes(
if applied.Len() > 0 { if applied.Len() > 0 {
modeReply := applied.String() + appliedArgs.String() modeReply := applied.String() + appliedArgs.String()
params := slices.Concat( c.send(FormatMessage(
[]string{channel}, strings.Fields(modeReply), c.hostmask(), "MODE", channel, modeReply,
) ))
c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.svc.BroadcastMode( c.svc.BroadcastMode(
ctx, c.clientID, c.currentNick(), channel, chID, modeReply, ctx, c.nick, channel, chID, modeReply,
) )
} }
} }
// handleUserMode handles MODE for a nick: a query without a // handleUserMode handles MODE for a nick: a query without a
// mode string, otherwise a change. Only the client's own // mode string, otherwise a change. Only the client's own
// nick is allowed. // nick, in any letter case, is allowed.
func (c *Conn) handleUserMode( func (c *Conn) handleUserMode(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
if msg.Params[0] != c.currentNick() { if !strings.EqualFold(msg.Params[0], c.currentNick()) {
c.sendNumeric( c.sendNumeric(
irc.ErrUsersDoNotMatch, irc.ErrUsersDoNotMatch,
"Can't change mode for other users", "Can't change mode for other users",
@@ -781,11 +760,10 @@ func (c *Conn) handleNames(
c.deliverNames(ctx, channel, chID) c.deliverNames(ctx, channel, chID)
} }
// handleList sends the channel list, leaving out +s // handleList sends the channel list.
// channels the user is not on.
func (c *Conn) handleList(ctx context.Context) { func (c *Conn) handleList(ctx context.Context) {
channels, err := c.database.ListAllChannelsWithCountsFiltered( channels, err := c.database.ListAllChannelsWithCounts(
ctx, c.sessionID, ctx,
) )
if err != nil { if err != nil {
c.sendNumeric( c.sendNumeric(
@@ -905,15 +883,14 @@ func (c *Conn) whoisOper(
} }
} }
// whoisChannels sends 319 RPL_WHOISCHANNELS, leaving out // whoisChannels sends 319 RPL_WHOISCHANNELS.
// +s channels the querier is not on.
func (c *Conn) whoisChannels( func (c *Conn) whoisChannels(
ctx context.Context, ctx context.Context,
target string, target string,
targetID int64, targetID int64,
) { ) {
userChannels, _ := c.database.GetSessionChannelsFiltered( userChannels, _ := c.database.GetSessionChannels(
ctx, targetID, c.sessionID, ctx, targetID,
) )
if len(userChannels) == 0 { if len(userChannels) == 0 {
return return
@@ -1186,7 +1163,7 @@ func (c *Conn) handleKick(
} }
err := c.svc.KickUser( err := c.svc.KickUser(
ctx, c.sessionID, c.clientID, c.currentNick(), ctx, c.sessionID, c.nick,
channel, targetNick, reason, channel, targetNick, reason,
) )
if err != nil { if err != nil {
@@ -1217,16 +1194,17 @@ func (c *Conn) handlePassPostReg(
} }
password := msg.Params[0] 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 return
} }
err := c.setPassword(ctx, password) c.setPassword(ctx, password)
if err != nil {
return
}
c.sendFromServer("NOTICE", c.currentNick(), c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+ "Password set. You can reconnect using "+
"PASS <password> with your nick.", "PASS <password> with your nick.",
) )
@@ -1249,11 +1227,36 @@ func (c *Conn) handleInvite(
targetNick := msg.Params[0] targetNick := msg.Params[0]
channel := msg.Params[1] channel := msg.Params[1]
targetID, err := c.svc.InviteUser( chID, err := c.database.GetChannelByName(ctx, channel)
ctx, c.sessionID, c.currentNick(), targetNick, 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 { if err != nil {
c.sendIRCError(err) c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick",
)
return return
} }
@@ -1262,13 +1265,14 @@ func (c *Conn) handleInvite(
irc.RplInviting, targetNick, channel, irc.RplInviting, targetNick, channel,
) )
// Queue the INVITE to the target with the body the HTTP // Send INVITE notice to target via service fan-out.
// API uses. body, _ := json.Marshal( //nolint:errchkjson
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson []string{"You have been invited to " + channel},
)
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.currentNick(), targetNick, targetID, 0, ctx, "INVITE", c.nick, targetNick,
nil, body, nil, []int64{targetID}, 0, nil, body, nil, []int64{targetID},
) )
} }
@@ -1309,14 +1313,12 @@ func (c *Conn) handleVersion() {
// handleAdmin replies with the server's admin info. // handleAdmin replies with the server's admin info.
func (c *Conn) handleAdmin() { func (c *Conn) handleAdmin() {
location1, location2, email := c.svc.AdminLines(c.serverSfx)
c.sendNumeric( c.sendNumeric(
irc.RplAdminMe, c.serverSfx, "Administrative info", irc.RplAdminMe, c.serverSfx, "Administrative info",
) )
c.sendNumeric(irc.RplAdminLoc1, location1) c.sendNumeric(irc.RplAdminLoc1, "neoirc server")
c.sendNumeric(irc.RplAdminLoc2, location2) c.sendNumeric(irc.RplAdminLoc2, "IRC over HTTP")
c.sendNumeric(irc.RplAdminEmail, email) c.sendNumeric(irc.RplAdminEmail, "admin@"+c.serverSfx)
} }
// handleInfo replies with the server's software info. // handleInfo replies with the server's software info.
@@ -1350,7 +1352,7 @@ func (c *Conn) handleKill(
return return
} }
reason := "" reason := "KILLed"
if len(msg.Params) > 1 { if len(msg.Params) > 1 {
reason = msg.Params[1] reason = msg.Params[1]
} }
+23 -113
View File
@@ -20,14 +20,15 @@ import (
const ( const (
maxLineLen = 512 maxLineLen = 512
readTimeout = 5 * time.Minute
writeTimeout = 30 * time.Second writeTimeout = 30 * time.Second
killWriteWindow = 2 * time.Second killWriteWindow = 2 * time.Second
dnsTimeout = 3 * time.Second dnsTimeout = 3 * time.Second
pollInterval = 100 * time.Millisecond pollInterval = 100 * time.Millisecond
pingInterval = 90 * time.Second pingInterval = 90 * time.Second
pongDeadline = 30 * time.Second pongDeadline = 30 * time.Second
maxNickLen = 32
minPasswordLen = 8 minPasswordLen = 8
maxPasswordLen = 72 // bcrypt cannot hash a longer one
maxHashcashBits = 40 maxHashcashBits = 40
) )
@@ -63,7 +64,6 @@ type Conn struct {
registered bool registered bool
gotNick bool gotNick bool
gotUser bool gotUser bool
gotPass bool
passWord string passWord string
lastQueueID int64 lastQueueID int64
@@ -72,6 +72,7 @@ type Conn struct {
} }
func newConn( func newConn(
ctx context.Context,
tcpConn net.Conn, tcpConn net.Conn,
log *slog.Logger, log *slog.Logger,
database *db.Database, database *db.Database,
@@ -95,6 +96,7 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
hostname: resolveHost(ctx, host),
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -212,42 +214,18 @@ func resolveHost(ctx context.Context, addr string) string {
} }
// serve is the main loop for a single IRC client connection. // serve is the main loop for a single IRC client connection.
// It first sets the client's hostname to what resolve returns
// for the client's address.
// Cancelling ctx when it returns stops the relay goroutine. // Cancelling ctx when it returns stops the relay goroutine.
// When nothing has been read for interval the client is sent func (c *Conn) serve(ctx context.Context) {
// 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,
) {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
// Stopping the server cancels ctx, and the cleanup must defer c.cleanup(ctx)
// 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()
scanner := bufio.NewScanner(c.conn) scanner := bufio.NewScanner(c.conn)
scanner.Buffer(make([]byte, maxLineLen), maxLineLen) scanner.Buffer(make([]byte, maxLineLen), maxLineLen)
for { for {
// Every line read starts both waits again.
ping.Reset(interval)
_ = c.conn.SetReadDeadline( _ = c.conn.SetReadDeadline(
time.Now().Add(interval + deadline), time.Now().Add(readTimeout),
) )
if !scanner.Scan() { if !scanner.Scan() {
@@ -313,39 +291,17 @@ func (c *Conn) currentNick() string {
return c.nick return c.nick
} }
// loadSessionNick sets c.nick to the nick the connection's
// session holds in the database, read by session ID. The
// connection's own NICK and the relay passing on a NICK line
// both call it, and it reads and sets the nick under c.mu, so
// whichever runs last leaves the connection on its session's
// nick. If the read fails, c.nick is left as it is.
func (c *Conn) loadSessionNick(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
if err == nil {
c.nick = nick
}
}
// send writes a formatted IRC line to the connection. // send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) { func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line) c.sendWithin(writeTimeout, line)
} }
// sendWithin writes a formatted IRC line to the connection, // sendWithin writes a formatted IRC line to the connection,
// giving up after timeout. CR, LF and NUL are removed first, // giving up after timeout.
// as FormatMessage does for each parameter, so that a line
// built without it cannot end early either.
func (c *Conn) sendWithin( func (c *Conn) sendWithin(
timeout time.Duration, timeout time.Duration,
line string, line string,
) { ) {
line = strings.ReplaceAll(line, "\r", "")
line = strings.ReplaceAll(line, "\n", "")
line = strings.ReplaceAll(line, "\x00", "")
c.writeMu.Lock() c.writeMu.Lock()
defer c.writeMu.Unlock() defer c.writeMu.Unlock()
@@ -361,7 +317,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType, code irc.IRCMessageType,
params ...string, params ...string,
) { ) {
nick := c.currentNick() nick := c.nick
if nick == "" { if nick == "" {
nick = "*" nick = "*"
} }
@@ -385,11 +341,9 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask // hostmask returns the client's full hostmask
// (nick!user@host). // (nick!user@host).
func (c *Conn) hostmask() string { func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username user := c.username
if user == "" { if user == "" {
user = nick user = c.nick
} }
host := c.hostname host := c.hostname
@@ -397,7 +351,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP host = c.remoteIP
} }
return nick + "!" + user + "@" + host return c.nick + "!" + user + "@" + host
} }
// handleMessage dispatches a parsed IRC message using // handleMessage dispatches a parsed IRC message using
@@ -407,24 +361,13 @@ func (c *Conn) handleMessage(
msg *Message, msg *Message,
) { ) {
// Before registration, only NICK, USER, PASS, PING, // Before registration, only NICK, USER, PASS, PING,
// PONG, QUIT, and CAP are accepted. // QUIT, and CAP are accepted.
if !c.registered { if !c.registered {
c.handlePreRegistration(ctx, msg) c.handlePreRegistration(ctx, msg)
return 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] handler, ok := c.commands[msg.Command]
if !ok { if !ok {
c.sendNumeric( c.sendNumeric(
@@ -456,7 +399,6 @@ func (c *Conn) handlePreRegistration(
} }
c.passWord = msg.Params[0] c.passWord = msg.Params[0]
c.gotPass = true
case irc.CmdNick: case irc.CmdNick:
if len(msg.Params) < 1 { if len(msg.Params) < 1 {
c.sendNumeric( c.sendNumeric(
@@ -467,16 +409,11 @@ func (c *Conn) handlePreRegistration(
return return
} }
if !service.ValidNick(msg.Params[0]) { c.nick = msg.Params[0]
c.sendNumeric( if len(c.nick) > maxNickLen {
irc.ErrErroneusNickname, c.nick = c.nick[:maxNickLen]
msg.Params[0], "Erroneous nickname",
)
return
} }
c.nick = msg.Params[0]
c.gotNick = true c.gotNick = true
case irc.CmdUser: case irc.CmdUser:
if len(msg.Params) < 4 { //nolint:mnd if len(msg.Params) < 4 { //nolint:mnd
@@ -494,10 +431,6 @@ func (c *Conn) handlePreRegistration(
case irc.CmdPing: case irc.CmdPing:
c.handlePing(msg) c.handlePing(msg)
return
case irc.CmdPong:
// The answer to serve's PING, which unregistered
// clients are sent too.
return return
case irc.CmdQuit: case irc.CmdQuit:
c.handleQuit(msg) c.handleQuit(msg)
@@ -571,20 +504,17 @@ func (c *Conn) completeRegistration(ctx context.Context) {
// So that KILL, from either transport, can close it. // So that KILL, from either transport, can close it.
c.svc.RegisterWireConn(sessionID, c) c.svc.RegisterWireConn(sessionID, c)
// If PASS was provided before registration, set the
// session password.
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
c.setPassword(ctx, c.passWord)
}
// Send welcome burst. // Send welcome burst.
c.deliverWelcome() c.deliverWelcome()
c.deliverLusers(ctx) c.deliverLusers(ctx)
c.deliverMOTD() c.deliverMOTD()
// If PASS was given before registration, store its
// password now, after the welcome burst, so that a
// NOTICE refusing it reaches a registered client. A
// failure to store it is only logged, as it is for PASS
// after registration.
if c.gotPass && c.checkPasswordLength(c.passWord) {
_ = c.setPassword(ctx, c.passWord)
}
// Start the message relay goroutine. // Start the message relay goroutine.
go c.relayMessages(ctx) go c.relayMessages(ctx)
} }
@@ -667,33 +597,13 @@ func (c *Conn) deliverMOTD() {
) )
} }
// checkPasswordLength reports whether pw is 8 to 72 bytes // setPassword sets a bcrypt password on the session.
// long, and sends the client a NOTICE saying so when it is func (c *Conn) setPassword(ctx context.Context, pw string) {
// not.
func (c *Conn) checkPasswordLength(pw string) bool {
if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen {
return true
}
c.sendFromServer("NOTICE", c.currentNick(),
"Password must be 8 to 72 bytes",
)
return false
}
// setPassword sets a bcrypt password on the session, and
// logs and returns the error when it was not stored.
func (c *Conn) setPassword(ctx context.Context, pw string) error {
// Use the database's auth module to hash and store. // Use the database's auth module to hash and store.
err := c.database.SetPassword(ctx, c.sessionID, pw) err := c.database.SetPassword(ctx, c.sessionID, pw)
if err != nil { if err != nil {
c.log.Error( c.log.Error(
"failed to set password", "error", err, "failed to set password", "error", err,
) )
return err
} }
return nil
} }
+2 -50
View File
@@ -5,7 +5,6 @@ import (
"log/slog" "log/slog"
"net" "net"
"os" "os"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -38,9 +37,7 @@ func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks // TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
// that Disconnect returns although the victim never reads, // that Disconnect returns although the victim never reads,
// so a KILL cannot stall the operator who sent it. The // so a KILL cannot stall the operator who sent it.
// limit is well below one KillWriteWindow, so a Disconnect
// that waits on any write to the victim fails.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim( func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T, t *testing.T,
) { ) {
@@ -56,7 +53,7 @@ func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
select { select {
case <-returned: case <-returned:
case <-time.After(ircserver.KillWriteWindow / 4): case <-time.After(testTimeout):
t.Fatal("Disconnect waited for the victim to read") t.Fatal("Disconnect waited for the victim to read")
} }
} }
@@ -101,48 +98,3 @@ func TestDisconnectNotifiesAndClosesVictim(t *testing.T) {
t.Errorf("missing ERROR line, got: %q", joined) 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)
}
}
+5 -49
View File
@@ -13,18 +13,13 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// NewTestServer creates a Server suitable for testing, whose // NewTestServer creates a Server suitable for testing.
// connections use interval and deadline in place of
// pingInterval and pongDeadline, and resolve in place of
// resolveHost.
// The caller must call Stop() when finished. // The caller must call Stop() when finished.
func NewTestServer( func NewTestServer(
log *slog.Logger, log *slog.Logger,
cfg *config.Config, cfg *config.Config,
database *db.Database, database *db.Database,
brk *broker.Broker, brk *broker.Broker,
interval, deadline time.Duration,
resolve func(ctx context.Context, addr string) string,
) *Server { ) *Server {
globs := &globals.Globals{ globs := &globals.Globals{
Appname: "neoirc", Appname: "neoirc",
@@ -43,19 +38,9 @@ func NewTestServer(
brk: brk, brk: brk,
svc: svc, svc: svc,
conns: make(map[*Conn]struct{}), 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. // Start exposes the unexported start method for tests.
func (s *Server) Start(addr string) error { func (s *Server) Start(addr string) error {
return s.start(context.Background(), addr) return s.start(context.Background(), addr)
@@ -71,38 +56,6 @@ func (s *Server) Listener() net.Listener {
return s.listener return s.listener
} }
// Service returns the service the server's connections use.
func (s *Server) Service() *service.Service {
return s.svc
}
// PingTimes returns how long the server's connections wait
// 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 // NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it. // server, database or service behind it.
func NewTestConn( func NewTestConn(
@@ -111,7 +64,10 @@ func NewTestConn(
tcpConn net.Conn, tcpConn net.Conn,
nick string, nick string,
) *Conn { ) *Conn {
conn := newConn(tcpConn, log, nil, nil, cfg, nil) conn := newConn(
context.Background(), tcpConn, log,
nil, nil, cfg, nil,
)
conn.nick = nick conn.nick = nick
return conn return conn
+4 -252
View File
@@ -1084,9 +1084,9 @@ func TestIntegrationWallops(t *testing.T) {
} }
// TestIntegrationUserMode covers MODE for a nick: changes // TestIntegrationUserMode covers MODE for a nick: changes
// and queries of your own modes, refusal for another nick, // and queries of your own modes in any letter case, refusal
// even one that differs from yours only in letter case, and // for another nick, and a rejected mode string changing
// a rejected mode string changing nothing. // nothing.
func TestIntegrationUserMode(t *testing.T) { func TestIntegrationUserMode(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1106,9 +1106,7 @@ func TestIntegrationUserMode(t *testing.T) {
for _, step := range []struct { for _, step := range []struct {
line, want string line, want string
}{ }{
{"MODE alice +w", " 221 alice +w"}, {"MODE ALICE +w", " 221 alice +w"},
{"MODE Alice", notYours},
{"MODE Alice -w", notYours},
{"MODE bob", notYours}, {"MODE bob", notYours},
{"MODE bob -w", notYours}, {"MODE bob -w", notYours},
{"MODE alice xw", unknown}, {"MODE alice xw", unknown},
@@ -1241,252 +1239,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 // TestIntegrationThirdClientObserver verifies that a third
// client observing the same channel receives messages from // client observing the same channel receives messages from
// the other two. // the other two.
+1 -8
View File
@@ -90,10 +90,7 @@ func ParseMessage(line string) *Message {
} }
// FormatMessage formats an IRC message into wire protocol // FormatMessage formats an IRC message into wire protocol
// format (without the trailing CR-LF). CR, LF and NUL are // format (without the trailing CR-LF).
// 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 ':'.
func FormatMessage( func FormatMessage(
prefix, command string, prefix, command string,
params ...string, params ...string,
@@ -109,10 +106,6 @@ func FormatMessage(
buf.WriteString(command) buf.WriteString(command)
for i, param := range params { for i, param := range params {
param = strings.ReplaceAll(param, "\r", "")
param = strings.ReplaceAll(param, "\n", "")
param = strings.ReplaceAll(param, "\x00", "")
buf.WriteByte(' ') buf.WriteByte(' ')
isLast := i == len(params)-1 isLast := i == len(params)-1
+62 -58
View File
@@ -3,7 +3,6 @@ package ircserver
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"slices"
"strings" "strings"
"time" "time"
@@ -27,30 +26,22 @@ func (c *Conn) relayMessages(ctx context.Context) {
default: default:
} }
// Register with the broker before draining, so that a
// message queued after the drain has read the queue
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. // Drain any available messages.
delivered := c.drainQueue(ctx) delivered := c.drainQueue(ctx)
if delivered { if delivered {
c.brk.Remove(c.sessionID, waitCh)
// Tight loop while there are messages. // Tight loop while there are messages.
continue continue
} }
// Wait for notification or timeout. // Wait for notification or timeout.
waitCh := c.brk.Wait(c.sessionID)
select { select {
case <-waitCh: case <-waitCh:
// New message notification — loop back. // New message notification — loop back.
case <-ticker.C: case <-ticker.C:
// Periodic check. Nothing used waitCh, so take it // Periodic check.
// out of the broker before the next pass adds
// another.
c.brk.Remove(c.sessionID, waitCh)
case <-ctx.Done(): case <-ctx.Done():
c.brk.Remove(c.sessionID, waitCh) c.brk.Remove(c.sessionID, waitCh)
@@ -92,7 +83,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
// //
//nolint:cyclop // dispatch table //nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage( func (c *Conn) deliverIRCMessage(
ctx context.Context, _ context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
) { ) {
command := msg.Command command := msg.Command
@@ -114,13 +105,13 @@ func (c *Conn) deliverIRCMessage(
case isNumeric(command): case isNumeric(command):
c.deliverNumeric(msg, text) c.deliverNumeric(msg, text)
case command == irc.CmdPrivmsg || command == irc.CmdNotice: case command == irc.CmdPrivmsg || command == irc.CmdNotice:
c.deliverTextMessage(msg, command, bodyLines) c.deliverTextMessage(msg, command, text)
case command == irc.CmdJoin: case command == irc.CmdJoin:
c.deliverJoin(msg) c.deliverJoin(msg)
case command == irc.CmdPart: case command == irc.CmdPart:
c.deliverPart(msg, text) c.deliverPart(msg, text)
case command == irc.CmdNick: case command == irc.CmdNick:
c.deliverNickChange(ctx, msg, text) c.deliverNickChange(msg, text)
case command == irc.CmdQuit: case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text) c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic: case command == irc.CmdTopic:
@@ -132,7 +123,7 @@ func (c *Conn) deliverIRCMessage(
case command == irc.CmdWallops: case command == irc.CmdWallops:
c.deliverWallops(msg, text) c.deliverWallops(msg, text)
case command == irc.CmdMode: case command == irc.CmdMode:
c.deliverMode(msg, bodyLines) c.deliverMode(msg, text)
case command == irc.CmdPing: case command == irc.CmdPing:
// Server-originated PING — reply with PONG. // Server-originated PING — reply with PONG.
c.sendFromServer("PING", c.serverSfx) c.sendFromServer("PING", c.serverSfx)
@@ -188,28 +179,43 @@ func (c *Conn) deliverNumeric(
c.send(FormatMessage(from, msg.Command, allParams...)) c.send(FormatMessage(from, msg.Command, allParams...))
} }
// deliverTextMessage sends PRIVMSG or NOTICE, one line for // deliverTextMessage sends PRIVMSG or NOTICE.
// each line of the body.
func (c *Conn) deliverTextMessage( func (c *Conn) deliverTextMessage(
msg *db.IRCMessage, msg *db.IRCMessage,
command string, command, text string,
bodyLines []string,
) { ) {
from := msg.From from := msg.From
target := msg.To target := msg.To
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own messages back.
if strings.EqualFold(from, nick) {
return
}
prefix := from prefix := from
if !strings.Contains(prefix, "!") { if !strings.Contains(prefix, "!") {
prefix = from + "!" + from + "@*" prefix = from + "!" + from + "@*"
} }
for _, line := range bodyLines { c.send(FormatMessage(prefix, command, target, text))
c.send(FormatMessage(prefix, command, target, line))
}
} }
// deliverJoin sends a JOIN notification. // deliverJoin sends a JOIN notification.
func (c *Conn) deliverJoin(msg *db.IRCMessage) { func (c *Conn) deliverJoin(msg *db.IRCMessage) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own JOINs (we already sent them
// during joinChannel).
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -218,6 +224,14 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification. // deliverPart sends a PART notification.
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To channel := msg.To
@@ -230,17 +244,18 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
} }
} }
// deliverNickChange sends a NICK change notification. The // deliverNickChange sends a NICK change notification.
// change may be its own session's, made from another client,
// so the connection first takes the nick its session holds
// now. It does not go by the line's old nick: the connection
// may have taken that nick after another user gave it up.
func (c *Conn) deliverNickChange( func (c *Conn) deliverNickChange(
ctx context.Context,
msg *db.IRCMessage, msg *db.IRCMessage,
newNick string, newNick string,
) { ) {
c.loadSessionNick(ctx) c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
@@ -252,6 +267,14 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage, msg *db.IRCMessage,
text string, text string,
) { ) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
if text != "" { if text != "" {
@@ -304,24 +327,16 @@ func (c *Conn) deliverKickMsg(
} }
} }
// deliverInviteMsg sends an INVITE line from the inviter // deliverInviteMsg sends an INVITE notification.
// for the channel named in text, or nothing when text names
// no channel.
func (c *Conn) deliverInviteMsg( func (c *Conn) deliverInviteMsg(
msg *db.IRCMessage, _ *db.IRCMessage,
text string, text string,
) { ) {
if text == "" {
return
}
c.mu.Lock() c.mu.Lock()
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
prefix := msg.From + "!" + msg.From + "@*" c.sendFromServer("NOTICE", nick, text)
c.send(FormatMessage(prefix, "INVITE", nick, text))
} }
// deliverWallops sends a WALLOPS notification. // deliverWallops sends a WALLOPS notification.
@@ -336,26 +351,15 @@ func (c *Conn) deliverWallops(
)) ))
} }
// deliverMode sends a MODE change notification. The first // deliverMode sends a MODE change notification.
// 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( func (c *Conn) deliverMode(
msg *db.IRCMessage, msg *db.IRCMessage,
bodyLines []string, text string,
) { ) {
if len(bodyLines) == 0 || bodyLines[0] == "" {
return
}
prefix := msg.From + "!" + msg.From + "@*" prefix := msg.From + "!" + msg.From + "@*"
target := msg.To
params := slices.Concat( if text != "" {
[]string{msg.To}, c.send(FormatMessage(prefix, "MODE", target, text))
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" "log/slog"
"net" "net"
"sync" "sync"
"time"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
@@ -16,10 +15,6 @@ import (
"sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// stopTimeout is the longest stop waits for the connections'
// cleanup, which ends their sessions.
const stopTimeout = 5 * time.Second
// Params defines the dependencies for creating an IRC // Params defines the dependencies for creating an IRC
// Server. // Server.
type Params struct { type Params struct {
@@ -43,19 +38,6 @@ type Server struct {
mu sync.Mutex mu sync.Mutex
conns map[*Conn]struct{} conns map[*Conn]struct{}
cancel context.CancelFunc cancel context.CancelFunc
// serving counts the connections whose serve, cleanup
// included, has not returned yet.
serving sync.WaitGroup
// pingInterval and pongDeadline are the constants of the
// 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 // New creates a new IRC Server and registers its lifecycle
@@ -75,11 +57,6 @@ func New(
listener: nil, listener: nil,
cancel: nil, cancel: nil,
mu: sync.Mutex{}, mu: sync.Mutex{},
serving: sync.WaitGroup{},
pingInterval: pingInterval,
pongDeadline: pongDeadline,
resolveHost: resolveHost,
} }
listenAddr := params.Config.IRCListenAddr listenAddr := params.Config.IRCListenAddr
@@ -126,10 +103,7 @@ func (s *Server) start(ctx context.Context, addr string) error {
return nil return nil
} }
// stop shuts down the listener and all connections, then // stop shuts down the listener and all connections.
// waits up to stopTimeout for every connection's cleanup to
// finish, so that their sessions are ended before the
// database closes.
func (s *Server) stop() { func (s *Server) stop() {
if s.cancel != nil { if s.cancel != nil {
s.cancel() s.cancel()
@@ -144,22 +118,6 @@ func (s *Server) stop() {
c.conn.Close() //nolint:errcheck,gosec c.conn.Close() //nolint:errcheck,gosec
} }
s.mu.Unlock() s.mu.Unlock()
done := make(chan struct{})
go func() {
s.serving.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(stopTimeout):
s.log.Error(
"irc connections not cleaned up before stop timeout",
"timeout", stopTimeout,
)
}
} }
// acceptLoop accepts new connections. // acceptLoop accepts new connections.
@@ -180,39 +138,22 @@ func (s *Server) acceptLoop(ctx context.Context) {
} }
client := newConn( client := newConn(
tcpConn, s.log, ctx, tcpConn, s.log,
s.database, s.brk, s.cfg, s.svc, s.database, s.brk, s.cfg, s.svc,
) )
s.mu.Lock() s.mu.Lock()
// stop cancels ctx before it takes s.mu to close the
// connections, so a connection accepted while stop
// runs is either closed and waited for by stop or
// closed here.
if ctx.Err() != nil {
s.mu.Unlock()
tcpConn.Close() //nolint:errcheck,gosec
return
}
s.conns[client] = struct{}{} s.conns[client] = struct{}{}
s.serving.Add(1)
s.mu.Unlock() s.mu.Unlock()
go func() { go func() {
defer s.serving.Done()
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
delete(s.conns, client) delete(s.conns, client)
s.mu.Unlock() s.mu.Unlock()
}() }()
client.serve( client.serve(ctx)
ctx, s.resolveHost,
s.pingInterval, s.pongDeadline,
)
}() }()
} }
} }
+11 -682
View File
@@ -2,27 +2,20 @@ package ircserver_test
import ( import (
"bufio" "bufio"
"context"
"crypto/rand"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"time" "time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -76,42 +69,9 @@ func newTestEnvWithConfig(
) *testEnv { ) *testEnv {
t.Helper() 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( dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared", "file:%s?mode=memory&cache=shared&_journal_mode=WAL",
rand.Text(), t.Name(),
) )
conn, err := sql.Open("sqlite", dsn) conn, err := sql.Open("sqlite", dsn)
@@ -119,13 +79,6 @@ func newTestEnvWithResolver(
t.Fatalf("open db: %v", err) t.Fatalf("open db: %v", err)
} }
t.Cleanup(func() {
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
conn.SetMaxOpenConns(1) conn.SetMaxOpenConns(1)
_, err = conn.ExecContext( _, err = conn.ExecContext(
@@ -163,18 +116,21 @@ func newTestEnvWithResolver(
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
)) ))
srv := ircserver.NewTestServer( srv := ircserver.NewTestServer(log, cfg, database, brk)
log, cfg, database, brk, interval, deadline, resolve,
)
err = srv.Start(addr) err = srv.Start(addr)
if err != nil { if err != nil {
t.Fatalf("start irc server: %v", err) t.Fatalf("start irc server: %v", err)
} }
// Cleanups run last registered first, so the server stops t.Cleanup(func() {
// before its database is closed. srv.Stop()
t.Cleanup(srv.Stop)
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{ return &testEnv{
database: database, database: database,
@@ -213,34 +169,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. // testClient wraps a raw TCP connection with helpers.
type testClient struct { type testClient struct {
t *testing.T t *testing.T
@@ -339,24 +267,6 @@ func (tc *testClient) readUntilClosed() []string {
return 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 // assertContains checks that at least one line matches the
// given substring. // given substring.
func assertContains( func assertContains(
@@ -432,63 +342,6 @@ func TestRegistration(t *testing.T) {
assertContains(t, lines, " 001 ", "RPL_WELCOME") assertContains(t, lines, " 001 ", "RPL_WELCOME")
} }
// TestRegistrationWhileAnotherLookupIsPending checks that a
// client is accepted and registered while the hostname lookup
// of an earlier client has not returned, and that it
// registers with the hostname its own lookup returned.
func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
t.Parallel()
const lookedUpHost = "client.example"
var lookups atomic.Int32
firstLookupStarted := make(chan struct{})
// The first lookup returns only when the server stops.
resolve := func(ctx context.Context, _ string) string {
if lookups.Add(1) == 1 {
close(firstLookupStarted)
<-ctx.Done()
}
return lookedUpHost
}
env := newTestEnvWithResolver(
t, &config.Config{ServerName: testServerName},
ircserver.PingInterval, ircserver.PongDeadline, resolve,
)
env.dial(t)
select {
case <-firstLookupStarted:
case <-time.After(testTimeout):
t.Fatal("the first client's lookup did not start")
}
lines := env.dial(t).register("second")
assertContains(
t, lines, "second!second@"+lookedUpHost,
"RPL_WELCOME with the looked-up hostname",
)
}
// TestEachTestEnvHasItsOwnDatabase checks that a test
// 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) { func TestWelcomeContainsNick(t *testing.T) {
t.Parallel() t.Parallel()
@@ -516,164 +369,6 @@ func TestPingPong(t *testing.T) {
assertContains(t, lines, "PONG", "PONG response") 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) { func TestJoinChannel(t *testing.T) {
t.Parallel() t.Parallel()
@@ -691,37 +386,6 @@ func TestJoinChannel(t *testing.T) {
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES") 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) { func TestPrivmsgBetweenClients(t *testing.T) {
t.Parallel() t.Parallel()
@@ -772,68 +436,6 @@ func TestDuplicateNick(t *testing.T) {
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE") 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) { func TestListChannels(t *testing.T) {
t.Parallel() t.Parallel()
@@ -875,120 +477,6 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") assertContains(t, lines, "goodbye", "QUIT reason")
} }
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
// its channel membership are gone from the database, and a
// channel peer has been sent the QUIT.
func TestStopEndsSessions(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("shutirc")
client.joinAndDrain("#shut")
ctx := t.Context()
// A peer on the HTTP API, whose session outlives the stop.
peerID, peerClientID, _, err := env.database.CreateSession(
ctx, "peer", "peer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#shut")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, peerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
env.srv.Stop()
_, err = env.database.GetSessionByNick(ctx, "shutirc")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("look up shutirc after stop: %v, want no rows", err)
}
clients, err := env.database.GetClientCount(ctx)
if err != nil {
t.Fatalf("count clients: %v", err)
}
if clients != 1 {
t.Errorf("%d clients after stop, want only the peer's", clients)
}
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
if err != nil {
t.Fatalf("get members: %v", err)
}
if len(members) != 1 || members[0] != peerID {
t.Errorf(
"members of #shut after stop: %v, want only the peer %d",
members, peerID,
)
}
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == "QUIT" && msg.From == "shutirc" {
return
}
}
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
}
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
// waiting for a connection's cleanup after StopTimeout, so a
// cleanup that cannot finish does not hold up stopping.
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("stuck")
// The test database has one connection. While the test
// holds it, the cleanup's first query waits for it.
held, err := env.database.GetDB().Conn(t.Context())
if err != nil {
t.Fatalf("take the database connection: %v", err)
}
stopped := make(chan struct{})
go func() {
env.srv.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(ircserver.StopTimeout + testTimeout):
t.Error("Stop did not return while a cleanup was stuck")
}
// Lets the cleanup finish before the database is closed.
err = held.Close()
if err != nil {
t.Errorf("release the database connection: %v", err)
}
}
func TestTopicSetAndGet(t *testing.T) { func TestTopicSetAndGet(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1160,165 +648,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) { func TestPreRegistrationNotRegistered(t *testing.T) {
t.Parallel() 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 ( import (
"io/fs" "io/fs"
"net/http" "net/http"
"strings"
"time" "time"
"sneak.berlin/go/neoirc/web" "sneak.berlin/go/neoirc/web"
@@ -138,10 +137,8 @@ func (srv *Server) setupSPA() {
return return
} }
// The path is empty, not "/", for a request line such
// as "GET http://host HTTP/1.1".
fileData, readErr := readFS.ReadFile( fileData, readErr := readFS.ReadFile(
strings.TrimPrefix(request.URL.Path, "/"), request.URL.Path[1:],
) )
if readErr != nil || len(fileData) == 0 { if readErr != nil || len(fileData) == 0 {
indexHTML, indexErr := readFS.ReadFile( 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" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -40,15 +39,17 @@ type Params struct {
Config *config.Config Config *config.Config
Middleware *middleware.Middleware Middleware *middleware.Middleware
Handlers *handlers.Handlers Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
} }
// Server is the main HTTP server. // Server is the main HTTP server.
// It manages routing, middleware, and lifecycle. // It manages routing, middleware, and lifecycle.
type Server struct { type Server struct {
startupTime time.Time startupTime time.Time
exitCode int
sentryEnabled bool sentryEnabled bool
log *slog.Logger log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
cancelFunc context.CancelFunc
httpServer *http.Server httpServer *http.Server
router *chi.Mux router *chi.Mux
params Params params Params
@@ -68,7 +69,7 @@ func New(
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error { OnStart: func(_ context.Context) error {
srv.startupTime = time.Now() srv.startupTime = time.Now()
// Build the router before OnStart returns, so srv can // Build the router before OnStart returns, so srv can
@@ -77,14 +78,13 @@ func New(
srv.enableSentry() srv.enableSentry()
srv.SetupRoutes() srv.SetupRoutes()
// A closed stdout or stderr must not end the process. // The start hook's context ends when the hook
signal.Ignore(syscall.SIGPIPE) // returns; serving must outlive it.
go srv.serve() //nolint:contextcheck,gosec // G118
return srv.startHTTP(ctx) return nil
}, },
OnStop: func(ctx context.Context) error { OnStop: func(_ context.Context) error {
srv.cleanShutdown(ctx)
return nil return nil
}, },
}) })
@@ -129,15 +129,44 @@ func (srv *Server) enableSentry() {
srv.sentryEnabled = true srv.sentryEnabled = true
} }
func (srv *Server) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go srv.serveUntilShutdown()
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() { func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up") srv.log.Info("cleaning up")
} }
// cleanShutdown stops the HTTP server, waiting up to func (srv *Server) cleanShutdown() {
// shutdownTimeout for the requests in progress to finish. srv.exitCode = 0
func (srv *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout( ctxShutdown, shutdownCancel := context.WithTimeout(
ctx, shutdownTimeout, context.Background(), shutdownTimeout,
) )
err := srv.httpServer.Shutdown(ctxShutdown) err := srv.httpServer.Shutdown(ctxShutdown)
@@ -162,23 +191,11 @@ func (srv *Server) configure() {
// Server configuration placeholder. // Server configuration placeholder.
} }
// startHTTP binds the HTTP listen address, so that a port func (srv *Server) serveUntilShutdown() {
// that is taken fails startup, then serves the HTTP server
// from a new goroutine. srv.httpServer is set before that
// goroutine starts and never changes, so it and cleanShutdown
// read it without a lock.
func (srv *Server) startHTTP(ctx context.Context) error {
listenAddr := fmt.Sprintf( listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port, ":%d", srv.params.Config.Port,
) )
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(ctx, "tcp", listenAddr)
if err != nil {
return fmt.Errorf("http listen: %w", err)
}
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr, Addr: listenAddr,
ReadTimeout: httpReadTimeout, ReadTimeout: httpReadTimeout,
@@ -187,30 +204,16 @@ func (srv *Server) startHTTP(ctx context.Context) error {
Handler: srv, Handler: srv,
} }
go func() {
srv.serveUntilShutdown(listener)
}()
return nil
}
// serveUntilShutdown serves the HTTP server on listener until
// cleanShutdown stops it. If serving fails before then, it
// stops the app with exit code 1.
func (srv *Server) serveUntilShutdown(listener net.Listener) {
srv.log.Info( srv.log.Info(
"http begin listen", "listenaddr", srv.httpServer.Addr, "http begin listen", "listenaddr", listenAddr,
) )
err := srv.httpServer.Serve(listener) err := srv.httpServer.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
return srv.log.Error("listen error", "error", err)
}
srv.log.Error("listen error", "error", err) if srv.cancelFunc != nil {
srv.cancelFunc()
err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1)) }
if err != nil {
srv.log.Error("shutdown failed", "error", err)
} }
} }
-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
}
+219 -388
View File
@@ -10,7 +10,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -21,7 +20,6 @@ import (
"sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
@@ -31,31 +29,12 @@ const (
msgNoSuchChannel = "No such channel" msgNoSuchChannel = "No such channel"
msgNoSuchNick = "No such nick/channel" msgNoSuchNick = "No such nick/channel"
msgNotChannelOp = "You're not channel operator" msgNotChannelOp = "You're not channel operator"
msgNotOnChannel = "You're not on that channel"
) )
// maxUserhostNicks is how many nicks one USERHOST answers // maxUserhostNicks is how many nicks one USERHOST answers
// for (RFC 2812). // for (RFC 2812).
const maxUserhostNicks = 5 const maxUserhostNicks = 5
// validChannelRe matches the channel names JOIN accepts: #
// followed by 1 to 63 letters, digits, _ or -.
var validChannelRe = regexp.MustCompile(
`^#[a-zA-Z0-9_\-]{1,63}$`,
)
// validNickRe matches the nicks both transports accept: a
// letter or _, then up to 31 letters, digits or
// _-[]\^{}|`.
var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
)
// ValidNick reports whether validNickRe matches nick.
func ValidNick(nick string) bool {
return validNickRe.MatchString(nick)
}
// Params defines the dependencies for creating a Service. // Params defines the dependencies for creating a Service.
type Params struct { type Params struct {
fx.In fx.In
@@ -77,12 +56,11 @@ type WireConn interface {
// Service provides shared business logic for IRC commands. // Service provides shared business logic for IRC commands.
type Service struct { type Service struct {
db *db.Database db *db.Database
broker *broker.Broker broker *broker.Broker
config *config.Config config *config.Config
globals *globals.Globals globals *globals.Globals
log *slog.Logger log *slog.Logger
channelHashcash *hashcash.ChannelValidator
wireMu sync.Mutex wireMu sync.Mutex
wireConns map[int64]WireConn wireConns map[int64]WireConn
@@ -91,13 +69,12 @@ type Service struct {
// New creates a new Service. // New creates a new Service.
func New(params Params) *Service { func New(params Params) *Service {
return &Service{ return &Service{
db: params.Database, db: params.Database,
broker: params.Broker, broker: params.Broker,
config: params.Config, config: params.Config,
globals: params.Globals, globals: params.Globals,
log: params.Logger.Get(), log: params.Logger.Get(),
channelHashcash: hashcash.NewChannelValidator(), wireConns: make(map[int64]WireConn),
wireConns: make(map[int64]WireConn),
} }
} }
@@ -111,13 +88,12 @@ func NewTestService(
log *slog.Logger, log *slog.Logger,
) *Service { ) *Service {
return &Service{ return &Service{
db: database, db: database,
broker: brk, broker: brk,
config: cfg, config: cfg,
globals: globs, globals: globs,
log: log, log: log,
channelHashcash: hashcash.NewChannelValidator(), wireConns: make(map[int64]WireConn),
wireConns: make(map[int64]WireConn),
} }
} }
@@ -143,15 +119,6 @@ func (s *Service) InfoLines() []string {
} }
} }
// AdminLines returns the texts of the RPL_ADMINLOC1,
// RPL_ADMINLOC2 and RPL_ADMINEMAIL replies, in that order,
// that ADMIN sends on both transports.
func (s *Service) AdminLines(
serverName string,
) (string, string, string) {
return "neoirc server", "IRC over HTTP", "admin@" + serverName
}
// UserhostReply returns the RPL_USERHOST text for the first // UserhostReply returns the RPL_USERHOST text for the first
// five of the given nicks: "nick=+user@host" entries joined // five of the given nicks: "nick=+user@host" entries joined
// by spaces, with * after the nick of an operator and - in // by spaces, with * after the nick of an operator and - in
@@ -232,8 +199,7 @@ func (s *Service) UnregisterWireConn(
// KillUser carries out an operator's KILL: the target's // KillUser carries out an operator's KILL: the target's
// channel peers see it quit, its session is deleted, and // channel peers see it quit, its session is deleted, and
// its IRC connection, if it has one, is closed. An empty // its IRC connection, if it has one, is closed.
// reason is given as "KILLed".
func (s *Service) KillUser( func (s *Service) KillUser(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
@@ -265,10 +231,6 @@ func (s *Service) KillUser(
} }
} }
if reason == "" {
reason = "KILLed"
}
quitReason := "Killed (" + nick + " (" + reason + "))" quitReason := "Killed (" + nick + " (" + reason + "))"
s.BroadcastQuit(ctx, targetSID, targetNick, quitReason) s.BroadcastQuit(ctx, targetSID, targetNick, quitReason)
@@ -311,8 +273,8 @@ func (s *Service) SendWallops(
} }
_, _, err = s.FanOut( _, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*", 0, 0, ctx, irc.CmdWallops, nick, "*",
nil, body, nil, recipients, 0, nil, body, nil, recipients,
) )
if err != nil { if err != nil {
return fmt.Errorf("wallops: %w", err) return fmt.Errorf("wallops: %w", err)
@@ -344,66 +306,71 @@ type DirectMsgResult struct {
} }
// FanOut inserts a message and enqueues it to all given // FanOut inserts a message and enqueues it to all given
// session IDs, notifying each via the broker. toSessionID // session IDs, notifying each via the broker.
// and toChannelID are the recipient InsertMessage records
// for history, 0 when there is none.
//
// skipClientID is the client whose queue the message is
// not added to: the IRC listener connection that sent the
// command, which has already shown it to its user. The HTTP
// API passes 0, as its clients see their own commands
// through their queues.
func (s *Service) FanOut( func (s *Service) FanOut(
ctx context.Context, ctx context.Context,
command, from, to string, command, from, to string,
toSessionID, toChannelID int64,
params, body, meta json.RawMessage, params, body, meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
skipClientID int64,
) (int64, string, error) { ) (int64, string, error) {
dbID, msgUUID, err := s.db.InsertMessage( dbID, msgUUID, err := s.db.InsertMessage(
ctx, command, from, to, ctx, command, from, to, params, body, meta,
toSessionID, toChannelID, params, body, meta,
) )
if err != nil { if err != nil {
return 0, "", fmt.Errorf("insert message: %w", err) return 0, "", fmt.Errorf("insert message: %w", err)
} }
for _, sid := range sessionIDs { for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID) _ = s.db.EnqueueToSession(ctx, sid, dbID)
s.broker.Notify(sid) s.broker.Notify(sid)
} }
return dbID, msgUUID, nil return dbID, msgUUID, nil
} }
// SendChannelMessage checks membership, bans and // excludeSession returns a copy of ids without the given
// moderation, then the hashcash stamp of a PRIVMSG to a +H // session.
// channel, then fans out a message to all channel members, func excludeSession(
// the sender's session included, skipping client ids []int64,
// skipClientID as FanOut does. The stamp is checked after exclude int64,
// the other checks so that a message they refuse does not ) []int64 {
// spend it. Returns the message UUID and any error. out := make([]int64, 0, len(ids))
func (s *Service) SendChannelMessage(
ctx context.Context, for _, id := range ids {
sessionID, skipClientID int64, if id != exclude {
nick, command, channel string, out = append(out, id)
body, meta json.RawMessage, }
) (string, error) {
noSuchChannel := &IRCError{
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
} }
return out
}
// SendChannelMessage validates membership and moderation,
// then fans out a message to all channel members except
// the sender. Returns the database row ID, message UUID,
// and any error. The dbID lets callers enqueue the same
// message to the sender when echo is needed (HTTP
// transport).
func (s *Service) SendChannelMessage(
ctx context.Context,
sessionID int64,
nick, command, channel string,
body, meta json.RawMessage,
) (int64, string, error) {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil { if err != nil {
return "", noSuchChannel return 0, "", &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
msgNoSuchChannel,
}
} }
isMember, _ := s.db.IsChannelMember( isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if !isMember { if !isMember {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel", "Cannot send to channel",
@@ -415,7 +382,7 @@ func (s *Service) SendChannelMessage(
ctx, chID, sessionID, ctx, chID, sessionID,
) )
if banErr == nil && isBanned { if banErr == nil && isBanned {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+b)", "Cannot send to channel (+b)",
@@ -432,7 +399,7 @@ func (s *Service) SendChannelMessage(
) )
if !isOp && !isVoiced { if !isOp && !isVoiced {
return "", &IRCError{ return 0, "", &IRCError{
irc.ErrCannotSendToChan, irc.ErrCannotSendToChan,
[]string{channel}, []string{channel},
"Cannot send to channel (+m)", "Cannot send to channel (+m)",
@@ -440,50 +407,36 @@ func (s *Service) SendChannelMessage(
} }
} }
if command == irc.CmdPrivmsg {
err = s.checkChannelHashcash(
ctx, chID, channel, body, meta,
)
if err != nil {
return "", err
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
_, uuid, fanErr := s.FanOut( dbID, uuid, fanErr := s.FanOut(
ctx, command, nick, channel, 0, chID, ctx, command, nick, channel,
nil, body, meta, memberIDs, skipClientID, nil, body, meta, recipients,
) )
// The channel was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) {
return "", noSuchChannel
}
if fanErr != nil { if fanErr != nil {
return "", fanErr return 0, "", fanErr
} }
return uuid, nil return dbID, uuid, nil
} }
// SendDirectMessage validates the target and sends a // SendDirectMessage validates the target and sends a
// direct message to the target and the sender's session, // direct message, returning the message UUID and any away
// skipping client skipClientID as FanOut does, and returns // message set on the target.
// the message UUID and any away message set on the target.
func (s *Service) SendDirectMessage( func (s *Service) SendDirectMessage(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, command, target string, nick, command, target string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (*DirectMsgResult, error) { ) (*DirectMsgResult, error) {
noSuchNick := &IRCError{
irc.ErrNoSuchNick, []string{target}, "No such nick",
}
targetSID, err := s.db.GetSessionByNick(ctx, target) targetSID, err := s.db.GetSessionByNick(ctx, target)
if err != nil { if err != nil {
return nil, noSuchNick return nil, &IRCError{
irc.ErrNoSuchNick,
[]string{target},
"No such nick",
}
} }
away, _ := s.db.GetAway(ctx, targetSID) away, _ := s.db.GetAway(ctx, targetSID)
@@ -494,14 +447,9 @@ func (s *Service) SendDirectMessage(
} }
_, uuid, fanErr := s.FanOut( _, uuid, fanErr := s.FanOut(
ctx, command, nick, target, targetSID, 0, ctx, command, nick, target,
nil, body, meta, recipients, skipClientID, nil, body, meta, recipients,
) )
// The target's session was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) {
return nil, noSuchNick
}
if fanErr != nil { if fanErr != nil {
return nil, fanErr return nil, fanErr
} }
@@ -511,35 +459,53 @@ func (s *Service) SendDirectMessage(
// JoinChannel creates or joins a channel, making the // JoinChannel creates or joins a channel, making the
// first joiner the operator. Fans out the JOIN to all // first joiner the operator. Fans out the JOIN to all
// channel members, skipping client skipClientID as FanOut // channel members.
// does. A channel name that validChannelRe does not match
// gets ERR_NOSUCHCHANNEL.
func (s *Service) JoinChannel( func (s *Service) JoinChannel(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, suppliedKey string, nick, channel, suppliedKey string,
) (*JoinResult, error) { ) (*JoinResult, error) {
if !validChannelRe.MatchString(channel) { chID, err := s.db.GetOrCreateChannel(ctx, channel)
return nil, &IRCError{ if err != nil {
irc.ErrNoSuchChannel, return nil, fmt.Errorf("get/create channel: %w", err)
[]string{channel}, }
msgNoSuchChannel,
memberCount, countErr := s.db.CountChannelMembers(
ctx, chID,
)
isCreator := countErr == nil && memberCount == 0
if !isCreator {
joinErr := checkJoinRestrictions(
ctx, s.db, chID, sessionID,
channel, suppliedKey, memberCount,
)
if joinErr != nil {
return nil, joinErr
} }
} }
chID, isCreator, err := s.db.CreateOrJoinChannel( if isCreator {
ctx, channel, sessionID, suppliedKey, err = s.db.JoinChannelAsOperator(
) ctx, chID, sessionID,
if err != nil { )
return nil, joinError(channel, err) } else {
err = s.db.JoinChannel(ctx, chID, sessionID)
} }
if err != nil {
return nil, fmt.Errorf("join channel: %w", err)
}
// Clear invite after successful join.
_ = s.db.ClearChannelInvite(ctx, chID, sessionID)
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdJoin, nick, channel, 0, chID, ctx, irc.CmdJoin, nick, channel,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
return &JoinResult{ return &JoinResult{
@@ -548,12 +514,12 @@ func (s *Service) JoinChannel(
}, nil }, nil
} }
// PartChannel validates membership, broadcasts PART to all // PartChannel validates membership, broadcasts PART to
// channel members, skipping client skipClientID as FanOut // remaining members, removes the user, and cleans up empty
// does, removes the user, and cleans up empty channels. // channels.
func (s *Service) PartChannel( func (s *Service) PartChannel(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, reason string, nick, channel, reason string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -572,29 +538,30 @@ func (s *Service) PartChannel(
return &IRCError{ return &IRCError{
irc.ErrNotOnChannel, irc.ErrNotOnChannel,
[]string{channel}, []string{channel},
msgNotOnChannel, "You're not on that channel",
} }
} }
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdPart, nick, channel, 0, chID, ctx, irc.CmdPart, nick, channel,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, recipients,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil return nil
} }
// SetTopic validates membership and topic-lock, sets the // SetTopic validates membership and topic-lock, sets the
// topic, and broadcasts the change to all channel members, // topic, and broadcasts the change.
// skipping client skipClientID as FanOut does.
func (s *Service) SetTopic( func (s *Service) SetTopic(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, topic string, nick, channel, topic string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -613,7 +580,7 @@ func (s *Service) SetTopic(
return &IRCError{ return &IRCError{
irc.ErrNotOnChannel, irc.ErrNotOnChannel,
[]string{channel}, []string{channel},
msgNotOnChannel, "You're not on that channel",
} }
} }
@@ -642,20 +609,19 @@ func (s *Service) SetTopic(
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdTopic, nick, channel, 0, chID, ctx, irc.CmdTopic, nick, channel,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
return nil return nil
} }
// KickUser validates operator status and target // KickUser validates operator status and target
// membership, broadcasts the KICK to all channel members, // membership, broadcasts the KICK, removes the target,
// skipping client skipClientID as FanOut does, removes the // and cleans up empty channels.
// target, and cleans up empty channels.
func (s *Service) KickUser( func (s *Service) KickUser(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
nick, channel, targetNick, reason string, nick, channel, targetNick, reason string,
) error { ) error {
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
@@ -707,93 +673,21 @@ func (s *Service) KickUser(
) )
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdKick, nick, channel, 0, chID, ctx, irc.CmdKick, nick, channel,
params, body, nil, memberIDs, skipClientID, params, body, nil, memberIDs,
) )
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
return nil return nil
} }
// InviteUser records an invite of targetNick to channel,
// which JoinChannel consumes, and returns the target's
// session ID. The inviter must be on the channel, and an
// operator if it is +i; the target must exist and not be
// on the channel already.
func (s *Service) InviteUser(
ctx context.Context,
sessionID int64,
nick, targetNick, channel string,
) (int64, error) {
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return 0, &IRCError{
irc.ErrNoSuchChannel,
[]string{channel},
msgNoSuchChannel,
}
}
isMember, memErr := s.db.IsChannelMember(
ctx, chID, sessionID,
)
if memErr != nil || !isMember {
return 0, &IRCError{
irc.ErrNotOnChannel,
[]string{channel},
msgNotOnChannel,
}
}
isInviteOnly, ioErr := s.db.IsChannelInviteOnly(ctx, chID)
if ioErr == nil && isInviteOnly {
isOp, opErr := s.db.IsChannelOperator(
ctx, chID, sessionID,
)
if opErr != nil || !isOp {
return 0, &IRCError{
irc.ErrChanOpPrivsNeeded,
[]string{channel},
msgNotChannelOp,
}
}
}
targetSID, err := s.db.GetSessionByNick(ctx, targetNick)
if err != nil {
return 0, &IRCError{
irc.ErrNoSuchNick,
[]string{targetNick},
msgNoSuchNick,
}
}
alreadyIn, aiErr := s.db.IsChannelMember(
ctx, chID, targetSID,
)
if aiErr == nil && alreadyIn {
return 0, &IRCError{
irc.ErrUserOnChannel,
[]string{targetNick, channel},
"is already on channel",
}
}
err = s.db.AddChannelInvite(ctx, chID, targetSID, nick)
if err != nil {
return 0, fmt.Errorf("add invite: %w", err)
}
return targetSID, nil
}
// ChangeNick changes a user's nickname and broadcasts the // ChangeNick changes a user's nickname and broadcasts the
// change to the user's own session and all users sharing // change to all users sharing channels.
// channels, skipping client skipClientID as FanOut does.
func (s *Service) ChangeNick( func (s *Service) ChangeNick(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
oldNick, newNick string, oldNick, newNick string,
) error { ) error {
err := s.db.ChangeNick(ctx, sessionID, newNick) err := s.db.ChangeNick(ctx, sessionID, newNick)
@@ -814,62 +708,66 @@ func (s *Service) ChangeNick(
} }
} }
s.broadcastNickChange( s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
ctx, sessionID, skipClientID, oldNick, newNick,
)
return nil return nil
} }
// BroadcastQuit parts the session from all its channels, // BroadcastQuit broadcasts a QUIT to all channel peers,
// deletes it, and queues a QUIT to the users who shared a // parts all channels, and deletes the session. Uses the
// channel with it, in one transaction, so that the QUIT is // FanOut pattern: one message row fanned out to all unique
// queued before anyone else can take the nick, and then // peer sessions.
// wakes those users' long polls. Of several calls for one
// session at once, only the one that deletes it sends the
// QUIT.
func (s *Service) BroadcastQuit( func (s *Service) BroadcastQuit(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
nick, reason string, nick, reason string,
) { ) {
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson channels, err := s.db.GetSessionChannels(
ctx, sessionID,
peerIDs, err := s.db.QuitSession(ctx, sessionID, nick, body) )
if err != nil { if err != nil {
s.log.Error("quit failed", "error", err)
return return
} }
for _, peerID := range peerIDs { notified := make(map[int64]bool)
s.broker.Notify(peerID)
}
}
// Logout deletes a client of the session and, if that was for _, ch := range channels {
// the session's last client, does what BroadcastQuit does. memberIDs, memErr := s.db.GetChannelMemberIDs(
// If that fails, nothing changes and Logout returns the ctx, ch.ID,
// error. )
func (s *Service) Logout( if memErr != nil {
ctx context.Context, continue
sessionID, clientID int64, }
nick string,
) error {
body, _ := json.Marshal([]string{"Connection closed"}) //nolint:errchkjson
peerIDs, err := s.db.LogoutClient( for _, mid := range memberIDs {
ctx, sessionID, clientID, nick, body, if mid == sessionID || notified[mid] {
) continue
if err != nil { }
return fmt.Errorf("logout: %w", err)
notified[mid] = true
}
} }
for _, peerID := range peerIDs { if len(notified) > 0 {
s.broker.Notify(peerID) recipients := make([]int64, 0, len(notified))
for sid := range notified {
recipients = append(recipients, sid)
}
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut(
ctx, irc.CmdQuit, nick, "",
nil, body, nil, recipients,
)
} }
return nil for _, ch := range channels {
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
}
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
} }
// SetAway sets or clears the away message. Returns true // SetAway sets or clears the away message. Returns true
@@ -1036,10 +934,9 @@ func (s *Service) SetChannelFlag(
} }
// BroadcastMode fans out a MODE change to all channel // BroadcastMode fans out a MODE change to all channel
// members, skipping client skipClientID as FanOut does. // members.
func (s *Service) BroadcastMode( func (s *Service) BroadcastMode(
ctx context.Context, ctx context.Context,
skipClientID int64,
nick, channel string, nick, channel string,
chID int64, chID int64,
modeText string, modeText string,
@@ -1048,8 +945,8 @@ func (s *Service) BroadcastMode(
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdMode, nick, channel, 0, chID, ctx, irc.CmdMode, nick, channel,
nil, body, nil, memberIDs, skipClientID, nil, body, nil, memberIDs,
) )
} }
@@ -1242,71 +1139,11 @@ func (s *Service) requireOper(
return nil return nil
} }
// checkChannelHashcash returns ERR_CANNOTSENDTOCHAN unless // broadcastNickChange notifies channel peers of a nick
// the channel has no +H, or meta carries a hashcash stamp
// for this channel and body with enough bits that has not
// been used before. Recording the stamp as spent is the
// check that it is unused, so a stamp sent in several
// concurrent requests passes in only one of them.
func (s *Service) checkChannelHashcash(
ctx context.Context,
chID int64,
channel string,
body, meta json.RawMessage,
) error {
bits, err := s.db.GetChannelHashcashBits(ctx, chID)
if err != nil {
return fmt.Errorf("channel hashcash bits: %w", err)
}
if bits <= 0 {
return nil
}
stamp := extractHashcashFromMeta(meta)
if stamp == "" {
return &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Channel requires hashcash proof-of-work",
}
}
err = s.channelHashcash.ValidateStamp(
stamp, bits, channel, hashcash.BodyHash(body),
)
if err != nil {
return &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Invalid hashcash: " + err.Error(),
}
}
stampKey := hashcash.StampHash(stamp)
recorded, err := s.db.RecordSpentHashcash(ctx, stampKey)
if err != nil {
return fmt.Errorf("record spent hashcash: %w", err)
}
if !recorded {
return &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Hashcash stamp already used",
}
}
return nil
}
// broadcastNickChange notifies the user's own session,
// except client skipClientID, and channel peers of a nick
// change. // change.
func (s *Service) broadcastNickChange( func (s *Service) broadcastNickChange(
ctx context.Context, ctx context.Context,
sessionID, skipClientID int64, sessionID int64,
oldNick, newNick string, oldNick, newNick string,
) { ) {
channels, err := s.db.GetSessionChannels( channels, err := s.db.GetSessionChannels(
@@ -1320,7 +1157,7 @@ func (s *Service) broadcastNickChange(
notified := make(map[int64]bool) notified := make(map[int64]bool)
dbID, _, insErr := s.db.InsertMessage( dbID, _, insErr := s.db.InsertMessage(
ctx, irc.CmdNick, oldNick, "", 0, 0, ctx, irc.CmdNick, oldNick, "",
nil, body, nil, nil, body, nil,
) )
if insErr != nil { if insErr != nil {
@@ -1328,7 +1165,7 @@ func (s *Service) broadcastNickChange(
} }
// Notify the user themselves (for multi-client sync). // Notify the user themselves (for multi-client sync).
_ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID) _ = s.db.EnqueueToSession(ctx, sessionID, dbID)
s.broker.Notify(sessionID) s.broker.Notify(sessionID)
notified[sessionID] = true notified[sessionID] = true
@@ -1347,36 +1184,60 @@ func (s *Service) broadcastNickChange(
notified[mid] = true notified[mid] = true
_ = s.db.EnqueueToSession(ctx, mid, dbID, 0) _ = s.db.EnqueueToSession(ctx, mid, dbID)
s.broker.Notify(mid) s.broker.Notify(mid)
} }
} }
} }
// joinError returns the IRC error for a JOIN of channel // checkJoinRestrictions validates Tier 2 join conditions:
// that db.CreateOrJoinChannel refused with err, or err // bans, invite-only, channel key, and user limit.
// wrapped if it is not a refusal. func checkJoinRestrictions(
func joinError(channel string, err error) error { ctx context.Context,
switch { database *db.Database,
case errors.Is(err, db.ErrBannedFromChannel): chID, sessionID int64,
channel, suppliedKey string,
memberCount int64,
) error {
isBanned, banErr := database.IsSessionBanned(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
return &IRCError{ return &IRCError{
Code: irc.ErrBannedFromChan, Code: irc.ErrBannedFromChan,
Params: []string{channel}, Params: []string{channel},
Message: "Cannot join channel (+b)", Message: "Cannot join channel (+b)",
} }
case errors.Is(err, db.ErrInviteOnlyChannel): }
return &IRCError{
Code: irc.ErrInviteOnlyChan, isInviteOnly, ioErr := database.IsChannelInviteOnly(
Params: []string{channel}, ctx, chID,
Message: "Cannot join channel (+i)", )
if ioErr == nil && isInviteOnly {
hasInvite, invErr := database.HasChannelInvite(
ctx, chID, sessionID,
)
if invErr != nil || !hasInvite {
return &IRCError{
Code: irc.ErrInviteOnlyChan,
Params: []string{channel},
Message: "Cannot join channel (+i)",
}
} }
case errors.Is(err, db.ErrBadChannelKey): }
key, keyErr := database.GetChannelKey(ctx, chID)
if keyErr == nil && key != "" && suppliedKey != key {
return &IRCError{ return &IRCError{
Code: irc.ErrBadChannelKey, Code: irc.ErrBadChannelKey,
Params: []string{channel}, Params: []string{channel},
Message: "Cannot join channel (+k)", Message: "Cannot join channel (+k)",
} }
case errors.Is(err, db.ErrChannelIsFull): }
limit, limErr := database.GetChannelUserLimit(ctx, chID)
if limErr == nil && limit > 0 &&
memberCount >= int64(limit) {
return &IRCError{ return &IRCError{
Code: irc.ErrChannelIsFull, Code: irc.ErrChannelIsFull,
Params: []string{channel}, Params: []string{channel},
@@ -1384,35 +1245,5 @@ func joinError(channel string, err error) error {
} }
} }
return fmt.Errorf("join channel: %w", err) return nil
}
// extractHashcashFromMeta parses the meta JSON and
// returns the hashcash stamp string, or empty string
// if not present.
func 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
} }
File diff suppressed because it is too large Load Diff
+63 -114
View File
@@ -58,13 +58,13 @@ async function mintHashcash(bits, resource) {
} }
} }
// Requests are authenticated by the auth cookie, which the browser
// sends on its own.
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token");
const headers = { const headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(opts.headers || {}), ...(opts.headers || {}),
}; };
if (token) headers["Authorization"] = `Bearer ${token}`;
const { signal, ...rest } = opts; const { signal, ...rest } = opts;
return fetch(API + path, { ...rest, headers, signal }).then(async (r) => { return fetch(API + path, { ...rest, headers, signal }).then(async (r) => {
@@ -122,11 +122,12 @@ function LoginScreen({ onLogin }) {
if (s.name) hashcashResourceRef.current = s.name; if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
// Resume the session if the browser still holds a valid auth const saved = localStorage.getItem("neoirc_token");
// cookie; a 401 leaves the login form up. if (saved) {
api("/state?initChannelState=1") api("/state?initChannelState=1")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => {}); .catch(() => localStorage.removeItem("neoirc_token"));
}
inputRef.current?.focus(); inputRef.current?.focus();
}, []); }, []);
@@ -151,6 +152,7 @@ function LoginScreen({ onLogin }) {
method: "POST", method: "POST",
body: JSON.stringify(reqBody), body: JSON.stringify(reqBody),
}); });
localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);
} catch (err) { } catch (err) {
setError(err.data?.error || "Connection failed"); setError(err.data?.error || "Connection failed");
@@ -225,9 +227,10 @@ function UserList({ members, onNickClick }) {
const regular = []; const regular = [];
for (const m of members) { for (const m of members) {
if (m.isOperator) { const mode = m.mode || "";
if (mode === "o") {
ops.push(m); ops.push(m);
} else if (m.isVoiced) { } else if (mode === "v") {
voiced.push(m); voiced.push(m);
} else { } else {
regular.push(m); regular.push(m);
@@ -300,14 +303,11 @@ function App() {
nickRef.current = nick; nickRef.current = nick;
}, [nick]); }, [nick]);
// Persist joined channels, once logged in: before then only the // Persist joined channels.
// Server tab exists, and writing would erase the saved channels
// that onLogin rejoins.
useEffect(() => { useEffect(() => {
if (!loggedIn) return;
const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name); const channels = tabs.filter((t) => t.type === "channel").map((t) => t.name);
localStorage.setItem("neoirc_channels", JSON.stringify(channels)); localStorage.setItem("neoirc_channels", JSON.stringify(channels));
}, [loggedIn, tabs]); }, [tabs]);
// Clear unread on tab switch. // Clear unread on tab switch.
useEffect(() => { useEffect(() => {
@@ -366,9 +366,7 @@ function App() {
command: msg.command, command: msg.command,
}; };
// A numeric reply carries its number in `code` and its name in switch (msg.command) {
// `command`; other messages have no `code`.
switch (msg.code || msg.command) {
case "PRIVMSG": case "PRIVMSG":
case "NOTICE": { case "NOTICE": {
let text = body; let text = body;
@@ -466,18 +464,8 @@ function App() {
break; break;
} }
case "MODE": { case "353": {
// The body is the change, with its value where it has one: // RPL_NAMREPLY — parse mode prefixes from names list.
// ["+o bob"], ["+m"], ["+k", "secretpass"].
const modes = Array.isArray(msg.body) ? msg.body.join(" ") : "";
const text = `${msg.from} sets mode ${modes} on ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
break;
}
case 353: {
// RPL_NAMREPLY — each name is a hostmask (nick!user@host),
// prefixed with @ for an operator or + for a voiced member.
if ( if (
Array.isArray(msg.params) && Array.isArray(msg.params) &&
msg.params.length >= 2 && msg.params.length >= 2 &&
@@ -489,18 +477,21 @@ function App() {
: String(msg.body); : String(msg.body);
const names = namesStr.split(/\s+/).filter(Boolean); const names = namesStr.split(/\s+/).filter(Boolean);
const parsed = names.map((n) => { const parsed = names.map((n) => {
const isOperator = n.startsWith("@"); if (n.startsWith("@")) {
const isVoiced = n.startsWith("+"); return { nick: n.slice(1), mode: "o" };
const hostmask = isOperator || isVoiced ? n.slice(1) : n; }
if (n.startsWith("+")) {
return { nick: n.slice(1), mode: "v" };
}
return { nick: hostmask.split("!")[0], isOperator, isVoiced }; return { nick: n, mode: "" };
}); });
setMembers((prev) => ({ ...prev, [channel]: parsed })); setMembers((prev) => ({ ...prev, [channel]: parsed }));
} }
break; break;
} }
case 332: { case "332": {
// RPL_TOPIC — set topic from server. // RPL_TOPIC — set topic from server.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const channel = msg.params[0]; const channel = msg.params[0];
@@ -512,7 +503,7 @@ function App() {
break; break;
} }
case 322: { case "322": {
// RPL_LIST — channel, member count, topic. // RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0]; const chName = msg.params[0];
@@ -527,7 +518,7 @@ function App() {
break; break;
} }
case 323: case "323":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of channel list", text: body || "End of channel list",
@@ -535,7 +526,7 @@ function App() {
}); });
break; break;
case 352: { case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags. // RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) { if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0]; const whoCh = msg.params[0];
@@ -550,7 +541,7 @@ function App() {
break; break;
} }
case 315: case "315":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHO list", text: body || "End of /WHO list",
@@ -558,20 +549,20 @@ function App() {
}); });
break; break;
case 311: { case "311": {
// RPL_WHOISUSER — params nick, user, host, *; body realname. // RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const [wiNick, wiUser, wiHost] = msg.params; const wiNick = msg.params[0];
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, text: `${wiNick} (${body})`,
system: true, system: true,
}); });
} }
break; break;
} }
case 312: { case "312": {
// RPL_WHOISSERVER — nick, server, server info. // RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) { if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -585,33 +576,7 @@ function App() {
break; break;
} }
case 313: { case "319": {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage("Server", {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
});
}
break;
}
case 317: {
// RPL_WHOISIDLE — nick, idle seconds, signon time (Unix).
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage("Server", {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
});
}
break;
}
case 319: {
// RPL_WHOISCHANNELS — nick, channels. // RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) { if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0]; const wiNick = msg.params[0];
@@ -624,7 +589,7 @@ function App() {
break; break;
} }
case 318: case "318":
addMessage("Server", { addMessage("Server", {
...base, ...base,
text: body || "End of /WHOIS list", text: body || "End of /WHOIS list",
@@ -632,21 +597,9 @@ function App() {
}); });
break; break;
case 324: { case "375":
// RPL_CHANNELMODEIS — channel, mode string. case "372":
if (Array.isArray(msg.params) && msg.params.length >= 2) { case "376":
addMessage("Server", {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
});
}
break;
}
case 375:
case 372:
case 376:
addMessage("Server", { ...base, text: body, system: true }); addMessage("Server", { ...base, text: body, system: true });
break; break;
@@ -684,14 +637,6 @@ function App() {
} catch (err) { } catch (err) {
if (!alive) break; if (!alive) break;
if (err.name === "AbortError") continue; if (err.name === "AbortError") continue;
// 401: the session is gone (QUIT from another client, or
// expiry). Reloading drops this page's state and brings
// back the login screen.
if (err.status === 401) {
window.location.reload();
return;
}
setConnected(false); setConnected(false);
await new Promise((r) => setTimeout(r, RECONNECT_DELAY)); await new Promise((r) => setTimeout(r, RECONNECT_DELAY));
} }
@@ -771,11 +716,6 @@ function App() {
const onLogin = useCallback( const onLogin = useCallback(
async (userNick, isResumed) => { async (userNick, isResumed) => {
// Read before setLoggedIn(true): from then on the effect under
// "Persist joined channels" writes the open channel tabs over it.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
setNick(userNick); setNick(userNick);
setLoggedIn(true); setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
@@ -800,6 +740,9 @@ function App() {
} }
// Fresh session — join any previously saved channels. // Fresh session — join any previously saved channels.
const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]",
);
for (const ch of saved) { for (const ch of saved) {
try { try {
await api("/messages", { await api("/messages", {
@@ -1029,22 +972,27 @@ function App() {
break; break;
} }
// With no arguments MODE is a query. A change is one sign and
// letter, then its value where it takes one: +m, +o nick.
const modeArgs = parts.slice(1); const modeArgs = parts.slice(1);
const body = if (modeArgs.length > 0) {
modeArgs.length > 0 try {
? { command: "MODE", to: tab.name, body: modeArgs } await api("/messages", {
: { command: "MODE", to: tab.name }; method: "POST",
try { body: JSON.stringify({
await api("/messages", { command: "MODE",
method: "POST", to: tab.name,
body: JSON.stringify(body), params: modeArgs,
}); }),
} catch (err) { });
} catch (err) {
addSystemMessage(
"Server",
`Mode change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage( addSystemMessage(
"Server", "Server",
`MODE failed: ${err.data?.error || "error"}`, "Usage: /mode <+/-mode> [params]",
); );
} }
@@ -1061,6 +1009,7 @@ function App() {
} catch (e) { } catch (e) {
// Ignore. // Ignore.
} }
localStorage.removeItem("neoirc_token");
localStorage.removeItem("neoirc_channels"); localStorage.removeItem("neoirc_channels");
window.location.reload(); window.location.reload();
@@ -1179,7 +1128,7 @@ function App() {
" /me action — Send an action", " /me action — Send an action",
" /nick newnick — Change your nickname", " /nick newnick — Change your nickname",
" /topic [text] — View or set channel topic", " /topic [text] — View or set channel topic",
" /mode [+/-flag [arg]] — View or set channel modes", " /mode +/-flags — Set channel modes",
" /motd — Display the message of the day", " /motd — Display the message of the day",
" /list — List all channels", " /list — List all channels",
" /who [#channel] — List users in a channel", " /who [#channel] — List users in a channel",