Bind history to the recipient session and channel, not the name (closes #148)
check / check (push) Canceled after 0s

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
those ids. A session's DM history now also follows it across nick
changes.

Model: opus-5-5
This commit is contained in:
2026-10-20 15:45:57 +00:00
parent 0b09cf838c
commit bd5caeaba9
10 changed files with 314 additions and 85 deletions
+20 -10
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.
@@ -1840,17 +1841,20 @@ 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`
@@ -2509,18 +2513,24 @@ 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) |
| `to_session_id` | INTEGER | FK → sessions.id (set to null on delete): the session a DM or `INVITE` was sent to |
| `to_channel_id` | INTEGER | FK → channels.id (set to null on delete): the channel a channel message was sent to |
| `params` | TEXT | JSON-encoded IRC-style positional parameters | | `params` | TEXT | JSON-encoded IRC-style positional parameters |
| `body` | TEXT | JSON-encoded body (array or object) | | `body` | TEXT | JSON-encoded body (array or object) |
| `meta` | TEXT | JSON-encoded metadata | | `meta` | TEXT | JSON-encoded metadata |
| `created_at` | DATETIME | Server timestamp | | `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 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`
+28 -15
View File
@@ -940,9 +940,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 +970,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,13 +1066,17 @@ 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, which
// no such message gives an error wrapping sql.ErrNoRows. // matches no message. A non-empty before is the id of a
// message 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 {
@@ -1076,8 +1088,9 @@ func (database *Database) GetHistory(
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 = ?
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 +1100,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
@@ -1109,8 +1122,7 @@ func (database *Database) GetHistory(
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 beforeID > 0 {
@@ -1118,10 +1130,11 @@ 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 = ? AND id < ? WHERE (to_session_id = ? OR to_channel_id = ?)
AND id < ?
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`, ORDER BY id DESC LIMIT ?`,
target, beforeID, limit) toSessionID, toChannelID, 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 +1148,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_session_id = ? OR to_channel_id = ?)
AND command = 'PRIVMSG' AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`, ORDER BY id DESC LIMIT ?`,
target, limit) toSessionID, toChannelID, 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
+95
View File
@@ -1671,6 +1671,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
@@ -25,7 +25,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)
+16 -12
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)
@@ -459,7 +463,7 @@ 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,
) )
if fanErr != nil { if fanErr != nil {
@@ -495,7 +499,7 @@ 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,
) )
if fanErr != nil { if fanErr != nil {
@@ -561,7 +565,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 +608,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 +670,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 +734,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 +890,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 +1078,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 +1355,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 {
+1 -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},
) )