fix: chat accepts posts

This commit is contained in:
Krispire Admin
2026-08-29 16:26:38 +05:30
parent 18bac9a22d
commit 7a730cd42e
8 changed files with 545 additions and 22 deletions
+206 -10
View File
@@ -43,6 +43,7 @@ var (
ErrChatNotAccepting = errors.New("this person isn't accepting messages from you")
ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction")
ErrChatBadQuote = errors.New("that quote is not a valid passage reference")
ErrChatBadEntity = errors.New("that post cannot be shared")
ErrChatBadAccountAge = errors.New("account age must be between 1 and 120 months")
ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option")
)
@@ -62,6 +63,9 @@ const (
type ChatService struct {
chat *repository.ChatRepo
blocks *repository.BlockRepo
// posts resolves shared post cards. Optional: nil leaves entity
// attachments unresolved rather than failing a whole thread load.
posts *repository.PostRepo
rt *realtime.SoketiClient
r2 *repository.R2
links *unfurl.Fetcher
@@ -72,13 +76,14 @@ type ChatService struct {
func NewChatService(
chat *repository.ChatRepo,
blocks *repository.BlockRepo,
posts *repository.PostRepo,
rt *realtime.SoketiClient,
r2 *repository.R2,
cache *repository.Redis,
logger *zap.Logger,
) *ChatService {
return &ChatService{
chat: chat, blocks: blocks, rt: rt, r2: r2,
chat: chat, blocks: blocks, posts: posts, rt: rt, r2: r2,
links: unfurl.New(), cache: cache, logger: logger,
}
}
@@ -317,11 +322,12 @@ func (s *ChatService) Starred(ctx context.Context, userID, conversationID string
if err != nil {
return nil, err
}
return s.hydrateAttachments(ctx, msgs)
return s.hydrateAttachments(ctx, userID, msgs)
}
// hydrateAttachments fills in each message's attachments in one query.
func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Message) ([]model.Message, error) {
// hydrateAttachments fills in each message's attachments in one query, then
// resolves any shared entities for this particular viewer.
func (s *ChatService) hydrateAttachments(ctx context.Context, viewerID string, msgs []model.Message) ([]model.Message, error) {
ids := make([]string, 0, len(msgs))
for i := range msgs {
ids = append(ids, msgs[i].ID)
@@ -338,6 +344,11 @@ func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Messa
msgs[i].Attachments = []model.Attachment{}
}
}
pages := make([][]model.Attachment, 0, len(msgs))
for i := range msgs {
pages = append(pages, msgs[i].Attachments)
}
s.resolveEntities(ctx, viewerID, pages)
return msgs, nil
}
@@ -364,7 +375,7 @@ func (s *ChatService) Messages(ctx context.Context, userID, conversationID strin
return nil, err
}
return s.hydrateAttachments(ctx, msgs)
return s.hydrateAttachments(ctx, userID, msgs)
}
// SendRequest is one outgoing message from a client.
@@ -379,6 +390,8 @@ type SendRequest struct {
// fetches the title, description and image itself, because a client that
// could supply them could make any link claim to be anything.
PreviewURL *string
// Entity is something else in the app — a post — shared as a card.
Entity *EntityAttachment
// Quote is a passage shared from the library, rendered as a quote the
// reader can tap to return to the exact phrase.
Quote *QuoteAttachment
@@ -419,7 +432,7 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
body := strings.TrimSpace(req.Body)
// A photo or a shared passage with no caption is an ordinary message; only
// a message with nothing at all in it is empty.
if body == "" && len(req.Attachments) == 0 && req.Quote == nil {
if body == "" && len(req.Attachments) == 0 && req.Quote == nil && req.Entity == nil {
return nil, ErrChatEmptyBody
}
if len([]rune(body)) > maxBodyRunes {
@@ -451,14 +464,17 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
// the first time, and re-publishing would duplicate the bubble for
// everyone else.
if !created {
return s.withAttachments(ctx, msg)
return s.withAttachments(ctx, userID, msg)
}
if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil {
s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err))
}
s.attachQuote(ctx, conversationID, msg, req.Quote)
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
full, err := s.withAttachments(ctx, msg)
full, err := s.withAttachments(ctx, userID, msg)
if err != nil {
full = msg
}
@@ -467,7 +483,7 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
}
// withAttachments hydrates a message's attachments for the response.
func (s *ChatService) withAttachments(ctx context.Context, msg *model.Message) (*model.Message, error) {
func (s *ChatService) withAttachments(ctx context.Context, viewerID string, msg *model.Message) (*model.Message, error) {
byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID})
if err != nil {
return msg, err
@@ -476,6 +492,7 @@ func (s *ChatService) withAttachments(ctx context.Context, msg *model.Message) (
if msg.Attachments == nil {
msg.Attachments = []model.Attachment{}
}
s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments})
return msg, nil
}
@@ -509,6 +526,185 @@ func (s *ChatService) attachMedia(ctx context.Context, conversationID, userID st
}
}
// EntityAttachment is a reference to something else in the app, shared into a
// chat. Only posts for now; the column allows comments, profiles and
// collections when their cards are built.
type EntityAttachment struct {
Type string
ID string
}
// postSnapshot is the copy stored alongside the reference.
//
// It is a FALLBACK, never the source of truth: resolveEntities re-reads the
// live post for each viewer, because a post can be deleted, hidden, or become
// invisible to one participant after it was shared. Rendering the snapshot
// unconditionally would leak a post to someone who has since lost access.
type postSnapshot struct {
ID string `json:"id"`
AuthorHandle string `json:"author_handle"`
AuthorName string `json:"author_name"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
Caption string `json:"caption"`
ImageURL *string `json:"image_url,omitempty"`
SourceTitle string `json:"source_title,omitempty"`
}
func snapshotOf(p *model.Post) postSnapshot {
snap := postSnapshot{
ID: p.ID, AuthorHandle: p.AuthorHandle, AuthorName: p.AuthorName,
AuthorAvatarURL: p.AuthorAvatarURL, Caption: p.Caption,
SourceTitle: p.SourceTitle,
}
if len(p.Images) > 0 {
url := p.Images[0].URL
snap.ImageURL = &url
}
return snap
}
// postVisibleTo decides whether a viewer may see a post inside a chat bubble.
//
// Same rules the feed applies: a hidden post is the owner's alone, and a block
// in either direction hides the author's content.
func (s *ChatService) postVisibleTo(ctx context.Context, viewerID string, p *model.Post) bool {
if p.IsHidden && p.UserID != viewerID {
return false
}
if p.UserID == viewerID {
return true
}
blocked, blockedBy, err := s.blocks.BlockState(ctx, viewerID, p.UserID)
if err != nil {
// Fail closed: an unreadable block table must not open a post up.
return false
}
return !blocked && !blockedBy
}
// attachEntity stores a shared post after checking the SENDER can see it —
// you cannot forward what you were never allowed to read.
func (s *ChatService) attachEntity(ctx context.Context, conversationID, userID string, msg *model.Message, e *EntityAttachment) error {
if e == nil {
return nil
}
if e.Type != model.EntityPost {
return ErrChatBadEntity
}
post, err := s.posts.FindByID(ctx, e.ID)
if err != nil || post == nil {
return ErrChatBadEntity
}
if !s.postVisibleTo(ctx, userID, post) {
return ErrChatBadEntity
}
snap, err := json.Marshal(snapshotOf(post))
if err != nil {
return ErrChatBadEntity
}
entityType, entityID := model.EntityPost, post.ID
if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{
MessageID: msg.ID, ConversationID: conversationID, Ord: 0,
Kind: model.AttachmentEntity, EntityType: &entityType,
EntityID: &entityID, Snapshot: snap,
}); err != nil {
s.logger.Warn("chat: attach entity failed", zap.Error(err))
}
return nil
}
// resolveEntities re-reads every shared entity on a page for THIS viewer.
//
// This is the permission re-check that makes sharing safe: access is decided
// at read time, per person, not frozen at send time. A post that was visible
// when shared and is hidden now renders as unavailable.
//
// It takes the whole page at once — three queries (posts, their images,
// blocks) regardless of how many shares are on it. Resolving per message
// instead would cost two queries per share and could not dedupe a post quoted
// in several messages.
func (s *ChatService) resolveEntities(ctx context.Context, viewerID string, pages [][]model.Attachment) {
if s.posts == nil {
return
}
// Gather the distinct posts referenced anywhere on the page.
shared := map[string]bool{}
for _, atts := range pages {
for i := range atts {
if atts[i].Kind == model.AttachmentEntity && atts[i].EntityID != nil {
shared[*atts[i].EntityID] = true
}
}
}
if len(shared) == 0 {
return
}
ids := make([]string, 0, len(shared))
for id := range shared {
ids = append(ids, id)
}
posts, err := s.posts.FindByIDs(ctx, ids)
if err != nil {
// Fail closed: every card becomes a tombstone rather than falling back
// to snapshots whose visibility we could not confirm.
s.logger.Warn("chat: resolving shared posts failed", zap.Error(err))
posts = nil
}
authors := make([]string, 0, len(posts))
for _, post := range posts {
if post.UserID != viewerID {
authors = append(authors, post.UserID)
}
}
blocked, err := s.blocks.BlockedWith(ctx, viewerID, authors)
if err != nil {
s.logger.Warn("chat: block lookup for shared posts failed", zap.Error(err))
// Fail closed again: an unreadable block table must not open posts up.
blocked = nil
for _, id := range authors {
if blocked == nil {
blocked = map[string]bool{}
}
blocked[id] = true
}
}
// Resolve each referenced post once, then fan the result out.
resolved := make(map[string]json.RawMessage, len(shared))
for id := range shared {
post := posts[id]
if post == nil {
continue
}
if post.IsHidden && post.UserID != viewerID {
continue
}
if blocked[post.UserID] {
continue
}
if b, mErr := json.Marshal(snapshotOf(post)); mErr == nil {
resolved[id] = b
}
}
for _, atts := range pages {
for i := range atts {
a := &atts[i]
if a.Kind != model.AttachmentEntity || a.EntityID == nil {
continue
}
// The stored snapshot is never sent to a client — only a copy
// resolved for this viewer — so a stale one cannot leak.
a.Entity = resolved[*a.EntityID]
a.EntityUnavailable = a.Entity == nil
}
}
}
// QuoteAttachment is a passage shared out of the library.
type QuoteAttachment struct {
// Path is an APP-RELATIVE deep link, e.g. "/books/bg/2/13?hl=…".
@@ -823,7 +1019,7 @@ func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toC
return nil, err
}
if created {
full, hErr := s.withAttachments(ctx, msg)
full, hErr := s.withAttachments(ctx, userID, msg)
if hErr == nil {
msg = full
}
+217 -1
View File
@@ -8,10 +8,13 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
@@ -40,7 +43,7 @@ func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *reposit
// Nil realtime/R2/cache clients make publishing, uploads and caching
// no-ops — these tests are about the rules, and none of those services are
// reachable from a test run.
return NewChatService(chat, repository.NewBlockRepo(pg), nil, nil, nil, zap.NewNop()), chat, pg
return NewChatService(chat, repository.NewBlockRepo(pg), repository.NewPostRepo(pg), nil, nil, nil, zap.NewNop()), chat, pg
}
// chatUserWithHandle returns both identifiers: ids for authoring, handles for
@@ -1766,3 +1769,216 @@ func TestQuoteAttachment_RejectsOffSitePaths(t *testing.T) {
t.Error("valid() = true for an oversized passage")
}
}
// makePost inserts a post owned by userID and returns its id.
func makePost(t *testing.T, pg *repository.Postgres, userID, caption string) string {
t.Helper()
var id string
if err := pg.Pool.QueryRow(context.Background(), `
INSERT INTO posts (user_id, caption, source_url, source_title)
VALUES ($1, $2, '/books/bg/2/13', 'Bhagavad-gītā 2.13') RETURNING id`,
userID, caption).Scan(&id); err != nil {
t.Fatalf("create post: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM posts WHERE id = $1`, id)
})
return id
}
func entityAttachment(t *testing.T, msgs []model.Message) *model.Attachment {
t.Helper()
for i := range msgs {
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentEntity {
return &msgs[i].Attachments[j]
}
}
}
return nil
}
// Sharing is a reference, not a copy: access is decided when the message is
// READ, per person. A post that stops being visible must stop rendering, even
// though the snapshot taken at send time still sits in the row.
func TestSharedPost_ResolvedPerViewerAtReadTime(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
postID := makePost(t, pg, a, "a passage worth sharing")
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
Entity: &EntityAttachment{Type: model.EntityPost, ID: postID},
}); err != nil {
t.Fatalf("share post: %v", err)
}
// Recipient sees the card.
msgs, err := svc.Messages(ctx, b, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
att := entityAttachment(t, msgs)
if att == nil {
t.Fatal("no entity attachment on the shared message")
}
if att.EntityUnavailable || len(att.Entity) == 0 {
t.Fatalf("recipient should see the card: unavailable=%v entity=%s",
att.EntityUnavailable, att.Entity)
}
// Author hides the post → it must stop resolving for the recipient...
if _, err := pg.Pool.Exec(ctx, `UPDATE posts SET is_hidden = true WHERE id = $1`, postID); err != nil {
t.Fatal(err)
}
msgs, err = svc.Messages(ctx, b, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
att = entityAttachment(t, msgs)
if att == nil || !att.EntityUnavailable {
t.Error("a hidden post must render as unavailable to the recipient")
}
if att != nil && len(att.Entity) != 0 {
t.Errorf("stale snapshot leaked after the post was hidden: %s", att.Entity)
}
// ...while its owner still sees it, matching the feed's rules.
msgs, err = svc.Messages(ctx, a, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
att = entityAttachment(t, msgs)
if att == nil || att.EntityUnavailable {
t.Error("the owner should still see their own hidden post")
}
}
// You cannot share what you were never allowed to read.
func TestSharedPost_SenderMustSeeItFirst(t *testing.T) {
svc, _, pg := testChatService(t)
ctx := context.Background()
a, b, stranger := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
hidden := makePost(t, pg, stranger, "not yours to share")
if _, err := pg.Pool.Exec(ctx, `UPDATE posts SET is_hidden = true WHERE id = $1`, hidden); err != nil {
t.Fatal(err)
}
msg, err := svc.Send(ctx, a, conv.ID, SendRequest{
Body: "look at this",
Entity: &EntityAttachment{Type: model.EntityPost, ID: hidden},
})
if err != nil {
t.Fatalf("send: %v", err)
}
full, err := svc.Messages(ctx, a, conv.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if att := entityAttachment(t, full); att != nil {
t.Errorf("a post the sender cannot see must not be attached (msg %s)", msg.ID)
}
}
// queryCounter counts every statement the pool issues.
type queryCounter struct{ n atomic.Int64 }
func (q *queryCounter) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context {
q.n.Add(1)
return ctx
}
func (q *queryCounter) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}
// Resolving shared posts must cost the same whether a page carries one share
// or many — otherwise a thread full of shared posts turns one screen into
// dozens of round-trips.
func TestSharedPosts_ResolutionDoesNotScaleWithShareCount(t *testing.T) {
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set")
}
ctx := context.Background()
cfg, err := pgxpool.ParseConfig(uri)
if err != nil {
t.Fatal(err)
}
counter := &queryCounter{}
cfg.ConnConfig.Tracer = counter
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pool.Close)
pg := &repository.Postgres{Pool: pool}
svc := NewChatService(repository.NewChatRepo(pg), repository.NewBlockRepo(pg),
repository.NewPostRepo(pg), nil, nil, nil, zap.NewNop())
// measure returns the statement count for reading a thread carrying
// `shares` distinct shared posts.
measure := func(shares int) int {
a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B")
conv, err := svc.OpenDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
cleanupConv(t, pg, conv.ID)
for i := 0; i < shares; i++ {
id := makePost(t, pg, a, "shared")
if _, err := svc.Send(ctx, a, conv.ID, SendRequest{
Entity: &EntityAttachment{Type: model.EntityPost, ID: id},
}); err != nil {
t.Fatal(err)
}
}
// Warm the pool so connection setup isn't counted.
if _, err := svc.Messages(ctx, b, conv.ID, 0, 50); err != nil {
t.Fatal(err)
}
before := counter.n.Load()
msgs, err := svc.Messages(ctx, b, conv.ID, 0, 50)
if err != nil {
t.Fatal(err)
}
after := counter.n.Load()
// Guard the measurement: the cards must actually have resolved, or a
// low count would just mean nothing was looked up.
resolved := 0
for i := range msgs {
for j := range msgs[i].Attachments {
if msgs[i].Attachments[j].Kind == model.AttachmentEntity &&
!msgs[i].Attachments[j].EntityUnavailable {
resolved++
}
}
}
if resolved != shares {
t.Fatalf("resolved %d of %d shared posts", resolved, shares)
}
return int(after - before)
}
one, many := measure(1), measure(8)
t.Logf("queries: 1 share = %d, 8 shares = %d", one, many)
if many != one {
t.Errorf("reading 8 shares cost %d queries vs %d for 1 — resolution is still per-share",
many, one)
}
}