eleven / messenger Public

server: fix DM-dedupe race + restrict add-member to groups

- postThread's DM branch ignored recordDMPair's error, so two concurrent
  'start DM' requests could create a duplicate + an orphan thread (no dm_pairs
  row, never dedupable). Now: on the PK conflict, delete the just-created thread
  and return the existing one (added deleteThread).
- postThreadMember had no kind check — adding to a DM/self thread would turn a
  2-person DM into a 3-way thread and break the dm_pairs invariant. Reject unless
  the thread is a group (added threadKind).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 4e65bfe7b03a2c7ffcbe814966b22948cd621d2c parent bb3d756
2 files changed, +47 −1
  • db.go +29 −0
  • main.go +18 −1
diff --git a/db.go b/db.go
index 477c7a1..e9c8bae 100644
--- a/db.go
+++ b/db.go
@@ -690,6 +690,35 @@ func (db *DB) messageInThread(id int64, threadID string) bool {
return db.sql.QueryRow("SELECT 1 FROM messages WHERE id = ? AND thread_id = ?", id, threadID).Scan(&x) == nil
}
+// threadKind returns a thread's kind ("self"/"dm"/"group"), or "" if unknown.
+func (db *DB) threadKind(threadID string) string {
+ var k string
+ if db.sql.QueryRow("SELECT kind FROM threads WHERE id = ?", threadID).Scan(&k) != nil {
+ return ""
+ }
+ return k
+}
+
+// deleteThread removes a thread and its membership/key rows. Used to roll back a
+// DM thread that lost the dedupe race (see postThread).
+func (db *DB) deleteThread(id string) error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, q := range []string{
+ "DELETE FROM thread_keys WHERE thread_id = ?",
+ "DELETE FROM thread_members WHERE thread_id = ?",
+ "DELETE FROM threads WHERE id = ?",
+ } {
+ if _, err := tx.Exec(q, id); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
// wrappedKeyFor returns a member's wrapped thread key (to unwrap client-side).
func (db *DB) wrappedKeyFor(threadID, userID string) (string, bool, error) {
var w string
diff --git a/main.go b/main.go
index a877a30..e46a7c7 100644
--- a/main.go
+++ b/main.go
@@ -450,7 +450,18 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) {
if other == uid {
other = body.Members[1].UserID
}
- _ = s.db.recordDMPair(uid, other, id)
+ // recordDMPair fails on the dm_pairs PK if a concurrent request already
+ // created the DM. In that case drop the duplicate thread we just made and
+ // return the winner, so a fast double-tap can't spawn two DM threads.
+ if err := s.db.recordDMPair(uid, other, id); err != nil {
+ if existing, found, _ := s.db.dmThreadFor(uid, other); found {
+ _ = s.db.deleteThread(id)
+ writeJSON(w, http.StatusOK, map[string]any{"thread_id": existing, "existing": true})
+ return
+ }
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
}
writeJSON(w, http.StatusOK, map[string]any{"thread_id": id})
}
@@ -481,6 +492,12 @@ func (s *Server) postThreadMember(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "not a thread member")
return
}
+ // Only groups grow. Adding to a DM/self thread would break the one-thread-per-
+ // pair invariant (dm_pairs) and silently turn a 2-person DM into a 3-way thread.
+ if s.db.threadKind(threadID) != "group" {
+ httpError(w, http.StatusBadRequest, "can only add people to a group")
+ return
+ }
if body.UserID == "" || !s.db.userExists(body.UserID) || body.Wrapped == "" || len(body.Wrapped) > 4096 {
httpError(w, http.StatusBadRequest, "bad member")
return