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>
2 files changed,
+47
−1
db.go+29 −0main.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 |