Bind history to the recipient session and channel, not the name (closes #148)
check / check (push) Waiting to run

History was selected by msg_to, so whoever next held a released nick, or
created a deleted channel again under its old name, could read the
earlier holder's messages. Each message row now records the session a
DM or INVITE was sent to (to_session_id) and the channel a channel
message was sent to (to_channel_id), and GET /api/v1/history selects by
the one that applies, using its index. A session's DM history now also
follows it across nick changes. A DM or channel message whose recipient
is deleted before it is stored gets the unknown nick or channel error.

Model: opus-5-5
This commit was merged in pull request #161.
This commit is contained in:
2026-10-20 19:48:35 +02:00
parent 5100b9a665
commit 164fc93a48
11 changed files with 569 additions and 135 deletions
+33 -23
View File
@@ -376,9 +376,10 @@ balancer, and CDN handles it correctly.
- DMs are **fan-out to both sender and recipient** — the sender sees their own - DMs are **fan-out to both sender and recipient** — the sender sees their own
DM echoed back in their message queue, enabling multi-client consistency (your DM echoed back in their message queue, enabling multi-client consistency (your
laptop sees DMs you sent from your phone). laptop sees DMs you sent from your phone).
- DM history is stored in the `messages` table with the recipient nick as the - DM history is stored in the `messages` table with the recipient's session as
`msg_to` field. This means DM history is queryable per-nick, but if a user the `to_session_id` field. A user's DM history therefore follows them across
changes their nick, old DMs are associated with the old nick. nick changes, and a user who later takes a released nick sees none of the DMs
sent to its previous holder.
- DMs are **not stored long-term** by default — they follow the same rotation - DMs are **not stored long-term** by default — they follow the same rotation
policy as channel messages. policy as channel messages.
@@ -1839,18 +1840,21 @@ 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` | string | (none) | The `id` of a message sent to `target`; return only messages older than it. Omit for the latest. | | `before` | string | (none) | The `id` of a message in this history; 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` 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. 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 your session,
`400` `target required`, `400` `unknown before id` when no message with that including those sent under its earlier nicks. A channel created again after its
`id` was sent to `target`, `404` `channel not found`, `403` last member left starts with no history, and a user who takes a released nick
gets none of the DMs sent to its previous holder. Errors: `400`
`target required`, `400` `unknown before id` when no message with that `id` is
in this history, `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`
@@ -2508,19 +2512,25 @@ Unique constraint on `(channel_id, session_id)`.
#### `messages` #### `messages`
| Column | Type | Description | | Column | Type | Description |
| ------------ | -------- | --------------------------------------------------------------- | | --------------- | -------- | ----------------------------------------------------------------------------------- |
| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | | `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. |
| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | | `uuid` | TEXT | UUID v4, exposed to clients as the message `id` |
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | | `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
| `msg_from` | TEXT | Sender nick | | `msg_from` | TEXT | Sender nick |
| `msg_to` | TEXT | Target (`#channel` or nick) | | `msg_to` | TEXT | Target (`#channel` or nick) |
| `params` | TEXT | JSON-encoded IRC-style positional parameters | | `to_session_id` | INTEGER | FK → sessions.id (set to null on delete): the session a DM or `INVITE` was sent to |
| `body` | TEXT | JSON-encoded body (array or object) | | `to_channel_id` | INTEGER | FK → channels.id (set to null on delete): the channel a channel message was sent to |
| `meta` | TEXT | JSON-encoded metadata | | `params` | TEXT | JSON-encoded IRC-style positional parameters |
| `created_at` | DATETIME | Server timestamp | | `body` | TEXT | JSON-encoded body (array or object) |
| `meta` | TEXT | JSON-encoded metadata |
| `created_at` | DATETIME | Server timestamp |
Indexes on `(msg_to, id)` and `(created_at)`. Indexes on `(to_session_id, id)`, `(to_channel_id, id)` and `(created_at)`.
`to_session_id` and `to_channel_id` are null on a message sent to neither, such
as `QUIT`. `GET /api/v1/history` selects by one of these two columns rather than
by `msg_to`, because nicks and channel names are reused once released, and
session and channel ids are not.
#### `spent_hashcash` #### `spent_hashcash`
+11
View File
@@ -17,3 +17,14 @@ 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
}
+33 -17
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -940,9 +941,14 @@ 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, params json.RawMessage,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage, meta json.RawMessage,
@@ -965,9 +971,12 @@ func (database *Database) InsertMessage(
res, err := database.conn.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 (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0),
?, ?, ?, ?)`,
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(
@@ -1058,26 +1067,31 @@ func (database *Database) PollMessages(
return msgs, lastQID, nil return msgs, lastQID, nil
} }
// GetHistory returns message history for a target. A // GetHistory returns the PRIVMSG history of one recipient:
// non-empty before is the id of a message sent to target, // the DMs sent to session toSessionID, or the messages sent
// and only messages older than it are returned; an id with // to channel toChannelID. Pass 0 for the other. A non-empty
// no such message gives an error wrapping sql.ErrNoRows. // 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,
target, before string, toSessionID, toChannelID int64,
before string,
limit int, limit int,
) ([]IRCMessage, error) { ) ([]IRCMessage, error) {
if limit <= 0 { if limit <= 0 {
limit = defaultHistLimit limit = defaultHistLimit
} }
var beforeID int64 // With no before, every message is older than this.
beforeID := int64(math.MaxInt64)
if before != "" { if before != "" {
err := database.conn.QueryRowContext(ctx, err := database.conn.QueryRowContext(ctx,
`SELECT id FROM messages `SELECT id FROM messages
WHERE uuid = ? AND msg_to = ?`, WHERE uuid = ? AND command = 'PRIVMSG'
before, target, AND (to_session_id = ? OR to_channel_id = ?)`,
before, toSessionID, toChannelID,
).Scan(&beforeID) ).Scan(&beforeID)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -1087,7 +1101,7 @@ func (database *Database) GetHistory(
} }
rows, err := database.queryHistory( rows, err := database.queryHistory(
ctx, target, beforeID, limit, ctx, toSessionID, toChannelID, beforeID, limit,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1107,21 +1121,23 @@ 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,
target string, toSessionID, toChannelID, beforeID int64,
beforeID int64,
limit int, limit int,
) (*sql.Rows, error) { ) (*sql.Rows, error) {
if beforeID > 0 { if toSessionID != 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 msg_to = ? AND id < ? WHERE to_session_id = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`, ORDER BY id DESC LIMIT ?`,
target, beforeID, limit) toSessionID, beforeID, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"query history: %w", err, "query history: %w", err,
@@ -1135,10 +1151,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 msg_to = ? WHERE to_channel_id = ? AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`, ORDER BY id DESC LIMIT ?`,
target, limit) toChannelID, beforeID, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("query history: %w", err) return nil, fmt.Errorf("query history: %w", err)
} }
+95 -6
View File
@@ -619,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", nil, body, nil, ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -653,7 +653,7 @@ 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", nil, body, nil, ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -706,11 +706,16 @@ 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", ctx, "PRIVMSG", "user", "#hist", 0, chID,
nil, json.RawMessage(`["msg"]`), nil, nil, json.RawMessage(`["msg"]`), nil,
) )
if err != nil { if err != nil {
@@ -721,7 +726,7 @@ func TestGetHistory(t *testing.T) {
const histLimit = 5 const histLimit = 5
msgs, err := database.GetHistory( msgs, err := database.GetHistory(
ctx, "#hist", "", histLimit, ctx, 0, chID, "", histLimit,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -737,6 +742,90 @@ func TestGetHistory(t *testing.T) {
} }
} }
// TestGetHistoryReusedNames checks that history follows the
// session and the channel row, not the nick or the channel
// name: a session that takes a released nick, and a channel
// created again under a deleted one's name, start with none.
func TestGetHistoryReusedNames(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
historyLen := func(toSessionID, toChannelID int64) int {
t.Helper()
msgs, err := database.GetHistory(
ctx, toSessionID, toChannelID, "", 0,
)
if err != nil {
t.Fatal(err)
}
return len(msgs)
}
oldSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
)
if err != nil {
t.Fatal(err)
}
oldChID, err := database.GetOrCreateChannel(ctx, "#reused")
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 {
t.Fatal(err)
}
_, _, err = database.InsertMessage(
ctx, "PRIVMSG", "sender", "#reused", 0, oldChID,
nil, body, nil,
)
if err != nil {
t.Fatal(err)
}
if historyLen(oldSID, 0) != 1 || historyLen(0, oldChID) != 1 {
t.Fatal("expected one DM and one channel message")
}
err = database.DeleteSession(ctx, oldSID)
if err != nil {
t.Fatal(err)
}
err = database.DeleteChannelIfEmpty(ctx, oldChID)
if err != nil {
t.Fatal(err)
}
newSID, _, _, err := database.CreateSession(
ctx, "reused", "", "", "",
)
if err != nil {
t.Fatal(err)
}
newChID, err := database.GetOrCreateChannel(ctx, "#reused")
if err != nil {
t.Fatal(err)
}
if historyLen(newSID, 0) != 0 || historyLen(0, newChID) != 0 {
t.Fatal("expected no history for the names' new holders")
}
}
func TestDeleteSession(t *testing.T) { func TestDeleteSession(t *testing.T) {
t.Parallel() t.Parallel()
@@ -863,7 +952,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", nil, body, nil, ctx, "PRIVMSG", "sender", "#ch", 0, 0, nil, body, nil,
) )
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+8 -2
View File
@@ -85,19 +85,25 @@ CREATE TABLE IF NOT EXISTS channel_members (
UNIQUE(channel_id, session_id) UNIQUE(channel_id, session_id)
); );
-- Messages: IRC envelope format -- Messages: IRC envelope format. to_session_id is the session a DM
-- or INVITE was sent to, to_channel_id the channel a channel message
-- was sent to. History is selected by these ids because nicks and
-- channel names are reused once released, and ids are not.
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_id ON messages(msg_to, id); CREATE INDEX IF NOT EXISTS idx_messages_to_session ON messages(to_session_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_to_channel ON messages(to_channel_id, id);
CREATE INDEX IF NOT EXISTS idx_messages_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)
+36 -24
View File
@@ -566,7 +566,7 @@ func (hdlr *Handlers) enqueueNumeric(
} }
dbID, _, insertErr := hdlr.params.Database.InsertMessage( dbID, _, insertErr := hdlr.params.Database.InsertMessage(
ctx, command, hdlr.serverName(), nick, ctx, command, hdlr.serverName(), nick, 0, 0,
paramsJSON, json.RawMessage(body), nil, paramsJSON, json.RawMessage(body), nil,
) )
if insertErr != nil { if insertErr != nil {
@@ -652,7 +652,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,
nil, nil, nil, 0, chanInfo.ID, nil, nil, nil,
) )
if insErr != nil { if insErr != nil {
hdlr.log.Error( hdlr.log.Error(
@@ -2404,7 +2404,7 @@ func (hdlr *Handlers) executeBanChange(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2522,7 +2522,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, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2571,7 +2571,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, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2646,7 +2646,7 @@ func (hdlr *Handlers) setChannelLimitMode(
) )
if mErr == nil { if mErr == nil {
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdMode, nick, channel, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2696,7 +2696,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, request, irc.CmdMode, nick, channel, 0, chID,
json.RawMessage(modeBody), memberIDs, json.RawMessage(modeBody), memberIDs,
) )
} }
@@ -2755,7 +2755,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,
json.RawMessage(invBody), targetSID, 0, json.RawMessage(invBody),
[]int64{targetSID}, []int64{targetSID},
) )
} }
@@ -3250,9 +3250,10 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
return return
} }
if !hdlr.canAccessHistory( toSessionID, toChannelID, ok := hdlr.canAccessHistory(
writer, request, sessionID, nick, target, writer, request, sessionID, nick, target,
) { )
if !ok {
return return
} }
@@ -3264,7 +3265,7 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
} }
msgs, err := hdlr.params.Database.GetHistory( msgs, err := hdlr.params.Database.GetHistory(
request.Context(), target, request.Context(), toSessionID, toChannelID,
request.URL.Query().Get("before"), limit, request.URL.Query().Get("before"), limit,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -3297,17 +3298,23 @@ 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). // for the given target (channel or DM participant) and
// returns the session id and channel id to pass to
// GetHistory: the user's own session for its nick, or the
// channel's id, with 0 for the other. On false it has
// written the error response.
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,
) bool { ) (int64, int64, bool) {
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
return hdlr.canAccessChannelHistory( chID, ok := 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
@@ -3319,18 +3326,20 @@ func (hdlr *Handlers) canAccessHistory(
http.StatusForbidden, http.StatusForbidden,
) )
return false return 0, 0, false
} }
return true return sessionID, 0, 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,
) bool { ) (int64, bool) {
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target, request.Context(), target,
) )
@@ -3341,7 +3350,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusNotFound, http.StatusNotFound,
) )
return false return 0, false
} }
isMember, err := hdlr.params.Database.IsChannelMember( isMember, err := hdlr.params.Database.IsChannelMember(
@@ -3357,7 +3366,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusInternalServerError, http.StatusInternalServerError,
) )
return false return 0, false
} }
if !isMember { if !isMember {
@@ -3367,10 +3376,10 @@ func (hdlr *Handlers) canAccessChannelHistory(
http.StatusForbidden, http.StatusForbidden,
) )
return false return 0, false
} }
return true return chID, true
} }
// HandleLogout deletes the authenticated client's token // HandleLogout deletes the authenticated client's token
@@ -3679,17 +3688,19 @@ 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. // sessions. toSessionID and toChannelID are the recipient
// 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,
nil, body, meta, toSessionID, toChannelID, nil, body, meta,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -3714,12 +3725,13 @@ 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,
body, nil, sessionIDs, toSessionID, toChannelID, body, nil, sessionIDs,
) )
return err return err
+242 -38
View File
@@ -1551,6 +1551,60 @@ func (tserver *testServer) getHistory(
return resp.StatusCode, msgs return resp.StatusCode, msgs
} }
// getAllHistory pages back through target's history
// pageSize messages at a time and returns the message texts,
// oldest first, and the number of pages that held any. It
// stops after maxPages pages, so a before that is ignored
// cannot loop forever.
func (tserver *testServer) getAllHistory(
token, target string,
pageSize, maxPages int,
) ([]string, int) {
tserver.t.Helper()
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 maxPages {
status, page := tserver.getHistory(
token, target, before, pageSize,
)
if status != http.StatusOK {
tserver.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 {
tserver.t.Fatalf("decode body: %v", err)
}
texts = append(texts, body...)
}
got = slices.Insert(got, 0, texts...)
before = page[0].ID
}
return got, pages
}
func TestHistoryPaging(t *testing.T) { func TestHistoryPaging(t *testing.T) {
const ( const (
channel = "#paging" channel = "#paging"
@@ -1579,46 +1633,10 @@ func TestHistoryPaging(t *testing.T) {
}) })
} }
var ( got, pages := tserver.getAllHistory(
got []string token, channel, pageSize, msgCount,
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 { if pages != wantPages {
t.Fatalf( t.Fatalf(
"expected %d pages, got %d", wantPages, pages, "expected %d pages, got %d", wantPages, pages,
@@ -1630,6 +1648,81 @@ func TestHistoryPaging(t *testing.T) {
} }
} }
// TestHistoryDMPaging pages through a nick's DM history,
// which holds the DMs its session got under an earlier nick
// and none sent to another session, and checks that the id
// of a DM sent to another session is not accepted as before.
func TestHistoryDMPaging(t *testing.T) {
const (
firstNick = "readerFirst"
laterNick = "readerLater"
otherNick = "otherReader"
// DMs sent to firstNick before the nick change.
sentEarly = 2
pageSize = 2
wantPages = 3
)
tserver := newTestServer(t)
senderToken := tserver.createSession("dmPager")
readerToken := tserver.createSession(firstNick)
otherToken := tserver.createSession(otherNick)
sendDM := func(to, text string) {
tserver.sendCommand(senderToken, map[string]any{
commandKey: privmsgCmd,
toKey: to,
bodyKey: []string{text},
})
}
want := []string{"dm 1", "dm 2", "dm 3", "dm 4", "dm 5"}
for _, text := range want[:sentEarly] {
sendDM(firstNick, text)
}
_, result := tserver.sendCommand(readerToken, map[string]any{
commandKey: irc.CmdNick,
bodyKey: []string{laterNick},
})
if result[nickKey] != laterNick {
t.Fatalf("expected nick %s, got %v", laterNick, result)
}
sendDM(otherNick, "not for the reader")
for _, text := range want[sentEarly:] {
sendDM(laterNick, text)
}
got, pages := tserver.getAllHistory(
readerToken, laterNick, pageSize, len(want),
)
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)
}
_, otherDMs := tserver.getHistory(otherToken, otherNick, "", 1)
if len(otherDMs) != 1 {
t.Fatalf("expected 1 DM, got %d", len(otherDMs))
}
status, _ := tserver.getHistory(
readerToken, laterNick, otherDMs[0].ID, pageSize,
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
func TestHistoryUnknownBefore(t *testing.T) { func TestHistoryUnknownBefore(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("lost") token := tserver.createSession("lost")
@@ -1654,9 +1747,25 @@ func TestHistoryUnknownBefore(t *testing.T) {
) )
} }
// History holds only PRIVMSG, so the id of the JOIN to
// #here is not in it.
var joinID string
queued, _ := tserver.pollMessages(token, 0)
for _, msg := range queued {
if msg[commandKey] == joinCmd && msg[toKey] == "#here" {
joinID, _ = msg["id"].(string)
}
}
if joinID == "" {
t.Fatal("expected a JOIN to #here in the queue")
}
cases := []struct{ name, before string }{ cases := []struct{ name, before string }{
{"made-up id", "no-such-message"}, {"made-up id", "no-such-message"},
{"id from another channel", elsewhere[0].ID}, {"id from another channel", elsewhere[0].ID},
{"id of a JOIN", joinID},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -1671,6 +1780,101 @@ func TestHistoryUnknownBefore(t *testing.T) {
} }
} }
// TestHistoryReusedNick checks that a user who takes a nick
// released by QUIT does not get the DMs sent to its previous
// holder.
func TestHistoryReusedNick(t *testing.T) {
const victim = "victimV"
tserver := newTestServer(t)
senderToken := tserver.createSession("dmA")
victimToken := tserver.createSession(victim)
newcomerToken := tserver.createSession("newcomer")
tserver.sendCommand(senderToken, map[string]any{
commandKey: privmsgCmd,
toKey: victim,
bodyKey: []string{"for the first victimV only"},
})
_, msgs := tserver.getHistory(victimToken, victim, "", 10)
if len(msgs) != 1 {
t.Fatalf("expected 1 DM for %s, got %d", victim, len(msgs))
}
tserver.sendCommand(victimToken, map[string]any{
commandKey: irc.CmdQuit,
})
_, result := tserver.sendCommand(newcomerToken, map[string]any{
commandKey: irc.CmdNick,
bodyKey: []string{victim},
})
if result[nickKey] != victim {
t.Fatalf("expected nick %s, got %v", victim, result)
}
status, msgs := tserver.getHistory(newcomerToken, victim, "", 10)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if len(msgs) != 0 {
t.Fatalf("expected no history, got %v", msgs)
}
}
// TestHistoryRecreatedChannel checks that a channel created
// again under the name of a deleted one starts with no
// history, even when the old one had a key.
func TestHistoryRecreatedChannel(t *testing.T) {
const channel = "#priv"
tserver := newTestServer(t)
ownerToken := tserver.createSession("roomR")
strangerToken := tserver.createSession("stranger")
tserver.sendCommand(ownerToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(ownerToken, map[string]any{
commandKey: irc.CmdMode,
toKey: channel,
bodyKey: []string{"+k", "topsecret"},
})
tserver.sendCommand(ownerToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"members only"},
})
_, msgs := tserver.getHistory(ownerToken, channel, "", 10)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
// The last member leaving deletes the channel.
tserver.sendCommand(ownerToken, map[string]any{
commandKey: irc.CmdPart, toKey: channel,
})
_, result := tserver.sendCommand(strangerToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
if result[statusKey] != joinedStatus {
t.Fatalf("expected the join to succeed, got %v", result)
}
status, msgs := tserver.getHistory(strangerToken, channel, "", 10)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if len(msgs) != 0 {
t.Fatalf("expected no history, got %v", msgs)
}
}
func TestChannelList(t *testing.T) { func TestChannelList(t *testing.T) {
tserver := newTestServer(t) tserver := newTestServer(t)
token := tserver.createSession("lister") token := tserver.createSession("lister")
+1 -1
View File
@@ -1268,7 +1268,7 @@ func (c *Conn) handleInvite(
) )
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.nick, targetNick, ctx, "INVITE", c.nick, targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, nil, body, nil, []int64{targetID},
) )
} }
+1 -1
View File
@@ -28,7 +28,7 @@ func (env *testEnv) queueMessage(
} }
msgID, _, err := env.database.InsertMessage( msgID, _, err := env.database.InsertMessage(
ctx, command, from, to, nil, bodyJSON, nil, ctx, command, from, to, 0, 0, nil, bodyJSON, nil,
) )
if err != nil { if err != nil {
t.Fatalf("insert message: %v", err) t.Fatalf("insert message: %v", err)
+36 -22
View File
@@ -311,7 +311,7 @@ func (s *Service) SendWallops(
} }
_, _, err = s.FanOut( _, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*", ctx, irc.CmdWallops, nick, "*", 0, 0,
nil, body, nil, recipients, nil, body, nil, recipients,
) )
if err != nil { if err != nil {
@@ -344,15 +344,19 @@ 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. // session IDs, notifying each via the broker. toSessionID
// and toChannelID are the recipient InsertMessage records
// for history, 0 when there is none.
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,
) (int64, string, error) { ) (int64, string, error) {
dbID, msgUUID, err := s.db.InsertMessage( dbID, msgUUID, err := s.db.InsertMessage(
ctx, command, from, to, params, body, meta, ctx, command, from, to,
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)
@@ -396,13 +400,13 @@ func (s *Service) SendChannelMessage(
nick, command, channel string, nick, command, channel string,
body, meta json.RawMessage, body, meta json.RawMessage,
) (int64, string, error) { ) (int64, string, error) {
noSuchChannel := &IRCError{
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
}
chID, err := s.db.GetChannelByName(ctx, channel) chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil { if err != nil {
return 0, "", &IRCError{ return 0, "", noSuchChannel
irc.ErrNoSuchChannel,
[]string{channel},
msgNoSuchChannel,
}
} }
if command == irc.CmdPrivmsg { if command == irc.CmdPrivmsg {
@@ -459,9 +463,14 @@ func (s *Service) SendChannelMessage(
recipients := excludeSession(memberIDs, sessionID) recipients := excludeSession(memberIDs, sessionID)
dbID, uuid, fanErr := s.FanOut( dbID, uuid, fanErr := s.FanOut(
ctx, command, nick, channel, ctx, command, nick, channel, 0, chID,
nil, body, meta, recipients, nil, body, meta, recipients,
) )
// The channel was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) {
return 0, "", noSuchChannel
}
if fanErr != nil { if fanErr != nil {
return 0, "", fanErr return 0, "", fanErr
} }
@@ -478,13 +487,13 @@ func (s *Service) SendDirectMessage(
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, &IRCError{ return nil, noSuchNick
irc.ErrNoSuchNick,
[]string{target},
"No such nick",
}
} }
away, _ := s.db.GetAway(ctx, targetSID) away, _ := s.db.GetAway(ctx, targetSID)
@@ -495,9 +504,14 @@ func (s *Service) SendDirectMessage(
} }
_, uuid, fanErr := s.FanOut( _, uuid, fanErr := s.FanOut(
ctx, command, nick, target, ctx, command, nick, target, targetSID, 0,
nil, body, meta, recipients, 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
} }
@@ -561,7 +575,7 @@ func (s *Service) JoinChannel(
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, ctx, irc.CmdJoin, nick, channel, 0, chID,
nil, body, nil, memberIDs, nil, body, nil, memberIDs,
) )
@@ -604,7 +618,7 @@ func (s *Service) PartChannel(
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, ctx, irc.CmdPart, nick, channel, 0, chID,
nil, body, nil, recipients, nil, body, nil, recipients,
) )
@@ -666,7 +680,7 @@ 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, ctx, irc.CmdTopic, nick, channel, 0, chID,
nil, body, nil, memberIDs, nil, body, nil, memberIDs,
) )
@@ -730,7 +744,7 @@ func (s *Service) KickUser(
) )
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdKick, nick, channel, ctx, irc.CmdKick, nick, channel, 0, chID,
params, body, nil, memberIDs, params, body, nil, memberIDs,
) )
@@ -886,7 +900,7 @@ func (s *Service) BroadcastQuit(
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
_, _, _ = s.FanOut( _, _, _ = s.FanOut(
ctx, irc.CmdQuit, nick, "", ctx, irc.CmdQuit, nick, "", 0, 0,
nil, body, nil, recipients, nil, body, nil, recipients,
) )
} }
@@ -1074,7 +1088,7 @@ 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, ctx, irc.CmdMode, nick, channel, 0, chID,
nil, body, nil, memberIDs, nil, body, nil, memberIDs,
) )
} }
@@ -1351,7 +1365,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, "", ctx, irc.CmdNick, oldNick, "", 0, 0,
nil, body, nil, nil, body, nil,
) )
if insErr != nil { if insErr != nil {
+73 -1
View File
@@ -164,7 +164,7 @@ func TestFanOut(t *testing.T) {
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
dbID, uuid, err := env.svc.FanOut( dbID, uuid, err := env.svc.FanOut(
ctx, irc.CmdPrivmsg, "alice", "#test", ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
nil, body, nil, nil, body, nil,
[]int64{sid1, sid2}, []int64{sid1, sid2},
) )
@@ -339,6 +339,78 @@ func TestBroadcastQuit(t *testing.T) {
} }
} }
// TestSendDirectMessageTargetQuits checks that a DM whose
// target quits after its nick is looked up gets the same
// error as a DM to an unknown nick. The trigger deletes the
// target's session just before the DM is stored.
func TestSendDirectMessageTargetQuits(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
createSession(ctx, t, env.db, "bob")
_, err := env.db.GetDB().ExecContext(ctx,
`CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages
BEGIN
DELETE FROM sessions WHERE id = NEW.to_session_id;
END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendDirectMessage(
ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil,
)
var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrNoSuchNick {
t.Fatalf("want ERR_NOSUCHNICK, got %v", err)
}
}
// TestSendChannelMessageChannelDeleted checks that a channel
// message whose channel is deleted after it is looked up gets
// the same error as a message to an unknown channel. The
// trigger deletes the channel just before the message is
// stored.
func TestSendChannelMessageChannelDeleted(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.db.GetDB().ExecContext(ctx,
`CREATE TRIGGER delete_recipient
BEFORE INSERT ON messages
BEGIN
DELETE FROM channels WHERE id = NEW.to_channel_id;
END`)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, _, err = env.svc.SendChannelMessage(
ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
)
var ircErr *service.IRCError
if !errors.As(err, &ircErr) ||
ircErr.Code != irc.ErrNoSuchChannel {
t.Fatalf("want ERR_NOSUCHCHANNEL, got %v", err)
}
}
func TestSendChannelMessage_Moderated(t *testing.T) { func TestSendChannelMessage_Moderated(t *testing.T) {
env := newTestEnv(t) env := newTestEnv(t)
ctx := t.Context() ctx := t.Context()