fix: chat accepts posts
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user