fix: chat accepts quotes now
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -41,6 +42,7 @@ var (
|
||||
ErrAvatarUnset = errors.New("this group has no photo")
|
||||
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")
|
||||
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")
|
||||
)
|
||||
@@ -377,6 +379,9 @@ 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
|
||||
// Quote is a passage shared from the library, rendered as a quote the
|
||||
// reader can tap to return to the exact phrase.
|
||||
Quote *QuoteAttachment
|
||||
// Attachments are files already uploaded to the private bucket via
|
||||
// PresignAttachment; their keys are checked against the caller's prefix.
|
||||
Attachments []MediaAttachment
|
||||
@@ -407,10 +412,14 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Quote != nil && !req.Quote.valid() {
|
||||
return nil, ErrChatBadQuote
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(req.Body)
|
||||
// A photo with no caption is an ordinary message; only a message with
|
||||
// neither text nor attachments is empty.
|
||||
if body == "" && len(req.Attachments) == 0 {
|
||||
// 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 {
|
||||
return nil, ErrChatEmptyBody
|
||||
}
|
||||
if len([]rune(body)) > maxBodyRunes {
|
||||
@@ -445,6 +454,7 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
||||
return s.withAttachments(ctx, msg)
|
||||
}
|
||||
|
||||
s.attachQuote(ctx, conversationID, msg, req.Quote)
|
||||
s.attachMedia(ctx, conversationID, userID, msg, req.Attachments)
|
||||
s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL)
|
||||
|
||||
@@ -499,6 +509,83 @@ func (s *ChatService) attachMedia(ctx context.Context, conversationID, userID st
|
||||
}
|
||||
}
|
||||
|
||||
// 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=…".
|
||||
//
|
||||
// Deliberately not a full URL. A quote renders as trusted in-app content,
|
||||
// so allowing an absolute URL would let a sender dress an off-site link up
|
||||
// as a scripture citation. A path cannot leave the app, which removes the
|
||||
// question of which origins to trust.
|
||||
Path string
|
||||
// Title is the source reference shown above the passage.
|
||||
Title string
|
||||
// Text is the quoted passage itself.
|
||||
Text string
|
||||
}
|
||||
|
||||
const (
|
||||
maxQuoteTitleRunes = 200
|
||||
maxQuoteTextRunes = 2000
|
||||
maxQuotePathBytes = 2048
|
||||
)
|
||||
|
||||
func (q *QuoteAttachment) valid() bool {
|
||||
if strings.TrimSpace(q.Text) == "" {
|
||||
return false
|
||||
}
|
||||
if len([]rune(q.Title)) > maxQuoteTitleRunes || len([]rune(q.Text)) > maxQuoteTextRunes {
|
||||
return false
|
||||
}
|
||||
return safeAppPath(q.Path)
|
||||
}
|
||||
|
||||
// safeAppPath accepts only a path within this app.
|
||||
//
|
||||
// "//evil.com" and "/\evil.com" are protocol-relative and would navigate
|
||||
// off-site, so a leading slash alone is not enough to check.
|
||||
func safeAppPath(p string) bool {
|
||||
if p == "" || len(p) > maxQuotePathBytes {
|
||||
return false
|
||||
}
|
||||
if p[0] != '/' {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(p, "\\") {
|
||||
return false
|
||||
}
|
||||
for _, r := range p {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// A parsed path must not carry a scheme or host of its own.
|
||||
u, err := url.Parse(p)
|
||||
if err != nil || u.Scheme != "" || u.Host != "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// attachQuote stores a shared passage. Unlike a link card, the contents come
|
||||
// from the client — but they are only ever rendered as a quotation, and the
|
||||
// path has already been constrained to this app.
|
||||
func (s *ChatService) attachQuote(ctx context.Context, conversationID string, msg *model.Message, q *QuoteAttachment) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
path, title, text := q.Path, strings.TrimSpace(q.Title), strings.TrimSpace(q.Text)
|
||||
if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{
|
||||
MessageID: msg.ID, ConversationID: conversationID, Ord: 0,
|
||||
Kind: model.AttachmentQuote, URL: &path, Title: &title, Description: &text,
|
||||
}); err != nil {
|
||||
s.logger.Warn("chat: attach quote failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// attachLinkPreview unfurls the chosen link and stores the card.
|
||||
//
|
||||
// Two rules make this safe. The URL must be one the parser actually found in
|
||||
|
||||
Reference in New Issue
Block a user