Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion internal/handlers/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions internal/handlers/drafts_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
35 changes: 29 additions & 6 deletions internal/handlers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"})
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions internal/handlers/reactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 18 additions & 8 deletions internal/handlers/thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading