fix: chat accepts quotes now

This commit is contained in:
Krispire Admin
2026-08-29 11:58:20 +05:30
parent d50031cdb3
commit 18bac9a22d
6 changed files with 186 additions and 4 deletions
+90 -3
View File
@@ -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
+47
View File
@@ -7,6 +7,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -1719,3 +1720,49 @@ func TestOpenDMByHandle(t *testing.T) {
t.Errorf("unknown handle: err = %v, want ErrUserGone", err)
}
}
// A quote renders as trusted in-app content, so its link must be unable to
// leave the app. Anything that could navigate off-site has to be refused.
func TestQuoteAttachment_RejectsOffSitePaths(t *testing.T) {
good := []string{
"/books/bg/2/13",
"/books/bg/2/13?hl=abc%20def",
"/u/someone/posts#post-1",
"/",
}
for _, p := range good {
q := QuoteAttachment{Path: p, Title: "Bg 2.13", Text: "the passage"}
if !q.valid() {
t.Errorf("valid() = false for in-app path %q", p)
}
}
bad := map[string]string{
"absolute http": "http://evil.example/x",
"absolute https": "https://evil.example/x",
"protocol relative": "//evil.example/x",
"backslash relative": "/\\evil.example/x",
"scheme only": "javascript:alert(1)",
"data uri": "data:text/html,<script>",
"no leading slash": "books/bg/2/13",
"empty": "",
"newline injection": "/books\n/evil",
"tab injection": "/books\t/evil",
}
for name, p := range bad {
q := QuoteAttachment{Path: p, Title: "Bg 2.13", Text: "the passage"}
if q.valid() {
t.Errorf("%s: valid() = true for %q — must be refused", name, p)
}
}
// A quote with no passage is not a quote.
if (&QuoteAttachment{Path: "/books/bg/2/13", Text: " "}).valid() {
t.Error("valid() = true for a blank passage")
}
// Oversized fields are refused rather than truncated.
long := strings.Repeat("x", maxQuoteTextRunes+1)
if (&QuoteAttachment{Path: "/books/bg/2/13", Text: long}).valid() {
t.Error("valid() = true for an oversized passage")
}
}