diff --git a/internal/handler/broadcasting_auth.go b/internal/handler/broadcasting_auth.go index d26a819..e76879d 100644 --- a/internal/handler/broadcasting_auth.go +++ b/internal/handler/broadcasting_auth.go @@ -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"` diff --git a/internal/handler/broadcasting_auth_test.go b/internal/handler/broadcasting_auth_test.go index 2ec5bf8..a261f55 100644 --- a/internal/handler/broadcasting_auth_test.go +++ b/internal/handler/broadcasting_auth_test.go @@ -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 { diff --git a/internal/handler/chat.go b/internal/handler/chat.go new file mode 100644 index 0000000..8e0831e --- /dev/null +++ b/internal/handler/chat.go @@ -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 +} diff --git a/internal/model/chat.go b/internal/model/chat.go new file mode 100644 index 0000000..f6344a1 --- /dev/null +++ b/internal/model/chat.go @@ -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"` +} diff --git a/internal/model/social.go b/internal/model/social.go index e223cf8..ff8d6b8 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -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 diff --git a/internal/repository/chat_repo.go b/internal/repository/chat_repo.go new file mode 100644 index 0000000..513646e --- /dev/null +++ b/internal/repository/chat_repo.go @@ -0,0 +1,1202 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +var ( + // ErrNotParticipant is the authorization failure for every chat read and + // write: if you are not in the thread, it does not exist for you. + ErrNotParticipant = errors.New("not a participant in this conversation") + ErrConversationGone = errors.New("conversation not found") + // ErrReplyTargetNotHere guards against replying to a message from another + // thread. The foreign key only proves the target exists somewhere, so + // without this check a client could quote a conversation it was never in. + ErrReplyTargetNotHere = errors.New("cannot reply to a message from another conversation") + ErrAttachmentGone = errors.New("attachment not found") + ErrMessageNotHere = errors.New("message is not in this conversation") + ErrUserGone = errors.New("user not found") +) + +type ChatRepo struct{ pg *Postgres } + +func NewChatRepo(pg *Postgres) *ChatRepo { return &ChatRepo{pg: pg} } + +// dmKey builds the sorted pair key that makes "one DM thread per pair" a +// database guarantee rather than a service-layer race. +func dmKey(a, b string) string { + if a < b { + return a + ":" + b + } + return b + ":" + a +} + +// EnsureDM returns the DM between two users, creating it if absent. +// +// Idempotent under concurrency: two people tapping "message" simultaneously +// race on the partial unique index, and the loser falls through to the SELECT +// rather than erroring or creating a second thread. +func (r *ChatRepo) EnsureDM(ctx context.Context, userA, userB string) (*model.Conversation, error) { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin dm tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed + + key := dmKey(userA, userB) + var c model.Conversation + err = tx.QueryRow(ctx, ` + INSERT INTO conversations (kind, dm_key, created_by) + VALUES ('dm', $1, $2) + ON CONFLICT (dm_key) WHERE kind = 'dm' DO NOTHING + RETURNING id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at`, + key, userA, + ).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt) + + if errors.Is(err, pgx.ErrNoRows) { + // Already existed — read it back. + err = tx.QueryRow(ctx, ` + SELECT id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at + FROM conversations WHERE dm_key = $1 AND kind = 'dm'`, key, + ).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt) + } + if err != nil { + return nil, fmt.Errorf("ensure dm: %w", err) + } + + // Participants are inserted unconditionally: a thread created moments ago + // by the other party may not list us yet. + if _, err := tx.Exec(ctx, ` + INSERT INTO conversation_participants (conversation_id, user_id) + VALUES ($1, $2), ($1, $3) + ON CONFLICT (conversation_id, user_id) DO NOTHING`, + c.ID, userA, userB, + ); err != nil { + return nil, fmt.Errorf("ensure dm participants: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit dm: %w", err) + } + return &c, nil +} + +// CreateGroup opens a group thread with creator as owner and members joined. +func (r *ChatRepo) CreateGroup(ctx context.Context, creatorID, title string, memberIDs []string) (*model.Conversation, error) { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin group tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + var c model.Conversation + if err := tx.QueryRow(ctx, ` + INSERT INTO conversations (kind, title, created_by) + VALUES ('group', $1, $2) + RETURNING id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at`, + title, creatorID, + ).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt); err != nil { + return nil, fmt.Errorf("create group: %w", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO conversation_participants (conversation_id, user_id, role) + VALUES ($1, $2, 'owner') ON CONFLICT DO NOTHING`, c.ID, creatorID, + ); err != nil { + return nil, fmt.Errorf("add group owner: %w", err) + } + for _, m := range memberIDs { + if m == creatorID { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO conversation_participants (conversation_id, user_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING`, c.ID, m, + ); err != nil { + return nil, fmt.Errorf("add group member: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit group: %w", err) + } + return &c, nil +} + +// EnsureAIThread returns the caller's Kautilya thread, creating it on first +// use. One participant; the assistant has no user row. +func (r *ChatRepo) EnsureAIThread(ctx context.Context, userID string) (*model.Conversation, error) { + var c model.Conversation + err := r.pg.Pool.QueryRow(ctx, ` + SELECT c.id, c.kind, c.title, c.avatar_key, c.send_policy, c.last_seq, c.last_message_at, c.created_at + FROM conversations c + JOIN conversation_participants p ON p.conversation_id = c.id AND p.user_id = $1 + WHERE c.kind = 'ai' ORDER BY c.created_at LIMIT 1`, userID, + ).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt) + if err == nil { + return &c, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("find ai thread: %w", err) + } + + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin ai tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + if err := tx.QueryRow(ctx, ` + INSERT INTO conversations (kind, created_by) VALUES ('ai', $1) + RETURNING id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at`, userID, + ).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt); err != nil { + return nil, fmt.Errorf("create ai thread: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`, c.ID, userID, + ); err != nil { + return nil, fmt.Errorf("add ai participant: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit ai thread: %w", err) + } + return &c, nil +} + +// Conversation loads a thread by id, without any membership check — callers +// pair it with Participant when the caller's access matters. +func (r *ChatRepo) Conversation(ctx context.Context, id string) (*model.Conversation, error) { + var c model.Conversation + err := r.pg.Pool.QueryRow(ctx, ` + SELECT id, kind, title, avatar_key, send_policy, last_seq, last_message_at, created_at + FROM conversations WHERE id = $1`, id, + ).Scan(&c.ID, &c.Kind, &c.Title, &c.AvatarKey, &c.SendPolicy, &c.LastSeq, &c.LastMessageAt, &c.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrConversationGone + } + if err != nil { + return nil, fmt.Errorf("load conversation: %w", err) + } + return &c, nil +} + +// Participant loads the caller's membership row — the authorization primitive +// behind every other chat call and the realtime channel rule. +func (r *ChatRepo) Participant(ctx context.Context, conversationID, userID string) (*model.Participant, error) { + var p model.Participant + err := r.pg.Pool.QueryRow(ctx, ` + SELECT conversation_id, user_id, role, last_read_seq, last_delivered_seq, + muted_until, joined_at, left_at + FROM conversation_participants + WHERE conversation_id = $1 AND user_id = $2 AND left_at IS NULL`, + conversationID, userID, + ).Scan(&p.ConversationID, &p.UserID, &p.Role, &p.LastReadSeq, &p.LastDeliveredSeq, + &p.MutedUntil, &p.JoinedAt, &p.LeftAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotParticipant + } + if err != nil { + return nil, fmt.Errorf("load participant: %w", err) + } + return &p, nil +} + +// Participants lists everyone still in the thread with the details needed to +// render a name and avatar beside their messages. +func (r *ChatRepo) Participants(ctx context.Context, conversationID string) ([]model.ChatPeer, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT u.id, u.handle, u.name, u.avatar_url, p.role, p.joined_at + FROM conversation_participants p + JOIN users u ON u.id = p.user_id + WHERE p.conversation_id = $1 AND p.left_at IS NULL + -- Owner first, then admins, then members by seniority: the order the + -- member list is read in. + ORDER BY CASE p.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, + p.joined_at`, conversationID) + if err != nil { + return nil, fmt.Errorf("query participants: %w", err) + } + defer rows.Close() + + people := []model.ChatPeer{} + for rows.Next() { + var c model.ChatPeer + if err := rows.Scan(&c.ID, &c.Handle, &c.Name, &c.AvatarURL, &c.Role, &c.JoinedAt); err != nil { + return nil, fmt.Errorf("scan participant: %w", err) + } + people = append(people, c) + } + return people, rows.Err() +} + +// ParticipantIDs lists everyone still in the thread — the fan-out list for +// realtime publishes and push. +func (r *ChatRepo) ParticipantIDs(ctx context.Context, conversationID string) ([]string, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT user_id FROM conversation_participants + WHERE conversation_id = $1 AND left_at IS NULL`, conversationID) + if err != nil { + return nil, fmt.Errorf("participant ids: %w", err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan participant id: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// Inbox lists the caller's threads of the given kinds, most recent first. +// +// The last-message line comes from a LATERAL on (conversation_id, seq DESC) +// rather than a denormalized column, so an edit or delete is reflected without +// a second write path that could drift. Messages the caller deleted for +// themselves are skipped, so their inbox preview matches their thread. +func (r *ChatRepo) Inbox(ctx context.Context, userID string, kinds []string, limit int) ([]model.InboxItem, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT c.id, c.kind, c.title, c.avatar_key, c.send_policy, c.last_seq, c.last_message_at, c.created_at, + GREATEST(c.last_seq - p.last_read_seq, 0) AS unread, + p.muted_until, + m.seq, m.sender_id, m.kind, m.body, m.deleted_at, + COALESCE(su.name, ''), + peer.id, peer.handle, peer.name, peer.avatar_url + FROM conversation_participants p + JOIN conversations c ON c.id = p.conversation_id + LEFT JOIN LATERAL ( + SELECT msg.seq, msg.sender_id, msg.kind, msg.body, msg.deleted_at + FROM messages msg + WHERE msg.conversation_id = c.id + AND NOT EXISTS (SELECT 1 FROM message_hides h + WHERE h.message_id = msg.id AND h.user_id = $1) + ORDER BY msg.seq DESC LIMIT 1 + ) m ON TRUE + LEFT JOIN users su ON su.id = m.sender_id + LEFT JOIN LATERAL ( + SELECT u2.id, u2.handle, u2.name, u2.avatar_url + FROM conversation_participants p2 + JOIN users u2 ON u2.id = p2.user_id + WHERE p2.conversation_id = c.id AND p2.user_id <> $1 + LIMIT 1 + ) peer ON c.kind = 'dm' + WHERE p.user_id = $1 AND p.left_at IS NULL AND c.kind = ANY($2) + ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC + LIMIT $3`, userID, kinds, limit) + if err != nil { + return nil, fmt.Errorf("query inbox: %w", err) + } + defer rows.Close() + + items := []model.InboxItem{} + for rows.Next() { + var it model.InboxItem + var seq *int64 + var senderID, msgKind, body *string + var deletedAt *time.Time + var senderName string + var peerID, peerHandle, peerName, peerAvatar *string + + if err := rows.Scan( + &it.ID, &it.Kind, &it.Title, &it.AvatarKey, &it.SendPolicy, &it.LastSeq, &it.LastMessageAt, &it.CreatedAt, + &it.Unread, &it.MutedUntil, + &seq, &senderID, &msgKind, &body, &deletedAt, + &senderName, + &peerID, &peerHandle, &peerName, &peerAvatar, + ); err != nil { + return nil, fmt.Errorf("scan inbox row: %w", err) + } + + if seq != nil { + it.LastMessage = &model.MessagePreview{ + Seq: *seq, SenderID: senderID, SenderName: senderName, + Kind: derefOr(msgKind, model.MessageText), Body: derefOr(body, ""), + DeletedAt: deletedAt, + } + } + if peerID != nil { + it.Peer = &model.ChatPeer{ + ID: *peerID, Handle: derefOr(peerHandle, ""), + Name: derefOr(peerName, ""), AvatarURL: peerAvatar, + } + } + items = append(items, it) + } + return items, rows.Err() +} + +// Messages returns one page of history, newest first. beforeSeq == 0 starts at +// the newest message; otherwise it is an exclusive upper bound, which makes +// paging stable even as new messages arrive at the top. +func (r *ChatRepo) Messages(ctx context.Context, conversationID, userID string, beforeSeq int64, limit int) ([]model.Message, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities, + m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, m.deleted_at, m.created_at, + rep.id, rep.seq, rep.sender_id, rep.body, rep.deleted_at, COALESCE(rep.sender_name, '') + FROM messages m + LEFT JOIN LATERAL ( + SELECT r.id, r.seq, r.sender_id, r.body, r.deleted_at, ru.name AS sender_name + FROM messages r + LEFT JOIN users ru ON ru.id = r.sender_id + -- The conversation guard is repeated here on purpose: even a row + -- written before the write-side check existed cannot leak another + -- thread's text through a quote. + WHERE r.id = m.reply_to_id AND r.conversation_id = m.conversation_id + ) rep ON TRUE + WHERE m.conversation_id = $1 + AND ($2 = 0 OR m.seq < $2) + AND NOT EXISTS (SELECT 1 FROM message_hides h + WHERE h.message_id = m.id AND h.user_id = $3) + ORDER BY m.seq DESC + LIMIT $4`, conversationID, beforeSeq, userID, limit) + if err != nil { + return nil, fmt.Errorf("query messages: %w", err) + } + defer rows.Close() + + msgs := []model.Message{} + for rows.Next() { + var m model.Message + var entities []byte + var repID *string + var repSeq *int64 + var repSender *string + var repBody *string + var repDeleted *time.Time + var repSenderName string + + if err := rows.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind, &m.Body, + &entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID, + &m.EditedAt, &m.DeletedAt, &m.CreatedAt, + &repID, &repSeq, &repSender, &repBody, &repDeleted, &repSenderName); err != nil { + return nil, fmt.Errorf("scan message: %w", err) + } + m.Entities = json.RawMessage(entities) + if repID != nil { + m.ReplyTo = &model.ReplyPreview{ + ID: *repID, Seq: derefInt64(repSeq), SenderID: repSender, + SenderName: repSenderName, Body: derefOr(repBody, ""), + Deleted: repDeleted != nil, + } + } + msgs = append(msgs, m) + } + return msgs, rows.Err() +} + +// AppendParams carries everything needed to persist one outgoing message. +type AppendParams struct { + ConversationID string + SenderID *string + Kind string + Body string + Entities json.RawMessage + ClientMsgID *string + ReplyToID *string + ForwardedFromID *string +} + +// Append persists a message and assigns its sequence number. +// +// The conversation row is locked FOR UPDATE first. That serialises sends within +// a thread — which is what makes `seq` gapless — and it also means the +// duplicate check below runs under the lock, so a retried send returns the +// original message instead of consuming a sequence number and leaving a hole. +func (r *ChatRepo) Append(ctx context.Context, p AppendParams) (*model.Message, bool, error) { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return nil, false, fmt.Errorf("begin append tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + var lastSeq int64 + if err := tx.QueryRow(ctx, + `SELECT last_seq FROM conversations WHERE id = $1 FOR UPDATE`, p.ConversationID, + ).Scan(&lastSeq); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, ErrConversationGone + } + return nil, false, fmt.Errorf("lock conversation: %w", err) + } + + if p.ClientMsgID != nil { + existing, err := scanMessage(tx.QueryRow(ctx, ` + SELECT id, conversation_id, seq, sender_id, kind, body, entities, + client_msg_id, reply_to_id, forwarded_from_id, edited_at, deleted_at, created_at + FROM messages WHERE conversation_id = $1 AND client_msg_id = $2`, + p.ConversationID, *p.ClientMsgID)) + if err == nil { + return existing, false, tx.Commit(ctx) + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, false, fmt.Errorf("dedup lookup: %w", err) + } + } + + // Checked under the conversation lock we already hold. + if p.ReplyToID != nil { + var sameThread bool + if err := tx.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND conversation_id = $2)`, + *p.ReplyToID, p.ConversationID, + ).Scan(&sameThread); err != nil { + return nil, false, fmt.Errorf("validate reply target: %w", err) + } + if !sameThread { + return nil, false, ErrReplyTargetNotHere + } + } + + entities := p.Entities + if len(entities) == 0 { + entities = json.RawMessage(`[]`) + } + + seq := lastSeq + 1 + m, err := scanMessage(tx.QueryRow(ctx, ` + INSERT INTO messages (conversation_id, seq, sender_id, kind, body, entities, + client_msg_id, reply_to_id, forwarded_from_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id, conversation_id, seq, sender_id, kind, body, entities, + client_msg_id, reply_to_id, forwarded_from_id, edited_at, deleted_at, created_at`, + p.ConversationID, seq, p.SenderID, p.Kind, p.Body, entities, + p.ClientMsgID, p.ReplyToID, p.ForwardedFromID)) + if err != nil { + return nil, false, fmt.Errorf("insert message: %w", err) + } + + if _, err := tx.Exec(ctx, ` + UPDATE conversations SET last_seq = $2, last_message_at = now(), updated_at = now() + WHERE id = $1`, p.ConversationID, seq); err != nil { + return nil, false, fmt.Errorf("bump conversation: %w", err) + } + + // The sender has trivially seen their own message; advancing their cursors + // here keeps their unread count at zero without a follow-up write. + if p.SenderID != nil { + if _, err := tx.Exec(ctx, ` + UPDATE conversation_participants + SET last_read_seq = GREATEST(last_read_seq, $3), + last_delivered_seq = GREATEST(last_delivered_seq, $3) + WHERE conversation_id = $1 AND user_id = $2`, + p.ConversationID, *p.SenderID, seq); err != nil { + return nil, false, fmt.Errorf("advance sender cursor: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, false, fmt.Errorf("commit append: %w", err) + } + return m, true, nil +} + +// MarkRead advances the caller's read cursor. GREATEST means an out-of-order +// or replayed request can never move a cursor backwards and resurrect unreads. +// Reading implies delivery, so both cursors move together. +func (r *ChatRepo) MarkRead(ctx context.Context, conversationID, userID string, seq int64) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE conversation_participants + SET last_read_seq = GREATEST(last_read_seq, $3), + last_delivered_seq = GREATEST(last_delivered_seq, $3) + WHERE conversation_id = $1 AND user_id = $2`, conversationID, userID, seq) + if err != nil { + return fmt.Errorf("mark read: %w", err) + } + return nil +} + +// MarkDelivered advances only the delivery cursor — the client received the +// message but the user has not opened the thread. +func (r *ChatRepo) MarkDelivered(ctx context.Context, conversationID, userID string, seq int64) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE conversation_participants + SET last_delivered_seq = GREATEST(last_delivered_seq, $3) + WHERE conversation_id = $1 AND user_id = $2`, conversationID, userID, seq) + if err != nil { + return fmt.Errorf("mark delivered: %w", err) + } + return nil +} + +// AddAttachmentParams describes one attachment to persist. +type AddAttachmentParams struct { + MessageID string + ConversationID string + Ord int + Kind string + + R2Key *string + Mime *string + SizeBytes *int64 + Width *int + Height *int + DurationMs *int + Blurhash *string + FileName *string + + URL *string + Title *string + Description *string + SiteName *string + ImageURL *string +} + +// AddAttachment persists one attachment. conversation_id is denormalized here +// so the chat profile's media and links tabs are a single index scan. +func (r *ChatRepo) AddAttachment(ctx context.Context, p AddAttachmentParams) (*model.Attachment, error) { + var a model.Attachment + err := r.pg.Pool.QueryRow(ctx, ` + INSERT INTO message_attachments + (message_id, conversation_id, ord, kind, r2_key, mime, size_bytes, + width, height, duration_ms, blurhash, file_name, + url, title, description, site_name, image_url) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + RETURNING id, message_id, ord, kind, mime, size_bytes, width, height, + duration_ms, blurhash, file_name, url, title, description, + site_name, image_url, created_at`, + p.MessageID, p.ConversationID, p.Ord, p.Kind, p.R2Key, p.Mime, p.SizeBytes, + p.Width, p.Height, p.DurationMs, p.Blurhash, p.FileName, + p.URL, p.Title, p.Description, p.SiteName, p.ImageURL, + ).Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, &a.Width, + &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, &a.Title, + &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt) + if err != nil { + return nil, fmt.Errorf("add attachment: %w", err) + } + return &a, nil +} + +// AttachmentsFor loads the attachments of the given messages, keyed by message. +func (r *ChatRepo) AttachmentsFor(ctx context.Context, messageIDs []string) (map[string][]model.Attachment, error) { + out := map[string][]model.Attachment{} + if len(messageIDs) == 0 { + return out, nil + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT id, message_id, ord, kind, mime, size_bytes, width, height, + duration_ms, blurhash, file_name, url, title, description, + site_name, image_url, created_at + FROM message_attachments + WHERE message_id = ANY($1) + ORDER BY message_id, ord`, messageIDs) + if err != nil { + return nil, fmt.Errorf("query attachments: %w", err) + } + defer rows.Close() + for rows.Next() { + var a model.Attachment + if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, + &a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, + &a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("scan attachment: %w", err) + } + out[a.MessageID] = append(out[a.MessageID], a) + } + return out, rows.Err() +} + +// AttachmentLocation returns an attachment's storage key and the conversation +// it belongs to, so a download can be authorized before a URL is minted. +func (r *ChatRepo) AttachmentLocation(ctx context.Context, attachmentID string) (conversationID, r2Key string, err error) { + var key *string + err = r.pg.Pool.QueryRow(ctx, ` + SELECT conversation_id, r2_key FROM message_attachments WHERE id = $1`, + attachmentID).Scan(&conversationID, &key) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", ErrAttachmentGone + } + if err != nil { + return "", "", fmt.Errorf("attachment location: %w", err) + } + if key == nil || *key == "" { + return "", "", ErrAttachmentGone + } + return conversationID, *key, nil +} + +// GalleryKindsMedia / GalleryKindsLink name the two chat-profile tabs. +var ( + GalleryKindsMedia = []string{model.AttachmentImage, model.AttachmentVideo, model.AttachmentAudio, model.AttachmentFile} + GalleryKindsLink = []string{model.AttachmentLink} +) + +// Gallery lists a thread's attachments of the given kinds, newest first. +// +// Served by message_attachments_gallery_idx, which is why conversation_id is +// denormalized onto attachments: this is one index range scan rather than a +// join back through messages. +// +// Attachments of messages the caller deleted for themselves are excluded, so +// the gallery matches the thread they actually see. +func (r *ChatRepo) Gallery(ctx context.Context, conversationID, userID string, kinds []string, before *time.Time, limit int) ([]model.Attachment, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT a.id, a.message_id, a.ord, a.kind, a.mime, a.size_bytes, a.width, + a.height, a.duration_ms, a.blurhash, a.file_name, a.url, a.title, + a.description, a.site_name, a.image_url, a.created_at + FROM message_attachments a + JOIN messages m ON m.id = a.message_id + WHERE a.conversation_id = $1 + AND a.kind = ANY($2) + AND m.deleted_at IS NULL + AND ($3::timestamptz IS NULL OR a.created_at < $3) + AND NOT EXISTS (SELECT 1 FROM message_hides h + WHERE h.message_id = a.message_id AND h.user_id = $4) + ORDER BY a.created_at DESC + LIMIT $5`, conversationID, kinds, before, userID, limit) + if err != nil { + return nil, fmt.Errorf("query gallery: %w", err) + } + defer rows.Close() + + out := []model.Attachment{} + for rows.Next() { + var a model.Attachment + if err := rows.Scan(&a.ID, &a.MessageID, &a.Ord, &a.Kind, &a.Mime, &a.SizeBytes, + &a.Width, &a.Height, &a.DurationMs, &a.Blurhash, &a.FileName, &a.URL, + &a.Title, &a.Description, &a.SiteName, &a.ImageURL, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("scan gallery row: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} + +// SetStar stars or unstars a message for one user. +// +// The message must belong to the conversation: the caller's membership is +// checked against the thread, so accepting a message id from elsewhere would +// let them star something they cannot see. +func (r *ChatRepo) SetStar(ctx context.Context, conversationID, messageID, userID string, starred bool) error { + if !starred { + _, err := r.pg.Pool.Exec(ctx, + `DELETE FROM message_stars WHERE message_id = $1 AND user_id = $2`, messageID, userID) + if err != nil { + return fmt.Errorf("unstar: %w", err) + } + return nil + } + + tag, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO message_stars (message_id, user_id, conversation_id) + SELECT $1, $2, $3 + WHERE EXISTS (SELECT 1 FROM messages WHERE id = $1 AND conversation_id = $3) + ON CONFLICT (message_id, user_id) DO NOTHING`, messageID, userID, conversationID) + if err != nil { + return fmt.Errorf("star: %w", err) + } + // No row inserted and no conflict means the message isn't in this thread. + if tag.RowsAffected() == 0 { + var exists bool + if err := r.pg.Pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM message_stars WHERE message_id=$1 AND user_id=$2)`, + messageID, userID).Scan(&exists); err != nil { + return fmt.Errorf("star check: %w", err) + } + if !exists { + return ErrMessageNotHere + } + } + return nil +} + +// Starred lists the caller's starred messages in a thread, newest first. +func (r *ChatRepo) Starred(ctx context.Context, conversationID, userID string, limit int) ([]model.Message, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities, + m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, + m.deleted_at, m.created_at + FROM message_stars st + JOIN messages m ON m.id = st.message_id + WHERE st.user_id = $1 AND st.conversation_id = $2 AND m.deleted_at IS NULL + ORDER BY st.created_at DESC + LIMIT $3`, userID, conversationID, limit) + if err != nil { + return nil, fmt.Errorf("query starred: %w", err) + } + defer rows.Close() + + msgs := []model.Message{} + for rows.Next() { + var m model.Message + var entities []byte + if err := rows.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind, + &m.Body, &entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID, + &m.EditedAt, &m.DeletedAt, &m.CreatedAt); err != nil { + return nil, fmt.Errorf("scan starred: %w", err) + } + m.Entities = json.RawMessage(entities) + m.Attachments = []model.Attachment{} + msgs = append(msgs, m) + } + return msgs, rows.Err() +} + +// StarredIDs reports which of the given messages the caller has starred, so a +// thread can show the star on each bubble. +func (r *ChatRepo) StarredIDs(ctx context.Context, userID string, messageIDs []string) (map[string]bool, error) { + out := map[string]bool{} + if len(messageIDs) == 0 { + return out, nil + } + rows, err := r.pg.Pool.Query(ctx, + `SELECT message_id FROM message_stars WHERE user_id = $1 AND message_id = ANY($2)`, + userID, messageIDs) + if err != nil { + return nil, fmt.Errorf("query stars: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan star: %w", err) + } + out[id] = true + } + return out, rows.Err() +} + +// FollowEdges returns the accepted-follow state between two users in both +// directions, plus when the first of them was accepted. +func (r *ChatRepo) FollowEdges(ctx context.Context, a, b string) (aFollowsB, bFollowsA bool, since *time.Time, err error) { + rows, qErr := r.pg.Pool.Query(ctx, ` + SELECT follower_id, status, COALESCE(responded_at, created_at) + FROM follows + WHERE (follower_id = $1 AND following_id = $2) + OR (follower_id = $2 AND following_id = $1)`, a, b) + if qErr != nil { + return false, false, nil, fmt.Errorf("follow edges: %w", qErr) + } + defer rows.Close() + + for rows.Next() { + var follower, status string + var at time.Time + if err := rows.Scan(&follower, &status, &at); err != nil { + return false, false, nil, fmt.Errorf("scan follow edge: %w", err) + } + if status != "accepted" { + continue // a pending request is not a friendship + } + if follower == a { + aFollowsB = true + } else { + bFollowsA = true + } + // "Friends since" is the earliest accepted edge either way. + if since == nil || at.Before(*since) { + at := at + since = &at + } + } + return aFollowsB, bFollowsA, since, rows.Err() +} + +// Settings returns a user's chat preferences, falling back to the defaults +// when they have never changed anything. +func (r *ChatRepo) Settings(ctx context.Context, userID string) (model.ChatSettings, error) { + out := model.DefaultChatSettings() + var gender *string + err := r.pg.Pool.QueryRow(ctx, ` + SELECT COALESCE(s.read_receipts, TRUE), + COALESCE(s.who_can_message, 'anyone'), + COALESCE(s.min_account_age_months, 3), + COALESCE(s.train_kautilya, TRUE), + u.gender + FROM users u + LEFT JOIN chat_settings s ON s.user_id = u.id + WHERE u.id = $1`, userID, + ).Scan(&out.ReadReceipts, &out.WhoCanMessage, &out.MinAccountAgeMonths, + &out.TrainKautilya, &gender) + if errors.Is(err, pgx.ErrNoRows) { + return out, ErrUserGone + } + if err != nil { + return out, fmt.Errorf("chat settings: %w", err) + } + out.GenderSet = gender != nil && *gender != "" + return out, nil +} + +// SaveSettings upserts a user's chat preferences. +func (r *ChatRepo) SaveSettings(ctx context.Context, userID string, s model.ChatSettings) error { + _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO chat_settings + (user_id, read_receipts, who_can_message, min_account_age_months, train_kautilya) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id) DO UPDATE SET + read_receipts = EXCLUDED.read_receipts, + who_can_message = EXCLUDED.who_can_message, + min_account_age_months = EXCLUDED.min_account_age_months, + train_kautilya = EXCLUDED.train_kautilya, + updated_at = now()`, + userID, s.ReadReceipts, s.WhoCanMessage, s.MinAccountAgeMonths, s.TrainKautilya) + if err != nil { + return fmt.Errorf("save chat settings: %w", err) + } + return nil +} + +// CanStartConversation applies the recipient's "who can message you" rule. +// +// Only gates STARTING a thread. An existing conversation keeps working, so +// tightening the setting never strands people mid-exchange. +func (r *ChatRepo) CanStartConversation(ctx context.Context, senderID, recipientID string) (bool, error) { + settings, err := r.Settings(ctx, recipientID) + if err != nil { + return false, err + } + + switch settings.WhoCanMessage { + case model.WhoAnyone: + return true, nil + + case model.WhoFriends: + // An accepted follow in EITHER direction counts as a connection. + following, followedBy, _, err := r.FollowEdges(ctx, senderID, recipientID) + if err != nil { + return false, err + } + return following || followedBy, nil + + case model.WhoAccountAge: + var oldEnough bool + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT created_at <= now() - make_interval(months => $2) + FROM users WHERE id = $1`, senderID, settings.MinAccountAgeMonths, + ).Scan(&oldEnough); err != nil { + return false, fmt.Errorf("account age: %w", err) + } + return oldEnough, nil + + case model.WhoSameGender: + // Unusable without the recipient's own gender; treated as "anyone" + // rather than locking them out of their own inbox. + if !settings.GenderSet { + return true, nil + } + var same bool + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT s.gender IS NOT NULL AND s.gender = r.gender + FROM users s, users r WHERE s.id = $1 AND r.id = $2`, + senderID, recipientID).Scan(&same); err != nil { + return false, fmt.Errorf("gender match: %w", err) + } + return same, nil + } + return true, nil +} + +// ReadReceiptsEnabled reports which of the given users allow read receipts. +func (r *ChatRepo) ReadReceiptsEnabled(ctx context.Context, userIDs []string) (map[string]bool, error) { + out := map[string]bool{} + if len(userIDs) == 0 { + return out, nil + } + rows, err := r.pg.Pool.Query(ctx, ` + SELECT u.id, COALESCE(s.read_receipts, TRUE) + FROM users u LEFT JOIN chat_settings s ON s.user_id = u.id + WHERE u.id = ANY($1)`, userIDs) + if err != nil { + return nil, fmt.Errorf("read receipt settings: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + var on bool + if err := rows.Scan(&id, &on); err != nil { + return nil, fmt.Errorf("scan receipt setting: %w", err) + } + out[id] = on + } + return out, rows.Err() +} + +// UserIDsByHandles resolves handles to ids, skipping any that don't exist. +// +// Member operations take handles because that is what the UI has: search +// results and follow lists identify people by handle, and handles are what the +// rest of the API keys on (/u/{handle}/block, /u/{handle}/followers). +func (r *ChatRepo) UserIDsByHandles(ctx context.Context, handles []string) ([]string, error) { + if len(handles) == 0 { + return nil, nil + } + rows, err := r.pg.Pool.Query(ctx, + // Banned users are skipped: they cannot be added to a group. + `SELECT id FROM users WHERE handle = ANY($1) AND banned_at IS NULL`, handles) + if err != nil { + return nil, fmt.Errorf("resolve handles: %w", err) + } + defer rows.Close() + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan handle id: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// AddParticipants adds members to a group, reviving anyone who had left. +func (r *ChatRepo) AddParticipants(ctx context.Context, conversationID string, userIDs []string) error { + for _, id := range userIDs { + if _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) + VALUES ($1, $2, 'member', now()) + ON CONFLICT (conversation_id, user_id) + -- Re-adding someone who left clears left_at and restarts their + -- tenure; it must not resurrect an old role. + DO UPDATE SET left_at = NULL, role = 'member', joined_at = now() + WHERE conversation_participants.left_at IS NOT NULL`, + conversationID, id); err != nil { + return fmt.Errorf("add participant: %w", err) + } + } + return nil +} + +// RemoveParticipant marks someone as having left. The row stays so their past +// messages still resolve to a member when history is rendered. +func (r *ChatRepo) RemoveParticipant(ctx context.Context, conversationID, userID string) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE conversation_participants SET left_at = now() + WHERE conversation_id = $1 AND user_id = $2 AND left_at IS NULL`, + conversationID, userID) + if err != nil { + return fmt.Errorf("remove participant: %w", err) + } + return nil +} + +// SetRole changes a participant's role. +func (r *ChatRepo) SetRole(ctx context.Context, conversationID, userID, role string) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE conversation_participants SET role = $3 + WHERE conversation_id = $1 AND user_id = $2 AND left_at IS NULL`, + conversationID, userID, role) + if err != nil { + return fmt.Errorf("set role: %w", err) + } + return nil +} + +// UpdateGroup changes a group's title and/or send policy. Nil leaves a field +// untouched. +func (r *ChatRepo) UpdateGroup(ctx context.Context, conversationID string, title, sendPolicy, avatarKey *string) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE conversations + SET title = COALESCE($2, title), + send_policy = COALESCE($3, send_policy), + avatar_key = COALESCE($4, avatar_key), + updated_at = now() + WHERE id = $1`, conversationID, title, sendPolicy, avatarKey) + if err != nil { + return fmt.Errorf("update group: %w", err) + } + return nil +} + +// SuccessorOwner picks who inherits a group when its owner leaves: the +// longest-serving remaining admin, else the longest-serving member. Empty when +// nobody is left. +// +// Without this a group whose owner walks away would have no one able to +// administer it. +func (r *ChatRepo) SuccessorOwner(ctx context.Context, conversationID, leavingUserID string) (string, error) { + var id string + err := r.pg.Pool.QueryRow(ctx, ` + SELECT user_id FROM conversation_participants + WHERE conversation_id = $1 AND user_id <> $2 AND left_at IS NULL + ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, joined_at + LIMIT 1`, conversationID, leavingUserID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("successor owner: %w", err) + } + return id, nil +} + +// MessageConversation returns the thread a message belongs to, plus its +// sender — enough to authorize a delete, a forward or a report. +func (r *ChatRepo) MessageConversation(ctx context.Context, messageID string) (conversationID string, senderID *string, err error) { + err = r.pg.Pool.QueryRow(ctx, + `SELECT conversation_id, sender_id FROM messages WHERE id = $1`, messageID, + ).Scan(&conversationID, &senderID) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil, ErrMessageNotHere + } + if err != nil { + return "", nil, fmt.Errorf("message conversation: %w", err) + } + return conversationID, senderID, nil +} + +// MessageByID loads a single message. Callers authorize separately — this +// only fetches. +func (r *ChatRepo) MessageByID(ctx context.Context, messageID string) (*model.Message, error) { + m, err := scanMessage(r.pg.Pool.QueryRow(ctx, ` + SELECT id, conversation_id, seq, sender_id, kind, body, entities, + client_msg_id, reply_to_id, forwarded_from_id, edited_at, + deleted_at, created_at + FROM messages WHERE id = $1`, messageID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrMessageNotHere + } + if err != nil { + return nil, fmt.Errorf("message by id: %w", err) + } + return m, nil +} + +// HideMessage removes a message from one user's view only. Others keep seeing +// it — this is "delete for me". +func (r *ChatRepo) HideMessage(ctx context.Context, messageID, userID string) error { + _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO message_hides (message_id, user_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, messageID, userID) + if err != nil { + return fmt.Errorf("hide message: %w", err) + } + return nil +} + +// DeleteMessage tombstones a message for everyone. +// +// The row survives on purpose: `seq` must stay gapless for the client's +// catch-up cursor, and other participants need something to replace the bubble +// with rather than a hole. Body and entities are cleared so the text is +// actually gone, and attachments go with it. +func (r *ChatRepo) DeleteMessage(ctx context.Context, messageID, byUserID string) error { + tx, err := r.pg.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin delete tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + if _, err := tx.Exec(ctx, ` + UPDATE messages + SET deleted_at = now(), deleted_by = $2, body = '', entities = '[]'::jsonb + WHERE id = $1`, messageID, byUserID); err != nil { + return fmt.Errorf("delete message: %w", err) + } + if _, err := tx.Exec(ctx, + `DELETE FROM message_attachments WHERE message_id = $1`, messageID); err != nil { + return fmt.Errorf("delete message attachments: %w", err) + } + return tx.Commit(ctx) +} + +// MessageWithContext returns a message and the few around it — the snapshot a +// report captures so moderators never need to read the live thread. +func (r *ChatRepo) MessageWithContext(ctx context.Context, messageID string, around int) ([]model.Message, error) { + rows, err := r.pg.Pool.Query(ctx, ` + WITH target AS ( + SELECT conversation_id, seq FROM messages WHERE id = $1 + ) + SELECT m.id, m.conversation_id, m.seq, m.sender_id, m.kind, m.body, m.entities, + m.client_msg_id, m.reply_to_id, m.forwarded_from_id, m.edited_at, + m.deleted_at, m.created_at + FROM messages m, target t + WHERE m.conversation_id = t.conversation_id + AND m.seq BETWEEN t.seq - $2 AND t.seq + $2 + ORDER BY m.seq`, messageID, around) + if err != nil { + return nil, fmt.Errorf("message context: %w", err) + } + defer rows.Close() + + msgs := []model.Message{} + for rows.Next() { + var m model.Message + var entities []byte + if err := rows.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind, + &m.Body, &entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID, + &m.EditedAt, &m.DeletedAt, &m.CreatedAt); err != nil { + return nil, fmt.Errorf("scan context message: %w", err) + } + m.Entities = json.RawMessage(entities) + msgs = append(msgs, m) + } + return msgs, rows.Err() +} + +// ReportMessage records a report with its evidence copied in. +func (r *ChatRepo) ReportMessage(ctx context.Context, reporterID, messageID, conversationID string, reportedUserID *string, category, reason string, snapshot []byte) error { + _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO message_reports + (reporter_id, message_id, conversation_id, reported_user_id, + category, reason, snapshot) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + reporterID, messageID, conversationID, reportedUserID, category, reason, snapshot) + if err != nil { + return fmt.Errorf("report message: %w", err) + } + return nil +} + +// SetMute sets or clears the caller's mute on a thread. A nil until unmutes. +// +// Mute is per participant, not per conversation: muting a group silences it for +// you alone. +func (r *ChatRepo) SetMute(ctx context.Context, conversationID, userID string, until *time.Time) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE conversation_participants SET muted_until = $3 + WHERE conversation_id = $1 AND user_id = $2`, conversationID, userID, until) + if err != nil { + return fmt.Errorf("set mute: %w", err) + } + return nil +} + +// Receipts returns the delivery/read cursors across everyone EXCEPT the +// caller, so the caller can draw ticks on their own messages. MIN is the +// deliberate choice: in a group, blue ticks mean everyone has read it. +func (r *ChatRepo) Receipts(ctx context.Context, conversationID, excludeUserID string) (model.Receipts, error) { + var rc model.Receipts + err := r.pg.Pool.QueryRow(ctx, ` + SELECT COALESCE(MIN(last_delivered_seq), 0), COALESCE(MIN(last_read_seq), 0) + FROM conversation_participants + WHERE conversation_id = $1 AND user_id <> $2 AND left_at IS NULL`, + conversationID, excludeUserID).Scan(&rc.DeliveredSeq, &rc.ReadSeq) + if err != nil { + return rc, fmt.Errorf("receipts: %w", err) + } + return rc, nil +} + +func scanMessage(row pgx.Row) (*model.Message, error) { + var m model.Message + var entities []byte + if err := row.Scan(&m.ID, &m.ConversationID, &m.Seq, &m.SenderID, &m.Kind, &m.Body, + &entities, &m.ClientMsgID, &m.ReplyToID, &m.ForwardedFromID, + &m.EditedAt, &m.DeletedAt, &m.CreatedAt); err != nil { + return nil, err + } + m.Entities = json.RawMessage(entities) + return &m, nil +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +func derefOr(s *string, fallback string) string { + if s == nil { + return fallback + } + return *s +} diff --git a/internal/repository/chat_repo_test.go b/internal/repository/chat_repo_test.go new file mode 100644 index 0000000..f59d33a --- /dev/null +++ b/internal/repository/chat_repo_test.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 30e3ae8..a5db798 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/service/chat_entities.go b/internal/service/chat_entities.go new file mode 100644 index 0000000..c1d5c5f --- /dev/null +++ b/internal/service/chat_entities.go @@ -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 +} diff --git a/internal/service/chat_entities_test.go b/internal/service/chat_entities_test.go new file mode 100644 index 0000000..b22fc2d --- /dev/null +++ b/internal/service/chat_entities_test.go @@ -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 "" + } + 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) + } +} diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go new file mode 100644 index 0000000..8386dd4 --- /dev/null +++ b/internal/service/chat_service.go @@ -0,0 +1,1342 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/pkg/realtime" + "github.com/prabhupadasays/spsays-bff/pkg/unfurl" + "go.uber.org/zap" +) + +var ( + ErrChatBlocked = errors.New("cannot message this user") + ErrChatEmptyBody = errors.New("message is empty") + ErrChatBodyTooLong = errors.New("message is too long") + ErrChatReadOnly = errors.New("this conversation cannot be posted to") + ErrChatBadMuteDuration = errors.New("duration must be one of 8h, 1w, always, off") + ErrChatNoPreview = errors.New("no preview available for that link") + ErrChatLinkNotInBody = errors.New("that link does not appear in the message") + ErrChatMediaOff = errors.New("media uploads are not configured") + ErrChatBadMedia = errors.New("unsupported attachment type") + ErrChatNotSender = errors.New("only the sender can delete a message for everyone") + ErrMessageDeleted = errors.New("that message was deleted") + ErrChatBadReport = errors.New("a report category is required") + ErrChatNotAdmin = errors.New("only group admins can do that") + ErrChatNotGroup = errors.New("that conversation is not a group") + ErrChatBadRole = errors.New("role must be admin or member") + ErrChatBadPolicy = errors.New("send policy must be everyone or admins") + ErrChatOwnerImmutable = errors.New("the group owner's role cannot be changed") + ErrChatAdminsOnly = errors.New("only admins can post in this group") + ErrChatBadAvatarKey = errors.New("that image was not uploaded for this group") + 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") + 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") +) + +// maxBodyRunes caps a single message. Generous for conversation, small enough +// that one message can't be used to stuff the database. +const maxBodyRunes = 8000 + +// defaultPageSize / maxPageSize bound history paging. +const ( + defaultPageSize = 30 + maxPageSize = 100 +) + +// ChatService owns conversation and message rules: who may read a thread, who +// may post to it, and who hears about it in realtime. +type ChatService struct { + chat *repository.ChatRepo + blocks *repository.BlockRepo + rt *realtime.SoketiClient + r2 *repository.R2 + links *unfurl.Fetcher + cache *repository.Redis + logger *zap.Logger +} + +func NewChatService( + chat *repository.ChatRepo, + blocks *repository.BlockRepo, + rt *realtime.SoketiClient, + r2 *repository.R2, + cache *repository.Redis, + logger *zap.Logger, +) *ChatService { + return &ChatService{ + chat: chat, blocks: blocks, rt: rt, r2: r2, + links: unfurl.New(), cache: cache, logger: logger, + } +} + +// ConversationChannel is the private realtime channel for one thread. Exported +// so the channel-authorization rule and the publisher agree on the name. +func ConversationChannel(conversationID string) string { + return "private-conversation." + conversationID +} + +// KindsForTab maps an inbox tab to the conversation kinds it shows. The two +// tabs are a filter over one pipeline, not two implementations. +func KindsForTab(tab string) []string { + if tab == "kautilya" { + return []string{model.ConversationAI} + } + return []string{model.ConversationDM, model.ConversationGroup} +} + +// IsParticipant backs the realtime channel-authorization rule. It returns +// (false, nil) for "not a member" and an error only when the check itself +// failed, so a database blip is never mistaken for a denial. +func (s *ChatService) IsParticipant(ctx context.Context, userID, conversationID string) (bool, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + if errors.Is(err, repository.ErrNotParticipant) { + return false, nil + } + return false, err + } + return true, nil +} + +// Settings returns the caller's chat preferences. +func (s *ChatService) Settings(ctx context.Context, userID string) (model.ChatSettings, error) { + return s.chat.Settings(ctx, userID) +} + +// SaveSettings validates and stores the caller's chat preferences. +func (s *ChatService) SaveSettings(ctx context.Context, userID string, in model.ChatSettings) (model.ChatSettings, error) { + switch in.WhoCanMessage { + case model.WhoAnyone, model.WhoFriends, model.WhoAccountAge, model.WhoSameGender: + default: + return model.ChatSettings{}, ErrChatBadWhoCanMessage + } + if in.MinAccountAgeMonths < 1 || in.MinAccountAgeMonths > 120 { + return model.ChatSettings{}, ErrChatBadAccountAge + } + + current, err := s.chat.Settings(ctx, userID) + if err != nil { + return model.ChatSettings{}, err + } + // Choosing same-gender without a gender on the profile would silently do + // nothing, so refuse and let the UI say why. + if in.WhoCanMessage == model.WhoSameGender && !current.GenderSet { + return model.ChatSettings{}, ErrChatGenderUnset + } + + if err := s.chat.SaveSettings(ctx, userID, in); err != nil { + return model.ChatSettings{}, err + } + return s.chat.Settings(ctx, userID) +} + +// Inbox lists the caller's threads for one tab. +func (s *ChatService) Inbox(ctx context.Context, userID, tab string, limit int) ([]model.InboxItem, error) { + if limit <= 0 || limit > maxPageSize { + limit = maxPageSize + } + return s.chat.Inbox(ctx, userID, KindsForTab(tab), limit) +} + +// OpenDM returns (creating if needed) the DM between the caller and peer. +// +// Blocking is enforced in BOTH directions: a blocked user must not be able to +// open a thread, and neither should someone who blocked the peer — silently +// leaving a one-way channel open would be worse than refusing. +func (s *ChatService) OpenDM(ctx context.Context, userID, peerID string) (*model.Conversation, error) { + if userID == peerID { + return nil, errors.New("cannot open a conversation with yourself") + } + blocked, blockedBy, err := s.blocks.BlockState(ctx, userID, peerID) + if err != nil { + return nil, fmt.Errorf("block state: %w", err) + } + if blocked || blockedBy { + return nil, ErrChatBlocked + } + + // The recipient's "who can message you" rule gates opening the thread. An + // existing conversation is unaffected, so tightening it never strands + // people mid-exchange. + allowed, err := s.chat.CanStartConversation(ctx, userID, peerID) + if err != nil { + return nil, err + } + if !allowed { + return nil, ErrChatNotAccepting + } + return s.chat.EnsureDM(ctx, userID, peerID) +} + +// OpenDMByHandle resolves a handle and opens the DM. +func (s *ChatService) OpenDMByHandle(ctx context.Context, userID, handle string) (*model.Conversation, error) { + ids, err := s.chat.UserIDsByHandles(ctx, []string{handle}) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return nil, repository.ErrUserGone + } + return s.OpenDM(ctx, userID, ids[0]) +} + +// CreateGroup opens a group thread. Members who have blocked the creator (or +// are blocked by them) are dropped rather than failing the whole call. +func (s *ChatService) CreateGroup(ctx context.Context, userID, title string, memberHandles []string) (*model.Conversation, error) { + title = strings.TrimSpace(title) + if title == "" { + return nil, errors.New("group title is required") + } + memberIDs, err := s.chat.UserIDsByHandles(ctx, memberHandles) + if err != nil { + return nil, err + } + allowed := make([]string, 0, len(memberIDs)) + for _, m := range memberIDs { + if m == userID { + continue + } + blocked, blockedBy, err := s.blocks.BlockState(ctx, userID, m) + if err != nil { + return nil, fmt.Errorf("block state: %w", err) + } + if blocked || blockedBy { + continue + } + allowed = append(allowed, m) + } + return s.chat.CreateGroup(ctx, userID, title, allowed) +} + +// OpenAIThread returns the caller's Kautilya thread. +func (s *ChatService) OpenAIThread(ctx context.Context, userID string) (*model.Conversation, error) { + return s.chat.EnsureAIThread(ctx, userID) +} + +// Detail returns one thread with its participants and the caller's own state. +func (s *ChatService) Detail(ctx context.Context, userID, conversationID string) (*model.ConversationDetail, error) { + me, err := s.chat.Participant(ctx, conversationID, userID) + if err != nil { + return nil, err + } + conv, err := s.chat.Conversation(ctx, conversationID) + if err != nil { + return nil, err + } + people, err := s.chat.Participants(ctx, conversationID) + if err != nil { + return nil, err + } + + detail := &model.ConversationDetail{ + Conversation: *conv, + Participants: people, + Unread: max64(conv.LastSeq-me.LastReadSeq, 0), + MutedUntil: me.MutedUntil, + MyRole: me.Role, + } + if conv.Kind == model.ConversationDM { + for i := range people { + if people[i].ID != userID { + detail.Peer = &people[i] + break + } + } + if detail.Peer != nil { + rel, err := s.relationship(ctx, userID, detail.Peer.ID) + if err != nil { + return nil, err + } + detail.Relationship = rel + } + } + return detail, nil +} + +// relationship assembles the follow and block state the chat profile shows. +func (s *ChatService) relationship(ctx context.Context, userID, peerID string) (*model.ChatRelationship, error) { + following, followedBy, since, err := s.chat.FollowEdges(ctx, userID, peerID) + if err != nil { + return nil, err + } + blocked, blockedBy, err := s.blocks.BlockState(ctx, userID, peerID) + if err != nil { + return nil, fmt.Errorf("block state: %w", err) + } + return &model.ChatRelationship{ + Following: following, FollowedBy: followedBy, + Blocked: blocked, BlockedBy: blockedBy, FriendsSince: since, + }, nil +} + +// Gallery lists a thread's shared media or links for the chat profile. +func (s *ChatService) Gallery(ctx context.Context, userID, conversationID, tab string, before *time.Time, limit int) ([]model.Attachment, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return nil, err + } + if limit <= 0 || limit > maxPageSize { + limit = maxPageSize + } + kinds := repository.GalleryKindsMedia + if tab == "links" { + kinds = repository.GalleryKindsLink + } + return s.chat.Gallery(ctx, conversationID, userID, kinds, before, limit) +} + +// SetStar stars or unstars a message for the caller. +func (s *ChatService) SetStar(ctx context.Context, userID, conversationID, messageID string, starred bool) error { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + return s.chat.SetStar(ctx, conversationID, messageID, userID, starred) +} + +// Starred lists the caller's starred messages in a thread. +func (s *ChatService) Starred(ctx context.Context, userID, conversationID string, limit int) ([]model.Message, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return nil, err + } + if limit <= 0 || limit > maxPageSize { + limit = maxPageSize + } + msgs, err := s.chat.Starred(ctx, conversationID, userID, limit) + if err != nil { + return nil, err + } + return s.hydrateAttachments(ctx, msgs) +} + +// hydrateAttachments fills in each message's attachments in one query. +func (s *ChatService) hydrateAttachments(ctx context.Context, msgs []model.Message) ([]model.Message, error) { + ids := make([]string, 0, len(msgs)) + for i := range msgs { + ids = append(ids, msgs[i].ID) + } + byMessage, err := s.chat.AttachmentsFor(ctx, ids) + if err != nil { + return nil, err + } + for i := range msgs { + if a := byMessage[msgs[i].ID]; a != nil { + msgs[i].Attachments = a + } else { + // Always a slice, never nil, so clients need no guard. + msgs[i].Attachments = []model.Attachment{} + } + } + return msgs, nil +} + +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} + +// Messages returns a page of history after checking membership. +func (s *ChatService) Messages(ctx context.Context, userID, conversationID string, beforeSeq int64, limit int) ([]model.Message, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return nil, err + } + if limit <= 0 { + limit = defaultPageSize + } + if limit > maxPageSize { + limit = maxPageSize + } + msgs, err := s.chat.Messages(ctx, conversationID, userID, beforeSeq, limit) + if err != nil { + return nil, err + } + + return s.hydrateAttachments(ctx, msgs) +} + +// SendRequest is one outgoing message from a client. +type SendRequest struct { + // Body is raw composer text; formatting markers are parsed out here, so + // clients never send markup or entity spans of their own. + Body string + // ClientMsgID makes a retry idempotent. + ClientMsgID *string + ReplyToID *string + // PreviewURL asks for a link card. Only the URL is accepted — the BFF + // fetches the title, description and image itself, because a client that + // could supply them could make any link claim to be anything. + PreviewURL *string + // Attachments are files already uploaded to the private bucket via + // PresignAttachment; their keys are checked against the caller's prefix. + Attachments []MediaAttachment +} + +// MediaAttachment is one uploaded file being attached to a message. +type MediaAttachment struct { + Key string + Mime string + SizeBytes *int64 + Width *int + Height *int + DurationMs *int + Blurhash *string + FileName *string +} + +// Send validates, persists and publishes a message. +// +// Entities are parsed server-side rather than trusted from the client: they +// drive rendering, so accepting client-supplied spans would let one user +// control how their text is displayed in someone else's thread. +func (s *ChatService) Send(ctx context.Context, userID, conversationID string, req SendRequest) (*model.Message, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return nil, err + } + if err := s.assertCanPost(ctx, userID, conversationID); err != nil { + return nil, err + } + + 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 { + return nil, ErrChatEmptyBody + } + if len([]rune(body)) > maxBodyRunes { + return nil, ErrChatBodyTooLong + } + + clean, entities := ParseEntities(body) + encoded, err := json.Marshal(entities) + if err != nil { + return nil, fmt.Errorf("encode entities: %w", err) + } + if entities == nil { + encoded = []byte(`[]`) + } + + msg, created, err := s.chat.Append(ctx, repository.AppendParams{ + ConversationID: conversationID, + SenderID: &userID, + Kind: model.MessageText, + Body: clean, + Entities: encoded, + ClientMsgID: req.ClientMsgID, + ReplyToID: req.ReplyToID, + }) + if err != nil { + return nil, err + } + // A retry returns the stored message as-is: its attachments were persisted + // the first time, and re-publishing would duplicate the bubble for + // everyone else. + if !created { + return s.withAttachments(ctx, msg) + } + + s.attachMedia(ctx, conversationID, userID, msg, req.Attachments) + s.attachLinkPreview(ctx, conversationID, msg, entities, req.PreviewURL) + + full, err := s.withAttachments(ctx, msg) + if err != nil { + full = msg + } + s.publish(ctx, conversationID, userID, full) + return full, nil +} + +// withAttachments hydrates a message's attachments for the response. +func (s *ChatService) withAttachments(ctx context.Context, msg *model.Message) (*model.Message, error) { + byMessage, err := s.chat.AttachmentsFor(ctx, []string{msg.ID}) + if err != nil { + return msg, err + } + msg.Attachments = byMessage[msg.ID] + if msg.Attachments == nil { + msg.Attachments = []model.Attachment{} + } + return msg, nil +} + +// attachMedia persists already-uploaded files. +// +// Each key must sit under this conversation and this uploader's prefix. Without +// that check a caller could attach any key it could guess — including one +// uploaded to a thread it is not in. +func (s *ChatService) attachMedia(ctx context.Context, conversationID, userID string, msg *model.Message, media []MediaAttachment) { + prefix := path.Join("chat", conversationID, userID) + "/" + for i, m := range media { + if !strings.HasPrefix(m.Key, prefix) { + s.logger.Warn("chat: refusing attachment key outside the caller's prefix", + zap.String("user_id", userID), zap.String("conversation_id", conversationID)) + continue + } + kind, ok := attachmentKindFor(m.Mime) + if !ok { + continue + } + key := m.Key + mime := m.Mime + if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{ + MessageID: msg.ID, ConversationID: conversationID, Ord: i, Kind: kind, + R2Key: &key, Mime: &mime, SizeBytes: m.SizeBytes, Width: m.Width, + Height: m.Height, DurationMs: m.DurationMs, Blurhash: m.Blurhash, + FileName: m.FileName, + }); err != nil { + s.logger.Warn("chat: attach media 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 +// the message body — otherwise a message could carry a card for a link it never +// mentions. And the metadata comes from our own fetch, never from the client. +// +// Unfurling happens after the message is stored, so a slow remote page delays +// the card, never the message. +func (s *ChatService) attachLinkPreview(ctx context.Context, conversationID string, msg *model.Message, entities []Entity, previewURL *string) { + if previewURL == nil || *previewURL == "" { + return // the sender dismissed the preview, or there was none + } + if !urlInEntities(*previewURL, entities) { + s.logger.Warn("chat: preview url not present in message body") + return + } + + preview, err := s.LinkPreview(ctx, *previewURL) + if err != nil { + return // no card rather than a broken one + } + + params := repository.AddAttachmentParams{ + MessageID: msg.ID, ConversationID: conversationID, + Ord: len(msg.Attachments), Kind: model.AttachmentLink, + URL: &preview.URL, + } + if preview.Title != "" { + params.Title = &preview.Title + } + if preview.Description != "" { + params.Description = &preview.Description + } + if preview.SiteName != "" { + params.SiteName = &preview.SiteName + } + if preview.ImageURL != "" { + params.ImageURL = &preview.ImageURL + } + if _, err := s.chat.AddAttachment(ctx, params); err != nil { + s.logger.Warn("chat: attach link preview failed", zap.Error(err)) + } +} + +// urlInEntities reports whether the parser found this URL in the body. +func urlInEntities(target string, entities []Entity) bool { + for _, e := range entities { + if e.Type == EntityURL && e.URL == target { + return true + } + } + return false +} + +// linkCacheTTL keeps a fetched preview around long enough that the composer's +// lookup and the send that follows it don't fetch the same page twice. +const linkCacheTTL = 30 * time.Minute + +// LinkPreview fetches metadata for a URL the user is about to send. +// +// Cached by URL because the composer asks as soon as a link is typed and the +// send asks again moments later; without it every message with a link would +// fetch the remote page twice. +func (s *ChatService) LinkPreview(ctx context.Context, rawURL string) (*unfurl.Preview, error) { + key := "chat:unfurl:" + rawURL + + if s.cache != nil { + if cached, err := s.cache.Client.Get(ctx, key).Bytes(); err == nil { + var p unfurl.Preview + if json.Unmarshal(cached, &p) == nil { + return &p, nil + } + } + } + + p, err := s.links.Fetch(ctx, rawURL) + if err != nil { + // A blocked or unreadable link isn't an error worth showing in detail: + // the composer simply offers no card. + return nil, ErrChatNoPreview + } + + if s.cache != nil { + if encoded, err := json.Marshal(p); err == nil { + _ = s.cache.Client.Set(ctx, key, encoded, linkCacheTTL).Err() + } + } + return p, nil +} + +// PresignAttachment issues a one-shot upload URL into the PRIVATE bucket. +// +// Keys are namespaced by conversation and uploader, which is what lets the +// send path verify that a key being attached was uploaded by this caller for +// this thread rather than guessed. +func (s *ChatService) PresignAttachment(ctx context.Context, userID, conversationID, contentType string) (uploadURL, key string, err error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return "", "", err + } + if _, ok := attachmentKindFor(contentType); !ok { + return "", "", ErrChatBadMedia + } + if s.r2 == nil || !s.r2.PrivateConfigured() { + return "", "", ErrChatMediaOff + } + + suffix := make([]byte, 12) + if _, err := rand.Read(suffix); err != nil { + return "", "", err + } + key = path.Join("chat", conversationID, userID, hex.EncodeToString(suffix)) + uploadURL, err = s.r2.PresignPutPrivate(ctx, key, contentType, 5*time.Minute) + if err != nil { + return "", "", err + } + return uploadURL, key, nil +} + +// AttachmentURL mints a short-lived read URL, but only for a participant. +// +// The client holds a stable /chat/attachments/{id} URL and this re-checks +// membership on every fetch — so access revoked after the link was shared +// stops working, which a long-lived signed URL in the payload could not do. +func (s *ChatService) AttachmentURL(ctx context.Context, userID, attachmentID string) (string, error) { + conversationID, key, err := s.chat.AttachmentLocation(ctx, attachmentID) + if err != nil { + return "", err + } + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return "", err + } + if s.r2 == nil || !s.r2.PrivateConfigured() { + return "", ErrChatMediaOff + } + return s.r2.PresignGet(ctx, key, 5*time.Minute) +} + +// attachmentKindFor maps a MIME type onto an attachment kind, and reports +// whether we accept it at all. +func attachmentKindFor(mime string) (string, bool) { + switch { + case strings.HasPrefix(mime, "image/"): + return model.AttachmentImage, true + case strings.HasPrefix(mime, "video/"): + return model.AttachmentVideo, true + case strings.HasPrefix(mime, "audio/"): + return model.AttachmentAudio, true + case mime == "application/pdf": + return model.AttachmentFile, true + default: + return "", false + } +} + +// DeleteScope selects which kind of delete the caller asked for. +const ( + DeleteForMe = "me" + DeleteForEveryone = "everyone" + reportContextSpan = 3 // messages either side captured with a report + maxReportReasonLen = 2000 +) + +// DeleteMessage removes a message for the caller, or for everyone. +// +// "For everyone" is the sender's alone: letting a recipient erase someone +// else's words for all participants would be a moderation power, not a chat +// feature. Anyone in the thread may hide a message for themselves. +func (s *ChatService) DeleteMessage(ctx context.Context, userID, messageID, scope string) error { + conversationID, senderID, err := s.chat.MessageConversation(ctx, messageID) + if err != nil { + return err + } + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + + if scope == DeleteForEveryone { + if senderID == nil || *senderID != userID { + return ErrChatNotSender + } + if err := s.chat.DeleteMessage(ctx, messageID, userID); err != nil { + return err + } + // Tell the open thread so the bubble is replaced in place rather than + // lingering until a reload. + if s.rt != nil { + if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "message_deleted", + map[string]any{"message_id": messageID}); err != nil { + s.logger.Debug("chat: delete publish failed", zap.Error(err)) + } + } + return nil + } + + return s.chat.HideMessage(ctx, messageID, userID) +} + +// ForwardMessage copies a message into another thread. +// +// Two memberships are checked, not one: the caller must be able to SEE the +// source and to POST to the destination. Checking only the destination would +// turn a message id into a way to copy text out of a thread you were never in. +func (s *ChatService) ForwardMessage(ctx context.Context, userID, messageID, toConversationID string) (*model.Message, error) { + sourceID, _, err := s.chat.MessageConversation(ctx, messageID) + if err != nil { + return nil, err + } + if _, err := s.chat.Participant(ctx, sourceID, userID); err != nil { + return nil, err + } + if _, err := s.chat.Participant(ctx, toConversationID, userID); err != nil { + return nil, err + } + if err := s.assertCanPost(ctx, userID, toConversationID); err != nil { + return nil, err + } + + original, err := s.chat.MessageByID(ctx, messageID) + if err != nil { + return nil, err + } + if original.DeletedAt != nil { + return nil, ErrMessageDeleted + } + + msg, created, err := s.chat.Append(ctx, repository.AppendParams{ + ConversationID: toConversationID, + SenderID: &userID, + Kind: model.MessageText, + Body: original.Body, + Entities: original.Entities, + ForwardedFromID: &messageID, + }) + if err != nil { + return nil, err + } + if created { + full, hErr := s.withAttachments(ctx, msg) + if hErr == nil { + msg = full + } + s.publish(ctx, toConversationID, userID, msg) + } + return msg, nil +} + +// ReportMessage files a report, copying the message and a little context into +// the report itself. +// +// The snapshot is the point: moderators read a report record and never need +// read access to live conversations, so a report cannot become a way to browse +// someone's private threads. +func (s *ChatService) ReportMessage(ctx context.Context, userID, messageID, category, reason string) error { + conversationID, senderID, err := s.chat.MessageConversation(ctx, messageID) + if err != nil { + return err + } + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + category = strings.TrimSpace(category) + if category == "" { + return ErrChatBadReport + } + if len(reason) > maxReportReasonLen { + reason = reason[:maxReportReasonLen] + } + + context, err := s.chat.MessageWithContext(ctx, messageID, reportContextSpan) + if err != nil { + return err + } + snapshot, err := json.Marshal(map[string]any{ + "reported_message_id": messageID, + "conversation_id": conversationID, + "captured_at": time.Now().UTC(), + "messages": context, + }) + if err != nil { + return fmt.Errorf("encode report snapshot: %w", err) + } + return s.chat.ReportMessage(ctx, userID, messageID, conversationID, senderID, category, reason, snapshot) +} + +// isAdmin reports whether a role may administer a group. +func isAdmin(role string) bool { + return role == model.RoleOwner || role == model.RoleAdmin +} + +// requireGroupAdmin loads the caller's membership and insists they can +// administer this group. +func (s *ChatService) requireGroupAdmin(ctx context.Context, userID, conversationID string) (*model.Conversation, *model.Participant, error) { + me, err := s.chat.Participant(ctx, conversationID, userID) + if err != nil { + return nil, nil, err + } + conv, err := s.chat.Conversation(ctx, conversationID) + if err != nil { + return nil, nil, err + } + if conv.Kind != model.ConversationGroup { + return nil, nil, ErrChatNotGroup + } + if !isAdmin(me.Role) { + return nil, nil, ErrChatNotAdmin + } + return conv, me, nil +} + +// systemMessage records a membership change in the thread itself, so the +// history explains how the group came to look the way it does. +func (s *ChatService) systemMessage(ctx context.Context, conversationID, body string) { + msg, created, err := s.chat.Append(ctx, repository.AppendParams{ + ConversationID: conversationID, + Kind: model.MessageSystem, + Body: body, + }) + if err != nil { + s.logger.Warn("chat: system message failed", zap.Error(err)) + return + } + if created { + s.publish(ctx, conversationID, "", msg) + } +} + +// AddMembers adds people to a group. +// +// Blocked pairs are skipped rather than failing the whole call — one awkward +// relationship shouldn't stop the other five invitations. +func (s *ChatService) AddMembers(ctx context.Context, userID, conversationID string, memberHandles []string) error { + if _, _, err := s.requireGroupAdmin(ctx, userID, conversationID); err != nil { + return err + } + memberIDs, err := s.chat.UserIDsByHandles(ctx, memberHandles) + if err != nil { + return err + } + + allowed := make([]string, 0, len(memberIDs)) + for _, id := range memberIDs { + if id == userID { + continue + } + blocked, blockedBy, err := s.blocks.BlockState(ctx, userID, id) + if err != nil { + return fmt.Errorf("block state: %w", err) + } + if blocked || blockedBy { + continue + } + allowed = append(allowed, id) + } + if len(allowed) == 0 { + return nil + } + if err := s.chat.AddParticipants(ctx, conversationID, allowed); err != nil { + return err + } + + names, err := s.chat.Participants(ctx, conversationID) + if err == nil { + s.systemMessage(ctx, conversationID, addedNames(names, allowed)) + } + return nil +} + +func addedNames(people []model.ChatPeer, added []string) string { + byID := map[string]string{} + for _, p := range people { + byID[p.ID] = p.Name + } + labels := make([]string, 0, len(added)) + for _, id := range added { + if n, ok := byID[id]; ok { + labels = append(labels, n) + } + } + if len(labels) == 0 { + return "Members were added" + } + return strings.Join(labels, ", ") + " joined the group" +} + +// RemoveMember removes someone from a group, or lets them leave. +// +// Anyone may remove themselves. Removing someone else needs admin rights, and +// an admin cannot remove another admin or the owner — otherwise a single +// promoted member could empty the group's leadership. +func (s *ChatService) RemoveMember(ctx context.Context, userID, conversationID, targetHandle string) error { + targetIDs, err := s.chat.UserIDsByHandles(ctx, []string{targetHandle}) + if err != nil { + return err + } + if len(targetIDs) == 0 { + return repository.ErrNotParticipant + } + targetID := targetIDs[0] + leaving := userID == targetID + + me, err := s.chat.Participant(ctx, conversationID, userID) + if err != nil { + return err + } + conv, err := s.chat.Conversation(ctx, conversationID) + if err != nil { + return err + } + if conv.Kind != model.ConversationGroup { + return ErrChatNotGroup + } + + if !leaving { + if !isAdmin(me.Role) { + return ErrChatNotAdmin + } + target, err := s.chat.Participant(ctx, conversationID, targetID) + if err != nil { + return err + } + if target.Role == model.RoleOwner { + return ErrChatOwnerImmutable + } + if target.Role == model.RoleAdmin && me.Role != model.RoleOwner { + return ErrChatNotAdmin + } + } + + // A departing owner hands the group on, so it is never left unadministered. + if leaving && me.Role == model.RoleOwner { + successor, err := s.chat.SuccessorOwner(ctx, conversationID, userID) + if err != nil { + return err + } + if successor != "" { + if err := s.chat.SetRole(ctx, conversationID, successor, model.RoleOwner); err != nil { + return err + } + } + } + + if err := s.chat.RemoveParticipant(ctx, conversationID, targetID); err != nil { + return err + } + + name := targetID + if people, err := s.chat.Participants(ctx, conversationID); err == nil { + for _, p := range people { + if p.ID == targetID { + name = p.Name + } + } + } + if leaving { + s.systemMessage(ctx, conversationID, "A member left the group") + } else { + s.systemMessage(ctx, conversationID, name+" was removed from the group") + } + return nil +} + +// SetMemberRole promotes a member to admin or demotes an admin. +// +// Only the owner may change roles: an admin promoting other admins would let +// one compromised account escalate the whole group. +func (s *ChatService) SetMemberRole(ctx context.Context, userID, conversationID, targetHandle, role string) error { + if role != model.RoleAdmin && role != model.RoleMember { + return ErrChatBadRole + } + targetIDs, err := s.chat.UserIDsByHandles(ctx, []string{targetHandle}) + if err != nil { + return err + } + if len(targetIDs) == 0 { + return repository.ErrNotParticipant + } + targetID := targetIDs[0] + _, me, adminErr := s.requireGroupAdmin(ctx, userID, conversationID) + if adminErr != nil { + return adminErr + } + if me.Role != model.RoleOwner { + return ErrChatNotAdmin + } + if targetID == userID { + return ErrChatOwnerImmutable + } + target, err := s.chat.Participant(ctx, conversationID, targetID) + if err != nil { + return err + } + if target.Role == model.RoleOwner { + return ErrChatOwnerImmutable + } + if err := s.chat.SetRole(ctx, conversationID, targetID, role); err != nil { + return err + } + + name := targetID + if people, err := s.chat.Participants(ctx, conversationID); err == nil { + for _, p := range people { + if p.ID == targetID { + name = p.Name + } + } + } + if role == model.RoleAdmin { + s.systemMessage(ctx, conversationID, name+" is now an admin") + } else { + s.systemMessage(ctx, conversationID, name+" is no longer an admin") + } + return nil +} + +// groupAvatarPrefix namespaces a group's avatar objects. +func groupAvatarPrefix(conversationID string) string { + return path.Join("chat", conversationID, "avatar") + "/" +} + +// PresignGroupAvatar issues an upload URL for a new group picture. +// +// The key is namespaced under this conversation's avatar prefix, which is what +// lets UpdateGroup verify the key being set was uploaded for THIS group. +func (s *ChatService) PresignGroupAvatar(ctx context.Context, userID, conversationID, contentType string) (uploadURL, key string, err error) { + if _, _, err := s.requireGroupAdmin(ctx, userID, conversationID); err != nil { + return "", "", err + } + if kind, ok := attachmentKindFor(contentType); !ok || kind != model.AttachmentImage { + return "", "", ErrChatBadMedia + } + if s.r2 == nil || !s.r2.PrivateConfigured() { + return "", "", ErrChatMediaOff + } + + suffix := make([]byte, 12) + if _, err := rand.Read(suffix); err != nil { + return "", "", err + } + key = groupAvatarPrefix(conversationID) + hex.EncodeToString(suffix) + uploadURL, err = s.r2.PresignPutPrivate(ctx, key, contentType, 5*time.Minute) + if err != nil { + return "", "", err + } + return uploadURL, key, nil +} + +// GroupAvatarURL mints a short-lived read URL for a group's picture. +// +// Members only: a group's photo is group content, so it lives in the private +// bucket behind the same membership check as everything else in the thread. +func (s *ChatService) GroupAvatarURL(ctx context.Context, userID, conversationID string) (string, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return "", err + } + conv, err := s.chat.Conversation(ctx, conversationID) + if err != nil { + return "", err + } + if conv.AvatarKey == nil || *conv.AvatarKey == "" { + return "", ErrAvatarUnset + } + if s.r2 == nil || !s.r2.PrivateConfigured() { + return "", ErrChatMediaOff + } + return s.r2.PresignGet(ctx, *conv.AvatarKey, 5*time.Minute) +} + +// UpdateGroup renames a group, changes who may post in it, or sets its photo. +func (s *ChatService) UpdateGroup(ctx context.Context, userID, conversationID string, title, sendPolicy, avatarKey *string) error { + if _, _, err := s.requireGroupAdmin(ctx, userID, conversationID); err != nil { + return err + } + if sendPolicy != nil && *sendPolicy != model.SendPolicyEveryone && *sendPolicy != model.SendPolicyAdmins { + return ErrChatBadPolicy + } + if title != nil { + trimmed := strings.TrimSpace(*title) + if trimmed == "" { + return errors.New("group title is required") + } + title = &trimmed + } + if avatarKey != nil { + // The key must sit under THIS group's avatar prefix. Without the check + // an admin could point avatar_key at another thread's media, and every + // member here would be served a file from a conversation they are not + // in. + if !strings.HasPrefix(*avatarKey, groupAvatarPrefix(conversationID)) { + return ErrChatBadAvatarKey + } + } + if err := s.chat.UpdateGroup(ctx, conversationID, title, sendPolicy, avatarKey); err != nil { + return err + } + if avatarKey != nil { + s.systemMessage(ctx, conversationID, "The group photo was updated") + } + if sendPolicy != nil { + if *sendPolicy == model.SendPolicyAdmins { + s.systemMessage(ctx, conversationID, "Only admins can send messages now") + } else { + s.systemMessage(ctx, conversationID, "Everyone can send messages now") + } + } + return nil +} + +// assertCanPost applies the per-kind posting rules. +func (s *ChatService) assertCanPost(ctx context.Context, userID, conversationID string) error { + conv, err := s.chat.Conversation(ctx, conversationID) + if err != nil { + return err + } + + switch conv.Kind { + case model.ConversationAI: + // Kautilya is still coming soon: the thread and its tab exist so the + // plumbing is in place, but nothing consumes messages yet, so refuse + // rather than silently swallow them. + return ErrChatReadOnly + + case model.ConversationDM: + // Either direction of a block closes a DM. A one-way channel — where + // the blocker can still be written to — would be worse than refusing. + ids, err := s.chat.ParticipantIDs(ctx, conversationID) + if err != nil { + return err + } + for _, id := range ids { + if id == userID { + continue + } + blocked, blockedBy, err := s.blocks.BlockState(ctx, userID, id) + if err != nil { + return fmt.Errorf("block state: %w", err) + } + if blocked || blockedBy { + return ErrChatBlocked + } + } + return nil + + default: + // Groups: blocks are enforced when members are added, not on every + // send — one blocked pair shouldn't silence the whole room. What does + // gate a send is the group's own policy. + if conv.SendPolicy == model.SendPolicyAdmins { + me, err := s.chat.Participant(ctx, conversationID, userID) + if err != nil { + return err + } + if !isAdmin(me.Role) { + return ErrChatAdminsOnly + } + } + return nil + } +} + +// MarkRead advances the caller's read cursor and tells the other participants, +// so their ticks turn blue without polling. +func (s *ChatService) MarkRead(ctx context.Context, userID, conversationID string, seq int64) error { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + // The cursor still advances — it is what clears the caller's own unread + // count. What changes is whether anyone else is told. + if err := s.chat.MarkRead(ctx, conversationID, userID, seq); err != nil { + return err + } + mine, err := s.chat.Settings(ctx, userID) + if err != nil { + return err + } + if mine.ReadReceipts { + s.publishReceipt(ctx, conversationID, userID, "read", seq) + } + return nil +} + +// MarkDelivered advances the delivery cursor only. +func (s *ChatService) MarkDelivered(ctx context.Context, userID, conversationID string, seq int64) error { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return err + } + if err := s.chat.MarkDelivered(ctx, conversationID, userID, seq); err != nil { + return err + } + s.publishReceipt(ctx, conversationID, userID, "delivered", seq) + return nil +} + +// MuteDuration values accepted by SetMute. +const ( + MuteEightHours = "8h" + MuteOneWeek = "1w" + MuteAlways = "always" + MuteOff = "off" +) + +// muteForever is the timestamp stored for an indefinite mute. A sentinel date +// rather than a nullable-plus-flag pair keeps every read a single comparison +// (muted_until > now), and Postgres `infinity` doesn't scan cleanly into +// time.Time. +var muteForever = time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC) + +// SetMute silences a thread for the caller alone. +// +// Muting suppresses alerts, not delivery: unread counts keep accruing and the +// thread keeps updating live, which is what users expect from a muted chat. +func (s *ChatService) SetMute(ctx context.Context, userID, conversationID, duration string) (*time.Time, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return nil, err + } + + var until *time.Time + switch duration { + case MuteOff: + until = nil + case MuteEightHours: + t := time.Now().Add(8 * time.Hour) + until = &t + case MuteOneWeek: + t := time.Now().Add(7 * 24 * time.Hour) + until = &t + case MuteAlways: + t := muteForever + until = &t + default: + return nil, ErrChatBadMuteDuration + } + + if err := s.chat.SetMute(ctx, conversationID, userID, until); err != nil { + return nil, err + } + return until, nil +} + +// IsMuted reports whether the caller currently has the thread muted, expiring +// a lapsed mute without needing a sweep job. +func (s *ChatService) IsMuted(ctx context.Context, userID, conversationID string) (bool, error) { + p, err := s.chat.Participant(ctx, conversationID, userID) + if err != nil { + return false, err + } + return p.MutedUntil != nil && p.MutedUntil.After(time.Now()), nil +} + +// Receipts reports how far EVERY other participant has got, which is what +// draws the caller's tick states. +// +// Read receipts are reciprocal, as they are everywhere else this feature +// exists: someone who switches them off stops broadcasting their own reads AND +// stops seeing other people's. A one-way version would let a user watch others +// while staying invisible. Delivery ticks are unaffected — they say the device +// received the message, not that a person read it. +func (s *ChatService) Receipts(ctx context.Context, userID, conversationID string) (model.Receipts, error) { + if _, err := s.chat.Participant(ctx, conversationID, userID); err != nil { + return model.Receipts{}, err + } + receipts, err := s.chat.Receipts(ctx, conversationID, userID) + if err != nil { + return model.Receipts{}, err + } + + mine, err := s.chat.Settings(ctx, userID) + if err != nil { + return model.Receipts{}, err + } + if !mine.ReadReceipts { + receipts.ReadSeq = 0 + return receipts, nil + } + + // A read cursor only counts if that participant shares theirs. If any of + // them doesn't, the message can never be "read by everyone", which is what + // the blue tick means here. + others, err := s.chat.ParticipantIDs(ctx, conversationID) + if err != nil { + return model.Receipts{}, err + } + enabled, err := s.chat.ReadReceiptsEnabled(ctx, others) + if err != nil { + return model.Receipts{}, err + } + for _, id := range others { + if id != userID && !enabled[id] { + receipts.ReadSeq = 0 + break + } + } + return receipts, nil +} + +// publish fans a new message out to the open thread, and separately nudges each +// other participant's inbox. +func (s *ChatService) publish(ctx context.Context, conversationID, senderID string, msg *model.Message) { + if s.rt == nil { + return + } + if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "message", msg); err != nil { + s.logger.Warn("chat: publish message failed", + zap.String("conversation_id", conversationID), zap.Error(err)) + } + + ids, err := s.chat.ParticipantIDs(ctx, conversationID) + if err != nil { + s.logger.Warn("chat: participant fan-out failed", zap.Error(err)) + return + } + for _, id := range ids { + if id == senderID { + continue + } + // Deliberately NOT TriggerUser: that also publishes to the legacy + // PUBLIC notifications channel, which would expose who is messaging + // whom to anyone subscribed to it. Chat only ever uses the private one. + // + // Deliberately NOT the notification service either: chat activity + // highlights the thread in the inbox and must not create a + // notifications row or appear in the tray. + if err := s.rt.Trigger(ctx, realtime.UserChannel(id), "chat_activity", map[string]any{ + "conversation_id": conversationID, + "seq": msg.Seq, + "sender_id": senderID, + }); err != nil { + s.logger.Debug("chat: activity nudge failed", zap.String("user_id", id), zap.Error(err)) + } + } +} + +func (s *ChatService) publishReceipt(ctx context.Context, conversationID, userID, kind string, seq int64) { + if s.rt == nil { + return + } + if err := s.rt.Trigger(ctx, ConversationChannel(conversationID), "receipt", map[string]any{ + "user_id": userID, "kind": kind, "seq": seq, + }); err != nil { + s.logger.Debug("chat: receipt publish failed", zap.Error(err)) + } +} diff --git a/internal/service/chat_service_test.go b/internal/service/chat_service_test.go new file mode 100644 index 0000000..cf0410d --- /dev/null +++ b/internal/service/chat_service_test.go @@ -0,0 +1,1721 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/joho/godotenv" + "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "go.uber.org/zap" +) + +// The service's rules — blocks, Kautilya being read-only, entities being parsed +// server-side, a retry not re-broadcasting — are all statements about real rows, +// so they're exercised against a real database. Skipped without POSTGRES_URI. +func testChatService(t *testing.T) (*ChatService, *repository.ChatRepo, *repository.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 service integration tests") + } + pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop()) + if err != nil { + t.Skipf("postgres unreachable: %v", err) + } + t.Cleanup(pg.Close) + chat := repository.NewChatRepo(pg) + // Nil realtime/R2/cache clients make publishing, uploads and caching + // no-ops — these tests are about the rules, and none of those services are + // reachable from a test run. + return NewChatService(chat, repository.NewBlockRepo(pg), nil, nil, nil, zap.NewNop()), chat, pg +} + +// chatUserWithHandle returns both identifiers: ids for authoring, handles for +// the member-management API. +func chatUserWithHandle(t *testing.T, pg *repository.Postgres, label string) (id, handle string) { + t.Helper() + id = uuid.NewString() + suffix := id[:8] + handle = "chatsvc_" + suffix + if _, err := pg.Pool.Exec(context.Background(), ` + INSERT INTO users (id, email, password_hash, name, handle) + VALUES ($1, $2, 'x', $3, $4)`, + id, "chatsvc-"+suffix+"@example.invalid", "Chat Svc "+label, handle); err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id) + }) + return id, handle +} + +func chatUser(t *testing.T, pg *repository.Postgres, label string) string { + t.Helper() + id, _ := chatUserWithHandle(t, pg, label) + return id +} + +// handleOf looks up a test user's handle for the member-management calls. +func handleOf(t *testing.T, pg *repository.Postgres, userID string) string { + t.Helper() + var h string + if err := pg.Pool.QueryRow(context.Background(), + `SELECT handle FROM users WHERE id = $1`, userID).Scan(&h); err != nil { + t.Fatalf("handle for %s: %v", userID, err) + } + return h +} + +func cleanupConv(t *testing.T, pg *repository.Postgres, id string) { + t.Cleanup(func() { + _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id) + }) +} + +// Blocking must close a DM in BOTH directions. A one-way channel — where the +// person who blocked can still be written to — would be worse than refusing. +func TestOpenDM_BlockedBothDirections(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + blocks := repository.NewBlockRepo(pg) + + t.Run("blocked by peer", func(t *testing.T) { + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + if err := blocks.Block(ctx, b, a); err != nil { + t.Fatal(err) + } + if _, err := svc.OpenDM(ctx, a, b); !errors.Is(err, ErrChatBlocked) { + t.Errorf("err = %v, want ErrChatBlocked", err) + } + }) + + t.Run("caller blocked the peer", func(t *testing.T) { + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + if err := blocks.Block(ctx, a, b); err != nil { + t.Fatal(err) + } + if _, err := svc.OpenDM(ctx, a, b); !errors.Is(err, ErrChatBlocked) { + t.Errorf("err = %v, want ErrChatBlocked", err) + } + }) +} + +// A block placed after the thread exists must stop new messages too — checking +// only at thread creation would leave an open channel behind. +func TestSend_BlockedAfterThreadExists(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "before"}); err != nil { + t.Fatalf("send before block: %v", err) + } + if err := repository.NewBlockRepo(pg).Block(ctx, b, a); err != nil { + t.Fatal(err) + } + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "after"}); !errors.Is(err, ErrChatBlocked) { + t.Errorf("send after block: err = %v, want ErrChatBlocked", err) + } +} + +func TestSend_RejectsOutsidersAndEmptyBodies(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + if _, err := svc.Send(ctx, outsider, conv.ID, SendRequest{Body: "hi"}); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider send: err = %v, want ErrNotParticipant", err) + } + if _, err := svc.Messages(ctx, outsider, conv.ID, 0, 10); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider read: err = %v, want ErrNotParticipant", err) + } + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: " "}); !errors.Is(err, ErrChatEmptyBody) { + t.Errorf("whitespace-only send: err = %v, want ErrChatEmptyBody", err) + } +} + +// Kautilya's tab and plumbing exist, but nothing consumes messages yet, so the +// thread must refuse rather than silently swallow them. +func TestSend_KautilyaIsReadOnly(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a := chatUser(t, pg, "A") + + conv, err := svc.OpenAIThread(ctx, a) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "who is Kṛṣṇa?"}); !errors.Is(err, ErrChatReadOnly) { + t.Errorf("err = %v, want ErrChatReadOnly", err) + } +} + +// Entities are derived server-side. A client that sends markup gets it parsed; +// it cannot dictate how its text is decorated in someone else's thread. +func TestSend_ParsesEntitiesServerSide(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "read *this* at https://prabhupadasays.org"}) + if err != nil { + t.Fatal(err) + } + if msg.Body != "read this at https://prabhupadasays.org" { + t.Errorf("stored body = %q — markers should be stripped", msg.Body) + } + var spans []Entity + if err := json.Unmarshal(msg.Entities, &spans); err != nil { + t.Fatalf("entities: %v (%s)", err, msg.Entities) + } + kinds := map[string]bool{} + for _, e := range spans { + kinds[e.Type] = true + } + if !kinds[EntityBold] || !kinds[EntityURL] { + t.Errorf("entities = %+v, want a bold span and a url span", spans) + } +} + +// A retried send must return the stored message, not a second bubble. +func TestSend_RetryIsIdempotent(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + id := uuid.NewString() + req := SendRequest{Body: "only once", ClientMsgID: &id} + first, err := svc.Send(ctx, a, conv.ID, req) + if err != nil { + t.Fatal(err) + } + again, err := svc.Send(ctx, a, conv.ID, req) + if err != nil { + t.Fatal(err) + } + if again.ID != first.ID { + t.Errorf("retry created a second message: %s vs %s", again.ID, first.ID) + } + msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10) + if len(msgs) != 1 { + t.Errorf("thread has %d messages after a retry, want 1", len(msgs)) + } +} + +func TestIsParticipant_DistinguishesDenialFromFailure(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + ok, err := svc.IsParticipant(ctx, a, conv.ID) + if err != nil || !ok { + t.Errorf("member: (%v, %v), want (true, nil)", ok, err) + } + // Not a member is a plain denial, not an error — otherwise the channel + // rule would return 500 instead of 403 for every outsider. + ok, err = svc.IsParticipant(ctx, outsider, conv.ID) + if err != nil || ok { + t.Errorf("outsider: (%v, %v), want (false, nil)", ok, err) + } +} + +func TestKindsForTab(t *testing.T) { + if got := KindsForTab("kautilya"); len(got) != 1 || got[0] != model.ConversationAI { + t.Errorf("kautilya tab = %v", got) + } + for _, tab := range []string{"chats", ""} { + got := KindsForTab(tab) + if len(got) != 2 || got[0] != model.ConversationDM || got[1] != model.ConversationGroup { + t.Errorf("tab %q = %v, want dm+group", tab, got) + } + } +} + +// Muting silences alerts for one participant without touching delivery: the +// other member is unaffected, and unread keeps climbing for the muter. +func TestSetMute(t *testing.T) { + svc, chat, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + if muted, _ := svc.IsMuted(ctx, b, conv.ID); muted { + t.Error("thread starts muted") + } + + until, err := svc.SetMute(ctx, b, conv.ID, MuteEightHours) + if err != nil { + t.Fatal(err) + } + if until == nil || !until.After(time.Now()) { + t.Fatalf("muted_until = %v, want a future time", until) + } + if muted, _ := svc.IsMuted(ctx, b, conv.ID); !muted { + t.Error("not muted after an 8h mute") + } + // Mute is per participant: A must be unaffected by B muting. + if muted, _ := svc.IsMuted(ctx, a, conv.ID); muted { + t.Error("muting for B also muted A") + } + + // Delivery continues while muted — unread still accrues. + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "still arrives"}); err != nil { + t.Fatal(err) + } + inbox, err := svc.Inbox(ctx, b, "chats", 10) + if err != nil { + t.Fatal(err) + } + var row *model.InboxItem + for i := range inbox { + if inbox[i].ID == conv.ID { + row = &inbox[i] + } + } + if row == nil { + t.Fatal("muted thread vanished from the inbox") + } + if row.Unread == 0 { + t.Error("muted thread stopped counting unread — mute silences alerts, not delivery") + } + if row.MutedUntil == nil { + t.Error("inbox row does not report muted_until, so the client can't show the muted icon") + } + + if _, err := svc.SetMute(ctx, b, conv.ID, MuteOff); err != nil { + t.Fatal(err) + } + if muted, _ := svc.IsMuted(ctx, b, conv.ID); muted { + t.Error("still muted after unmuting") + } + p, _ := chat.Participant(ctx, conv.ID, b) + if p.MutedUntil != nil { + t.Errorf("muted_until = %v after unmute, want NULL", p.MutedUntil) + } +} + +// A mute whose deadline has passed reads as unmuted, so no sweep job is needed. +func TestIsMuted_ExpiredMuteReadsAsUnmuted(t *testing.T) { + svc, chat, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + past := time.Now().Add(-time.Hour) + if err := chat.SetMute(ctx, conv.ID, b, &past); err != nil { + t.Fatal(err) + } + if muted, err := svc.IsMuted(ctx, b, conv.ID); err != nil || muted { + t.Errorf("expired mute: muted = %v, err = %v — want false", muted, err) + } +} + +func TestSetMute_RejectsUnknownDurationAndOutsiders(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + if _, err := svc.SetMute(ctx, a, conv.ID, "forever-and-ever"); !errors.Is(err, ErrChatBadMuteDuration) { + t.Errorf("bad duration: err = %v, want ErrChatBadMuteDuration", err) + } + if _, err := svc.SetMute(ctx, outsider, conv.ID, MuteAlways); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider mute: err = %v, want ErrNotParticipant", err) + } +} + +// "Always" must not silently expire — it is stored far enough out that no +// realistic clock reaches it. +func TestSetMute_AlwaysDoesNotExpire(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + until, err := svc.SetMute(ctx, a, conv.ID, MuteAlways) + if err != nil { + t.Fatal(err) + } + if until.Year() < 9000 { + t.Errorf("always-mute expires in %d", until.Year()) + } + if muted, _ := svc.IsMuted(ctx, a, conv.ID); !muted { + t.Error("always-mute does not read as muted") + } +} + +// Replying must be confined to the thread you are in. The foreign key alone +// only proves the target exists somewhere, so without the write-side check a +// client could quote a conversation it was never part of — and the reply +// preview would then render that text to everyone here. +func TestSend_ReplyTargetMustBeInSameConversation(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + + here, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, here.ID) + elsewhere, err := svc.OpenDM(ctx, a, c) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, elsewhere.ID) + + secret, err := svc.Send(ctx, a, elsewhere.ID, SendRequest{Body: "private to C"}) + if err != nil { + t.Fatal(err) + } + + _, err = svc.Send(ctx, a, here.ID, SendRequest{Body: "quoting", ReplyToID: &secret.ID}) + if !errors.Is(err, repository.ErrReplyTargetNotHere) { + t.Fatalf("cross-thread reply: err = %v, want ErrReplyTargetNotHere", err) + } + + // And nothing was persisted by the rejected attempt. + msgs, _ := svc.Messages(ctx, a, here.ID, 0, 10) + if len(msgs) != 0 { + t.Errorf("rejected reply still wrote %d message(s)", len(msgs)) + } +} + +// A reply within the thread is hydrated for rendering: the bubble needs the +// quoted sender and a line of text. +func TestSend_ReplyIsHydratedOnRead(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + original, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the original"}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Send(ctx, b, conv.ID, SendRequest{Body: "answering", ReplyToID: &original.ID}); err != nil { + t.Fatal(err) + } + + msgs, err := svc.Messages(ctx, a, conv.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + // Newest first. + reply := msgs[0] + if reply.ReplyTo == nil { + t.Fatal("reply has no hydrated preview") + } + if reply.ReplyTo.ID != original.ID || reply.ReplyTo.Body != "the original" { + t.Errorf("preview = %+v, want the original message", reply.ReplyTo) + } + if reply.ReplyTo.SenderName == "" { + t.Error("preview has no sender name — the quote line needs it") + } + // A plain message carries no preview. + if msgs[1].ReplyTo != nil { + t.Errorf("non-reply carries a preview: %+v", msgs[1].ReplyTo) + } +} + +// The thread screen needs participants by name in one request — previously it +// scanned both inbox tabs to find a single conversation. +func TestDetail(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "hello"}); err != nil { + t.Fatal(err) + } + + d, err := svc.Detail(ctx, b, conv.ID) + if err != nil { + t.Fatal(err) + } + if len(d.Participants) != 2 { + t.Errorf("participants = %d, want 2", len(d.Participants)) + } + for _, p := range d.Participants { + if p.Name == "" || p.Handle == "" { + t.Errorf("participant %+v is missing a name/handle — the reply banner needs them", p) + } + } + if d.Peer == nil || d.Peer.ID != a { + t.Errorf("peer = %+v, want user A", d.Peer) + } + if d.Unread != 1 { + t.Errorf("unread = %d, want 1", d.Unread) + } + + // Same 404-shaped denial as every other chat read. + if _, err := svc.Detail(ctx, outsider, conv.ID); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider: err = %v, want ErrNotParticipant", err) + } +} + +// A group's detail carries every member and no peer. +func TestDetail_GroupHasNoPeer(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.CreateGroup(ctx, a, "Kīrtana", []string{handleOf(t, pg, b), handleOf(t, pg, c)}) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + d, err := svc.Detail(ctx, a, conv.ID) + if err != nil { + t.Fatal(err) + } + if d.Peer != nil { + t.Errorf("group has a peer: %+v", d.Peer) + } + if len(d.Participants) != 3 { + t.Errorf("participants = %d, want 3", len(d.Participants)) + } +} + +// A link card may only be attached for a URL the parser actually found in the +// body. Without this a message could carry a card for a link it never mentions +// — the text says one thing, the card says another. +func TestSend_PreviewURLMustAppearInBody(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + elsewhere := "https://example.org/not-mentioned" + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Body: "just some text with no link", + PreviewURL: &elsewhere, + }) + if err != nil { + t.Fatal(err) + } + for _, att := range msg.Attachments { + if att.Kind == model.AttachmentLink { + t.Errorf("attached a card for a URL not in the body: %+v", att) + } + } +} + +// Media keys are namespaced by conversation and uploader. Attaching a key from +// outside the caller's own prefix must be refused, or a guessed key could pull +// a file from a thread the caller is not in. +func TestSend_RejectsForeignAttachmentKeys(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + foreign := []MediaAttachment{ + {Key: "chat/" + conv.ID + "/" + b + "/deadbeef", Mime: "image/png"}, // another user's prefix + {Key: "chat/some-other-conversation/" + a + "/deadbeef", Mime: "image/png"}, + {Key: "avatars/" + a + "/photo.png", Mime: "image/png"}, // outside chat entirely + {Key: "../../etc/passwd", Mime: "image/png"}, + } + for _, m := range foreign { + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Body: "look at this", Attachments: []MediaAttachment{m}, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if len(msg.Attachments) != 0 { + t.Errorf("key %q was attached; want it refused", m.Key) + } + } +} + +// Uploads require the private bucket. Falling back to the public one would put +// chat media at a URL anyone could fetch. +func TestPresignAttachment_RequiresPrivateBucket(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + // This service was built with a nil R2 (see testChatService). + if _, _, err := svc.PresignAttachment(ctx, a, conv.ID, "image/png"); !errors.Is(err, ErrChatMediaOff) { + t.Errorf("err = %v, want ErrChatMediaOff", err) + } + // Membership is still checked before anything else. + outsider := chatUser(t, pg, "C") + if _, _, err := svc.PresignAttachment(ctx, outsider, conv.ID, "image/png"); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider: err = %v, want ErrNotParticipant", err) + } +} + +func TestAttachmentKindFor(t *testing.T) { + cases := map[string]string{ + "image/png": model.AttachmentImage, + "image/webp": model.AttachmentImage, + "video/mp4": model.AttachmentVideo, + "audio/mpeg": model.AttachmentAudio, + "application/pdf": model.AttachmentFile, + } + for mime, want := range cases { + if got, ok := attachmentKindFor(mime); !ok || got != want { + t.Errorf("attachmentKindFor(%q) = (%q, %v), want %q", mime, got, ok, want) + } + } + // Anything executable or unknown is refused rather than stored as a file. + for _, mime := range []string{"text/html", "application/x-sh", "application/octet-stream", ""} { + if _, ok := attachmentKindFor(mime); ok { + t.Errorf("attachmentKindFor(%q) accepted it", mime) + } + } +} + +// Attachments always serialize as an array so clients need no nil check. +func TestMessages_AttachmentsAlwaysPresent(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "plain"}); err != nil { + t.Fatal(err) + } + + msgs, err := svc.Messages(ctx, a, conv.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + if msgs[0].Attachments == nil { + t.Error("attachments is nil, want an empty slice") + } + encoded, _ := json.Marshal(msgs[0].Attachments) + if string(encoded) != "[]" { + t.Errorf("serialized as %s, want []", encoded) + } +} + +// A photo with no caption is an ordinary message — only a message with neither +// text nor attachments is empty. +func TestSend_AttachmentWithoutCaptionIsAllowed(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + key := "chat/" + conv.ID + "/" + a + "/abc123" + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Body: "", + Attachments: []MediaAttachment{{Key: key, Mime: "image/jpeg"}}, + }) + if err != nil { + t.Fatalf("captionless attachment rejected: %v", err) + } + if len(msg.Attachments) != 1 || msg.Attachments[0].Kind != model.AttachmentImage { + t.Errorf("attachments = %+v, want one image", msg.Attachments) + } + if msg.Body != "" { + t.Errorf("body = %q, want empty", msg.Body) + } + + // Neither text nor attachments is still empty. + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: " "}); !errors.Is(err, ErrChatEmptyBody) { + t.Errorf("empty send: err = %v, want ErrChatEmptyBody", err) + } +} + +// Starring is scoped to the thread: a message id from another conversation +// must be refused, or a caller could star something they cannot see. +func TestSetStar_ScopedToConversation(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + + here, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, here.ID) + elsewhere, err := svc.OpenDM(ctx, a, c) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, elsewhere.ID) + + mine, err := svc.Send(ctx, a, here.ID, SendRequest{Body: "star me"}) + if err != nil { + t.Fatal(err) + } + other, err := svc.Send(ctx, a, elsewhere.ID, SendRequest{Body: "different thread"}) + if err != nil { + t.Fatal(err) + } + + if err := svc.SetStar(ctx, a, here.ID, mine.ID, true); err != nil { + t.Fatalf("star own thread's message: %v", err) + } + if err := svc.SetStar(ctx, a, here.ID, other.ID, true); !errors.Is(err, repository.ErrMessageNotHere) { + t.Errorf("cross-thread star: err = %v, want ErrMessageNotHere", err) + } + + starred, err := svc.Starred(ctx, a, here.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(starred) != 1 || starred[0].ID != mine.ID { + t.Errorf("starred = %d message(s), want just the one in this thread", len(starred)) + } + + // Stars are per user — B sees none of A's. + theirs, _ := svc.Starred(ctx, b, here.ID, 10) + if len(theirs) != 0 { + t.Errorf("other participant sees %d starred, want 0", len(theirs)) + } + + // Unstar is idempotent. + for i := 0; i < 2; i++ { + if err := svc.SetStar(ctx, a, here.ID, mine.ID, false); err != nil { + t.Fatalf("unstar: %v", err) + } + } + if after, _ := svc.Starred(ctx, a, here.ID, 10); len(after) != 0 { + t.Errorf("still starred after unstar: %d", len(after)) + } +} + +// The chat profile's tabs are a kind filter over the same gallery index. +func TestGallery_SplitsMediaFromLinks(t *testing.T) { + svc, chat, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Body: "with a photo", + Attachments: []MediaAttachment{{Key: "chat/" + conv.ID + "/" + a + "/pic", Mime: "image/jpeg"}}, + }) + if err != nil { + t.Fatal(err) + } + url := "https://example.org/x" + if _, err := chat.AddAttachment(ctx, repository.AddAttachmentParams{ + MessageID: msg.ID, ConversationID: conv.ID, Ord: 1, + Kind: model.AttachmentLink, URL: &url, + }); err != nil { + t.Fatal(err) + } + + media, err := svc.Gallery(ctx, a, conv.ID, "media", nil, 50) + if err != nil { + t.Fatal(err) + } + if len(media) != 1 || media[0].Kind != model.AttachmentImage { + t.Errorf("media tab = %+v, want one image", media) + } + + links, err := svc.Gallery(ctx, a, conv.ID, "links", nil, 50) + if err != nil { + t.Fatal(err) + } + if len(links) != 1 || links[0].Kind != model.AttachmentLink { + t.Errorf("links tab = %+v, want one link", links) + } + + outsider := chatUser(t, pg, "C") + if _, err := svc.Gallery(ctx, outsider, conv.ID, "media", nil, 50); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider gallery: err = %v, want ErrNotParticipant", err) + } +} + +// The profile line reads "friends since" off the accepted follow, in whichever +// direction it happened, and reports one-way follows honestly. +func TestDetail_Relationship(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + follows := repository.NewFollowRepo(pg) + + // No edges yet. + d, err := svc.Detail(ctx, a, conv.ID) + if err != nil { + t.Fatal(err) + } + if d.Relationship == nil { + t.Fatal("DM detail has no relationship") + } + if d.Relationship.Following || d.Relationship.FollowedBy || d.Relationship.FriendsSince != nil { + t.Errorf("relationship = %+v, want all-false with no date", d.Relationship) + } + + // A pending request is not a friendship. + if _, err := follows.Request(ctx, a, b); err != nil { + t.Fatal(err) + } + d, _ = svc.Detail(ctx, a, conv.ID) + if d.Relationship.Following || d.Relationship.FriendsSince != nil { + t.Errorf("pending request counted as a follow: %+v", d.Relationship) + } + + // Accepted: one-way. + if _, err := follows.Accept(ctx, a, b); err != nil { + t.Fatal(err) + } + d, _ = svc.Detail(ctx, a, conv.ID) + if !d.Relationship.Following || d.Relationship.FollowedBy { + t.Errorf("one-way follow reported as %+v", d.Relationship) + } + if d.Relationship.FriendsSince == nil { + t.Error("friends_since is nil after an accepted follow") + } + + // From B's side the same edge is a follower, not a following. + fromB, _ := svc.Detail(ctx, b, conv.ID) + if fromB.Relationship.Following || !fromB.Relationship.FollowedBy { + t.Errorf("peer's view = %+v, want followed_by only", fromB.Relationship) + } +} + +// A group has no single peer, so no relationship block. +func TestDetail_GroupHasNoRelationship(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.CreateGroup(ctx, a, "Group", []string{handleOf(t, pg, b)}) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + d, err := svc.Detail(ctx, a, conv.ID) + if err != nil { + t.Fatal(err) + } + if d.Relationship != nil { + t.Errorf("group carries a relationship: %+v", d.Relationship) + } +} + +// "Delete for everyone" is the sender's alone — letting a recipient erase +// someone else's words for all participants would be a moderation power, not a +// chat feature. Anyone may hide a message for themselves. +func TestDeleteMessage_Scopes(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + fromA, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "A wrote this"}) + if err != nil { + t.Fatal(err) + } + + // B cannot delete A's message for everyone. + if err := svc.DeleteMessage(ctx, b, fromA.ID, DeleteForEveryone); !errors.Is(err, ErrChatNotSender) { + t.Errorf("recipient delete-for-everyone: err = %v, want ErrChatNotSender", err) + } + // But B can hide it from their own view, leaving A's intact. + if err := svc.DeleteMessage(ctx, b, fromA.ID, DeleteForMe); err != nil { + t.Fatalf("delete for me: %v", err) + } + bSees, _ := svc.Messages(ctx, b, conv.ID, 0, 10) + if len(bSees) != 0 { + t.Errorf("hider still sees %d message(s)", len(bSees)) + } + aSees, _ := svc.Messages(ctx, a, conv.ID, 0, 10) + if len(aSees) != 1 { + t.Errorf("sender sees %d, want their message still there", len(aSees)) + } + + // A deletes for everyone: the row survives as a tombstone with its text + // gone, so seq stays gapless and the bubble can be replaced in place. + if err := svc.DeleteMessage(ctx, a, fromA.ID, DeleteForEveryone); err != nil { + t.Fatalf("delete for everyone: %v", err) + } + after, _ := svc.Messages(ctx, a, conv.ID, 0, 10) + if len(after) != 1 { + t.Fatalf("tombstone missing: %d messages", len(after)) + } + if after[0].DeletedAt == nil { + t.Error("message not marked deleted") + } + if after[0].Body != "" { + t.Errorf("body = %q, want the text cleared", after[0].Body) + } +} + +// Forwarding checks BOTH memberships. Checking only the destination would turn +// a message id into a way to copy text out of a thread you were never in. +func TestForwardMessage_RequiresAccessToSourceAndDestination(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, c := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + + private, err := svc.OpenDM(ctx, b, c) // A is NOT in this one + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, private.ID) + mine, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, mine.ID) + + secret, err := svc.Send(ctx, b, private.ID, SendRequest{Body: "not for A"}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.ForwardMessage(ctx, a, secret.ID, mine.ID); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("forwarding from an unseen thread: err = %v, want ErrNotParticipant", err) + } + + // A legitimate forward carries the text and records its origin. + ours, err := svc.Send(ctx, a, mine.ID, SendRequest{Body: "*worth* sharing"}) + if err != nil { + t.Fatal(err) + } + elsewhere, err := svc.OpenDM(ctx, a, c) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, elsewhere.ID) + + fwd, err := svc.ForwardMessage(ctx, a, ours.ID, elsewhere.ID) + if err != nil { + t.Fatalf("forward: %v", err) + } + if fwd.Body != ours.Body { + t.Errorf("forwarded body = %q, want %q", fwd.Body, ours.Body) + } + if fwd.ForwardedFromID == nil || *fwd.ForwardedFromID != ours.ID { + t.Errorf("forwarded_from_id = %v, want %s", fwd.ForwardedFromID, ours.ID) + } + if fwd.ConversationID != elsewhere.ID { + t.Errorf("landed in %s, want %s", fwd.ConversationID, elsewhere.ID) + } +} + +func TestForwardMessage_RefusesDeleted(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + other, err := svc.OpenDM(ctx, a, chatUser(t, pg, "C")) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, other.ID) + + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "gone soon"}) + if err != nil { + t.Fatal(err) + } + if err := svc.DeleteMessage(ctx, a, msg.ID, DeleteForEveryone); err != nil { + t.Fatal(err) + } + if _, err := svc.ForwardMessage(ctx, a, msg.ID, other.ID); !errors.Is(err, ErrMessageDeleted) { + t.Errorf("err = %v, want ErrMessageDeleted", err) + } +} + +// A report copies its evidence in, so moderators read the report rather than +// the live thread — and the evidence survives the sender deleting it. +func TestReportMessage_SnapshotsEvidence(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + for i := 0; i < 3; i++ { + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "context"}); err != nil { + t.Fatal(err) + } + } + bad, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "the offending message"}) + if err != nil { + t.Fatal(err) + } + + if err := svc.ReportMessage(ctx, outsider, bad.ID, "abuse", "n/a"); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider report: err = %v, want ErrNotParticipant", err) + } + if err := svc.ReportMessage(ctx, b, bad.ID, " ", "no category"); !errors.Is(err, ErrChatBadReport) { + t.Errorf("blank category: err = %v, want ErrChatBadReport", err) + } + if err := svc.ReportMessage(ctx, b, bad.ID, "abuse", "please review"); err != nil { + t.Fatalf("report: %v", err) + } + + var snapshot []byte + var reported *string + if err := pg.Pool.QueryRow(ctx, ` + SELECT snapshot, reported_user_id FROM message_reports WHERE message_id = $1`, + bad.ID).Scan(&snapshot, &reported); err != nil { + t.Fatal(err) + } + if reported == nil || *reported != a { + t.Errorf("reported_user_id = %v, want the sender", reported) + } + var payload struct { + Messages []struct { + ID string `json:"id"` + Body string `json:"body"` + } `json:"messages"` + } + if err := json.Unmarshal(snapshot, &payload); err != nil { + t.Fatalf("snapshot is not valid JSON: %v", err) + } + if len(payload.Messages) < 2 { + t.Errorf("snapshot captured %d messages, want the reported one plus context", len(payload.Messages)) + } + found := false + for _, m := range payload.Messages { + if m.ID == bad.ID && m.Body == "the offending message" { + found = true + } + } + if !found { + t.Error("snapshot does not contain the reported message's text") + } + + // The evidence must outlive the sender deleting the message. + if err := svc.DeleteMessage(ctx, a, bad.ID, DeleteForEveryone); err != nil { + t.Fatal(err) + } + var stillThere []byte + if err := pg.Pool.QueryRow(ctx, + `SELECT snapshot FROM message_reports WHERE reporter_id = $1`, b).Scan(&stillThere); err != nil { + t.Fatalf("report vanished with the message: %v", err) + } + if !bytes.Contains(stillThere, []byte("the offending message")) { + t.Error("snapshot lost its evidence when the message was deleted") + } +} + +func groupOf(t *testing.T, svc *ChatService, pg *repository.Postgres, owner string, members ...string) string { + t.Helper() + handles := make([]string, 0, len(members)) + for _, m := range members { + handles = append(handles, handleOf(t, pg, m)) + } + conv, err := svc.CreateGroup(context.Background(), owner, "Kīrtana", handles) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + return conv.ID +} + +// An admins-only group is an announcement channel: members read, admins post. +func TestSendPolicy_AdminsOnly(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") + id := groupOf(t, svc, pg, owner, member) + + // Default lets everyone post — the migration must not mute existing groups. + if _, err := svc.Send(ctx, member, id, SendRequest{Body: "hello"}); err != nil { + t.Fatalf("member send under the default policy: %v", err) + } + + admins := model.SendPolicyAdmins + if err := svc.UpdateGroup(ctx, owner, id, nil, &admins, nil); err != nil { + t.Fatal(err) + } + if _, err := svc.Send(ctx, member, id, SendRequest{Body: "still talking"}); !errors.Is(err, ErrChatAdminsOnly) { + t.Errorf("member send under admins-only: err = %v, want ErrChatAdminsOnly", err) + } + if _, err := svc.Send(ctx, owner, id, SendRequest{Body: "announcement"}); err != nil { + t.Errorf("owner blocked by their own policy: %v", err) + } + + // Promoting the member restores their ability to post. + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), model.RoleAdmin); err != nil { + t.Fatal(err) + } + if _, err := svc.Send(ctx, member, id, SendRequest{Body: "now an admin"}); err != nil { + t.Errorf("promoted member still blocked: %v", err) + } + + // Reverting the policy lets everyone back in. + everyone := model.SendPolicyEveryone + if err := svc.UpdateGroup(ctx, owner, id, nil, &everyone, nil); err != nil { + t.Fatal(err) + } + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), model.RoleMember); err != nil { + t.Fatal(err) + } + if _, err := svc.Send(ctx, member, id, SendRequest{Body: "talking again"}); err != nil { + t.Errorf("member blocked after reverting the policy: %v", err) + } +} + +// Only admins administer, and only the owner changes roles — an admin +// promoting other admins would let one account escalate the whole group. +func TestGroupAdministration_Permissions(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + owner := chatUser(t, pg, "owner") + admin := chatUser(t, pg, "admin") + member := chatUser(t, pg, "member") + outsider := chatUser(t, pg, "outsider") + id := groupOf(t, svc, pg, owner, admin, member) + + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, admin), model.RoleAdmin); err != nil { + t.Fatal(err) + } + + t.Run("a plain member cannot administer", func(t *testing.T) { + if err := svc.AddMembers(ctx, member, id, []string{handleOf(t, pg, outsider)}); !errors.Is(err, ErrChatNotAdmin) { + t.Errorf("add: err = %v, want ErrChatNotAdmin", err) + } + title := "Renamed" + if err := svc.UpdateGroup(ctx, member, id, &title, nil, nil); !errors.Is(err, ErrChatNotAdmin) { + t.Errorf("rename: err = %v, want ErrChatNotAdmin", err) + } + }) + + t.Run("an admin can add but cannot change roles", func(t *testing.T) { + if err := svc.AddMembers(ctx, admin, id, []string{handleOf(t, pg, outsider)}); err != nil { + t.Errorf("admin add: %v", err) + } + if err := svc.SetMemberRole(ctx, admin, id, handleOf(t, pg, member), model.RoleAdmin); !errors.Is(err, ErrChatNotAdmin) { + t.Errorf("admin promoting: err = %v, want ErrChatNotAdmin", err) + } + }) + + t.Run("the owner is immutable", func(t *testing.T) { + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, owner), model.RoleMember); !errors.Is(err, ErrChatOwnerImmutable) { + t.Errorf("self-demote: err = %v, want ErrChatOwnerImmutable", err) + } + if err := svc.RemoveMember(ctx, admin, id, handleOf(t, pg, owner)); !errors.Is(err, ErrChatOwnerImmutable) { + t.Errorf("admin removing owner: err = %v, want ErrChatOwnerImmutable", err) + } + }) + + t.Run("an admin cannot remove another admin", func(t *testing.T) { + second := chatUser(t, pg, "admin2") + if err := svc.AddMembers(ctx, owner, id, []string{handleOf(t, pg, second)}); err != nil { + t.Fatal(err) + } + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, second), model.RoleAdmin); err != nil { + t.Fatal(err) + } + if err := svc.RemoveMember(ctx, admin, id, handleOf(t, pg, second)); !errors.Is(err, ErrChatNotAdmin) { + t.Errorf("admin removing admin: err = %v, want ErrChatNotAdmin", err) + } + // The owner can. + if err := svc.RemoveMember(ctx, owner, id, handleOf(t, pg, second)); err != nil { + t.Errorf("owner removing admin: %v", err) + } + }) + + t.Run("bad roles and policies are rejected", func(t *testing.T) { + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, member), "superuser"); !errors.Is(err, ErrChatBadRole) { + t.Errorf("err = %v, want ErrChatBadRole", err) + } + bad := "nobody" + if err := svc.UpdateGroup(ctx, owner, id, nil, &bad, nil); !errors.Is(err, ErrChatBadPolicy) { + t.Errorf("err = %v, want ErrChatBadPolicy", err) + } + }) + + t.Run("group-only operations refuse DMs", func(t *testing.T) { + dm, err := svc.OpenDM(ctx, owner, member) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, dm.ID) + if err := svc.AddMembers(ctx, owner, dm.ID, []string{handleOf(t, pg, outsider)}); !errors.Is(err, ErrChatNotGroup) { + t.Errorf("err = %v, want ErrChatNotGroup", err) + } + }) +} + +// Anyone may leave, and a departing owner hands the group on — otherwise it +// would be left with nobody able to administer it. +func TestRemoveMember_LeavingAndSuccession(t *testing.T) { + svc, chat, pg := testChatService(t) + ctx := context.Background() + owner := chatUser(t, pg, "owner") + admin := chatUser(t, pg, "admin") + member := chatUser(t, pg, "member") + id := groupOf(t, svc, pg, owner, admin, member) + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, admin), model.RoleAdmin); err != nil { + t.Fatal(err) + } + + // A member removes themselves without any admin rights. + if err := svc.RemoveMember(ctx, member, id, handleOf(t, pg, member)); err != nil { + t.Fatalf("self-removal: %v", err) + } + if _, err := chat.Participant(ctx, id, member); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("after leaving: err = %v, want ErrNotParticipant", err) + } + + // The owner leaves; the admin inherits. + if err := svc.RemoveMember(ctx, owner, id, handleOf(t, pg, owner)); err != nil { + t.Fatalf("owner leaving: %v", err) + } + successor, err := chat.Participant(ctx, id, admin) + if err != nil { + t.Fatal(err) + } + if successor.Role != model.RoleOwner { + t.Errorf("successor role = %q, want owner", successor.Role) + } +} + +// Re-adding someone who left restarts them as a plain member; it must not +// resurrect the admin rights they had before. +func TestAddMembers_RejoinDoesNotRestoreAdmin(t *testing.T) { + svc, chat, pg := testChatService(t) + ctx := context.Background() + owner, other := chatUser(t, pg, "owner"), chatUser(t, pg, "other") + id := groupOf(t, svc, pg, owner, other) + + if err := svc.SetMemberRole(ctx, owner, id, handleOf(t, pg, other), model.RoleAdmin); err != nil { + t.Fatal(err) + } + if err := svc.RemoveMember(ctx, other, id, handleOf(t, pg, other)); err != nil { + t.Fatal(err) + } + if err := svc.AddMembers(ctx, owner, id, []string{handleOf(t, pg, other)}); err != nil { + t.Fatal(err) + } + + back, err := chat.Participant(ctx, id, other) + if err != nil { + t.Fatalf("rejoined member is not a participant: %v", err) + } + if back.Role != model.RoleMember { + t.Errorf("role after rejoining = %q, want member", back.Role) + } +} + +// The caller's own role rides on Detail so the UI can show admin controls +// without deriving it from the member list. +func TestDetail_ReportsMyRoleAndMemberRoles(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") + id := groupOf(t, svc, pg, owner, member) + + asOwner, err := svc.Detail(ctx, owner, id) + if err != nil { + t.Fatal(err) + } + if asOwner.MyRole != model.RoleOwner { + t.Errorf("my_role = %q, want owner", asOwner.MyRole) + } + if asOwner.SendPolicy != model.SendPolicyEveryone { + t.Errorf("send_policy = %q, want everyone", asOwner.SendPolicy) + } + // The owner sorts first in the member list. + if len(asOwner.Participants) != 2 || asOwner.Participants[0].Role != model.RoleOwner { + t.Errorf("participants = %+v, want the owner first", asOwner.Participants) + } + + asMember, _ := svc.Detail(ctx, member, id) + if asMember.MyRole != model.RoleMember { + t.Errorf("member's my_role = %q, want member", asMember.MyRole) + } +} + +// A group's photo must come from that group's own upload prefix. Without the +// check an admin could point avatar_key at another thread's media, and every +// member here would be served a file from a conversation they are not in. +func TestUpdateGroup_AvatarKeyMustBelongToThisGroup(t *testing.T) { + svc, chat, pg := testChatService(t) + ctx := context.Background() + owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") + id := groupOf(t, svc, pg, owner, member) + other := groupOf(t, svc, pg, owner) + + foreign := []string{ + "chat/" + other + "/avatar/deadbeef", // another group's avatar + "chat/" + id + "/" + owner + "/photo", // a message attachment here + "avatars/" + owner + "/me.png", // outside chat entirely + "../../etc/passwd", + } + for _, key := range foreign { + k := key + if err := svc.UpdateGroup(ctx, owner, id, nil, nil, &k); !errors.Is(err, ErrChatBadAvatarKey) { + t.Errorf("key %q: err = %v, want ErrChatBadAvatarKey", key, err) + } + } + + // A key from this group's own prefix is accepted and stored. + valid := "chat/" + id + "/avatar/abc123" + if err := svc.UpdateGroup(ctx, owner, id, nil, nil, &valid); err != nil { + t.Fatalf("valid avatar key rejected: %v", err) + } + conv, err := chat.Conversation(ctx, id) + if err != nil { + t.Fatal(err) + } + if conv.AvatarKey == nil || *conv.AvatarKey != valid { + t.Errorf("avatar_key = %v, want %q", conv.AvatarKey, valid) + } + + // And a plain member cannot set it at all. + another := "chat/" + id + "/avatar/xyz" + if err := svc.UpdateGroup(ctx, member, id, nil, nil, &another); !errors.Is(err, ErrChatNotAdmin) { + t.Errorf("member setting the photo: err = %v, want ErrChatNotAdmin", err) + } +} + +// Reading a group photo is members-only, and says so clearly when unset. +func TestGroupAvatarURL_Access(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + owner, outsider := chatUser(t, pg, "owner"), chatUser(t, pg, "outsider") + id := groupOf(t, svc, pg, owner) + + if _, err := svc.GroupAvatarURL(ctx, outsider, id); !errors.Is(err, repository.ErrNotParticipant) { + t.Errorf("outsider: err = %v, want ErrNotParticipant", err) + } + // A member of a group with no photo gets a clear "unset", not a 500. + if _, err := svc.GroupAvatarURL(ctx, owner, id); !errors.Is(err, ErrAvatarUnset) { + t.Errorf("no photo: err = %v, want ErrAvatarUnset", err) + } +} + +// Only image uploads are offered, and only to admins. +func TestPresignGroupAvatar_Restrictions(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + owner, member := chatUser(t, pg, "owner"), chatUser(t, pg, "member") + id := groupOf(t, svc, pg, owner, member) + + if _, _, err := svc.PresignGroupAvatar(ctx, member, id, "image/png"); !errors.Is(err, ErrChatNotAdmin) { + t.Errorf("member: err = %v, want ErrChatNotAdmin", err) + } + // A PDF is a valid attachment but not a valid photo. + if _, _, err := svc.PresignGroupAvatar(ctx, owner, id, "application/pdf"); !errors.Is(err, ErrChatBadMedia) { + t.Errorf("pdf: err = %v, want ErrChatBadMedia", err) + } + // With R2 unconfigured in tests, an admin with a valid image gets as far as + // the storage check — proving the admin and MIME gates both passed. + if _, _, err := svc.PresignGroupAvatar(ctx, owner, id, "image/jpeg"); !errors.Is(err, ErrChatMediaOff) { + t.Errorf("admin with an image: err = %v, want ErrChatMediaOff", err) + } +} + +// Absent settings must read as the product defaults, or a user who never opened +// the settings page would get whatever zero values happen to be. +func TestSettings_DefaultsWhenNeverSaved(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a := chatUser(t, pg, "A") + + got, err := svc.Settings(ctx, a) + if err != nil { + t.Fatal(err) + } + want := model.DefaultChatSettings() + if got.ReadReceipts != want.ReadReceipts || got.WhoCanMessage != want.WhoCanMessage || + got.MinAccountAgeMonths != want.MinAccountAgeMonths || got.TrainKautilya != want.TrainKautilya { + t.Errorf("defaults = %+v, want %+v", got, want) + } + if !got.ReadReceipts || got.WhoCanMessage != model.WhoAnyone || !got.TrainKautilya { + t.Error("defaults must be: receipts on, anyone can message, training on") + } +} + +func TestSaveSettings_Validation(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a := chatUser(t, pg, "A") // test users are created with no gender + + base := model.DefaultChatSettings() + + bad := base + bad.WhoCanMessage = "nobody-at-all" + if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatBadWhoCanMessage) { + t.Errorf("unknown policy: err = %v, want ErrChatBadWhoCanMessage", err) + } + + for _, months := range []int{0, -1, 121} { + bad = base + bad.WhoCanMessage = model.WhoAccountAge + bad.MinAccountAgeMonths = months + if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatBadAccountAge) { + t.Errorf("%d months: err = %v, want ErrChatBadAccountAge", months, err) + } + } + + // same_gender without a gender on the profile would silently do nothing, + // so it is refused with an error the UI can explain. + bad = base + bad.WhoCanMessage = model.WhoSameGender + if _, err := svc.SaveSettings(ctx, a, bad); !errors.Is(err, ErrChatGenderUnset) { + t.Errorf("same_gender with no gender: err = %v, want ErrChatGenderUnset", err) + } + + // Once a gender is set it becomes selectable. + if _, err := pg.Pool.Exec(ctx, `UPDATE users SET gender='female' WHERE id=$1`, a); err != nil { + t.Fatal(err) + } + if saved, err := svc.SaveSettings(ctx, a, bad); err != nil { + t.Errorf("same_gender with a gender: %v", err) + } else if saved.WhoCanMessage != model.WhoSameGender || !saved.GenderSet { + t.Errorf("saved = %+v", saved) + } +} + +// "Who can message you" gates STARTING a thread. +func TestWhoCanMessage_GatesNewConversations(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + follows := repository.NewFollowRepo(pg) + + t.Run("friends only", func(t *testing.T) { + recipient, stranger := chatUser(t, pg, "R"), chatUser(t, pg, "S") + s := model.DefaultChatSettings() + s.WhoCanMessage = model.WhoFriends + if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { + t.Fatal(err) + } + if _, err := svc.OpenDM(ctx, stranger, recipient); !errors.Is(err, ErrChatNotAccepting) { + t.Errorf("stranger: err = %v, want ErrChatNotAccepting", err) + } + // An accepted follow in either direction counts as a connection. + if _, err := follows.Request(ctx, stranger, recipient); err != nil { + t.Fatal(err) + } + if _, err := follows.Accept(ctx, stranger, recipient); err != nil { + t.Fatal(err) + } + conv, err := svc.OpenDM(ctx, stranger, recipient) + if err != nil { + t.Fatalf("friend: %v", err) + } + cleanupConv(t, pg, conv.ID) + }) + + t.Run("account age", func(t *testing.T) { + recipient, fresh := chatUser(t, pg, "R"), chatUser(t, pg, "F") + s := model.DefaultChatSettings() + s.WhoCanMessage = model.WhoAccountAge + s.MinAccountAgeMonths = 6 + if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { + t.Fatal(err) + } + if _, err := svc.OpenDM(ctx, fresh, recipient); !errors.Is(err, ErrChatNotAccepting) { + t.Errorf("new account: err = %v, want ErrChatNotAccepting", err) + } + // Age the account past the threshold. + if _, err := pg.Pool.Exec(ctx, + `UPDATE users SET created_at = now() - interval '1 year' WHERE id=$1`, fresh); err != nil { + t.Fatal(err) + } + conv, err := svc.OpenDM(ctx, fresh, recipient) + if err != nil { + t.Fatalf("old enough: %v", err) + } + cleanupConv(t, pg, conv.ID) + }) + + t.Run("same gender", func(t *testing.T) { + recipient, same, other := chatUser(t, pg, "R"), chatUser(t, pg, "S"), chatUser(t, pg, "O") + if _, err := pg.Pool.Exec(ctx, + `UPDATE users SET gender = CASE id WHEN $1 THEN 'female' WHEN $2 THEN 'female' ELSE 'male' END + WHERE id = ANY($3)`, recipient, same, []string{recipient, same, other}); err != nil { + t.Fatal(err) + } + s := model.DefaultChatSettings() + s.WhoCanMessage = model.WhoSameGender + if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { + t.Fatal(err) + } + if _, err := svc.OpenDM(ctx, other, recipient); !errors.Is(err, ErrChatNotAccepting) { + t.Errorf("different gender: err = %v, want ErrChatNotAccepting", err) + } + conv, err := svc.OpenDM(ctx, same, recipient) + if err != nil { + t.Fatalf("same gender: %v", err) + } + cleanupConv(t, pg, conv.ID) + }) + + // Tightening the setting must not strand an existing conversation. + t.Run("existing threads keep working", func(t *testing.T) { + recipient, stranger := chatUser(t, pg, "R"), chatUser(t, pg, "S") + conv, err := svc.OpenDM(ctx, stranger, recipient) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + s := model.DefaultChatSettings() + s.WhoCanMessage = model.WhoFriends + if _, err := svc.SaveSettings(ctx, recipient, s); err != nil { + t.Fatal(err) + } + if _, err := svc.Send(ctx, stranger, conv.ID, SendRequest{Body: "still here"}); err != nil { + t.Errorf("existing thread was closed by the setting: %v", err) + } + }) +} + +// Read receipts are reciprocal: switching them off stops you broadcasting your +// reads AND stops you seeing everyone else's. A one-way version would let a +// user watch others while staying invisible. +func TestReadReceipts_AreReciprocal(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + msg, err := svc.Send(ctx, a, conv.ID, SendRequest{Body: "read me"}) + if err != nil { + t.Fatal(err) + } + if err := svc.MarkRead(ctx, b, conv.ID, msg.Seq); err != nil { + t.Fatal(err) + } + + // Both on: A sees B's read. + if r, err := svc.Receipts(ctx, a, conv.ID); err != nil || r.ReadSeq != msg.Seq { + t.Fatalf("receipts = %+v (err %v), want read_seq %d", r, err, msg.Seq) + } + + // B switches them off: A no longer sees B's read... + off := model.DefaultChatSettings() + off.ReadReceipts = false + if _, err := svc.SaveSettings(ctx, b, off); err != nil { + t.Fatal(err) + } + if r, _ := svc.Receipts(ctx, a, conv.ID); r.ReadSeq != 0 { + t.Errorf("A still sees a read cursor for someone who switched receipts off: %+v", r) + } + // ...and B doesn't get to see A's either. + if err := svc.MarkRead(ctx, a, conv.ID, msg.Seq); err != nil { + t.Fatal(err) + } + if r, _ := svc.Receipts(ctx, b, conv.ID); r.ReadSeq != 0 { + t.Errorf("B switched receipts off but still sees others': %+v", r) + } + + // Delivery is unaffected — it reports that a device received the message, + // not that a person read it. + if err := svc.MarkDelivered(ctx, b, conv.ID, msg.Seq); err != nil { + t.Fatal(err) + } + if r, _ := svc.Receipts(ctx, a, conv.ID); r.DeliveredSeq != msg.Seq { + t.Errorf("delivery cursor = %+v, want %d", r, msg.Seq) + } + + // The muter's own unread still clears — their cursor advanced locally. + inbox, _ := svc.Inbox(ctx, b, "chats", 10) + for _, row := range inbox { + if row.ID == conv.ID && row.Unread != 0 { + t.Errorf("unread = %d for someone who read the thread with receipts off", row.Unread) + } + } +} + +// The Message button is offered from RelationshipState.CanMessage, so it must +// agree with what OpenDM actually does — a button that fails on tap is worse +// than no button. +func TestCanStartConversation_MatchesOpenDM(t *testing.T) { + svc, chat, pg := testChatService(t) + ctx := context.Background() + blocks := repository.NewBlockRepo(pg) + follows := repository.NewFollowRepo(pg) + + cases := []struct { + name string + setup func(t *testing.T, viewer, target string) + want bool + }{ + {"no restrictions", func(*testing.T, string, string) {}, true}, + {"friends only, not a friend", func(t *testing.T, _, target string) { + s := model.DefaultChatSettings() + s.WhoCanMessage = model.WhoFriends + if _, err := svc.SaveSettings(ctx, target, s); err != nil { + t.Fatal(err) + } + }, false}, + {"friends only, is a friend", func(t *testing.T, viewer, target string) { + s := model.DefaultChatSettings() + s.WhoCanMessage = model.WhoFriends + if _, err := svc.SaveSettings(ctx, target, s); err != nil { + t.Fatal(err) + } + if _, err := follows.Request(ctx, viewer, target); err != nil { + t.Fatal(err) + } + if _, err := follows.Accept(ctx, viewer, target); err != nil { + t.Fatal(err) + } + }, true}, + {"target blocked the viewer", func(t *testing.T, viewer, target string) { + if err := blocks.Block(ctx, target, viewer); err != nil { + t.Fatal(err) + } + }, true}, // CanStartConversation doesn't consider blocks; OpenDM checks them first + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + viewer, target := chatUser(t, pg, "V"), chatUser(t, pg, "T") + tc.setup(t, viewer, target) + + got, err := chat.CanStartConversation(ctx, viewer, target) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Errorf("CanStartConversation = %v, want %v", got, tc.want) + } + + // Whatever the predicate says, OpenDM must behave the same way for + // the settings-based cases. + conv, err := svc.OpenDM(ctx, viewer, target) + if conv != nil { + cleanupConv(t, pg, conv.ID) + } + refusedBySetting := errors.Is(err, ErrChatNotAccepting) + if got && refusedBySetting { + t.Error("predicate allowed it but OpenDM refused on the setting") + } + if !got && !refusedBySetting { + t.Errorf("predicate refused it but OpenDM gave err = %v", err) + } + }) + } +} + +// Opening by handle is what the profile's Message button uses. +func TestOpenDMByHandle(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + viewer := chatUser(t, pg, "V") + target, targetHandle := chatUserWithHandle(t, pg, "T") + + conv, err := svc.OpenDMByHandle(ctx, viewer, targetHandle) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + // Same thread as opening by id — the handle path is a resolver, not a + // second way to create conversations. + byID, err := svc.OpenDM(ctx, viewer, target) + if err != nil { + t.Fatal(err) + } + if byID.ID != conv.ID { + t.Errorf("handle path made a different thread: %s vs %s", conv.ID, byID.ID) + } + + if _, err := svc.OpenDMByHandle(ctx, viewer, "nobody_by_this_handle"); !errors.Is(err, repository.ErrUserGone) { + t.Errorf("unknown handle: err = %v, want ErrUserGone", err) + } +} diff --git a/internal/service/social_graph_service.go b/internal/service/social_graph_service.go index 7c84f08..94b35bc 100644 --- a/internal/service/social_graph_service.go +++ b/internal/service/social_graph_service.go @@ -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 } diff --git a/migrations/0048_create_chat.down.sql b/migrations/0048_create_chat.down.sql new file mode 100644 index 0000000..cdcd2e9 --- /dev/null +++ b/migrations/0048_create_chat.down.sql @@ -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; diff --git a/migrations/0048_create_chat.up.sql b/migrations/0048_create_chat.up.sql new file mode 100644 index 0000000..8f3de24 --- /dev/null +++ b/migrations/0048_create_chat.up.sql @@ -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; diff --git a/migrations/0049_group_send_policy.down.sql b/migrations/0049_group_send_policy.down.sql new file mode 100644 index 0000000..2a71836 --- /dev/null +++ b/migrations/0049_group_send_policy.down.sql @@ -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; diff --git a/migrations/0049_group_send_policy.up.sql b/migrations/0049_group_send_policy.up.sql new file mode 100644 index 0000000..bd8bbe3 --- /dev/null +++ b/migrations/0049_group_send_policy.up.sql @@ -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; diff --git a/migrations/0050_chat_settings.down.sql b/migrations/0050_chat_settings.down.sql new file mode 100644 index 0000000..c07cc55 --- /dev/null +++ b/migrations/0050_chat_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS chat_settings; diff --git a/migrations/0050_chat_settings.up.sql b/migrations/0050_chat_settings.up.sql new file mode 100644 index 0000000..864f8e0 --- /dev/null +++ b/migrations/0050_chat_settings.up.sql @@ -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) +); diff --git a/pkg/unfurl/unfurl.go b/pkg/unfurl/unfurl.go new file mode 100644 index 0000000..24b0738 --- /dev/null +++ b/pkg/unfurl/unfurl.go @@ -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 + 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)]*?)/?>`) + attrRe = regexp.MustCompile(`(?is)([a-z0-9:_.-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`) + titleRe = regexp.MustCompile(`(?is)]*>(.*?)`) + headRe = regexp.MustCompile(`(?is)`) + 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 elements, and this +// keeps the BFF free of another dependency. +func extract(doc string) *Preview { + // Metadata lives in ; 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])) + "…" +} diff --git a/pkg/unfurl/unfurl_test.go b/pkg/unfurl/unfurl_test.go new file mode 100644 index 0000000..2839a60 --- /dev/null +++ b/pkg/unfurl/unfurl_test.go @@ -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, ` + Fallback title + + + + + ignored`) + })) + 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, ` Plain & simple + `) + })) + 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, `nothing here`) + })) + 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, ``) + 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(``) + 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(` + + + `) + 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 ; a forged in the body must not win. +func TestExtract_IgnoresBodyContent(t *testing.T) { + p := extract(` + `) + if p.Title != "Real" { + t.Errorf("title = %q, want the head's value", p.Title) + } +}