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:**
|
**Query Parameters:**
|
||||||
|
|
||||||
| Param | Type | Default | Description |
|
| Param | Type | Default | Description |
|
||||||
| -------- | ------- | ---------- | -------------------------------------------------------------------------------- |
|
| -------- | ------- | ---------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| `target` | string | (required) | Channel name (e.g., `#general`) |
|
| `target` | string | (required) | Channel name (e.g., `#general`) |
|
||||||
| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. |
|
| `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. |
|
| `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:
|
`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.
|
`not a member of this channel`, and `403` `forbidden` for any other nick.
|
||||||
|
|
||||||
**Response:** `200 OK`
|
**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" \
|
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
|
||||||
-b cookies.txt | jq .
|
-b cookies.txt | jq .
|
||||||
|
|
||||||
# Older messages (pagination)
|
# The 50 messages before uuid-1 (pagination)
|
||||||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
|
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=uuid-1&limit=50" \
|
||||||
-b cookies.txt | jq .
|
-b cookies.txt | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2656,7 +2660,9 @@ IRC_LISTEN_ADDR: ""
|
|||||||
- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is
|
- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is
|
||||||
ignored, for compatibility with modern clients.
|
ignored, for compatibility with modern clients.
|
||||||
- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if
|
- **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
|
- **First joiner**: The first user to join a channel is automatically granted
|
||||||
operator status (`@`).
|
operator status (`@`).
|
||||||
- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external),
|
- **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
|
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 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
|
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`.
|
Each line of a multi-line `body` reaches an IRC listener user as its own
|
||||||
- A `+k`, `+l` or `+b` change made over the HTTP API reaches IRC listener
|
`PRIVMSG` or `NOTICE` line. A CR, LF or NUL inside a line is removed before it
|
||||||
members without its key, limit or mask.
|
is sent, so it cannot start a line of its own. A mode change made over the HTTP
|
||||||
- The IRC listener's `JOIN` accepts any channel name, while over the HTTP API a
|
API reaches IRC listener members as an IRC `MODE` line with its value as a
|
||||||
name that is not `#` followed by 1–63 letters, digits, `_` or `-` gets
|
separate parameter, e.g. `MODE #general +k secretpass` or
|
||||||
ERR_NOSUCHCHANNEL (403), so HTTP API users cannot join such a channel.
|
`MODE #general +o bob`.
|
||||||
- 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/127 tracks the first three and
|
One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`,
|
||||||
https://git.eeqj.de/sneak/neoirc/issues/128 the last.
|
`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
|
### Docker Usage
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -1058,17 +1058,34 @@ func (database *Database) PollMessages(
|
|||||||
return msgs, lastQID, nil
|
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(
|
func (database *Database) GetHistory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
target string,
|
target, before string,
|
||||||
beforeID int64,
|
|
||||||
limit int,
|
limit int,
|
||||||
) ([]IRCMessage, error) {
|
) ([]IRCMessage, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = defaultHistLimit
|
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(
|
rows, err := database.queryHistory(
|
||||||
ctx, target, beforeID, limit,
|
ctx, target, beforeID, limit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -721,7 +721,7 @@ func TestGetHistory(t *testing.T) {
|
|||||||
const histLimit = 5
|
const histLimit = 5
|
||||||
|
|
||||||
msgs, err := database.GetHistory(
|
msgs, err := database.GetHistory(
|
||||||
ctx, "#hist", 0, histLimit,
|
ctx, "#hist", "", histLimit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
+13
-19
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -28,10 +29,6 @@ var validNickRe = regexp.MustCompile(
|
|||||||
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
|
`^[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}$`,
|
||||||
)
|
)
|
||||||
@@ -1488,16 +1485,6 @@ 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
|
||||||
|
|
||||||
@@ -3558,10 +3545,6 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
|
|||||||
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"),
|
||||||
)
|
)
|
||||||
@@ -3570,8 +3553,19 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := hdlr.params.Database.GetHistory(
|
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 {
|
if err != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
"get history failed", "error", err,
|
"get history failed", "error", err,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"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) {
|
func TestChannelList(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
token := tserver.createSession("lister")
|
token := tserver.createSession("lister")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -683,9 +684,11 @@ func (c *Conn) applyChannelModes(
|
|||||||
if applied.Len() > 0 {
|
if applied.Len() > 0 {
|
||||||
modeReply := applied.String() + appliedArgs.String()
|
modeReply := applied.String() + appliedArgs.String()
|
||||||
|
|
||||||
c.send(FormatMessage(
|
params := slices.Concat(
|
||||||
c.hostmask(), "MODE", channel, modeReply,
|
[]string{channel}, strings.Fields(modeReply),
|
||||||
))
|
)
|
||||||
|
|
||||||
|
c.send(FormatMessage(c.hostmask(), "MODE", params...))
|
||||||
|
|
||||||
c.svc.BroadcastMode(
|
c.svc.BroadcastMode(
|
||||||
ctx, c.nick, channel, chID, modeReply,
|
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,
|
// 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(
|
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()
|
||||||
|
|
||||||
|
|||||||
+28
-12
@@ -3,6 +3,7 @@ package ircserver
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ 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, text)
|
c.deliverTextMessage(msg, command, bodyLines)
|
||||||
case command == irc.CmdJoin:
|
case command == irc.CmdJoin:
|
||||||
c.deliverJoin(msg)
|
c.deliverJoin(msg)
|
||||||
case command == irc.CmdPart:
|
case command == irc.CmdPart:
|
||||||
@@ -123,7 +124,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, text)
|
c.deliverMode(msg, bodyLines)
|
||||||
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)
|
||||||
@@ -179,10 +180,12 @@ func (c *Conn) deliverNumeric(
|
|||||||
c.send(FormatMessage(from, msg.Command, allParams...))
|
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(
|
func (c *Conn) deliverTextMessage(
|
||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
command, text string,
|
command string,
|
||||||
|
bodyLines []string,
|
||||||
) {
|
) {
|
||||||
from := msg.From
|
from := msg.From
|
||||||
target := msg.To
|
target := msg.To
|
||||||
@@ -201,7 +204,9 @@ func (c *Conn) deliverTextMessage(
|
|||||||
prefix = from + "!" + from + "@*"
|
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.
|
// 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(
|
func (c *Conn) deliverMode(
|
||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
text string,
|
bodyLines []string,
|
||||||
) {
|
) {
|
||||||
prefix := msg.From + "!" + msg.From + "@*"
|
if len(bodyLines) == 0 || bodyLines[0] == "" {
|
||||||
target := msg.To
|
return
|
||||||
|
|
||||||
if text != "" {
|
|
||||||
c.send(FormatMessage(prefix, "MODE", target, text))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
params := slices.Concat(
|
||||||
|
[]string{msg.To},
|
||||||
|
strings.Fields(bodyLines[0]),
|
||||||
|
bodyLines[1:],
|
||||||
|
)
|
||||||
|
|
||||||
|
c.send(FormatMessage(prefix, "MODE", params...))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -35,6 +36,12 @@ const (
|
|||||||
// 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}$`,
|
||||||
|
)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -473,12 +480,21 @@ 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.
|
// channel members. 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 int64,
|
sessionID int64,
|
||||||
nick, channel, suppliedKey string,
|
nick, channel, suppliedKey string,
|
||||||
) (*JoinResult, error) {
|
) (*JoinResult, error) {
|
||||||
|
if !validChannelRe.MatchString(channel) {
|
||||||
|
return nil, &IRCError{
|
||||||
|
irc.ErrNoSuchChannel,
|
||||||
|
[]string{channel},
|
||||||
|
msgNoSuchChannel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
chID, err := s.db.GetOrCreateChannel(ctx, channel)
|
chID, err := s.db.GetOrCreateChannel(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get/create channel: %w", err)
|
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user