Compare commits
2
Commits
54ecbda488
...
e9a5e55ed6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9a5e55ed6 | ||
|
|
1019419b17 |
@@ -1836,14 +1836,18 @@ Fetch historical messages for a channel. Returns messages in chronological order
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| -------- | ------- | ---------- | -------------------------------------------------------------------------------- |
|
||||
| `target` | string | (required) | Channel name (e.g., `#general`) |
|
||||
| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. |
|
||||
| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. |
|
||||
| Param | Type | Default | Description |
|
||||
| -------- | ------- | ---------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `target` | string | (required) | Channel name (e.g., `#general`) |
|
||||
| `before` | string | (none) | The `id` of a message sent to `target`; return only messages older than it. Omit for the latest. |
|
||||
| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. |
|
||||
|
||||
To page back, pass the `id` of the first (oldest) message of a page as `before`
|
||||
for the next; an empty page means there is nothing older.
|
||||
|
||||
`target` may also be your own nick, which returns the DMs sent to you. Errors:
|
||||
`400` `target required`, `404` `channel not found`, `403`
|
||||
`400` `target required`, `400` `unknown before id` when no message with that
|
||||
`id` was sent to `target`, `404` `channel not found`, `403`
|
||||
`not a member of this channel`, and `403` `forbidden` for any other nick.
|
||||
|
||||
**Response:** `200 OK`
|
||||
@@ -1881,8 +1885,8 @@ events). Event messages are delivered via the live queue only.
|
||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
|
||||
-b cookies.txt | jq .
|
||||
|
||||
# Older messages (pagination)
|
||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
|
||||
# The 50 messages before uuid-1 (pagination)
|
||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=uuid-1&limit=50" \
|
||||
-b cookies.txt | jq .
|
||||
```
|
||||
|
||||
@@ -2656,7 +2660,9 @@ IRC_LISTEN_ADDR: ""
|
||||
- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is
|
||||
ignored, for compatibility with modern clients.
|
||||
- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if
|
||||
omitted; other commands use the name as given.
|
||||
omitted; other commands use the name as given. As over the HTTP API, `JOIN`
|
||||
answers a name that is not `#` followed by 1–63 letters, digits, `_` or `-`
|
||||
with ERR_NOSUCHCHANNEL (403).
|
||||
- **First joiner**: The first user to join a channel is automatically granted
|
||||
operator status (`@`).
|
||||
- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external),
|
||||
@@ -2678,21 +2684,20 @@ IRC_LISTEN_ADDR: ""
|
||||
Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients
|
||||
and vice versa. The IRC listener and HTTP API share the same database, broker,
|
||||
and session infrastructure, so a user connected via IRC and a user connected via
|
||||
the HTTP API can talk in the same channels, with these gaps today:
|
||||
the HTTP API can talk in the same channels.
|
||||
|
||||
- An IRC listener user receives only the first line of a multi-line `body`.
|
||||
- A `+k`, `+l` or `+b` change made over the HTTP API reaches IRC listener
|
||||
members without its key, limit or mask.
|
||||
- The IRC listener's `JOIN` accepts any channel name, while over the HTTP API a
|
||||
name that is not `#` followed by 1–63 letters, digits, `_` or `-` gets
|
||||
ERR_NOSUCHCHANNEL (403), so HTTP API users cannot join such a channel.
|
||||
- The IRC listener does not pass on a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`,
|
||||
`NICK` or `QUIT` whose sender's nick matches the user's own nick ignoring
|
||||
case, so an IRC listener user `alice` never sees these from `Alice`, although
|
||||
nicks are case-sensitive.
|
||||
Each line of a multi-line `body` reaches an IRC listener user as its own
|
||||
`PRIVMSG` or `NOTICE` line. A CR, LF or NUL inside a line is removed before it
|
||||
is sent, so it cannot start a line of its own. A mode change made over the HTTP
|
||||
API reaches IRC listener members as an IRC `MODE` line with its value as a
|
||||
separate parameter, e.g. `MODE #general +k secretpass` or
|
||||
`MODE #general +o bob`.
|
||||
|
||||
https://git.eeqj.de/sneak/neoirc/issues/127 tracks the first three and
|
||||
https://git.eeqj.de/sneak/neoirc/issues/128 the last.
|
||||
One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`,
|
||||
`JOIN`, `PART`, `NICK` or `QUIT` whose sender's nick matches the user's own nick
|
||||
ignoring case, so an IRC listener user `alice` never sees these from `Alice`,
|
||||
although nicks are case-sensitive. https://git.eeqj.de/sneak/neoirc/issues/128
|
||||
tracks it.
|
||||
|
||||
### Docker Usage
|
||||
|
||||
|
||||
+20
-3
@@ -1058,17 +1058,34 @@ func (database *Database) PollMessages(
|
||||
return msgs, lastQID, nil
|
||||
}
|
||||
|
||||
// GetHistory returns message history for a target.
|
||||
// GetHistory returns message history for a target. A
|
||||
// non-empty before is the id of a message sent to target,
|
||||
// and only messages older than it are returned; an id with
|
||||
// no such message gives an error wrapping sql.ErrNoRows.
|
||||
func (database *Database) GetHistory(
|
||||
ctx context.Context,
|
||||
target string,
|
||||
beforeID int64,
|
||||
target, before string,
|
||||
limit int,
|
||||
) ([]IRCMessage, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultHistLimit
|
||||
}
|
||||
|
||||
var beforeID int64
|
||||
|
||||
if before != "" {
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT id FROM messages
|
||||
WHERE uuid = ? AND msg_to = ?`,
|
||||
before, target,
|
||||
).Scan(&beforeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"find message %q: %w", before, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := database.queryHistory(
|
||||
ctx, target, beforeID, limit,
|
||||
)
|
||||
|
||||
@@ -721,7 +721,7 @@ func TestGetHistory(t *testing.T) {
|
||||
const histLimit = 5
|
||||
|
||||
msgs, err := database.GetHistory(
|
||||
ctx, "#hist", 0, histLimit,
|
||||
ctx, "#hist", "", histLimit,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+13
-19
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -28,10 +29,6 @@ var validNickRe = regexp.MustCompile(
|
||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
||||
)
|
||||
|
||||
var validChannelRe = regexp.MustCompile(
|
||||
`^#[a-zA-Z0-9_\-]{1,63}$`,
|
||||
)
|
||||
|
||||
var validUsernameRe = regexp.MustCompile(
|
||||
`^[a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{1,32}$`,
|
||||
)
|
||||
@@ -1488,16 +1485,6 @@ func (hdlr *Handlers) handleJoin(
|
||||
channel = "#" + channel
|
||||
}
|
||||
|
||||
if !validChannelRe.MatchString(channel) {
|
||||
hdlr.respondIRCError(
|
||||
writer, request, clientID, sessionID,
|
||||
irc.ErrNoSuchChannel, nick, []string{channel},
|
||||
"No such channel",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Extract key from body lines (JOIN #channel key).
|
||||
var suppliedKey string
|
||||
|
||||
@@ -3558,10 +3545,6 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
beforeID, _ := strconv.ParseInt(
|
||||
request.URL.Query().Get("before"), 10, 64,
|
||||
)
|
||||
|
||||
limit, _ := strconv.Atoi(
|
||||
request.URL.Query().Get("limit"),
|
||||
)
|
||||
@@ -3570,8 +3553,19 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
|
||||
}
|
||||
|
||||
msgs, err := hdlr.params.Database.GetHistory(
|
||||
request.Context(), target, beforeID, limit,
|
||||
request.Context(), target,
|
||||
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 {
|
||||
hdlr.log.Error(
|
||||
"get history failed", "error", err,
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -1482,6 +1483,164 @@ func TestHistoryNonMember(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// getHistory fetches one page of target's history and
|
||||
// returns the status and, on 200, the messages on it.
|
||||
func (tserver *testServer) getHistory(
|
||||
token, target, before string,
|
||||
limit int,
|
||||
) (int, []db.IRCMessage) {
|
||||
tserver.t.Helper()
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("target", target)
|
||||
query.Set("before", before)
|
||||
query.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
resp, err := doRequestAuth(
|
||||
tserver.t, http.MethodGet,
|
||||
tserver.url("/api/v1/history?"+query.Encode()),
|
||||
token, nil,
|
||||
)
|
||||
if err != nil {
|
||||
tserver.t.Fatalf("get history: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
var msgs []db.IRCMessage
|
||||
|
||||
decErr := json.NewDecoder(resp.Body).Decode(&msgs)
|
||||
if decErr != nil {
|
||||
tserver.t.Fatalf("decode history: %v", decErr)
|
||||
}
|
||||
|
||||
return resp.StatusCode, msgs
|
||||
}
|
||||
|
||||
func TestHistoryPaging(t *testing.T) {
|
||||
const (
|
||||
channel = "#paging"
|
||||
msgCount = 5
|
||||
pageSize = 2
|
||||
wantPages = 3
|
||||
)
|
||||
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("pager")
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: channel,
|
||||
})
|
||||
|
||||
want := make([]string, 0, msgCount)
|
||||
|
||||
for i := range msgCount {
|
||||
text := fmt.Sprintf("message %d", i+1)
|
||||
want = append(want, text)
|
||||
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: channel,
|
||||
bodyKey: []string{text},
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
got []string
|
||||
pages int
|
||||
)
|
||||
|
||||
// The first page has no before; each later one passes the
|
||||
// id of the oldest message on the page fetched before it.
|
||||
before := ""
|
||||
|
||||
for range msgCount {
|
||||
status, page := tserver.getHistory(
|
||||
token, channel, before, pageSize,
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if len(page) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pages++
|
||||
|
||||
texts := make([]string, 0, len(page))
|
||||
|
||||
for _, msg := range page {
|
||||
var body []string
|
||||
|
||||
err := json.Unmarshal(msg.Body, &body)
|
||||
if err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
|
||||
texts = append(texts, body...)
|
||||
}
|
||||
|
||||
got = slices.Insert(got, 0, texts...)
|
||||
before = page[0].ID
|
||||
}
|
||||
|
||||
if pages != wantPages {
|
||||
t.Fatalf(
|
||||
"expected %d pages, got %d", wantPages, pages,
|
||||
)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryUnknownBefore(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("lost")
|
||||
|
||||
for _, channel := range []string{"#here", "#elsewhere"} {
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: joinCmd, toKey: channel,
|
||||
})
|
||||
tserver.sendCommand(token, map[string]any{
|
||||
commandKey: privmsgCmd,
|
||||
toKey: channel,
|
||||
bodyKey: []string{"hi there"},
|
||||
})
|
||||
}
|
||||
|
||||
_, elsewhere := tserver.getHistory(
|
||||
token, "#elsewhere", "", 1,
|
||||
)
|
||||
if len(elsewhere) != 1 {
|
||||
t.Fatalf(
|
||||
"expected 1 message, got %d", len(elsewhere),
|
||||
)
|
||||
}
|
||||
|
||||
cases := []struct{ name, before string }{
|
||||
{"made-up id", "no-such-message"},
|
||||
{"id from another channel", elsewhere[0].ID},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
status, _ := tserver.getHistory(
|
||||
token, "#here", tc.before, 1,
|
||||
)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf(
|
||||
"%s: expected 400, got %d", tc.name, status,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelList(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("lister")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -683,9 +684,11 @@ func (c *Conn) applyChannelModes(
|
||||
if applied.Len() > 0 {
|
||||
modeReply := applied.String() + appliedArgs.String()
|
||||
|
||||
c.send(FormatMessage(
|
||||
c.hostmask(), "MODE", channel, modeReply,
|
||||
))
|
||||
params := slices.Concat(
|
||||
[]string{channel}, strings.Fields(modeReply),
|
||||
)
|
||||
|
||||
c.send(FormatMessage(c.hostmask(), "MODE", params...))
|
||||
|
||||
c.svc.BroadcastMode(
|
||||
ctx, c.nick, channel, chID, modeReply,
|
||||
|
||||
@@ -297,11 +297,19 @@ func (c *Conn) send(line string) {
|
||||
}
|
||||
|
||||
// sendWithin writes a formatted IRC line to the connection,
|
||||
// giving up after timeout.
|
||||
// giving up after timeout. CR, LF and NUL are removed first:
|
||||
// stored text can hold them, and on the wire they would end
|
||||
// the line early. They are removed, not replaced with a
|
||||
// space, because the line is already formatted and a space
|
||||
// would split a parameter in two.
|
||||
func (c *Conn) sendWithin(
|
||||
timeout time.Duration,
|
||||
line string,
|
||||
) {
|
||||
line = strings.ReplaceAll(line, "\r", "")
|
||||
line = strings.ReplaceAll(line, "\n", "")
|
||||
line = strings.ReplaceAll(line, "\x00", "")
|
||||
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
|
||||
+28
-12
@@ -3,6 +3,7 @@ package ircserver
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -105,7 +106,7 @@ func (c *Conn) deliverIRCMessage(
|
||||
case isNumeric(command):
|
||||
c.deliverNumeric(msg, text)
|
||||
case command == irc.CmdPrivmsg || command == irc.CmdNotice:
|
||||
c.deliverTextMessage(msg, command, text)
|
||||
c.deliverTextMessage(msg, command, bodyLines)
|
||||
case command == irc.CmdJoin:
|
||||
c.deliverJoin(msg)
|
||||
case command == irc.CmdPart:
|
||||
@@ -123,7 +124,7 @@ func (c *Conn) deliverIRCMessage(
|
||||
case command == irc.CmdWallops:
|
||||
c.deliverWallops(msg, text)
|
||||
case command == irc.CmdMode:
|
||||
c.deliverMode(msg, text)
|
||||
c.deliverMode(msg, bodyLines)
|
||||
case command == irc.CmdPing:
|
||||
// Server-originated PING — reply with PONG.
|
||||
c.sendFromServer("PING", c.serverSfx)
|
||||
@@ -179,10 +180,12 @@ func (c *Conn) deliverNumeric(
|
||||
c.send(FormatMessage(from, msg.Command, allParams...))
|
||||
}
|
||||
|
||||
// deliverTextMessage sends PRIVMSG or NOTICE.
|
||||
// deliverTextMessage sends PRIVMSG or NOTICE, one line for
|
||||
// each line of the body.
|
||||
func (c *Conn) deliverTextMessage(
|
||||
msg *db.IRCMessage,
|
||||
command, text string,
|
||||
command string,
|
||||
bodyLines []string,
|
||||
) {
|
||||
from := msg.From
|
||||
target := msg.To
|
||||
@@ -201,7 +204,9 @@ func (c *Conn) deliverTextMessage(
|
||||
prefix = from + "!" + from + "@*"
|
||||
}
|
||||
|
||||
c.send(FormatMessage(prefix, command, target, text))
|
||||
for _, line := range bodyLines {
|
||||
c.send(FormatMessage(prefix, command, target, line))
|
||||
}
|
||||
}
|
||||
|
||||
// deliverJoin sends a JOIN notification.
|
||||
@@ -351,15 +356,26 @@ func (c *Conn) deliverWallops(
|
||||
))
|
||||
}
|
||||
|
||||
// deliverMode sends a MODE change notification.
|
||||
// deliverMode sends a MODE change notification. The first
|
||||
// body line is the change, possibly followed by its values
|
||||
// separated by spaces ("+o bob"); any further lines are
|
||||
// values ("+k", "secret"). Each goes out as its own
|
||||
// parameter, as IRC sends them.
|
||||
func (c *Conn) deliverMode(
|
||||
msg *db.IRCMessage,
|
||||
text string,
|
||||
bodyLines []string,
|
||||
) {
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
target := msg.To
|
||||
|
||||
if text != "" {
|
||||
c.send(FormatMessage(prefix, "MODE", target, text))
|
||||
if len(bodyLines) == 0 || bodyLines[0] == "" {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := msg.From + "!" + msg.From + "@*"
|
||||
|
||||
params := slices.Concat(
|
||||
[]string{msg.To},
|
||||
strings.Fields(bodyLines[0]),
|
||||
bodyLines[1:],
|
||||
)
|
||||
|
||||
c.send(FormatMessage(prefix, "MODE", params...))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package ircserver_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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, 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)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
|
||||
env.brk.Notify(sessionID)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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{"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 :the end",
|
||||
}
|
||||
|
||||
got := lines[max(0, len(lines)-len(want)):]
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -438,6 +438,37 @@ func TestJoinChannel(t *testing.T) {
|
||||
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
|
||||
}
|
||||
|
||||
// TestJoinInvalidChannelName checks that JOIN refuses a name
|
||||
// that is not # followed by 1 to 63 letters, digits, _ or -
|
||||
// with ERR_NOSUCHCHANNEL, and creates no such channel.
|
||||
func TestJoinInvalidChannelName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
client := env.dial(t)
|
||||
|
||||
client.register("joiner")
|
||||
|
||||
for _, channel := range []string{
|
||||
"#bad.name",
|
||||
"#",
|
||||
"#" + strings.Repeat("a", 64),
|
||||
} {
|
||||
lines := client.sendAndExpect("JOIN "+channel, " 403 ")
|
||||
|
||||
assertContains(
|
||||
t, lines,
|
||||
" 403 joiner "+channel+" :No such channel",
|
||||
channel,
|
||||
)
|
||||
|
||||
_, err := env.database.GetChannelByName(t.Context(), channel)
|
||||
if err == nil {
|
||||
t.Errorf("channel %q was created", channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivmsgBetweenClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -35,6 +36,12 @@ const (
|
||||
// for (RFC 2812).
|
||||
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}$`,
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating a Service.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -473,12 +480,21 @@ func (s *Service) SendDirectMessage(
|
||||
|
||||
// JoinChannel creates or joins a channel, making the
|
||||
// first joiner the operator. Fans out the JOIN to all
|
||||
// channel members.
|
||||
// channel members. A channel name that validChannelRe
|
||||
// does not match gets ERR_NOSUCHCHANNEL.
|
||||
func (s *Service) JoinChannel(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, channel, suppliedKey string,
|
||||
) (*JoinResult, error) {
|
||||
if !validChannelRe.MatchString(channel) {
|
||||
return nil, &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
msgNoSuchChannel,
|
||||
}
|
||||
}
|
||||
|
||||
chID, err := s.db.GetOrCreateChannel(ctx, channel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user