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
+11
View File
@@ -17,3 +17,14 @@ func IsUniqueConstraintError(err error) bool {
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"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -940,9 +941,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 +971,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,26 +1067,31 @@ 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. A non-empty
// 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(
ctx context.Context,
target, before string,
toSessionID, toChannelID int64,
before string,
limit int,
) ([]IRCMessage, error) {
if limit <= 0 {
limit = defaultHistLimit
}
var beforeID int64
// With no before, every message is older than this.
beforeID := int64(math.MaxInt64)
if before != "" {
err := database.conn.QueryRowContext(ctx,
`SELECT id FROM messages
WHERE uuid = ? AND msg_to = ?`,
before, target,
WHERE uuid = ? AND command = 'PRIVMSG'
AND (to_session_id = ? OR to_channel_id = ?)`,
before, toSessionID, toChannelID,
).Scan(&beforeID)
if err != nil {
return nil, fmt.Errorf(
@@ -1087,7 +1101,7 @@ func (database *Database) GetHistory(
}
rows, err := database.queryHistory(
ctx, target, beforeID, limit,
ctx, toSessionID, toChannelID, beforeID, limit,
)
if err != nil {
return nil, err
@@ -1107,21 +1121,23 @@ func (database *Database) GetHistory(
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(
ctx context.Context,
target string,
beforeID int64,
toSessionID, toChannelID, beforeID int64,
limit int,
) (*sql.Rows, error) {
if beforeID > 0 {
if toSessionID != 0 {
rows, err := database.conn.QueryContext(ctx,
`SELECT id, uuid, command, msg_from,
msg_to, params, body, meta, created_at
FROM messages
WHERE msg_to = ? AND id < ?
WHERE to_session_id = ? AND id < ?
AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`,
target, beforeID, limit)
toSessionID, beforeID, limit)
if err != nil {
return nil, fmt.Errorf(
"query history: %w", err,
@@ -1135,10 +1151,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_channel_id = ? AND id < ?
AND command = 'PRIVMSG'
ORDER BY id DESC LIMIT ?`,
target, limit)
toChannelID, beforeID, limit)
if err != nil {
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"]`)
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)
+8 -2
View File
@@ -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)