diff --git a/README.md b/README.md index dff6762..efaf7cc 100644 --- a/README.md +++ b/README.md @@ -571,6 +571,10 @@ ws.onmessage = (e) => { Returns messages where the authenticated identity is a recipient (listed in `msg_to` or `msg_add_to`), ordered by message ID descending. +Only sent messages are listed. A draft is its sender's alone: until it is sent, +its recipients get `404` for it on every route (get, data, attachments, thread, +read, react and add-to), as if it did not exist. + Visibility is always scoped to exactly the one identity the caller authenticated as โ€” the owner, or (via a sub-account's own API-key token or the owner's `X-FMSG-Act-As` header) a single sub-account. A request never diff --git a/internal/handlers/attachments.go b/internal/handlers/attachments.go index 951645f..77ef36d 100644 --- a/internal/handlers/attachments.go +++ b/internal/handlers/attachments.go @@ -187,7 +187,8 @@ func (h *AttachmentHandler) Download(c *gin.Context) { // Check ownership or recipient access. var fromAddr string - err := h.q().QueryRow(ctx, "SELECT from_addr FROM msg WHERE id = $1", msgID).Scan(&fromAddr) + var timeSent *float64 + err := h.q().QueryRow(ctx, "SELECT from_addr, time_sent FROM msg WHERE id = $1", msgID).Scan(&fromAddr, &timeSent) if err != nil { if errors.Is(err, pgx.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) @@ -197,6 +198,10 @@ func (h *AttachmentHandler) Download(c *gin.Context) { } return } + if draftHidden([]string{identity}, fromAddr, timeSent) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // Check recipients (to or add_to) if not owner. if fromAddr != identity { diff --git a/internal/handlers/drafts_integration_test.go b/internal/handlers/drafts_integration_test.go new file mode 100644 index 0000000..e8e6c6c --- /dev/null +++ b/internal/handlers/drafts_integration_test.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "encoding/json" + "testing" +) + +// A draft is its sender's alone: its recipients can neither list nor reach it +// by any route until it is sent. +func TestRecipientsCannotSeeDrafts(t *testing.T) { + a := newFinalizationAPI(t) + const alice, bob = "@alice@example.com", "@bob@example.com" + id := a.draft(t, alice, "not ready yet", nil) + a.upload(t, id) + + listed := func() bool { + t.Helper() + w := a.request(bob, "GET", "/fmsg", nil) + if w.Code != 200 { + t.Fatalf("list %d: %s", w.Code, w.Body) + } + var items []struct { + ID json.Number `json:"id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil { + t.Fatal(err) + } + for _, it := range items { + if it.ID.String() == id { + return true + } + } + return false + } + if listed() { + t.Fatal("bob's inbox lists alice's draft") + } + + for _, r := range []struct{ method, path string }{ + {"GET", "/fmsg/" + id}, + {"GET", "/fmsg/" + id + "/data"}, + {"GET", "/fmsg/" + id + "/attach/note.txt"}, + {"GET", "/fmsg/" + id + "/thread"}, + {"GET", "/fmsg/" + id + "/thread/messages"}, + {"POST", "/fmsg/" + id + "/read"}, + } { + if w := a.request(bob, r.method, r.path, map[string]any{}); w.Code != 404 { + t.Errorf("bob %s %s: HTTP %d, want 404: %s", r.method, r.path, w.Code, w.Body) + } + } + if w := a.request(bob, "POST", "/fmsg/"+id+"/react", map[string]any{"emoji": "๐Ÿ‘"}); w.Code != 404 { + t.Errorf("bob react: HTTP %d, want 404: %s", w.Code, w.Body) + } + if w := a.request(bob, "POST", "/fmsg/"+id+"/add-to", map[string]any{"add_to": []string{"@carol@example.com"}}); w.Code != 404 { + t.Errorf("bob add-to: HTTP %d, want 404: %s", w.Code, w.Body) + } + + // The sender still has it. + for _, path := range []string{"/fmsg/" + id, "/fmsg/" + id + "/data", "/fmsg/" + id + "/attach/note.txt", "/fmsg/" + id + "/thread/messages"} { + if w := a.request(alice, "GET", path, nil); w.Code != 200 { + t.Errorf("alice GET %s: HTTP %d: %s", path, w.Code, w.Body) + } + } + + // Once sent, bob has it. + jsonResponse(t, a.request(alice, "POST", "/fmsg/"+id+"/send", map[string]any{}), 200) + if !listed() { + t.Fatal("bob's inbox does not list the sent message") + } + for _, path := range []string{"/fmsg/" + id, "/fmsg/" + id + "/data", "/fmsg/" + id + "/attach/note.txt", "/fmsg/" + id + "/thread/messages"} { + if w := a.request(bob, "GET", path, nil); w.Code != 200 { + t.Errorf("bob GET %s after send: HTTP %d: %s", path, w.Code, w.Body) + } + } + if w := a.request(bob, "POST", "/fmsg/"+id+"/read", map[string]any{}); w.Code != 200 { + t.Errorf("bob read after send: HTTP %d: %s", w.Code, w.Body) + } +} diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 090e9e9..63cdaba 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -322,8 +322,9 @@ func (h *MessageHandler) List(c *gin.Context) { (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = m.id AND lower(mat.addr) = ANY($1)) ) AS time_read FROM msg m - WHERE EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND lower(mt.addr) = ANY($1)) - OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND lower(mat.addr) = ANY($1)) + WHERE m.time_sent IS NOT NULL + AND (EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND lower(mt.addr) = ANY($1)) + OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND lower(mat.addr) = ANY($1))) ORDER BY m.id DESC LIMIT $2 OFFSET $3`, addrs, limit, offset, @@ -620,6 +621,11 @@ func (h *MessageHandler) Get(c *gin.Context) { return } + if draftHidden(addrs, msg.From, msg.Time) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + // Authorization: owner or recipient (to or add_to), across all visible addrs. isSender := isRecipient(addrs, msg.From) if !isSender && !anyRecipient(addrs, msg.To) && !anyAddToContains(addrs, msg.AddTo) { @@ -673,9 +679,10 @@ func (h *MessageHandler) DownloadData(c *gin.Context) { // Fetch message metadata for auth check and file path. var fromAddr string var dataPath string + var timeSent *float64 err = h.q().QueryRow(ctx, - "SELECT from_addr, filepath FROM msg WHERE id = $1", msgID, - ).Scan(&fromAddr, &dataPath) + "SELECT from_addr, filepath, time_sent FROM msg WHERE id = $1", msgID, + ).Scan(&fromAddr, &dataPath, &timeSent) if err != nil { if errors.Is(err, pgx.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) @@ -685,6 +692,10 @@ func (h *MessageHandler) DownloadData(c *gin.Context) { } return } + if draftHidden(addrs, fromAddr, timeSent) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // Authorize: must be owner or recipient (across all visible addrs). if !isRecipient(addrs, fromAddr) { @@ -1042,8 +1053,9 @@ func (h *MessageHandler) MarkRead(c *gin.Context) { (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND lower(mt.addr) = lower($2)), (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND lower(mat.addr) = lower($2)) ), - EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = $1 AND lower(mt.addr) = lower($2)) - OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = $1 AND lower(mat.addr) = lower($2))`, + (EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = $1 AND lower(mt.addr) = lower($2)) + OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = $1 AND lower(mat.addr) = lower($2))) + AND EXISTS (SELECT 1 FROM msg m WHERE m.id = $1 AND m.time_sent IS NOT NULL)`, msgID, identity, ).Scan(&existing, &recipient) if err != nil { @@ -1138,6 +1150,10 @@ func (h *MessageHandler) AddRecipients(c *gin.Context) { } return } + if draftHidden([]string{identity}, fromAddr, timeSent) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // A terminal message is a leaf: recipients cannot be added to it (fmsg // Specification v0.6.0 ยง12). if terminal { @@ -1603,6 +1619,13 @@ func sameAddr(a, b string) bool { return strings.EqualFold(a, b) } +// draftHidden reports whether a message is a draft that none of addrs sent. A +// draft is its sender's alone: until it is sent, its recipients get 404 for it +// on every route, as if it did not exist. +func draftHidden(addrs []string, from string, timeSent *float64) bool { + return timeSent == nil && !isRecipient(addrs, from) +} + func isRecipient(to []string, addr string) bool { for _, a := range to { if strings.EqualFold(a, addr) { diff --git a/internal/handlers/reactions.go b/internal/handlers/reactions.go index ea1b2d0..28bbde3 100644 --- a/internal/handlers/reactions.go +++ b/internal/handlers/reactions.go @@ -326,6 +326,10 @@ func (h *MessageHandler) React(c *gin.Context) { return } + if draftHidden([]string{identity}, subject.From, subject.Time) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // Only a participant of the subject may react to it (the host enforces // the same rule on the wire). if !strings.EqualFold(subject.From, identity) && !isRecipient(subject.To, identity) && !addToContains(subject.AddTo, identity) { diff --git a/internal/handlers/thread.go b/internal/handlers/thread.go index 3484585..bf7833f 100644 --- a/internal/handlers/thread.go +++ b/internal/handlers/thread.go @@ -289,8 +289,9 @@ func (h *MessageHandler) ThreadText(c *gin.Context) { ) SELECT c.id, c.from_addr, c.time_sent, c.type, c.size, c.filepath, (lower(c.from_addr) = ANY($2) - OR EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) - OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))) AS readable + OR (c.time_sent IS NOT NULL + AND (EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) + OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))))) AS readable FROM chain c ORDER BY c.depth DESC`, msgID, addrs, threadMaxHops, ) @@ -326,8 +327,12 @@ func (h *MessageHandler) ThreadText(c *gin.Context) { // Authorization gate is the requested message itself, same as GET // /fmsg/:id โ€” a non-participant learns nothing, not even chain shape. - if !entries[len(entries)-1].Readable { - c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + if last := entries[len(entries)-1]; !last.Readable { + if last.TimeSent == nil { // someone else's draft + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + } else { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + } return } @@ -395,8 +400,9 @@ func (h *MessageHandler) ThreadMessages(c *gin.Context) { c.is_terminal, c.time_sent, c.from_addr, c.topic, c.type, c.size, c.filepath, encode(c.sha256, 'hex'), encode(c.psha256, 'hex'), (lower(c.from_addr) = ANY($2) - OR EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) - OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))) + OR (c.time_sent IS NOT NULL + AND (EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) + OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))))) FROM chain c ORDER BY c.depth DESC`, msgID, addrs, threadMaxHops) if err != nil { log.Printf("thread messages: walk %d: %v", msgID, err) @@ -429,8 +435,12 @@ func (h *MessageHandler) ThreadMessages(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) return } - if !messages[len(messages)-1].Visible { - c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + if last := messages[len(messages)-1]; !last.Visible { + if last.Time == nil { // someone else's draft + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + } else { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + } return } if len(messages) == threadMaxHops && messages[0].PID != nil {