Bind history to the recipient session and channel, not the name (closes #148)
check / check (push) Waiting to run
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 those ids. A session's DM history now also follows it across nick changes. Model: opus-5-5
This commit is contained in:
@@ -376,9 +376,10 @@ balancer, and CDN handles it correctly.
|
||||
- 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
|
||||
laptop sees DMs you sent from your phone).
|
||||
- DM history is stored in the `messages` table with the recipient nick as the
|
||||
`msg_to` field. This means DM history is queryable per-nick, but if a user
|
||||
changes their nick, old DMs are associated with the old nick.
|
||||
- DM history is stored in the `messages` table with the recipient's session as
|
||||
the `to_session_id` field. A user's DM history therefore follows them across
|
||||
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
|
||||
policy as channel messages.
|
||||
|
||||
@@ -1840,17 +1841,20 @@ Fetch historical messages for a channel. Returns messages in chronological order
|
||||
**Query Parameters:**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| -------- | ------- | ---------- | ------------------------------------------------------------------------------------------------ |
|
||||
| -------- | ------- | ---------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `target` | string | (required) | Channel name (e.g., `#general`) |
|
||||
| `before` | 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. |
|
||||
|
||||
To page back, pass the `id` of the first (oldest) message of a page as `before`
|
||||
for the next; an empty page means there is nothing older.
|
||||
|
||||
`target` may also be your own nick, which returns the DMs sent to you. Errors:
|
||||
`400` `target required`, `400` `unknown before id` when no message with that
|
||||
`id` was sent to `target`, `404` `channel not found`, `403`
|
||||
`target` may also be your own nick, which returns the DMs sent to your session,
|
||||
including those sent under its earlier nicks. A channel created again after its
|
||||
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.
|
||||
|
||||
**Response:** `200 OK`
|
||||
@@ -2509,18 +2513,24 @@ Unique constraint on `(channel_id, session_id)`.
|
||||
#### `messages`
|
||||
|
||||
| Column | Type | Description |
|
||||
| ------------ | -------- | --------------------------------------------------------------- |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------------- |
|
||||
| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. |
|
||||
| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` |
|
||||
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
|
||||
| `msg_from` | TEXT | Sender 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 |
|
||||
| `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 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`
|
||||
|
||||
|
||||
+28
-15
@@ -940,9 +940,14 @@ func (database *Database) GetSessionChannelIDs(
|
||||
}
|
||||
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
command, from, target string,
|
||||
toSessionID, toChannelID int64,
|
||||
params json.RawMessage,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
@@ -965,9 +970,12 @@ func (database *Database) InsertMessage(
|
||||
res, err := database.conn.ExecContext(ctx,
|
||||
`INSERT INTO messages
|
||||
(uuid, command, msg_from, msg_to,
|
||||
to_session_id, to_channel_id,
|
||||
params, body, meta, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0),
|
||||
?, ?, ?, ?)`,
|
||||
msgUUID, command, from, target,
|
||||
toSessionID, toChannelID,
|
||||
string(params), string(body), string(meta), now)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf(
|
||||
@@ -1058,13 +1066,17 @@ func (database *Database) PollMessages(
|
||||
return msgs, lastQID, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// GetHistory returns the PRIVMSG history of one recipient:
|
||||
// the DMs sent to session toSessionID, or the messages sent
|
||||
// to channel toChannelID. Pass 0 for the other, which
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
target, before string,
|
||||
toSessionID, toChannelID int64,
|
||||
before string,
|
||||
limit int,
|
||||
) ([]IRCMessage, error) {
|
||||
if limit <= 0 {
|
||||
@@ -1076,8 +1088,9 @@ func (database *Database) GetHistory(
|
||||
if before != "" {
|
||||
err := database.conn.QueryRowContext(ctx,
|
||||
`SELECT id FROM messages
|
||||
WHERE uuid = ? AND msg_to = ?`,
|
||||
before, target,
|
||||
WHERE uuid = ?
|
||||
AND (to_session_id = ? OR to_channel_id = ?)`,
|
||||
before, toSessionID, toChannelID,
|
||||
).Scan(&beforeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
@@ -1087,7 +1100,7 @@ func (database *Database) GetHistory(
|
||||
}
|
||||
|
||||
rows, err := database.queryHistory(
|
||||
ctx, target, beforeID, limit,
|
||||
ctx, toSessionID, toChannelID, beforeID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1109,8 +1122,7 @@ func (database *Database) GetHistory(
|
||||
|
||||
func (database *Database) queryHistory(
|
||||
ctx context.Context,
|
||||
target string,
|
||||
beforeID int64,
|
||||
toSessionID, toChannelID, beforeID int64,
|
||||
limit int,
|
||||
) (*sql.Rows, error) {
|
||||
if beforeID > 0 {
|
||||
@@ -1118,10 +1130,11 @@ func (database *Database) queryHistory(
|
||||
`SELECT id, uuid, command, msg_from,
|
||||
msg_to, params, body, meta, created_at
|
||||
FROM messages
|
||||
WHERE msg_to = ? AND id < ?
|
||||
WHERE (to_session_id = ? OR to_channel_id = ?)
|
||||
AND id < ?
|
||||
AND command = 'PRIVMSG'
|
||||
ORDER BY id DESC LIMIT ?`,
|
||||
target, beforeID, limit)
|
||||
toSessionID, toChannelID, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"query history: %w", err,
|
||||
@@ -1135,10 +1148,10 @@ func (database *Database) queryHistory(
|
||||
`SELECT id, uuid, command, msg_from,
|
||||
msg_to, params, body, meta, created_at
|
||||
FROM messages
|
||||
WHERE msg_to = ?
|
||||
WHERE (to_session_id = ? OR to_channel_id = ?)
|
||||
AND command = 'PRIVMSG'
|
||||
ORDER BY id DESC LIMIT ?`,
|
||||
target, limit)
|
||||
toSessionID, toChannelID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query history: %w", err)
|
||||
}
|
||||
|
||||
@@ -619,7 +619,7 @@ func TestInsertMessage(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, msgUUID, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -653,7 +653,7 @@ func TestPollMessages(t *testing.T) {
|
||||
body := json.RawMessage(`["hello"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "poller", "#test", nil, body, nil,
|
||||
ctx, "PRIVMSG", "poller", "#test", 0, 0, nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -706,11 +706,16 @@ func TestGetHistory(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
chID, err := database.GetOrCreateChannel(ctx, "#hist")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const msgCount = 10
|
||||
|
||||
for range msgCount {
|
||||
_, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "user", "#hist",
|
||||
_, _, err = database.InsertMessage(
|
||||
ctx, "PRIVMSG", "user", "#hist", 0, chID,
|
||||
nil, json.RawMessage(`["msg"]`), nil,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -721,7 +726,7 @@ func TestGetHistory(t *testing.T) {
|
||||
const histLimit = 5
|
||||
|
||||
msgs, err := database.GetHistory(
|
||||
ctx, "#hist", "", histLimit,
|
||||
ctx, 0, chID, "", histLimit,
|
||||
)
|
||||
if err != nil {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -863,7 +952,7 @@ func TestEnqueueToClient(t *testing.T) {
|
||||
body := json.RawMessage(`["test"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "sender", "#ch", nil, body, nil,
|
||||
ctx, "PRIVMSG", "sender", "#ch", 0, 0, nil, body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -85,19 +85,25 @@ CREATE TABLE IF NOT EXISTS channel_members (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
command TEXT NOT NULL DEFAULT 'PRIVMSG',
|
||||
msg_from 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 '[]',
|
||||
body TEXT NOT NULL DEFAULT '[]',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
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);
|
||||
|
||||
-- Spent hashcash tokens for replay prevention (1-year TTL)
|
||||
|
||||
+36
-24
@@ -566,7 +566,7 @@ func (hdlr *Handlers) enqueueNumeric(
|
||||
}
|
||||
|
||||
dbID, _, insertErr := hdlr.params.Database.InsertMessage(
|
||||
ctx, command, hdlr.serverName(), nick,
|
||||
ctx, command, hdlr.serverName(), nick, 0, 0,
|
||||
paramsJSON, json.RawMessage(body), nil,
|
||||
)
|
||||
if insertErr != nil {
|
||||
@@ -652,7 +652,7 @@ func (hdlr *Handlers) initChannelState(
|
||||
dbID, _, insErr := hdlr.params.Database.
|
||||
InsertMessage(
|
||||
ctx, "JOIN", nick, chanInfo.Name,
|
||||
nil, nil, nil,
|
||||
0, chanInfo.ID, nil, nil, nil,
|
||||
)
|
||||
if insErr != nil {
|
||||
hdlr.log.Error(
|
||||
@@ -2404,7 +2404,7 @@ func (hdlr *Handlers) executeBanChange(
|
||||
)
|
||||
if mErr == nil {
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdMode, nick, channel,
|
||||
request, irc.CmdMode, nick, channel, 0, chID,
|
||||
json.RawMessage(modeBody), memberIDs,
|
||||
)
|
||||
}
|
||||
@@ -2522,7 +2522,7 @@ func (hdlr *Handlers) setChannelKeyMode(
|
||||
modeBody, mErr := json.Marshal([]string{"+k", key})
|
||||
if mErr == nil {
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdMode, nick, channel,
|
||||
request, irc.CmdMode, nick, channel, 0, chID,
|
||||
json.RawMessage(modeBody), memberIDs,
|
||||
)
|
||||
}
|
||||
@@ -2571,7 +2571,7 @@ func (hdlr *Handlers) clearChannelKeyMode(
|
||||
modeBody, mErr := json.Marshal([]string{"-k", "*"})
|
||||
if mErr == nil {
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdMode, nick, channel,
|
||||
request, irc.CmdMode, nick, channel, 0, chID,
|
||||
json.RawMessage(modeBody), memberIDs,
|
||||
)
|
||||
}
|
||||
@@ -2646,7 +2646,7 @@ func (hdlr *Handlers) setChannelLimitMode(
|
||||
)
|
||||
if mErr == nil {
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdMode, nick, channel,
|
||||
request, irc.CmdMode, nick, channel, 0, chID,
|
||||
json.RawMessage(modeBody), memberIDs,
|
||||
)
|
||||
}
|
||||
@@ -2696,7 +2696,7 @@ func (hdlr *Handlers) clearChannelLimitMode(
|
||||
modeBody, mErr := json.Marshal([]string{"-l"})
|
||||
if mErr == nil {
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdMode, nick, channel,
|
||||
request, irc.CmdMode, nick, channel, 0, chID,
|
||||
json.RawMessage(modeBody), memberIDs,
|
||||
)
|
||||
}
|
||||
@@ -2755,7 +2755,7 @@ func (hdlr *Handlers) handleInvite(
|
||||
if mErr == nil {
|
||||
_ = hdlr.fanOutSilent(
|
||||
request, irc.CmdInvite, nick, targetNick,
|
||||
json.RawMessage(invBody),
|
||||
targetSID, 0, json.RawMessage(invBody),
|
||||
[]int64{targetSID},
|
||||
)
|
||||
}
|
||||
@@ -3250,9 +3250,10 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if !hdlr.canAccessHistory(
|
||||
toSessionID, toChannelID, ok := hdlr.canAccessHistory(
|
||||
writer, request, sessionID, nick, target,
|
||||
) {
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3264,7 +3265,7 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
|
||||
}
|
||||
|
||||
msgs, err := hdlr.params.Database.GetHistory(
|
||||
request.Context(), target,
|
||||
request.Context(), toSessionID, toChannelID,
|
||||
request.URL.Query().Get("before"), limit,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -3297,17 +3298,23 @@ func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID int64,
|
||||
nick, target string,
|
||||
) bool {
|
||||
) (int64, int64, bool) {
|
||||
if strings.HasPrefix(target, "#") {
|
||||
return hdlr.canAccessChannelHistory(
|
||||
chID, ok := hdlr.canAccessChannelHistory(
|
||||
writer, request, sessionID, target,
|
||||
)
|
||||
|
||||
return 0, chID, ok
|
||||
}
|
||||
|
||||
// DM history: only allow if the target is the
|
||||
@@ -3319,18 +3326,20 @@ func (hdlr *Handlers) canAccessHistory(
|
||||
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(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
sessionID int64,
|
||||
target string,
|
||||
) bool {
|
||||
) (int64, bool) {
|
||||
chID, err := hdlr.params.Database.GetChannelByName(
|
||||
request.Context(), target,
|
||||
)
|
||||
@@ -3341,7 +3350,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
|
||||
http.StatusNotFound,
|
||||
)
|
||||
|
||||
return false
|
||||
return 0, false
|
||||
}
|
||||
|
||||
isMember, err := hdlr.params.Database.IsChannelMember(
|
||||
@@ -3357,7 +3366,7 @@ func (hdlr *Handlers) canAccessChannelHistory(
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
|
||||
return false
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
@@ -3367,10 +3376,10 @@ func (hdlr *Handlers) canAccessChannelHistory(
|
||||
http.StatusForbidden,
|
||||
)
|
||||
|
||||
return false
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return true
|
||||
return chID, true
|
||||
}
|
||||
|
||||
// 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
|
||||
// sessions.
|
||||
// sessions. toSessionID and toChannelID are the recipient
|
||||
// InsertMessage records for history, 0 when there is none.
|
||||
func (hdlr *Handlers) fanOut(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
toSessionID, toChannelID int64,
|
||||
body json.RawMessage,
|
||||
meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) (string, error) {
|
||||
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
||||
request.Context(), command, from, target,
|
||||
nil, body, meta,
|
||||
toSessionID, toChannelID, nil, body, meta,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert message: %w", err)
|
||||
@@ -3714,12 +3725,13 @@ func (hdlr *Handlers) fanOut(
|
||||
func (hdlr *Handlers) fanOutSilent(
|
||||
request *http.Request,
|
||||
command, from, target string,
|
||||
toSessionID, toChannelID int64,
|
||||
body json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) error {
|
||||
_, err := hdlr.fanOut(
|
||||
request, command, from, target,
|
||||
body, nil, sessionIDs,
|
||||
toSessionID, toChannelID, body, nil, sessionIDs,
|
||||
)
|
||||
|
||||
return err
|
||||
|
||||
@@ -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) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("lister")
|
||||
|
||||
@@ -1268,7 +1268,7 @@ func (c *Conn) handleInvite(
|
||||
)
|
||||
|
||||
_, _, _ = 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},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (env *testEnv) queueMessage(
|
||||
}
|
||||
|
||||
msgID, _, err := env.database.InsertMessage(
|
||||
ctx, command, from, to, nil, bodyJSON, nil,
|
||||
ctx, command, from, to, 0, 0, nil, bodyJSON, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert message: %v", err)
|
||||
|
||||
+16
-12
@@ -311,7 +311,7 @@ func (s *Service) SendWallops(
|
||||
}
|
||||
|
||||
_, _, err = s.FanOut(
|
||||
ctx, irc.CmdWallops, nick, "*",
|
||||
ctx, irc.CmdWallops, nick, "*", 0, 0,
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -344,15 +344,19 @@ type DirectMsgResult struct {
|
||||
}
|
||||
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
command, from, to string,
|
||||
toSessionID, toChannelID int64,
|
||||
params, body, meta json.RawMessage,
|
||||
sessionIDs []int64,
|
||||
) (int64, string, error) {
|
||||
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 {
|
||||
return 0, "", fmt.Errorf("insert message: %w", err)
|
||||
@@ -459,7 +463,7 @@ func (s *Service) SendChannelMessage(
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
|
||||
dbID, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, channel,
|
||||
ctx, command, nick, channel, 0, chID,
|
||||
nil, body, meta, recipients,
|
||||
)
|
||||
if fanErr != nil {
|
||||
@@ -495,7 +499,7 @@ func (s *Service) SendDirectMessage(
|
||||
}
|
||||
|
||||
_, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, target,
|
||||
ctx, command, nick, target, targetSID, 0,
|
||||
nil, body, meta, recipients,
|
||||
)
|
||||
if fanErr != nil {
|
||||
@@ -561,7 +565,7 @@ func (s *Service) JoinChannel(
|
||||
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdJoin, nick, channel,
|
||||
ctx, irc.CmdJoin, nick, channel, 0, chID,
|
||||
nil, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
@@ -604,7 +608,7 @@ func (s *Service) PartChannel(
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdPart, nick, channel,
|
||||
ctx, irc.CmdPart, nick, channel, 0, chID,
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
|
||||
@@ -666,7 +670,7 @@ func (s *Service) SetTopic(
|
||||
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdTopic, nick, channel,
|
||||
ctx, irc.CmdTopic, nick, channel, 0, chID,
|
||||
nil, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
@@ -730,7 +734,7 @@ func (s *Service) KickUser(
|
||||
)
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdKick, nick, channel,
|
||||
ctx, irc.CmdKick, nick, channel, 0, chID,
|
||||
params, body, nil, memberIDs,
|
||||
)
|
||||
|
||||
@@ -886,7 +890,7 @@ func (s *Service) BroadcastQuit(
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut(
|
||||
ctx, irc.CmdQuit, nick, "",
|
||||
ctx, irc.CmdQuit, nick, "", 0, 0,
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
}
|
||||
@@ -1074,7 +1078,7 @@ func (s *Service) BroadcastMode(
|
||||
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||
ctx, irc.CmdMode, nick, channel,
|
||||
ctx, irc.CmdMode, nick, channel, 0, chID,
|
||||
nil, body, nil, memberIDs,
|
||||
)
|
||||
}
|
||||
@@ -1351,7 +1355,7 @@ func (s *Service) broadcastNickChange(
|
||||
notified := make(map[int64]bool)
|
||||
|
||||
dbID, _, insErr := s.db.InsertMessage(
|
||||
ctx, irc.CmdNick, oldNick, "",
|
||||
ctx, irc.CmdNick, oldNick, "", 0, 0,
|
||||
nil, body, nil,
|
||||
)
|
||||
if insErr != nil {
|
||||
|
||||
@@ -164,7 +164,7 @@ func TestFanOut(t *testing.T) {
|
||||
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
||||
|
||||
dbID, uuid, err := env.svc.FanOut(
|
||||
ctx, irc.CmdPrivmsg, "alice", "#test",
|
||||
ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
|
||||
nil, body, nil,
|
||||
[]int64{sid1, sid2},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user