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 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:
+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
|
||||
|
||||
+242
-38
@@ -1551,6 +1551,60 @@ func (tserver *testServer) getHistory(
|
||||
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) {
|
||||
const (
|
||||
channel = "#paging"
|
||||
@@ -1579,46 +1633,10 @@ func TestHistoryPaging(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
got []string
|
||||
pages int
|
||||
got, pages := tserver.getAllHistory(
|
||||
token, channel, pageSize, msgCount,
|
||||
)
|
||||
|
||||
// The first page has no before; each later one passes the
|
||||
// id of the oldest message on the page fetched before it.
|
||||
before := ""
|
||||
|
||||
for range msgCount {
|
||||
status, page := tserver.getHistory(
|
||||
token, channel, before, pageSize,
|
||||
)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", status)
|
||||
}
|
||||
|
||||
if len(page) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pages++
|
||||
|
||||
texts := make([]string, 0, len(page))
|
||||
|
||||
for _, msg := range page {
|
||||
var body []string
|
||||
|
||||
err := json.Unmarshal(msg.Body, &body)
|
||||
if err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
|
||||
texts = append(texts, body...)
|
||||
}
|
||||
|
||||
got = slices.Insert(got, 0, texts...)
|
||||
before = page[0].ID
|
||||
}
|
||||
|
||||
if pages != wantPages {
|
||||
t.Fatalf(
|
||||
"expected %d pages, got %d", wantPages, pages,
|
||||
@@ -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) {
|
||||
tserver := newTestServer(t)
|
||||
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 }{
|
||||
{"made-up id", "no-such-message"},
|
||||
{"id from another channel", elsewhere[0].ID},
|
||||
{"id of a JOIN", joinID},
|
||||
}
|
||||
|
||||
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) {
|
||||
tserver := newTestServer(t)
|
||||
token := tserver.createSession("lister")
|
||||
|
||||
Reference in New Issue
Block a user