Author SHA1 Message Date
Krispire Admin d50031cdb3 feat: add chat 2026-08-29 03:49:30 +05:30
21 changed files with 6960 additions and 11 deletions
+16
View File
@@ -55,6 +55,22 @@ func NotificationsRule() ChannelRule {
}
}
// ConversationRule allows a caller to subscribe to a thread's channel only
// while they are a participant. isParticipant is injected rather than taking a
// repository dependency here, so the authorization check stays the service's
// single source of truth.
func ConversationRule(isParticipant func(ctx context.Context, userID, conversationID string) (bool, error)) ChannelRule {
return ChannelRule{
Prefix: "private-conversation.",
Authorize: func(ctx context.Context, userID, suffix string) (bool, error) {
if suffix == "" {
return false, nil
}
return isParticipant(ctx, userID, suffix)
},
}
}
type broadcastingAuthResp struct {
Auth string `json:"auth"`
ChannelData string `json:"channel_data,omitempty"`
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -77,6 +78,44 @@ func TestBroadcastingAuth_Authorization(t *testing.T) {
// A prefix match must not be mistaken for authorization: "…user-a-evil" starts
// with the victim's id but is a different channel.
// ConversationRule delegates to the chat service's membership check, so the
// realtime channel and the REST endpoints can never disagree about who is in a
// thread.
func TestConversationRule(t *testing.T) {
const member, outsider = "member-1", "outsider-1"
rule := ConversationRule(func(_ context.Context, userID, conversationID string) (bool, error) {
if conversationID == "boom" {
return false, errors.New("database down")
}
return userID == member && conversationID == "conv-1", nil
})
if rule.Prefix != "private-conversation." {
t.Errorf("prefix = %q", rule.Prefix)
}
tests := []struct {
name string
user string
suffix string
wantOK bool
wantErr bool
}{
{"participant", member, "conv-1", true, false},
{"outsider", outsider, "conv-1", false, false},
{"participant of another thread", member, "conv-2", false, false},
{"empty conversation id", member, "", false, false},
{"lookup failure is not a denial", member, "boom", false, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ok, err := rule.Authorize(context.Background(), tc.user, tc.suffix)
if ok != tc.wantOK || (err != nil) != tc.wantErr {
t.Errorf("= (%v, %v), want (%v, err=%v)", ok, err, tc.wantOK, tc.wantErr)
}
})
}
}
func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) {
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
if rec.Code != http.StatusForbidden {
+626
View File
@@ -0,0 +1,626 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// ChatHandler exposes the chat inbox, threads and messages.
//
// Every route is session-authenticated and every read re-checks membership in
// the service, so a conversation id is not a capability: knowing one gets you
// nothing if you were never in the thread (or have left it).
type ChatHandler struct {
svc *service.ChatService
}
func NewChatHandler(svc *service.ChatService) *ChatHandler { return &ChatHandler{svc: svc} }
// writeChatError maps service errors onto status codes. Not-a-participant is a
// 404, not a 403: confirming a thread exists to someone outside it leaks that
// two people are talking.
func writeChatError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrNotParticipant), errors.Is(err, repository.ErrConversationGone):
response.Error(w, http.StatusNotFound, "conversation not found")
case errors.Is(err, service.ErrAvatarUnset):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrChatBadAvatarKey):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrMessageNotHere):
response.Error(w, http.StatusNotFound, "message not found")
case errors.Is(err, repository.ErrAttachmentGone):
response.Error(w, http.StatusNotFound, "attachment not found")
case errors.Is(err, service.ErrChatMediaOff):
response.Error(w, http.StatusServiceUnavailable, err.Error())
case errors.Is(err, service.ErrChatBadMedia):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotAdmin), errors.Is(err, service.ErrChatOwnerImmutable),
errors.Is(err, service.ErrChatAdminsOnly):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatNotGroup), errors.Is(err, service.ErrChatBadRole),
errors.Is(err, service.ErrChatBadPolicy):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotSender):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrMessageDeleted), errors.Is(err, service.ErrChatBadReport):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotAccepting):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatBadWhoCanMessage), errors.Is(err, service.ErrChatBadAccountAge),
errors.Is(err, service.ErrChatGenderUnset):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBlocked):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatReadOnly):
response.Error(w, http.StatusConflict, "Kautilya is not available yet")
case errors.Is(err, service.ErrChatEmptyBody), errors.Is(err, service.ErrChatBodyTooLong),
errors.Is(err, service.ErrChatBadMuteDuration),
errors.Is(err, repository.ErrReplyTargetNotHere):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "chat request failed")
}
}
// Inbox — GET /chat/conversations?tab=chats|kautilya
func (h *ChatHandler) Inbox(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
tab := r.URL.Query().Get("tab")
items, err := h.svc.Inbox(r.Context(), userID, tab, atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
type openDMReq struct {
// Handle, not id: profiles are addressed by handle everywhere else, and
// the public profile payload carries no user id.
Handle string `json:"handle"`
}
// OpenDM — POST /chat/conversations/dm. Idempotent: returns the existing
// thread when there is one, so the client can call it every time it opens a
// profile without checking first.
func (h *ChatHandler) OpenDM(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
var req openDMReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" {
response.Error(w, http.StatusBadRequest, "handle is required")
return
}
conv, err := h.svc.OpenDMByHandle(r.Context(), userID, req.Handle)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, conv)
}
type createGroupReq struct {
Title string `json:"title"`
MemberHandles []string `json:"member_handles"`
}
// CreateGroup — POST /chat/conversations/group
func (h *ChatHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
var req createGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
conv, err := h.svc.CreateGroup(r.Context(), userID, req.Title, req.MemberHandles)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, conv)
}
// OpenAIThread — POST /chat/conversations/kautilya
func (h *ChatHandler) OpenAIThread(w http.ResponseWriter, r *http.Request) {
conv, err := h.svc.OpenAIThread(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, conv)
}
// Detail — GET /chat/conversations/{id}
func (h *ChatHandler) Detail(w http.ResponseWriter, r *http.Request) {
detail, err := h.svc.Detail(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, detail)
}
// Messages — GET /chat/conversations/{id}/messages?before_seq=&limit=
//
// before_seq is an exclusive upper bound rather than an offset, so paging stays
// stable while new messages keep arriving at the top.
func (h *ChatHandler) Messages(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
convID := chi.URLParam(r, "id")
msgs, err := h.svc.Messages(r.Context(), userID, convID,
int64(atoiOr(r.URL.Query().Get("before_seq"), 0)),
atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msgs)
}
type mediaReq struct {
Key string `json:"key"`
Mime string `json:"mime"`
SizeBytes *int64 `json:"size_bytes"`
Width *int `json:"width"`
Height *int `json:"height"`
DurationMs *int `json:"duration_ms"`
Blurhash *string `json:"blurhash"`
FileName *string `json:"file_name"`
}
type sendReq struct {
Body string `json:"body"`
ClientMsgID *string `json:"client_msg_id"`
// preview_url is the link the sender chose to keep a card for; omitted
// when they dismissed it. Only the URL — never the card's contents.
PreviewURL *string `json:"preview_url"`
Attachments []mediaReq `json:"attachments"`
ReplyToID *string `json:"reply_to_id"`
// 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
// accepting an unvalidated message reference in the meantime would let a
// client point at any message in the system.
}
// Send — POST /chat/conversations/{id}/messages
func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
convID := chi.URLParam(r, "id")
var req sendReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
media := make([]service.MediaAttachment, 0, len(req.Attachments))
for _, a := range req.Attachments {
media = append(media, service.MediaAttachment{
Key: a.Key, Mime: a.Mime, SizeBytes: a.SizeBytes, Width: a.Width,
Height: a.Height, DurationMs: a.DurationMs, Blurhash: a.Blurhash,
FileName: a.FileName,
})
}
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
Body: req.Body,
ClientMsgID: req.ClientMsgID,
ReplyToID: req.ReplyToID,
PreviewURL: req.PreviewURL,
Attachments: media,
})
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, msg)
}
// Settings — GET /chat/settings
func (h *ChatHandler) Settings(w http.ResponseWriter, r *http.Request) {
settings, err := h.svc.Settings(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, settings)
}
type chatSettingsReq struct {
ReadReceipts *bool `json:"read_receipts"`
WhoCanMessage *string `json:"who_can_message"`
MinAccountAgeMonths *int `json:"min_account_age_months"`
TrainKautilya *bool `json:"train_kautilya"`
}
// UpdateSettings — PATCH /chat/settings
//
// Fields are optional: anything omitted keeps its current value, so a toggle
// doesn't have to send the whole object back.
func (h *ChatHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
current, err := h.svc.Settings(r.Context(), userID)
if err != nil {
writeChatError(w, err)
return
}
var req chatSettingsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.ReadReceipts != nil {
current.ReadReceipts = *req.ReadReceipts
}
if req.WhoCanMessage != nil {
current.WhoCanMessage = *req.WhoCanMessage
}
if req.MinAccountAgeMonths != nil {
current.MinAccountAgeMonths = *req.MinAccountAgeMonths
}
if req.TrainKautilya != nil {
current.TrainKautilya = *req.TrainKautilya
}
saved, err := h.svc.SaveSettings(r.Context(), userID, current)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, saved)
}
type linkPreviewReq struct {
URL string `json:"url"`
}
// LinkPreview — POST /chat/link-preview
//
// The composer calls this as a link is typed so the sender can see the card
// and dismiss it before sending. Only the URL travels in either direction's
// trust boundary: the BFF fetches the metadata itself.
func (h *ChatHandler) LinkPreview(w http.ResponseWriter, r *http.Request) {
var req linkPreviewReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
response.Error(w, http.StatusBadRequest, "url is required")
return
}
preview, err := h.svc.LinkPreview(r.Context(), req.URL)
if err != nil {
// No card is a normal outcome, not a server fault.
response.Error(w, http.StatusUnprocessableEntity, "no preview available")
return
}
response.JSON(w, http.StatusOK, preview)
}
type chatPresignReq struct {
ContentType string `json:"content_type"`
}
// PresignAttachment — POST /chat/conversations/{id}/attachments/presign
func (h *ChatHandler) PresignAttachment(w http.ResponseWriter, r *http.Request) {
var req chatPresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignAttachment(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
if err != nil {
writeChatError(w, err)
return
}
// The key comes back so the client can name it when it sends the message;
// there is no public URL, by design.
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
// Attachment — GET /chat/attachments/{attachmentID}
//
// A stable URL that redirects to a short-lived signed one. Membership is
// re-checked on every fetch, so access revoked after a link was shared stops
// working — which a signed URL baked into the message payload could not do.
func (h *ChatHandler) Attachment(w http.ResponseWriter, r *http.Request) {
url, err := h.svc.AttachmentURL(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "attachmentID"))
if err != nil {
writeChatError(w, err)
return
}
// Private and short-lived: never let a shared cache hold onto it.
w.Header().Set("Cache-Control", "private, no-store")
http.Redirect(w, r, url, http.StatusFound)
}
// PresignGroupAvatar — POST /chat/conversations/{id}/avatar/presign
func (h *ChatHandler) PresignGroupAvatar(w http.ResponseWriter, r *http.Request) {
var req chatPresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignGroupAvatar(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
// GroupAvatar — GET /chat/conversations/{id}/avatar
//
// Stable URL that redirects to a short-lived signed one, re-checking
// membership each time, exactly as message attachments do.
func (h *ChatHandler) GroupAvatar(w http.ResponseWriter, r *http.Request) {
url, err := h.svc.GroupAvatarURL(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
w.Header().Set("Cache-Control", "private, no-store")
http.Redirect(w, r, url, http.StatusFound)
}
// Gallery — GET /chat/conversations/{id}/gallery?tab=media|links&before=
func (h *ChatHandler) Gallery(w http.ResponseWriter, r *http.Request) {
var before *time.Time
if raw := r.URL.Query().Get("before"); raw != "" {
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
before = &t
}
}
items, err := h.svc.Gallery(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), r.URL.Query().Get("tab"), before,
atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
// Starred — GET /chat/conversations/{id}/starred
func (h *ChatHandler) Starred(w http.ResponseWriter, r *http.Request) {
msgs, err := h.svc.Starred(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msgs)
}
// Star / Unstar — POST|DELETE /chat/conversations/{id}/messages/{messageID}/star
func (h *ChatHandler) Star(w http.ResponseWriter, r *http.Request) {
h.setStar(w, r, true)
}
func (h *ChatHandler) Unstar(w http.ResponseWriter, r *http.Request) {
h.setStar(w, r, false)
}
func (h *ChatHandler) setStar(w http.ResponseWriter, r *http.Request, starred bool) {
err := h.svc.SetStar(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"), starred)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// DeleteMessage — DELETE /chat/conversations/{id}/messages/{messageID}?scope=me|everyone
func (h *ChatHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
scope := r.URL.Query().Get("scope")
if scope == "" {
scope = service.DeleteForMe // the safer default
}
err := h.svc.DeleteMessage(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), scope)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type forwardReq struct {
MessageID string `json:"message_id"`
}
// Forward — POST /chat/conversations/{id}/forward
//
// The path names the DESTINATION; the body names the message being copied in.
func (h *ChatHandler) Forward(w http.ResponseWriter, r *http.Request) {
var req forwardReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" {
response.Error(w, http.StatusBadRequest, "message_id is required")
return
}
msg, err := h.svc.ForwardMessage(r.Context(), middleware.UserID(r.Context()),
req.MessageID, chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, msg)
}
type chatReportReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// ReportMessage — POST /chat/conversations/{id}/messages/{messageID}/report
func (h *ChatHandler) ReportMessage(w http.ResponseWriter, r *http.Request) {
var req chatReportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
err := h.svc.ReportMessage(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Category, req.Reason)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type membersReq struct {
// Handles, not ids: search results and follow lists identify people by
// handle, and it matches /u/{handle}/block elsewhere in the API.
MemberHandles []string `json:"member_handles"`
}
// AddMembers — POST /chat/conversations/{id}/members
func (h *ChatHandler) AddMembers(w http.ResponseWriter, r *http.Request) {
var req membersReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.AddMembers(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.MemberHandles); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// RemoveMember — DELETE /chat/conversations/{id}/members/{handle}
//
// Passing your own handle is how you leave.
func (h *ChatHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
if err := h.svc.RemoveMember(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "handle")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type roleReq struct {
Role string `json:"role"`
}
// SetMemberRole — PATCH /chat/conversations/{id}/members/{userID}
func (h *ChatHandler) SetMemberRole(w http.ResponseWriter, r *http.Request) {
var req roleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetMemberRole(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "handle"), req.Role); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type updateGroupReq struct {
Title *string `json:"title"`
SendPolicy *string `json:"send_policy"`
// AvatarKey comes from PresignGroupAvatar; the service checks it belongs
// to this group before storing it.
AvatarKey *string `json:"avatar_key"`
}
// UpdateGroup — PATCH /chat/conversations/{id}
func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
var req updateGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.UpdateGroup(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.Title, req.SendPolicy, req.AvatarKey); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type receiptReq struct {
Seq int64 `json:"seq"`
}
// MarkRead — POST /chat/conversations/{id}/read
func (h *ChatHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
h.receipt(w, r, h.svc.MarkRead)
}
// MarkDelivered — POST /chat/conversations/{id}/delivered
func (h *ChatHandler) MarkDelivered(w http.ResponseWriter, r *http.Request) {
h.receipt(w, r, h.svc.MarkDelivered)
}
func (h *ChatHandler) receipt(w http.ResponseWriter, r *http.Request, apply func(ctx context.Context, userID, convID string, seq int64) error) {
var req receiptReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := apply(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Seq); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type muteReq struct {
// Duration is one of 8h, 1w, always, off.
Duration string `json:"duration"`
}
// SetMute — POST /chat/conversations/{id}/mute
//
// Muting silences alerts for the caller only; the thread keeps receiving
// messages and its unread count keeps climbing.
func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
var req muteReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
until, err := h.svc.SetMute(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Duration)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
}
// Receipts — GET /chat/conversations/{id}/receipts
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, rc)
}
func atoiOr(s string, fallback int) int {
if s == "" {
return fallback
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return fallback
}
return n
}
+245
View File
@@ -0,0 +1,245 @@
package model
import (
"encoding/json"
"time"
)
// Who may start a conversation with a user.
const (
WhoAnyone = "anyone"
WhoFriends = "friends"
WhoAccountAge = "account_age"
WhoSameGender = "same_gender"
)
// ChatSettings are a user's own chat preferences. Absence of a stored row means
// all defaults, so DefaultChatSettings and the column defaults must agree.
type ChatSettings struct {
ReadReceipts bool `json:"read_receipts"`
WhoCanMessage string `json:"who_can_message"`
MinAccountAgeMonths int `json:"min_account_age_months"`
TrainKautilya bool `json:"train_kautilya"`
// GenderSet reports whether the user has a gender on their profile. The
// same_gender option is unusable without one, so the UI needs to know.
GenderSet bool `json:"gender_set"`
}
// DefaultChatSettings mirrors the column defaults in migration 0050.
func DefaultChatSettings() ChatSettings {
return ChatSettings{
ReadReceipts: true,
WhoCanMessage: WhoAnyone,
MinAccountAgeMonths: 3,
TrainKautilya: true,
}
}
// Conversation kinds. DMs and groups fill the inbox's "Chats" tab; ai fills
// the "Kautilya" tab.
// Group send policies. Only groups honour these — a DM has no administrators
// and the Kautilya thread has one participant.
const (
SendPolicyEveryone = "everyone"
SendPolicyAdmins = "admins"
)
// Participant roles, most privileged first.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
)
const (
ConversationDM = "dm"
ConversationGroup = "group"
ConversationAI = "ai"
)
// Message kinds describe who produced a message, not what it contains — the
// content type lives on its attachments.
const (
MessageText = "text"
MessageSystem = "system"
MessageAssistant = "assistant"
)
// Conversation is a chat thread.
type Conversation struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title *string `json:"title,omitempty"`
AvatarKey *string `json:"avatar_key,omitempty"`
// SendPolicy is "everyone" or "admins"; meaningful for groups only.
SendPolicy string `json:"send_policy"`
LastSeq int64 `json:"last_seq"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ChatPeer is the counterpart shown on a DM row: enough to render the inbox
// without a second round trip.
type ChatPeer struct {
ID string `json:"id"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
// Role and JoinedAt are filled for a thread's participant list; they are
// empty on the `peer` of a DM, where a role means nothing.
Role string `json:"role,omitempty"`
JoinedAt *time.Time `json:"joined_at,omitempty"`
}
// MessagePreview is the truncated last-message line on an inbox row.
type MessagePreview struct {
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name"`
Kind string `json:"kind"`
Body string `json:"body"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// InboxItem is one row of the chat inbox.
type InboxItem struct {
Conversation
Unread int64 `json:"unread"`
// MutedUntil is non-nil while the caller has this thread muted. Unread
// still accrues — muting silences the alert, it does not hide the chat.
MutedUntil *time.Time `json:"muted_until,omitempty"`
Peer *ChatPeer `json:"peer,omitempty"` // DMs only
LastMessage *MessagePreview `json:"last_message,omitempty"` // nil on an empty thread
}
// ChatRelationship is how two people in a DM stand with each other — what the
// chat profile shows above the shared-media tabs.
type ChatRelationship struct {
// Following: the caller follows the peer. FollowedBy: the reverse. Both
// true is mutual; one true is a one-way follow.
Following bool `json:"following"`
FollowedBy bool `json:"followed_by"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// FriendsSince is when the first follow between them was accepted — the
// "friends since" line. Nil when neither follows the other.
FriendsSince *time.Time `json:"friends_since,omitempty"`
}
// ConversationDetail is everything a thread screen needs in one request:
// the thread, who is in it, and the caller's own state. It replaces scanning
// the inbox to find a single conversation.
type ConversationDetail struct {
Conversation
// Peer is the counterpart on a DM, nil otherwise.
Peer *ChatPeer `json:"peer,omitempty"`
// Participants is everyone still in the thread, the caller included, so
// the UI can name a message's sender without another lookup.
Participants []ChatPeer `json:"participants"`
Unread int64 `json:"unread"`
MutedUntil *time.Time `json:"muted_until,omitempty"`
// MyRole is the caller's role in this thread, so the UI can show admin
// controls without deriving it from the participant list.
MyRole string `json:"my_role"`
// Relationship is populated for DMs only; a group has no single peer to
// stand in a relationship with.
Relationship *ChatRelationship `json:"relationship,omitempty"`
}
// Participant is a user's membership of a conversation, including the two
// cursors that drive tick state.
type Participant struct {
ConversationID string `json:"conversation_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
LastReadSeq int64 `json:"last_read_seq"`
LastDeliveredSeq int64 `json:"last_delivered_seq"`
MutedUntil *time.Time `json:"muted_until,omitempty"`
JoinedAt time.Time `json:"joined_at"`
LeftAt *time.Time `json:"left_at,omitempty"`
}
// Attachment kinds.
const (
AttachmentImage = "image"
AttachmentVideo = "video"
AttachmentAudio = "audio"
AttachmentFile = "file"
AttachmentLink = "link"
AttachmentEntity = "entity"
)
// 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
// bucket and is only ever reachable through a participant-checked, short-lived
// presigned GET, so there is no URL to store.
type Attachment struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
Ord int `json:"ord"`
Kind string `json:"kind"`
// Media
Mime *string `json:"mime,omitempty"`
SizeBytes *int64 `json:"size_bytes,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
DurationMs *int `json:"duration_ms,omitempty"`
Blurhash *string `json:"blurhash,omitempty"`
FileName *string `json:"file_name,omitempty"`
// Link preview. Metadata is fetched by the BFF, never supplied by a
// client — otherwise any link could be made to claim anything.
URL *string `json:"url,omitempty"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
SiteName *string `json:"site_name,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ReplyPreview is the quoted snippet shown above a reply. Deliberately small:
// the bubble needs a name and a line of text, not the whole message.
type ReplyPreview struct {
ID string `json:"id"`
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name"`
Body string `json:"body"`
Deleted bool `json:"deleted"`
}
// Message is one chat message. Entities carries the formatting spans produced
// by ParseEntities; it is always a JSON array, never null.
type Message struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
Kind string `json:"kind"`
Body string `json:"body"`
Entities json.RawMessage `json:"entities"`
ClientMsgID *string `json:"client_msg_id,omitempty"`
ReplyToID *string `json:"reply_to_id,omitempty"`
ForwardedFromID *string `json:"forwarded_from_id,omitempty"`
EditedAt *time.Time `json:"edited_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
// ReplyTo is hydrated on read when this message replies to another in the
// SAME conversation. Nil otherwise.
ReplyTo *ReplyPreview `json:"reply_to,omitempty"`
// Attachments is always present (possibly empty) so clients need no nil
// check before iterating.
Attachments []Attachment `json:"attachments"`
}
// Receipts are the conversation-wide delivery cursors used to draw ticks on
// the sender's own messages. Both are the MINIMUM across the other
// participants, so in a group a message is "read" only once everyone has read
// it — matching what users expect from the blue double tick.
type Receipts struct {
DeliveredSeq int64 `json:"delivered_seq"`
ReadSeq int64 `json:"read_seq"`
}
+9
View File
@@ -183,6 +183,15 @@ type RelationshipState struct {
RequestsYou bool `json:"requests_you"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// CanMessage reports whether the viewer may START a conversation with this
// profile, given the target's "who can message you" setting and any block.
// It lets the UI decide whether to offer a Message button rather than
// offering one that fails on tap.
CanMessage bool `json:"can_message"`
// MessageRestriction names the target's rule when CanMessage is false, so
// the UI can explain instead of silently hiding the button. Empty when
// messaging is allowed, or "blocked" when a block is the reason.
MessageRestriction string `json:"message_restriction,omitempty"`
}
// Follow is one row of the follows table — a directed, request-based
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.uber.org/zap"
)
// These exercise real SQL against a real Postgres — the LATERAL joins, the
// FOR UPDATE sequencing and the partial indexes are the parts most likely to be
// wrong, and none of them can be checked without a database. Skipped when no
// POSTGRES_URI is configured, so `go test ./...` still passes anywhere.
func testRepo(t *testing.T) (*ChatRepo, *Postgres) {
t.Helper()
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 — skipping chat repository integration tests")
}
pg, err := NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
return NewChatRepo(pg), pg
}
// makeUser inserts a throwaway user and removes it (and anything cascading
// from it) when the test finishes.
func makeUser(t *testing.T, pg *Postgres, label string) string {
t.Helper()
ctx := context.Background()
id := uuid.NewString()
suffix := id[:8]
_, err := pg.Pool.Exec(ctx, `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', $3, $4)`,
id, fmt.Sprintf("chattest-%s@example.invalid", suffix), "Chat Test "+label, "chattest_"+suffix)
if err != nil {
t.Fatalf("create test user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// dropConversation removes a thread when the test ends; messages, participants
// and attachments cascade with it.
func dropConversation(t *testing.T, pg *Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id)
})
}
func send(t *testing.T, r *ChatRepo, convID, sender, body string) *model.Message {
t.Helper()
m, _, err := r.Append(context.Background(), AppendParams{
ConversationID: convID, SenderID: &sender, Kind: model.MessageText, Body: body,
})
if err != nil {
t.Fatalf("append %q: %v", body, err)
}
return m
}
func TestEnsureDM_IsIdempotentFromEitherSide(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
first, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatalf("EnsureDM: %v", err)
}
dropConversation(t, pg, first.ID)
// Same pair, opposite argument order — must resolve to the same thread,
// which is the whole point of the sorted dm_key.
second, err := r.EnsureDM(ctx, b, a)
if err != nil {
t.Fatalf("EnsureDM reversed: %v", err)
}
if second.ID != first.ID {
t.Errorf("reversed EnsureDM made a second thread: %s vs %s", second.ID, first.ID)
}
for _, u := range []string{a, b} {
if _, err := r.Participant(ctx, first.ID, u); err != nil {
t.Errorf("user %s is not a participant: %v", u, err)
}
}
}
func TestParticipant_RejectsOutsiders(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, outsider := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
if _, err := r.Participant(ctx, c.ID, outsider); !errors.Is(err, ErrNotParticipant) {
t.Errorf("outsider got %v, want ErrNotParticipant", err)
}
}
func TestAppend_AssignsGaplessSequence(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := int64(1); i <= 3; i++ {
m := send(t, r, c.ID, a, fmt.Sprintf("msg %d", i))
if m.Seq != i {
t.Errorf("message %d got seq %d", i, m.Seq)
}
}
var lastSeq int64
if err := pg.Pool.QueryRow(ctx, `SELECT last_seq FROM conversations WHERE id=$1`, c.ID).Scan(&lastSeq); err != nil {
t.Fatal(err)
}
if lastSeq != 3 {
t.Errorf("conversation.last_seq = %d, want 3", lastSeq)
}
}
// A retried send must return the original message and must NOT burn a sequence
// number — a gap would break the client's catch-up cursor.
func TestAppend_DeduplicatesRetries(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
clientID := uuid.NewString()
p := AppendParams{ConversationID: c.ID, SenderID: &a, Kind: model.MessageText,
Body: "only once", ClientMsgID: &clientID}
first, created, err := r.Append(ctx, p)
if err != nil || !created {
t.Fatalf("first append: created=%v err=%v", created, err)
}
again, created, err := r.Append(ctx, p)
if err != nil {
t.Fatalf("retry: %v", err)
}
if created {
t.Error("retry reported created=true")
}
if again.ID != first.ID || again.Seq != first.Seq {
t.Errorf("retry returned a different message: %s/%d vs %s/%d", again.ID, again.Seq, first.ID, first.Seq)
}
var count, lastSeq int64
_ = pg.Pool.QueryRow(ctx, `SELECT count(*), max(seq) FROM messages WHERE conversation_id=$1`, c.ID).Scan(&count, &lastSeq)
if count != 1 || lastSeq != 1 {
t.Errorf("after retry: %d messages, last seq %d — want 1 and 1", count, lastSeq)
}
}
func TestMessages_KeysetPaging(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 1; i <= 5; i++ {
send(t, r, c.ID, a, fmt.Sprintf("m%d", i))
}
page1, err := r.Messages(ctx, c.ID, a, 0, 2)
if err != nil {
t.Fatal(err)
}
if len(page1) != 2 || page1[0].Seq != 5 || page1[1].Seq != 4 {
t.Fatalf("page1 = %v, want seq 5,4", seqsOf(page1))
}
page2, err := r.Messages(ctx, c.ID, a, page1[len(page1)-1].Seq, 2)
if err != nil {
t.Fatal(err)
}
if len(page2) != 2 || page2[0].Seq != 3 || page2[1].Seq != 2 {
t.Fatalf("page2 = %v, want seq 3,2", seqsOf(page2))
}
// entities must come back as a JSON array, never null — the client indexes
// into it without a nil check.
var spans []map[string]any
if err := json.Unmarshal(page1[0].Entities, &spans); err != nil {
t.Errorf("entities not valid JSON: %v (%s)", err, page1[0].Entities)
}
}
// Delete-for-me must hide a message from that user's history AND from their
// inbox preview, while leaving it visible to everyone else.
func TestMessageHides_ArePerUser(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "keep")
hidden := send(t, r, c.ID, a, "hide me")
if _, err := pg.Pool.Exec(ctx,
`INSERT INTO message_hides (message_id, user_id) VALUES ($1,$2)`, hidden.ID, a); err != nil {
t.Fatal(err)
}
mine, _ := r.Messages(ctx, c.ID, a, 0, 10)
if len(mine) != 1 || mine[0].Body != "keep" {
t.Errorf("hider still sees %v", bodiesOf(mine))
}
theirs, _ := r.Messages(ctx, c.ID, b, 0, 10)
if len(theirs) != 2 {
t.Errorf("other participant sees %v, want both", bodiesOf(theirs))
}
inbox, err := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil || row.LastMessage == nil {
t.Fatal("conversation missing from hider's inbox")
}
if row.LastMessage.Body != "keep" {
t.Errorf("hider's preview = %q, want the un-hidden message", row.LastMessage.Body)
}
}
func TestInbox_UnreadPeerAndPreview(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "first")
send(t, r, c.ID, a, "latest")
// Recipient has read nothing.
inbox, err := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil {
t.Fatal("thread missing from recipient inbox")
}
if row.Unread != 2 {
t.Errorf("unread = %d, want 2", row.Unread)
}
if row.LastMessage == nil || row.LastMessage.Body != "latest" {
t.Errorf("preview = %+v, want body %q", row.LastMessage, "latest")
}
if row.LastMessage != nil && row.LastMessage.SenderName == "" {
t.Error("preview has no sender name — the inbox line needs it")
}
if row.Peer == nil || row.Peer.ID != a {
t.Errorf("peer = %+v, want user A", row.Peer)
}
// The sender's own cursor advanced on send, so their unread is zero.
senderInbox, _ := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if sr := findConv(senderInbox, c.ID); sr == nil || sr.Unread != 0 {
t.Errorf("sender unread = %v, want 0", sr)
}
if err := r.MarkRead(ctx, c.ID, b, 2); err != nil {
t.Fatal(err)
}
after, _ := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if row := findConv(after, c.ID); row == nil || row.Unread != 0 {
t.Errorf("after MarkRead unread = %v, want 0", row)
}
}
// Cursors must never move backwards: a replayed or out-of-order receipt would
// otherwise resurrect already-read messages as unread.
func TestCursors_NeverGoBackwards(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 0; i < 3; i++ {
send(t, r, c.ID, a, "m")
}
if err := r.MarkRead(ctx, c.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, c.ID, b, 1); err != nil { // stale replay
t.Fatal(err)
}
p, err := r.Participant(ctx, c.ID, b)
if err != nil {
t.Fatal(err)
}
if p.LastReadSeq != 3 {
t.Errorf("last_read_seq = %d after a stale replay, want 3", p.LastReadSeq)
}
if p.LastDeliveredSeq != 3 {
t.Errorf("reading should imply delivery: last_delivered_seq = %d, want 3", p.LastDeliveredSeq)
}
}
func TestReceipts_ExcludeCallerAndUseMinimum(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, cUser := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
conv, err := r.CreateGroup(ctx, a, "Test group", []string{b, cUser})
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, conv.ID)
for i := 0; i < 3; i++ {
send(t, r, conv.ID, a, "m")
}
if err := r.MarkDelivered(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
// C has received nothing yet.
rc, err := r.Receipts(ctx, conv.ID, a)
if err != nil {
t.Fatal(err)
}
if rc.ReadSeq != 0 || rc.DeliveredSeq != 0 {
t.Errorf("receipts = %+v, want zeros while one member is behind", rc)
}
if err := r.MarkRead(ctx, conv.ID, cUser, 3); err != nil {
t.Fatal(err)
}
rc, _ = r.Receipts(ctx, conv.ID, a)
if rc.ReadSeq != 3 || rc.DeliveredSeq != 3 {
t.Errorf("receipts = %+v, want 3/3 once everyone has read", rc)
}
}
func TestEnsureAIThread_OnePerUserAndSeparateTab(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a := makeUser(t, pg, "A")
first, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, first.ID)
again, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
if again.ID != first.ID {
t.Errorf("second call made another AI thread: %s vs %s", again.ID, first.ID)
}
// The tabs are a kind filter: an AI thread must not appear under Chats.
chats, _ := r.Inbox(ctx, a, []string{model.ConversationDM, model.ConversationGroup}, 10)
if findConv(chats, first.ID) != nil {
t.Error("AI thread leaked into the Chats tab")
}
kautilya, _ := r.Inbox(ctx, a, []string{model.ConversationAI}, 10)
if findConv(kautilya, first.ID) == nil {
t.Error("AI thread missing from the Kautilya tab")
}
}
func findConv(items []model.InboxItem, id string) *model.InboxItem {
for i := range items {
if items[i].ID == id {
return &items[i]
}
}
return nil
}
func seqsOf(msgs []model.Message) []int64 {
out := make([]int64, len(msgs))
for i, m := range msgs {
out[i] = m.Seq
}
return out
}
func bodiesOf(msgs []model.Message) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
out[i] = m.Body
}
return out
}
+53 -6
View File
@@ -69,6 +69,7 @@ type Server struct {
relationH *handler.RelationshipHandler
soketiWebhookH *handler.SoketiWebhookHandler
broadcastAuthH *handler.BroadcastingAuthHandler
chatH *handler.ChatHandler
notificationH *handler.NotificationHandler
sadhnaH *handler.SadhnaHandler
pushH *handler.PushHandler
@@ -112,10 +113,10 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
discipleRepo: discipleRepo,
// Initialize handlers
bookH: handler.NewBookHandler(bookRepo),
letterH: handler.NewLetterHandler(letterRepo),
essayH: handler.NewEssayHandler(essayRepo),
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
bookH: handler.NewBookHandler(bookRepo),
letterH: handler.NewLetterHandler(letterRepo),
essayH: handler.NewEssayHandler(essayRepo),
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
// userRepo half is attached later in initAuth (Postgres isn't up yet).
discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
journalH: handler.NewJournalHandler(journalRepo),
@@ -258,10 +259,16 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
)
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc)
// Channel authorization for private-* subscriptions. Rules are explicit
// and default-deny; chat conversation channels get added here.
// and default-deny — an unlisted channel is refused.
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
handler.NotificationsRule(),
handler.ConversationRule(chatSvc.IsParticipant),
)
pushRepo := repository.NewPushRepo(s.pg)
@@ -296,7 +303,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// every 15 minutes (no-op when Redis is unavailable).
worker.NewLikesWorker(likeRepo, logger).Run(wctx)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, chatRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
s.relationH = handler.NewRelationshipHandler(graphSvc)
s.notificationH = handler.NewNotificationHandler(notifSvc)
s.postH = handler.NewPostHandler(postSvc, graphSvc)
@@ -895,6 +902,46 @@ func (s *Server) setupRouter() {
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
}
// Chat — inbox, threads, messages. Every route requires a session;
// membership is re-checked per request in the service, so a
// conversation id on its own grants nothing.
if s.chatH != nil {
r.Route("/chat", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/conversations", s.chatH.Inbox)
r.Post("/conversations/dm", s.chatH.OpenDM)
r.Post("/conversations/group", s.chatH.CreateGroup)
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
r.Post("/link-preview", s.chatH.LinkPreview)
r.Get("/settings", s.chatH.Settings)
r.Patch("/settings", s.chatH.UpdateSettings)
r.Get("/attachments/{attachmentID}", s.chatH.Attachment)
r.Route("/conversations/{id}", func(r chi.Router) {
r.Get("/", s.chatH.Detail)
r.Patch("/", s.chatH.UpdateGroup)
r.Post("/members", s.chatH.AddMembers)
r.Delete("/members/{handle}", s.chatH.RemoveMember)
r.Patch("/members/{handle}", s.chatH.SetMemberRole)
r.Get("/messages", s.chatH.Messages)
r.Post("/messages", s.chatH.Send)
r.Post("/read", s.chatH.MarkRead)
r.Post("/delivered", s.chatH.MarkDelivered)
r.Get("/receipts", s.chatH.Receipts)
r.Post("/mute", s.chatH.SetMute)
r.Post("/attachments/presign", s.chatH.PresignAttachment)
r.Post("/avatar/presign", s.chatH.PresignGroupAvatar)
r.Get("/avatar", s.chatH.GroupAvatar)
r.Get("/gallery", s.chatH.Gallery)
r.Get("/starred", s.chatH.Starred)
r.Post("/messages/{messageID}/star", s.chatH.Star)
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
r.Post("/forward", s.chatH.Forward)
})
})
}
// Pusher channel authorization — pusher-js posts here (as its
// `authEndpoint`) before subscribing to a private-/presence- channel.
// Session-authenticated: the signature is only minted for a caller
+208
View File
@@ -0,0 +1,208 @@
package service
import (
"regexp"
"strings"
"unicode"
)
// Message formatting.
//
// The composer accepts three inline markers — *bold*, ~strike~, `code` — and
// auto-detects links. Rather than store markup, we store CLEAN text plus a list
// of spans describing what to decorate. Two reasons: rendering spans can't
// inject HTML no matter what a user types, and every client (web today, native
// later) applies them natively instead of parsing a markup dialect.
//
// OFFSETS ARE UTF-16 CODE UNITS, not bytes and not runes. JavaScript strings
// are UTF-16, so `body.slice(offset, offset+length)` in the client only lines
// up if we count the same way it does. This matters constantly here: Devanāgarī
// and IAST are multi-byte, and emoji are surrogate pairs that count as 2.
// EntityType values understood by the renderer.
const (
EntityBold = "bold"
EntityStrike = "strike"
EntityCode = "code"
EntityURL = "url"
)
// Entity marks a formatted span of a message body.
type Entity struct {
Type string `json:"type"`
Offset int `json:"offset"` // in UTF-16 code units
Length int `json:"length"` // in UTF-16 code units
// URL carries the resolved href for EntityURL spans, so "www.x.org" in the
// text becomes a usable "https://www.x.org" without the client guessing.
URL string `json:"url,omitempty"`
}
// markers maps the opening/closing rune to the entity it produces. Code is
// handled with the same table but its contents are treated as literal.
var markers = map[rune]string{
'*': EntityBold,
'~': EntityStrike,
'`': EntityCode,
}
// urlRe matches http(s) URLs and bare www. hosts. Deliberately conservative:
// matching every bare domain turns "e.g" and "Bg 2.13" into links.
var urlRe = regexp.MustCompile(`(?i)(?:https?://|www\.)[^\s<>()\[\]{}"']+`)
// trailing punctuation that is far more likely to be sentence punctuation than
// part of the URL.
const urlTrailing = `.,;:!?'"`
// ParseEntities converts raw composer text into clean body text plus the spans
// describing its formatting. Markers are removed from the body — what the
// reader copies is the text, not the syntax.
func ParseEntities(raw string) (string, []Entity) {
var b strings.Builder
entities, _ := parseInto(&b, raw, 0)
body := b.String()
// Links are detected last, over the finished body, so offsets need no
// adjustment. Code spans are skipped: a URL inside `backticks` is being
// shown, not linked.
entities = append(entities, detectURLs(body, entities)...)
sortEntities(entities)
return body, entities
}
// parseInto appends the cleaned text of raw to b and returns its entities plus
// how many UTF-16 units were written. Returning the written count (rather than
// deriving it from the input) keeps offsets correct however many markers a
// nested parse strips.
func parseInto(b *strings.Builder, raw string, base int) (entities []Entity, written int) {
runes := []rune(raw)
for i := 0; i < len(runes); i++ {
r := runes[i]
kind, isMarker := markers[r]
if !isMarker {
b.WriteRune(r)
written += utf16Len(r)
continue
}
close := findClose(runes, i)
if close < 0 {
// An unmatched marker is just a character the user typed.
b.WriteRune(r)
written += utf16Len(r)
continue
}
inner := string(runes[i+1 : close])
start := base + written
if kind == EntityCode {
// Code is literal: markers inside it are content, not syntax.
b.WriteString(inner)
written += utf16LenString(inner)
} else {
// Recurse so *bold with `code` inside* works.
nested, n := parseInto(b, inner, start)
entities = append(entities, nested...)
written += n
}
entities = append(entities, Entity{Type: kind, Offset: start, Length: base + written - start})
i = close
}
return entities, written
}
// findClose returns the index of the marker closing the one at open, or -1.
//
// The rules mirror what chat users expect: an opener must start a word and be
// followed by content, a closer must end one. Without them "2*3*4" and
// "a_b_c"-style text would silently turn into formatting.
func findClose(runes []rune, open int) int {
marker := runes[open]
if open > 0 && isWordRune(runes[open-1]) {
return -1 // mid-word: not an opener
}
if open+1 >= len(runes) || unicode.IsSpace(runes[open+1]) || runes[open+1] == marker {
return -1 // empty or space-led content
}
for i := open + 1; i < len(runes); i++ {
if runes[i] != marker {
continue
}
if unicode.IsSpace(runes[i-1]) {
continue // closer must follow content
}
if i+1 < len(runes) && isWordRune(runes[i+1]) {
continue // mid-word: not a closer
}
return i
}
return -1
}
// detectURLs finds links in body, skipping anything already inside a code span.
func detectURLs(body string, existing []Entity) []Entity {
var out []Entity
for _, loc := range urlRe.FindAllStringIndex(body, -1) {
text := strings.TrimRight(body[loc[0]:loc[1]], urlTrailing)
if text == "" {
continue
}
offset := utf16LenString(body[:loc[0]])
length := utf16LenString(text)
if insideCode(existing, offset) {
continue
}
href := text
if !strings.Contains(href, "://") {
href = "https://" + href
}
out = append(out, Entity{Type: EntityURL, Offset: offset, Length: length, URL: href})
}
return out
}
func insideCode(entities []Entity, offset int) bool {
for _, e := range entities {
if e.Type == EntityCode && offset >= e.Offset && offset < e.Offset+e.Length {
return true
}
}
return false
}
// sortEntities orders by offset, then by longer span first, so a renderer can
// open outer decorations before the ones nested inside them.
func sortEntities(entities []Entity) {
for i := 1; i < len(entities); i++ {
for j := i; j > 0; j-- {
a, b := entities[j-1], entities[j]
if a.Offset < b.Offset || (a.Offset == b.Offset && a.Length >= b.Length) {
break
}
entities[j-1], entities[j] = b, a
}
}
}
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// utf16Len returns how many UTF-16 code units a rune occupies: 2 for anything
// outside the Basic Multilingual Plane (emoji), 1 otherwise.
func utf16Len(r rune) int {
if r > 0xFFFF {
return 2
}
return 1
}
func utf16LenString(s string) int {
n := 0
for _, r := range s {
n += utf16Len(r)
}
return n
}
+205
View File
@@ -0,0 +1,205 @@
package service
import (
"encoding/json"
"testing"
"unicode/utf16"
)
// utf16Slice extracts a span the way a JavaScript client would, so the tests
// assert the offsets are usable by the actual renderer rather than merely
// self-consistent.
func utf16Slice(s string, offset, length int) string {
u := utf16.Encode([]rune(s))
if offset < 0 || offset+length > len(u) {
return "<out of range>"
}
return string(utf16.Decode(u[offset : offset+length]))
}
func TestParseEntities_Markers(t *testing.T) {
tests := []struct {
name string
raw string
wantBody string
wantSpan map[string]string // entity type -> the text it should cover
}{
{"bold", "hello *world*", "hello world", map[string]string{"bold": "world"}},
{"strike", "~gone~ now", "gone now", map[string]string{"strike": "gone"}},
{"code", "run `go test` please", "run go test please", map[string]string{"code": "go test"}},
{"markers stripped from body", "*a* ~b~ `c`", "a b c", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
if body != tc.wantBody {
t.Fatalf("body = %q, want %q", body, tc.wantBody)
}
for kind, want := range tc.wantSpan {
found := false
for _, e := range entities {
if e.Type == kind {
if got := utf16Slice(body, e.Offset, e.Length); got != want {
t.Errorf("%s covers %q, want %q", kind, got, want)
}
found = true
}
}
if !found {
t.Errorf("no %s entity in %+v", kind, entities)
}
}
})
}
}
// Offsets are UTF-16 code units because the client slices JS strings with them.
// Devanāgarī is multi-byte and emoji are surrogate pairs, so a byte- or
// rune-based offset would silently mis-highlight in exactly this app's content.
func TestParseEntities_UTF16Offsets(t *testing.T) {
tests := []struct{ name, raw, want string }{
{"devanagari prefix", "श्री कृष्ण *bold*", "bold"},
{"emoji prefix", "🙏🙏 *namaste*", "namaste"},
{"emoji inside span", "*🙏 namaste*", "🙏 namaste"},
{"iast prefix", "Bhagavad-gītā ~old~", "old"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
if len(entities) == 0 {
t.Fatalf("no entities for %q (body %q)", tc.raw, body)
}
e := entities[len(entities)-1]
if got := utf16Slice(body, e.Offset, e.Length); got != tc.want {
t.Errorf("span covers %q, want %q (body %q, entity %+v)", got, tc.want, body, e)
}
})
}
}
// Text that merely contains marker characters must survive untouched.
func TestParseEntities_NotFormatting(t *testing.T) {
for _, raw := range []string{
"2*3*4", // mid-word asterisks
"a~b~c", // mid-word tildes
"* not bold", // space after opener
"unmatched *marker", // no closer
"**", // empty
"5 * 3 = 15", // spaced operators
} {
body, entities := ParseEntities(raw)
if body != raw {
t.Errorf("ParseEntities(%q) rewrote body to %q", raw, body)
}
for _, e := range entities {
if e.Type != EntityURL {
t.Errorf("ParseEntities(%q) produced %s entity %+v", raw, e.Type, e)
}
}
}
}
func TestParseEntities_Nesting(t *testing.T) {
body, entities := ParseEntities("*bold with `code` inside*")
const wantBody = "bold with code inside"
if body != wantBody {
t.Fatalf("body = %q, want %q", body, wantBody)
}
var bold, code *Entity
for i := range entities {
switch entities[i].Type {
case EntityBold:
bold = &entities[i]
case EntityCode:
code = &entities[i]
}
}
if bold == nil || code == nil {
t.Fatalf("want both bold and code, got %+v", entities)
}
if got := utf16Slice(body, bold.Offset, bold.Length); got != wantBody {
t.Errorf("bold covers %q, want the whole body", got)
}
if got := utf16Slice(body, code.Offset, code.Length); got != "code" {
t.Errorf("code covers %q, want %q", got, "code")
}
// Outer span must sort first so a renderer opens it before the nested one.
if entities[0].Type != EntityBold {
t.Errorf("entities[0] = %s, want the outer bold span first", entities[0].Type)
}
}
func TestParseEntities_URLs(t *testing.T) {
tests := []struct {
name string
raw string
wantText string
wantHref string
}{
{"https", "see https://prabhupadasays.org/bg/2/13 here",
"https://prabhupadasays.org/bg/2/13", "https://prabhupadasays.org/bg/2/13"},
{"bare www gets a scheme", "visit www.prabhupadasays.org now",
"www.prabhupadasays.org", "https://www.prabhupadasays.org"},
{"trailing sentence punctuation excluded", "read https://example.org/a.",
"https://example.org/a", "https://example.org/a"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
var url *Entity
for i := range entities {
if entities[i].Type == EntityURL {
url = &entities[i]
}
}
if url == nil {
t.Fatalf("no url entity in %+v", entities)
}
if got := utf16Slice(body, url.Offset, url.Length); got != tc.wantText {
t.Errorf("covers %q, want %q", got, tc.wantText)
}
if url.URL != tc.wantHref {
t.Errorf("href = %q, want %q", url.URL, tc.wantHref)
}
})
}
}
// A URL inside backticks is being shown, not linked.
func TestParseEntities_URLInsideCodeIsNotLinked(t *testing.T) {
_, entities := ParseEntities("run `curl https://example.org` first")
for _, e := range entities {
if e.Type == EntityURL {
t.Errorf("linked a URL inside a code span: %+v", e)
}
}
}
// "Bg 2.13" and "e.g" must not become links — the conservative pattern is what
// keeps scripture references from turning blue.
func TestParseEntities_NoFalsePositiveLinks(t *testing.T) {
for _, raw := range []string{"Bg 2.13 is the verse", "e.g. this one", "version 1.2.3"} {
_, entities := ParseEntities(raw)
for _, e := range entities {
if e.Type == EntityURL {
t.Errorf("ParseEntities(%q) linked %+v", raw, e)
}
}
}
}
// entities is stored as jsonb; empty must round-trip as [] rather than null so
// the column default and the parser agree.
func TestParseEntities_JSONShape(t *testing.T) {
_, entities := ParseEntities("plain text")
if entities == nil {
entities = []Entity{}
}
b, err := json.Marshal(entities)
if err != nil {
t.Fatal(err)
}
if string(b) != "[]" {
t.Errorf("marshalled %s, want []", b)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37 -5
View File
@@ -71,10 +71,13 @@ type SocialGraphService struct {
reports *repository.ReportRepo
posts *repository.PostRepo
users *repository.UserRepo
notif *NotificationService
mail mailer.Mailer
admins []string
logger *zap.Logger
// chat answers "may the viewer open a DM with this profile?" — the same
// rule OpenDM enforces, so the button and the action can't disagree.
chat *repository.ChatRepo
notif *NotificationService
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewSocialGraphService(
@@ -83,6 +86,7 @@ func NewSocialGraphService(
reports *repository.ReportRepo,
posts *repository.PostRepo,
users *repository.UserRepo,
chat *repository.ChatRepo,
notif *NotificationService,
mail mailer.Mailer,
admins []string,
@@ -90,7 +94,8 @@ func NewSocialGraphService(
) *SocialGraphService {
return &SocialGraphService{
follows: follows, blocks: blocks, reports: reports, posts: posts,
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
users: users, chat: chat, notif: notif, mail: mail, admins: admins,
logger: logger,
}
}
@@ -294,6 +299,33 @@ func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetI
}
out.Blocked = viewerBlocked
out.BlockedBy = blockedByTarget
// A block closes messaging in either direction; otherwise the target's own
// "who can message you" rule decides. Asking the chat repository — rather
// than reimplementing the rule here — is what keeps the offered button and
// the eventual OpenDM in agreement.
switch {
case viewerID == "" || viewerID == targetID:
out.CanMessage = false
case viewerBlocked || blockedByTarget:
out.CanMessage = false
out.MessageRestriction = "blocked"
case s.chat == nil:
out.CanMessage = true
default:
allowed, err := s.chat.CanStartConversation(ctx, viewerID, targetID)
if err != nil {
return nil, err
}
out.CanMessage = allowed
if !allowed {
settings, err := s.chat.Settings(ctx, targetID)
if err != nil {
return nil, err
}
out.MessageRestriction = settings.WhoCanMessage
}
}
return out, nil
}
+7
View File
@@ -0,0 +1,7 @@
DROP TABLE IF EXISTS message_reports;
DROP TABLE IF EXISTS message_stars;
DROP TABLE IF EXISTS message_attachments;
DROP TABLE IF EXISTS message_hides;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS conversation_participants;
DROP TABLE IF EXISTS conversations;
+203
View File
@@ -0,0 +1,203 @@
-- Chat — DMs, group threads, and Kautilya (AI) threads.
--
-- One pipeline serves all three. `conversations.kind` is the only thing that
-- differs, so the inbox's two tabs are a filter (dm/group vs ai) rather than a
-- second implementation, and the message list, composer, pagination and
-- context menu are written once.
--
-- Ordering is by a per-conversation `seq`, not by timestamp. Clocks skew and
-- timestamps collide; a gapless integer gives stable ordering, cheap unread
-- maths (last_seq - last_read_seq), and a sync cursor the client can resume
-- from after a reconnect (?after_seq=N).
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- dm → exactly two participants
-- group → many participants, has a title/avatar
-- ai → one participant; the other side is Kautilya (no user row)
kind VARCHAR(8) NOT NULL CHECK (kind IN ('dm', 'group', 'ai')),
title VARCHAR(120), -- groups (and AI thread titles)
avatar_key TEXT, -- R2 key, groups only
-- Sorted pair "least(a,b):greatest(a,b)" for DMs, NULL otherwise. Makes
-- "one thread per pair" a database guarantee instead of a service-layer
-- race between two people opening the chat simultaneously.
dm_key TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- Monotonic message counter. Bumped inside the send transaction, which
-- serialises assignment per conversation and leaves no gaps.
last_seq BIGINT NOT NULL DEFAULT 0,
-- Denormalized purely for inbox ordering; the preview text and its sender
-- are read with a LATERAL join on (conversation_id, seq DESC) so there is
-- one source of truth for message content even after an edit or delete.
last_message_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (kind <> 'dm' OR dm_key IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS conversations_dm_key_uniq
ON conversations (dm_key) WHERE kind = 'dm';
CREATE TABLE IF NOT EXISTS conversation_participants (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(8) NOT NULL DEFAULT 'member'
CHECK (role IN ('owner', 'admin', 'member')),
-- Delivery/read state lives here, per participant — NOT per message. A
-- receipt row per (message × participant) would grow with the product of
-- both; two cursors give the same three tick states at constant size:
-- sent → the row exists
-- delivered → last_delivered_seq >= message.seq
-- read → last_read_seq >= message.seq
last_read_seq BIGINT NOT NULL DEFAULT 0,
last_delivered_seq BIGINT NOT NULL DEFAULT 0,
muted_until TIMESTAMPTZ,
-- "Friends since" on the chat profile reads joined_at.
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Non-null = left the group. Kept (rather than deleted) so their past
-- messages still resolve to a member row when rendering history.
left_at TIMESTAMPTZ,
PRIMARY KEY (conversation_id, user_id)
);
-- The inbox query: this user's threads, most recent first. Partial on left_at
-- so departed groups drop out without a filter on every read.
CREATE INDEX IF NOT EXISTS conversation_participants_inbox_idx
ON conversation_participants (user_id) WHERE left_at IS NULL;
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
seq BIGINT NOT NULL,
-- NULL for messages no user wrote: 'system' (membership changes) and
-- 'assistant' (Kautilya). SET NULL on user deletion keeps the thread
-- readable for everyone else.
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
kind VARCHAR(12) NOT NULL DEFAULT 'text'
CHECK (kind IN ('text', 'system', 'assistant')),
body TEXT NOT NULL DEFAULT '',
-- Inline formatting and detected links as offset/length spans
-- ([{type,offset,length}]), never HTML. Storing spans instead of markup
-- keeps rendering injection-safe and lets each client render natively.
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Client-generated id, so a retried send is deduped rather than doubled.
client_msg_id UUID,
reply_to_id UUID REFERENCES messages(id) ON DELETE SET NULL,
-- Set when this message was forwarded from another. SET NULL so deleting
-- the original leaves the forward intact.
forwarded_from_id UUID REFERENCES messages(id) ON DELETE SET NULL,
edited_at TIMESTAMPTZ,
-- Delete-for-everyone: a tombstone, so the gap in `seq` is explainable and
-- other participants' clients can replace the bubble in place.
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (conversation_id, seq)
);
-- History paging, newest first, and the LATERAL that fetches each inbox row's
-- last message.
CREATE INDEX IF NOT EXISTS messages_conversation_idx
ON messages (conversation_id, seq DESC);
-- Idempotent send. Partial because client_msg_id is NULL for system and
-- assistant messages, which no client retries.
CREATE UNIQUE INDEX IF NOT EXISTS messages_client_dedup
ON messages (conversation_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
-- Delete-for-me. Distinct from messages.deleted_at (delete-for-everyone):
-- this hides a message from one person's view while others keep seeing it.
CREATE TABLE IF NOT EXISTS message_hides (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
);
CREATE TABLE IF NOT EXISTS message_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
-- Denormalized from the parent message so the chat profile's "shared
-- media" and "shared links" tabs are one index range scan, with no join
-- back through messages.
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
ord SMALLINT NOT NULL DEFAULT 0,
kind VARCHAR(8) NOT NULL
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity')),
-- Media (kind in image/video/audio/file). r2_key points at the PRIVATE
-- bucket: chat media is never public-by-URL, it is read through a
-- participant-checked presigned GET.
r2_key TEXT,
mime VARCHAR(128),
size_bytes BIGINT,
width INTEGER,
height INTEGER,
duration_ms INTEGER,
blurhash VARCHAR(64), -- instant placeholder while loading
file_name TEXT,
-- Link preview (kind = 'link'). Unfurled server-side AFTER the message is
-- persisted, so a slow remote page never delays the send. Absent when the
-- sender dismissed the preview in the composer.
url TEXT,
title TEXT,
description TEXT,
site_name VARCHAR(120),
image_url TEXT,
-- Shared post / comment / profile / collection (kind = 'entity').
-- entity_id is TEXT because these ids are not all UUIDs, and there is
-- deliberately no FK: the entity may be deleted or turn private later.
-- `snapshot` is a fallback for rendering, but permission is re-checked
-- per viewer at read time — a collection made private must stop resolving
-- for the recipient, so the snapshot alone must never be trusted.
entity_type VARCHAR(16) CHECK (entity_type IS NULL OR
entity_type IN ('post', 'comment', 'profile', 'collection')),
entity_id TEXT,
snapshot JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS message_attachments_message_idx
ON message_attachments (message_id, ord);
-- The chat profile's media / links tabs.
CREATE INDEX IF NOT EXISTS message_attachments_gallery_idx
ON message_attachments (conversation_id, kind, created_at DESC);
-- Starred messages are per-user, not per-conversation: two people in the same
-- thread star different things.
CREATE TABLE IF NOT EXISTS message_stars (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Denormalized so "starred in this chat" is a single index range scan.
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, user_id)
);
CREATE INDEX IF NOT EXISTS message_stars_user_conversation_idx
ON message_stars (user_id, conversation_id, created_at DESC);
-- Reported messages. `snapshot` is the whole point: it copies the reported
-- message (and limited surrounding context) at report time so moderators read
-- a report record and never need read access to live threads. message_id is
-- SET NULL on delete so a report survives the sender deleting the evidence.
CREATE TABLE IF NOT EXISTS message_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
reported_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
category VARCHAR(32) NOT NULL,
reason TEXT NOT NULL DEFAULT '',
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
resolution_note TEXT
);
CREATE INDEX IF NOT EXISTS message_reports_unresolved_idx
ON message_reports (created_at DESC) WHERE resolved_at IS NULL;
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS conversation_participants_role_idx;
ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_send_policy_check;
ALTER TABLE conversations DROP COLUMN IF EXISTS send_policy;
+26
View File
@@ -0,0 +1,26 @@
-- Who may post in a group.
--
-- 'everyone' keeps existing groups behaving exactly as they do today, which is
-- why it is the default: this migration must not silently mute anyone.
-- 'admins' makes the thread an announcement channel — owners and admins post,
-- everyone else reads.
--
-- DMs and Kautilya threads ignore this: a two-party conversation has no
-- administrators, and the AI thread has one participant.
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS send_policy VARCHAR(8) NOT NULL DEFAULT 'everyone';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'conversations_send_policy_check'
) THEN
ALTER TABLE conversations
ADD CONSTRAINT conversations_send_policy_check
CHECK (send_policy IN ('everyone', 'admins'));
END IF;
END $$;
-- "Who are this group's admins?" runs on every post in an admins-only thread.
CREATE INDEX IF NOT EXISTS conversation_participants_role_idx
ON conversation_participants (conversation_id, role) WHERE left_at IS NULL;
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS chat_settings;
+37
View File
@@ -0,0 +1,37 @@
-- Per-user chat preferences.
--
-- A row is created on first change, so absence means "all defaults". That is
-- why every column carries the product default rather than something neutral:
-- reading a missing row and reading an untouched row must give the same answer.
CREATE TABLE IF NOT EXISTS chat_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
-- Read receipts are reciprocal: turning them off also stops you seeing
-- other people's. Enforced in the service, not here.
read_receipts BOOLEAN NOT NULL DEFAULT TRUE,
-- Who may START a conversation with this user:
-- anyone — no restriction (default)
-- friends — people they follow, or who follow them, accepted
-- account_age — accounts at least min_account_age_months old
-- same_gender — accounts whose gender matches theirs; requires the user
-- to have set their own gender, since there is nothing to
-- match against otherwise
who_can_message VARCHAR(16) NOT NULL DEFAULT 'anyone',
-- Only meaningful when who_can_message = 'account_age'.
min_account_age_months INTEGER NOT NULL DEFAULT 3,
-- Consent for using this user's chats as training data for the Kautilya
-- assistant. Recorded now so the choice exists before the feature does —
-- collecting consent retroactively is worse than collecting it early.
train_kautilya BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chat_settings_who_can_message_check
CHECK (who_can_message IN ('anyone', 'friends', 'account_age', 'same_gender')),
CONSTRAINT chat_settings_account_age_check
CHECK (min_account_age_months BETWEEN 1 AND 120)
);
+313
View File
@@ -0,0 +1,313 @@
// Package unfurl fetches link preview metadata for URLs users paste into chat.
//
// Fetching a user-supplied URL from the server is a server-side request forgery
// primitive: without care, "https://x" can be pointed at 169.254.169.254 (cloud
// metadata), at a database on 10.x, or at localhost. Everything below exists to
// make that impossible:
//
// - only http/https,
// - every connection is vetted at the RESOLVED IP, so a hostname that
// resolves to a private address is refused (and re-checked on each redirect
// hop, which is what defeats DNS rebinding),
// - redirects capped, response size capped, timeouts on everything,
// - only text/html is parsed.
//
// The metadata is deliberately fetched by the server rather than accepted from
// the client. A client that could supply the title and image could make any
// link claim to be anything.
package unfurl
import (
"context"
"errors"
"fmt"
"html"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"syscall"
"time"
)
// ErrBlocked is returned for a URL that resolves somewhere we refuse to fetch.
var ErrBlocked = errors.New("refusing to fetch that address")
// ErrNotHTML is returned when the response isn't a page we can read metadata
// from — a PDF or an image has nothing to unfurl.
var ErrNotHTML = errors.New("not an HTML page")
const (
maxRedirects = 3
maxBodyBytes = 512 << 10 // 512 KiB is far more than any <head>
fetchTimeout = 6 * time.Second
dialTimeout = 3 * time.Second
maxFieldRunes = 300
)
// Preview is the metadata rendered as a link card.
type Preview struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
SiteName string `json:"site_name,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
// Fetcher retrieves previews. Safe for concurrent use.
type Fetcher struct {
client *http.Client
// allowLoopback exists only so the package's own tests can point at an
// httptest server. It is unexported and never set outside this package.
allowLoopback bool
}
// New returns a Fetcher with every guard enabled.
func New() *Fetcher {
f := &Fetcher{}
f.client = &http.Client{
Timeout: fetchTimeout,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: dialTimeout,
// Control runs after DNS resolution with the address actually
// being dialled, so it sees through a hostname that points at
// a private range — including one that changes between the
// lookup and the connection.
Control: func(network, address string, _ syscall.RawConn) error {
return f.checkAddr(network, address)
},
}).DialContext,
TLSHandshakeTimeout: dialTimeout,
ResponseHeaderTimeout: fetchTimeout,
DisableKeepAlives: true,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("too many redirects")
}
if !allowedScheme(req.URL) {
return ErrBlocked
}
return nil
},
}
return f
}
func (f *Fetcher) checkAddr(network, address string) error {
if network != "tcp4" && network != "tcp6" && network != "tcp" {
return ErrBlocked
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return ErrBlocked
}
ip := net.ParseIP(host)
if ip == nil {
return ErrBlocked
}
if f.allowLoopback && ip.IsLoopback() {
return nil
}
if blockedIP(ip) {
return ErrBlocked
}
return nil
}
// blockedIP reports whether an address is one we refuse to talk to.
func blockedIP(ip net.IP) bool {
// Normalise IPv4-mapped IPv6 (::ffff:127.0.0.1) so the v4 rules apply.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsInterfaceLocalMulticast() {
return true
}
// Ranges Go's helpers don't cover but which still reach infrastructure.
for _, cidr := range extraBlocked {
if cidr.Contains(ip) {
return true
}
}
return false
}
var extraBlocked = func() []*net.IPNet {
nets := []string{
"100.64.0.0/10", // CGNAT
"192.0.0.0/24", // IETF protocol assignments
"192.0.2.0/24", // TEST-NET-1
"198.18.0.0/15", // benchmarking
"198.51.100.0/24", // TEST-NET-2
"203.0.113.0/24", // TEST-NET-3
"240.0.0.0/4", // reserved
"::/128", // unspecified
"64:ff9b::/96", // NAT64, can map to private v4
}
out := make([]*net.IPNet, 0, len(nets))
for _, n := range nets {
if _, cidr, err := net.ParseCIDR(n); err == nil {
out = append(out, cidr)
}
}
return out
}()
func allowedScheme(u *url.URL) bool {
return u.Scheme == "http" || u.Scheme == "https"
}
// Fetch retrieves a preview for rawURL.
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Preview, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
if !allowedScheme(u) || u.Host == "" {
return nil, ErrBlocked
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
// Identify honestly; some sites serve richer metadata to a known crawler
// and it gives operators someone to contact.
req.Header.Set("User-Agent", "PrabhupadaSaysBot/1.0 (+https://prabhupadasays.org)")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := f.client.Do(req)
if err != nil {
// A blocked dial surfaces wrapped in a url.Error; unwrap so callers can
// tell "refused on purpose" from "the site is down".
if errors.Is(err, ErrBlocked) {
return nil, ErrBlocked
}
return nil, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("unfurl status %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "" &&
!strings.HasPrefix(strings.ToLower(ct), "text/html") &&
!strings.HasPrefix(strings.ToLower(ct), "application/xhtml") {
return nil, ErrNotHTML
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
// resp.Request.URL is the FINAL url after redirects — relative images must
// resolve against where we actually landed.
final := resp.Request.URL
p := extract(string(body))
p.URL = final.String()
if p.ImageURL != "" {
if abs, err := final.Parse(p.ImageURL); err == nil && allowedScheme(abs) {
p.ImageURL = abs.String()
} else {
p.ImageURL = ""
}
}
if p.Title == "" && p.Description == "" {
return nil, ErrNotHTML
}
return p, nil
}
var (
metaRe = regexp.MustCompile(`(?is)<meta\s+([^>]*?)/?>`)
attrRe = regexp.MustCompile(`(?is)([a-z0-9:_.-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`)
titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
headRe = regexp.MustCompile(`(?is)</head\s*>`)
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
)
// extract pulls Open Graph metadata out of a document's head.
//
// Written against the meta tags themselves rather than a full HTML parse: the
// fields wanted here are a handful of well-formed <meta> elements, and this
// keeps the BFF free of another dependency.
func extract(doc string) *Preview {
// Metadata lives in <head>; stopping there avoids scanning a whole page.
if loc := headRe.FindStringIndex(doc); loc != nil {
doc = doc[:loc[0]]
}
p := &Preview{}
for _, m := range metaRe.FindAllStringSubmatch(doc, -1) {
attrs := parseAttrs(m[1])
key := attrs["property"]
if key == "" {
key = attrs["name"]
}
content := attrs["content"]
if content == "" {
continue
}
switch strings.ToLower(key) {
case "og:title", "twitter:title":
setIfEmpty(&p.Title, content)
case "og:description", "twitter:description", "description":
setIfEmpty(&p.Description, content)
case "og:site_name":
setIfEmpty(&p.SiteName, content)
case "og:image", "og:image:url", "og:image:secure_url", "twitter:image":
setIfEmpty(&p.ImageURL, content)
}
}
if p.Title == "" {
if m := titleRe.FindStringSubmatch(doc); m != nil {
p.Title = clean(tagRe.ReplaceAllString(m[1], ""))
}
}
p.Title = truncate(p.Title, maxFieldRunes)
p.Description = truncate(p.Description, maxFieldRunes)
p.SiteName = truncate(p.SiteName, maxFieldRunes)
return p
}
func parseAttrs(s string) map[string]string {
out := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(s, -1) {
value := m[3]
if value == "" {
value = m[4]
}
if value == "" {
value = m[5]
}
out[strings.ToLower(m[1])] = clean(value)
}
return out
}
func setIfEmpty(dst *string, v string) {
if *dst == "" {
*dst = v
}
}
func clean(s string) string {
return strings.TrimSpace(html.UnescapeString(strings.Join(strings.Fields(s), " ")))
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n])) + "…"
}
+233
View File
@@ -0,0 +1,233 @@
package unfurl
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The blocklist is the security boundary of this package: everything else is
// plumbing around it. A URL that resolves into any of these ranges must never
// be fetched, or the BFF becomes a proxy into the private network.
func TestBlockedIP(t *testing.T) {
blocked := []string{
"127.0.0.1", // loopback
"::1", // loopback v6
"::ffff:127.0.0.1", // loopback via IPv4-mapped v6
"10.0.0.5", // RFC1918
"172.16.4.2", // RFC1918
"192.168.1.1", // RFC1918
"::ffff:10.0.0.5", // RFC1918 via IPv4-mapped v6
"169.254.169.254", // cloud instance metadata
"fe80::1", // link-local v6
"fd00::1", // unique local v6
"0.0.0.0", // unspecified
"::", // unspecified v6
"224.0.0.1", // multicast
"100.64.0.1", // CGNAT
"198.18.0.1", // benchmarking
"240.0.0.1", // reserved
}
for _, s := range blocked {
if ip := net.ParseIP(s); ip == nil || !blockedIP(ip) {
t.Errorf("blockedIP(%s) = false, want true", s)
}
}
allowed := []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"}
for _, s := range allowed {
if ip := net.ParseIP(s); ip == nil || blockedIP(ip) {
t.Errorf("blockedIP(%s) = true, want false", s)
}
}
}
// A hostname that resolves to a private address must be refused at dial time —
// this is what stops "http://internal.example.com" and DNS rebinding.
func TestFetch_RefusesPrivateAddresses(t *testing.T) {
f := New() // loopback NOT allowed
for _, raw := range []string{
"http://127.0.0.1:80/",
"http://localhost/",
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.1/",
"http://[::1]/",
} {
_, err := f.Fetch(context.Background(), raw)
if err == nil {
t.Errorf("Fetch(%s) succeeded — it must be refused", raw)
}
}
}
func TestFetch_RejectsNonHTTPSchemes(t *testing.T) {
f := New()
for _, raw := range []string{
"file:///etc/passwd",
"gopher://example.org/",
"ftp://example.org/",
"redis://10.0.0.1:6379",
"//example.org",
} {
if _, err := f.Fetch(context.Background(), raw); !errors.Is(err, ErrBlocked) {
t.Errorf("Fetch(%s) err = %v, want ErrBlocked", raw, err)
}
}
}
// testFetcher permits loopback so the fetch path can be exercised against an
// httptest server. The flag is unexported and set only here.
func testFetcher() *Fetcher {
f := New()
f.allowLoopback = true
return f
}
func TestFetch_ReadsOpenGraph(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<html><head>
<title>Fallback title</title>
<meta property="og:title" content="Bhagavad-g&#012ABC;t&#257; As It Is">
<meta property="og:description" content="Chapter 2, verse 13">
<meta property="og:site_name" content="Prabhupāda Says">
<meta property="og:image" content="/img/cover.jpg">
</head><body>ignored</body></html>`)
}))
defer srv.Close()
p, err := testFetcher().Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if p.Description != "Chapter 2, verse 13" {
t.Errorf("description = %q", p.Description)
}
if p.SiteName != "Prabhupāda Says" {
t.Errorf("site_name = %q", p.SiteName)
}
// Relative og:image must be resolved against the page it came from.
if !strings.HasPrefix(p.ImageURL, srv.URL) || !strings.HasSuffix(p.ImageURL, "/img/cover.jpg") {
t.Errorf("image = %q, want an absolute URL under %s", p.ImageURL, srv.URL)
}
}
func TestFetch_FallsBackToTitleTag(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head><title> Plain &amp; simple </title>
<meta name="description" content="From the name attribute"></head></html>`)
}))
defer srv.Close()
p, err := testFetcher().Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if p.Title != "Plain & simple" {
t.Errorf("title = %q, want entities decoded and whitespace collapsed", p.Title)
}
if p.Description != "From the name attribute" {
t.Errorf("description = %q", p.Description)
}
}
// A redirect that lands on a private address must be refused at the hop, not
// followed because the first host looked fine.
func TestFetch_RefusesRedirectToPrivateAddress(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
}))
defer srv.Close()
if _, err := testFetcher().Fetch(context.Background(), srv.URL); err == nil {
t.Fatal("followed a redirect into link-local space")
}
}
func TestFetch_RejectsNonHTML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
fmt.Fprint(w, "%PDF-1.7")
}))
defer srv.Close()
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
t.Errorf("err = %v, want ErrNotHTML", err)
}
}
// A page with no metadata is not a preview — better no card than an empty one.
func TestFetch_EmptyMetadataIsNotAPreview(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head></head><body>nothing here</body></html>`)
}))
defer srv.Close()
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
t.Errorf("err = %v, want ErrNotHTML", err)
}
}
// A body far larger than the cap must not be read whole.
func TestFetch_CapsBodySize(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head><meta property="og:title" content="Huge"></head><body>`)
chunk := strings.Repeat("x", 64<<10)
for i := 0; i < 64; i++ { // 4 MiB, well past maxBodyBytes
fmt.Fprint(w, chunk)
}
}))
defer srv.Close()
p, err := testFetcher().Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if p.Title != "Huge" {
t.Errorf("title = %q", p.Title)
}
}
func TestExtract_LongFieldsAreTruncated(t *testing.T) {
long := strings.Repeat("ā", 500)
p := extract(`<meta property="og:title" content="` + long + `">`)
if len([]rune(p.Title)) > maxFieldRunes+1 {
t.Errorf("title kept %d runes, want it truncated", len([]rune(p.Title)))
}
if !strings.HasSuffix(p.Title, "…") {
t.Error("truncated title should be marked with an ellipsis")
}
}
func TestExtract_HandlesAttributeOrderAndQuotes(t *testing.T) {
p := extract(`
<meta content='Single quoted' property='og:title'>
<meta content=Unquoted name=og:site_name>
<meta property="og:description" content="Ordered normally">`)
if p.Title != "Single quoted" {
t.Errorf("title = %q", p.Title)
}
if p.SiteName != "Unquoted" {
t.Errorf("site_name = %q", p.SiteName)
}
if p.Description != "Ordered normally" {
t.Errorf("description = %q", p.Description)
}
}
// Metadata is read from <head>; a <meta> forged in the body must not win.
func TestExtract_IgnoresBodyContent(t *testing.T) {
p := extract(`<html><head><meta property="og:title" content="Real"></head>
<body><meta property="og:title" content="Forged"></body></html>`)
if p.Title != "Real" {
t.Errorf("title = %q, want the head's value", p.Title)
}
}