fix: chat accepts posts
This commit is contained in:
@@ -36,6 +36,8 @@ func writeChatError(w http.ResponseWriter, err error) {
|
|||||||
response.Error(w, http.StatusNotFound, "conversation not found")
|
response.Error(w, http.StatusNotFound, "conversation not found")
|
||||||
case errors.Is(err, service.ErrAvatarUnset):
|
case errors.Is(err, service.ErrAvatarUnset):
|
||||||
response.Error(w, http.StatusNotFound, err.Error())
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, service.ErrChatBadEntity):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, service.ErrChatBadQuote):
|
case errors.Is(err, service.ErrChatBadQuote):
|
||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, service.ErrChatBadAvatarKey):
|
case errors.Is(err, service.ErrChatBadAvatarKey):
|
||||||
@@ -191,6 +193,9 @@ type sendReq struct {
|
|||||||
// quote is a passage shared from the library. `path` must be app-relative;
|
// quote is a passage shared from the library. `path` must be app-relative;
|
||||||
// the service refuses anything that could navigate off-site.
|
// the service refuses anything that could navigate off-site.
|
||||||
Quote *quoteReq `json:"quote"`
|
Quote *quoteReq `json:"quote"`
|
||||||
|
// entity shares something else in the app — a post — as a card. Only the
|
||||||
|
// reference; the BFF resolves the contents and re-checks visibility.
|
||||||
|
Entity *entityReq `json:"entity"`
|
||||||
ReplyToID *string `json:"reply_to_id"`
|
ReplyToID *string `json:"reply_to_id"`
|
||||||
// forward_from_id is deliberately NOT accepted yet: forwarding lands with
|
// forward_from_id is deliberately NOT accepted yet: forwarding lands with
|
||||||
// its own "can the caller see the source?" check in a later phase, and
|
// its own "can the caller see the source?" check in a later phase, and
|
||||||
@@ -221,12 +226,17 @@ func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: req.Quote.Path, Title: req.Quote.Title, Text: req.Quote.Text,
|
Path: req.Quote.Path, Title: req.Quote.Title, Text: req.Quote.Text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var entity *service.EntityAttachment
|
||||||
|
if req.Entity != nil {
|
||||||
|
entity = &service.EntityAttachment{Type: req.Entity.Type, ID: req.Entity.ID}
|
||||||
|
}
|
||||||
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
|
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
ClientMsgID: req.ClientMsgID,
|
ClientMsgID: req.ClientMsgID,
|
||||||
ReplyToID: req.ReplyToID,
|
ReplyToID: req.ReplyToID,
|
||||||
PreviewURL: req.PreviewURL,
|
PreviewURL: req.PreviewURL,
|
||||||
Quote: quote,
|
Quote: quote,
|
||||||
|
Entity: entity,
|
||||||
Attachments: media,
|
Attachments: media,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -566,6 +576,11 @@ func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.NoContent(w)
|
response.NoContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type entityReq struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
type quoteReq struct {
|
type quoteReq struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ const (
|
|||||||
AttachmentQuote = "quote"
|
AttachmentQuote = "quote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Entity types a message can reference.
|
||||||
|
const (
|
||||||
|
EntityPost = "post"
|
||||||
|
)
|
||||||
|
|
||||||
// Attachment is a media file, link preview or shared entity on a message.
|
// Attachment is a media file, link preview or shared entity on a message.
|
||||||
//
|
//
|
||||||
// Media carries an R2 key rather than a URL: chat media lives in the private
|
// Media carries an R2 key rather than a URL: chat media lives in the private
|
||||||
@@ -201,6 +206,16 @@ type Attachment struct {
|
|||||||
SiteName *string `json:"site_name,omitempty"`
|
SiteName *string `json:"site_name,omitempty"`
|
||||||
ImageURL *string `json:"image_url,omitempty"`
|
ImageURL *string `json:"image_url,omitempty"`
|
||||||
|
|
||||||
|
// Shared entity (a post, for now). EntityType/EntityID are the durable
|
||||||
|
// reference; Entity is resolved per viewer at read time, so the same row
|
||||||
|
// renders differently — or not at all — depending on who is looking.
|
||||||
|
EntityType *string `json:"entity_type,omitempty"`
|
||||||
|
EntityID *string `json:"entity_id,omitempty"`
|
||||||
|
Entity json.RawMessage `json:"entity,omitempty"`
|
||||||
|
// EntityUnavailable is set when the entity was deleted, hidden, or the
|
||||||
|
// viewer has since lost access. The client renders a tombstone.
|
||||||
|
EntityUnavailable bool `json:"entity_unavailable,omitempty"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -547,6 +547,12 @@ type AddAttachmentParams struct {
|
|||||||
Description *string
|
Description *string
|
||||||
SiteName *string
|
SiteName *string
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
|
|
||||||
|
// Shared entity: the durable reference plus a snapshot used only as a
|
||||||
|
// fallback. Live data is re-resolved per viewer at read time.
|
||||||
|
EntityType *string
|
||||||
|
EntityID *string
|
||||||
|
Snapshot []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAttachment persists one attachment. conversation_id is denormalized here
|
// AddAttachment persists one attachment. conversation_id is denormalized here
|
||||||
@@ -557,17 +563,20 @@ func (r *ChatRepo) AddAttachment(ctx context.Context, p AddAttachmentParams) (*m
|
|||||||
INSERT INTO message_attachments
|
INSERT INTO message_attachments
|
||||||
(message_id, conversation_id, ord, kind, r2_key, mime, size_bytes,
|
(message_id, conversation_id, ord, kind, r2_key, mime, size_bytes,
|
||||||
width, height, duration_ms, blurhash, file_name,
|
width, height, duration_ms, blurhash, file_name,
|
||||||
url, title, description, site_name, image_url)
|
url, title, description, site_name, image_url,
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
entity_type, entity_id, snapshot)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||||
RETURNING id, message_id, ord, kind, mime, size_bytes, width, height,
|
RETURNING id, message_id, ord, kind, mime, size_bytes, width, height,
|
||||||
duration_ms, blurhash, file_name, url, title, description,
|
duration_ms, blurhash, file_name, url, title, description,
|
||||||
site_name, image_url, created_at`,
|
site_name, image_url, entity_type, entity_id, snapshot, created_at`,
|
||||||
p.MessageID, p.ConversationID, p.Ord, p.Kind, p.R2Key, p.Mime, p.SizeBytes,
|
p.MessageID, p.ConversationID, p.Ord, p.Kind, p.R2Key, p.Mime, p.SizeBytes,
|
||||||
p.Width, p.Height, p.DurationMs, p.Blurhash, p.FileName,
|
p.Width, p.Height, p.DurationMs, p.Blurhash, p.FileName,
|
||||||
p.URL, p.Title, p.Description, p.SiteName, p.ImageURL,
|
p.URL, p.Title, p.Description, p.SiteName, p.ImageURL,
|
||||||
|
p.EntityType, p.EntityID, p.Snapshot,
|
||||||
).Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width,
|
).Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width,
|
||||||
&a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, &a.Title,
|
&a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, &a.Title,
|
||||||
&a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt)
|
&a.Description, &a.SiteName, &a.ImageURL,
|
||||||
|
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("add attachment: %w", err)
|
return nil, fmt.Errorf("add attachment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -583,7 +592,7 @@ func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map
|
|||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT id, message_id, ord, kind, mime, size_bytes, width, height,
|
SELECT id, message_id, ord, kind, mime, size_bytes, width, height,
|
||||||
duration_ms, blurhash, file_name, url, title, description,
|
duration_ms, blurhash, file_name, url, title, description,
|
||||||
site_name, image_url, created_at
|
site_name, image_url, entity_type, entity_id, snapshot, created_at
|
||||||
FROM message_attachments
|
FROM message_attachments
|
||||||
WHERE message_id = ANY($1)
|
WHERE message_id = ANY($1)
|
||||||
ORDER BY message_id, ord`, messageIDs)
|
ORDER BY message_id, ord`, messageIDs)
|
||||||
@@ -595,7 +604,8 @@ func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map
|
|||||||
var a model.Attachment
|
var a model.Attachment
|
||||||
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
|
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
|
||||||
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
|
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
|
||||||
&a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil {
|
&a.Title, &a.Description, &a.SiteName, &a.ImageURL,
|
||||||
|
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scan attachment: %w", err)
|
return nil, fmt.Errorf("scan attachment: %w", err)
|
||||||
}
|
}
|
||||||
out[a.MessageID] = append(out[a.MessageID], a)
|
out[a.MessageID] = append(out[a.MessageID], a)
|
||||||
@@ -640,7 +650,8 @@ func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, k
|
|||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT a.id, a.message_id, a.ord, a.kind, a.mime, a.size_bytes, a.width,
|
SELECT a.id, a.message_id, a.ord, a.kind, a.mime, a.size_bytes, a.width,
|
||||||
a.height, a.duration_ms, a.blurhash, a.file_name, a.url, a.title,
|
a.height, a.duration_ms, a.blurhash, a.file_name, a.url, a.title,
|
||||||
a.description, a.site_name, a.image_url, a.created_at
|
a.description, a.site_name, a.image_url,
|
||||||
|
a.entity_type, a.entity_id, a.snapshot, a.created_at
|
||||||
FROM message_attachments a
|
FROM message_attachments a
|
||||||
JOIN messages m ON m.id = a.message_id
|
JOIN messages m ON m.id = a.message_id
|
||||||
WHERE a.conversation_id = $1
|
WHERE a.conversation_id = $1
|
||||||
@@ -661,7 +672,8 @@ func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, k
|
|||||||
var a model.Attachment
|
var a model.Attachment
|
||||||
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
|
if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes,
|
||||||
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
|
&a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL,
|
||||||
&a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil {
|
&a.Title, &a.Description, &a.SiteName, &a.ImageURL,
|
||||||
|
&a.EntityType, &a.EntityID, &a.Entity, &a.CreatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scan gallery row: %w", err)
|
return nil, fmt.Errorf("scan gallery row: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
|
|||||||
@@ -160,6 +160,46 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByIDs loads many posts at once, keyed by id. Missing and soft-deleted
|
||||||
|
// ids are simply absent from the map.
|
||||||
|
//
|
||||||
|
// Exists so a page of chat messages sharing N posts costs one query instead of
|
||||||
|
// N — the same reason ImagesFor is batched.
|
||||||
|
func (r *PostRepo) FindByIDs(ctx context.Context, ids []string) (map[string]*model.Post, error) {
|
||||||
|
out := make(map[string]*model.Post, len(ids))
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = ANY($1) AND p.deleted_at IS NULL`
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, q, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find posts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
found := make([]string, 0, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
p := &model.Post{}
|
||||||
|
if err := scanPost(rows, p); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan post: %w", err)
|
||||||
|
}
|
||||||
|
out[p.ID] = p
|
||||||
|
found = append(found, p.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("find posts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imgs, err := r.ImagesFor(ctx, found)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for id, p := range out {
|
||||||
|
p.Images = imgs[id]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListByHandle returns the profile feed for a user. Unknown handle →
|
// ListByHandle returns the profile feed for a user. Unknown handle →
|
||||||
// empty page (matches pre-existing behaviour).
|
// empty page (matches pre-existing behaviour).
|
||||||
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
|||||||
@@ -242,6 +242,35 @@ func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) er
|
|||||||
|
|
||||||
// BlockState reports, in one round-trip, whether a blocked b and whether
|
// BlockState reports, in one round-trip, whether a blocked b and whether
|
||||||
// b blocked a.
|
// b blocked a.
|
||||||
|
// BlockedWith returns the subset of others that viewerID is blocked with in
|
||||||
|
// EITHER direction — the set whose content must be hidden from them.
|
||||||
|
//
|
||||||
|
// The batched counterpart to BlockState, for resolving many authors at once.
|
||||||
|
func (r *BlockRepo) BlockedWith(ctx context.Context, viewerID string, others []string) (map[string]bool, error) {
|
||||||
|
out := make(map[string]bool, len(others))
|
||||||
|
if viewerID == "" || len(others) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT CASE WHEN blocker_id = $1 THEN blocked_id ELSE blocker_id END
|
||||||
|
FROM user_blocks
|
||||||
|
WHERE (blocker_id = $1 AND blocked_id = ANY($2))
|
||||||
|
OR (blocked_id = $1 AND blocker_id = ANY($2))`,
|
||||||
|
viewerID, others)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("blocked with: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan blocked with: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
|
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
|
||||||
err = r.pg.Pool.QueryRow(ctx, `
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
)
|
)
|
||||||
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
|
||||||
chatRepo := repository.NewChatRepo(s.pg)
|
chatRepo := repository.NewChatRepo(s.pg)
|
||||||
chatSvc := service.NewChatService(chatRepo, blockRepo, rtClient, r2, s.redis, logger)
|
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo, rtClient, r2, s.redis, logger)
|
||||||
s.chatH = handler.NewChatHandler(chatSvc)
|
s.chatH = handler.NewChatHandler(chatSvc)
|
||||||
|
|
||||||
// Channel authorization for private-* subscriptions. Rules are explicit
|
// Channel authorization for private-* subscriptions. Rules are explicit
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ var (
|
|||||||
ErrChatNotAccepting = errors.New("this person isn't accepting messages from you")
|
ErrChatNotAccepting = errors.New("this person isn't accepting messages from you")
|
||||||
ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction")
|
ErrChatBadWhoCanMessage = errors.New("unknown messaging restriction")
|
||||||
ErrChatBadQuote = errors.New("that quote is not a valid passage reference")
|
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")
|
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")
|
ErrChatGenderUnset = errors.New("add your gender in profile settings to use this option")
|
||||||
)
|
)
|
||||||
@@ -62,6 +63,9 @@ const (
|
|||||||
type ChatService struct {
|
type ChatService struct {
|
||||||
chat *repository.ChatRepo
|
chat *repository.ChatRepo
|
||||||
blocks *repository.BlockRepo
|
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
|
rt *realtime.SoketiClient
|
||||||
r2 *repository.R2
|
r2 *repository.R2
|
||||||
links *unfurl.Fetcher
|
links *unfurl.Fetcher
|
||||||
@@ -72,13 +76,14 @@ type ChatService struct {
|
|||||||
func NewChatService(
|
func NewChatService(
|
||||||
chat *repository.ChatRepo,
|
chat *repository.ChatRepo,
|
||||||
blocks *repository.BlockRepo,
|
blocks *repository.BlockRepo,
|
||||||
|
posts *repository.PostRepo,
|
||||||
rt *realtime.SoketiClient,
|
rt *realtime.SoketiClient,
|
||||||
r2 *repository.R2,
|
r2 *repository.R2,
|
||||||
cache *repository.Redis,
|
cache *repository.Redis,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) *ChatService {
|
) *ChatService {
|
||||||
return &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,
|
links: unfurl.New(), cache: cache, logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,11 +322,12 @@ func (s *ChatService) Starred(ctx context.Context, userID, conversationID string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.hydrateAttachments(ctx, msgs)
|
return s.hydrateAttachments(ctx, userID, msgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hydrateAttachments fills in each message's attachments in one query.
|
// hydrateAttachments fills in each message's attachments in one query, then
|
||||||
func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Message) ([]model.Message, error) {
|
// 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))
|
ids := make([]string, 0, len(msgs))
|
||||||
for i := range msgs {
|
for i := range msgs {
|
||||||
ids = append(ids, msgs[i].ID)
|
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{}
|
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
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +375,7 @@ func (s *ChatService) Messages(ctx context.Context, userID, conversationID strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.hydrateAttachments(ctx, msgs)
|
return s.hydrateAttachments(ctx, userID, msgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendRequest is one outgoing message from a client.
|
// 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
|
// fetches the title, description and image itself, because a client that
|
||||||
// could supply them could make any link claim to be anything.
|
// could supply them could make any link claim to be anything.
|
||||||
PreviewURL *string
|
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
|
// Quote is a passage shared from the library, rendered as a quote the
|
||||||
// reader can tap to return to the exact phrase.
|
// reader can tap to return to the exact phrase.
|
||||||
Quote *QuoteAttachment
|
Quote *QuoteAttachment
|
||||||
@@ -419,7 +432,7 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
|||||||
body := strings.TrimSpace(req.Body)
|
body := strings.TrimSpace(req.Body)
|
||||||
// A photo or a shared passage with no caption is an ordinary message; only
|
// A photo or a shared passage with no caption is an ordinary message; only
|
||||||
// a message with nothing at all in it is empty.
|
// 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
|
return nil, ErrChatEmptyBody
|
||||||
}
|
}
|
||||||
if len([]rune(body)) > maxBodyRunes {
|
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
|
// the first time, and re-publishing would duplicate the bubble for
|
||||||
// everyone else.
|
// everyone else.
|
||||||
if !created {
|
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.attachQuote(ctx, conversationID, msg, req.Quote)
|
||||||
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
|
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
|
||||||
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
|
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
|
||||||
|
|
||||||
full, err := s.withAttachments(ctx, msg)
|
full, err := s.withAttachments(ctx, userID, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
full = msg
|
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.
|
// 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})
|
byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return msg, err
|
return msg, err
|
||||||
@@ -476,6 +492,7 @@ func (s *ChatService) withAttachments(ctx context.Context, msg *model.Message) (
|
|||||||
if msg.Attachments == nil {
|
if msg.Attachments == nil {
|
||||||
msg.Attachments = []model.Attachment{}
|
msg.Attachments = []model.Attachment{}
|
||||||
}
|
}
|
||||||
|
s.resolveEntities(ctx, viewerID, [][]model.Attachment{msg.Attachments})
|
||||||
return msg, nil
|
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.
|
// QuoteAttachment is a passage shared out of the library.
|
||||||
type QuoteAttachment struct {
|
type QuoteAttachment struct {
|
||||||
// Path is an APP-RELATIVE deep link, e.g. "/books/bg/2/13?hl=…".
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
if created {
|
if created {
|
||||||
full, hErr := s.withAttachments(ctx, msg)
|
full, hErr := s.withAttachments(ctx, userID, msg)
|
||||||
if hErr == nil {
|
if hErr == nil {
|
||||||
msg = full
|
msg = full
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"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
|
// Nil realtime/R2/cache clients make publishing, uploads and caching
|
||||||
// no-ops — these tests are about the rules, and none of those services are
|
// no-ops — these tests are about the rules, and none of those services are
|
||||||
// reachable from a test run.
|
// 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
|
// 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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user