feat: add chat
This commit is contained in:
@@ -55,6 +55,22 @@ func NotificationsRule() ChannelRule {
|
||||
}
|
||||
}
|
||||
|
||||
// ConversationRule allows a caller to subscribe to a thread's channel only
|
||||
// while they are a participant. isParticipant is injected rather than taking a
|
||||
// repository dependency here, so the authorization check stays the service's
|
||||
// single source of truth.
|
||||
func ConversationRule(isParticipant func(ctx context.Context, userID, conversationID string) (bool, error)) ChannelRule {
|
||||
return ChannelRule{
|
||||
Prefix: "private-conversation.",
|
||||
Authorize: func(ctx context.Context, userID, suffix string) (bool, error) {
|
||||
if suffix == "" {
|
||||
return false, nil
|
||||
}
|
||||
return isParticipant(ctx, userID, suffix)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type broadcastingAuthResp struct {
|
||||
Auth string `json:"auth"`
|
||||
ChannelData string `json:"channel_data,omitempty"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -77,6 +78,44 @@ func TestBroadcastingAuth_Authorization(t *testing.T) {
|
||||
|
||||
// A prefix match must not be mistaken for authorization: "…user-a-evil" starts
|
||||
// with the victim's id but is a different channel.
|
||||
// ConversationRule delegates to the chat service's membership check, so the
|
||||
// realtime channel and the REST endpoints can never disagree about who is in a
|
||||
// thread.
|
||||
func TestConversationRule(t *testing.T) {
|
||||
const member, outsider = "member-1", "outsider-1"
|
||||
rule := ConversationRule(func(_ context.Context, userID, conversationID string) (bool, error) {
|
||||
if conversationID == "boom" {
|
||||
return false, errors.New("database down")
|
||||
}
|
||||
return userID == member && conversationID == "conv-1", nil
|
||||
})
|
||||
|
||||
if rule.Prefix != "private-conversation." {
|
||||
t.Errorf("prefix = %q", rule.Prefix)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
user string
|
||||
suffix string
|
||||
wantOK bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"participant", member, "conv-1", true, false},
|
||||
{"outsider", outsider, "conv-1", false, false},
|
||||
{"participant of another thread", member, "conv-2", false, false},
|
||||
{"empty conversation id", member, "", false, false},
|
||||
{"lookup failure is not a denial", member, "boom", false, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ok, err := rule.Authorize(context.Background(), tc.user, tc.suffix)
|
||||
if ok != tc.wantOK || (err != nil) != tc.wantErr {
|
||||
t.Errorf("= (%v, %v), want (%v, err=%v)", ok, err, tc.wantOK, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) {
|
||||
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -183,6 +183,15 @@ type RelationshipState struct {
|
||||
RequestsYou bool `json:"requests_you"`
|
||||
Blocked bool `json:"blocked"`
|
||||
BlockedBy bool `json:"blocked_by"`
|
||||
// CanMessage reports whether the viewer may START a conversation with this
|
||||
// profile, given the target's "who can message you" setting and any block.
|
||||
// It lets the UI decide whether to offer a Message button rather than
|
||||
// offering one that fails on tap.
|
||||
CanMessage bool `json:"can_message"`
|
||||
// MessageRestriction names the target's rule when CanMessage is false, so
|
||||
// the UI can explain instead of silently hiding the button. Empty when
|
||||
// messaging is allowed, or "blocked" when a block is the reason.
|
||||
MessageRestriction string `json:"message_restriction,omitempty"`
|
||||
}
|
||||
|
||||
// Follow is one row of the follows table — a directed, request-based
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// utf16Slice extracts a span the way a JavaScript client would, so the tests
|
||||
// assert the offsets are usable by the actual renderer rather than merely
|
||||
// self-consistent.
|
||||
func utf16Slice(s string, offset, length int) string {
|
||||
u := utf16.Encode([]rune(s))
|
||||
if offset < 0 || offset+length > len(u) {
|
||||
return "<out of range>"
|
||||
}
|
||||
return string(utf16.Decode(u[offset : offset+length]))
|
||||
}
|
||||
|
||||
func TestParseEntities_Markers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantBody string
|
||||
wantSpan map[string]string // entity type -> the text it should cover
|
||||
}{
|
||||
{"bold", "hello *world*", "hello world", map[string]string{"bold": "world"}},
|
||||
{"strike", "~gone~ now", "gone now", map[string]string{"strike": "gone"}},
|
||||
{"code", "run `go test` please", "run go test please", map[string]string{"code": "go test"}},
|
||||
{"markers stripped from body", "*a* ~b~ `c`", "a b c", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, entities := ParseEntities(tc.raw)
|
||||
if body != tc.wantBody {
|
||||
t.Fatalf("body = %q, want %q", body, tc.wantBody)
|
||||
}
|
||||
for kind, want := range tc.wantSpan {
|
||||
found := false
|
||||
for _, e := range entities {
|
||||
if e.Type == kind {
|
||||
if got := utf16Slice(body, e.Offset, e.Length); got != want {
|
||||
t.Errorf("%s covers %q, want %q", kind, got, want)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no %s entity in %+v", kind, entities)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Offsets are UTF-16 code units because the client slices JS strings with them.
|
||||
// Devanāgarī is multi-byte and emoji are surrogate pairs, so a byte- or
|
||||
// rune-based offset would silently mis-highlight in exactly this app's content.
|
||||
func TestParseEntities_UTF16Offsets(t *testing.T) {
|
||||
tests := []struct{ name, raw, want string }{
|
||||
{"devanagari prefix", "श्री कृष्ण *bold*", "bold"},
|
||||
{"emoji prefix", "🙏🙏 *namaste*", "namaste"},
|
||||
{"emoji inside span", "*🙏 namaste*", "🙏 namaste"},
|
||||
{"iast prefix", "Bhagavad-gītā ~old~", "old"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, entities := ParseEntities(tc.raw)
|
||||
if len(entities) == 0 {
|
||||
t.Fatalf("no entities for %q (body %q)", tc.raw, body)
|
||||
}
|
||||
e := entities[len(entities)-1]
|
||||
if got := utf16Slice(body, e.Offset, e.Length); got != tc.want {
|
||||
t.Errorf("span covers %q, want %q (body %q, entity %+v)", got, tc.want, body, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Text that merely contains marker characters must survive untouched.
|
||||
func TestParseEntities_NotFormatting(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"2*3*4", // mid-word asterisks
|
||||
"a~b~c", // mid-word tildes
|
||||
"* not bold", // space after opener
|
||||
"unmatched *marker", // no closer
|
||||
"**", // empty
|
||||
"5 * 3 = 15", // spaced operators
|
||||
} {
|
||||
body, entities := ParseEntities(raw)
|
||||
if body != raw {
|
||||
t.Errorf("ParseEntities(%q) rewrote body to %q", raw, body)
|
||||
}
|
||||
for _, e := range entities {
|
||||
if e.Type != EntityURL {
|
||||
t.Errorf("ParseEntities(%q) produced %s entity %+v", raw, e.Type, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntities_Nesting(t *testing.T) {
|
||||
body, entities := ParseEntities("*bold with `code` inside*")
|
||||
const wantBody = "bold with code inside"
|
||||
if body != wantBody {
|
||||
t.Fatalf("body = %q, want %q", body, wantBody)
|
||||
}
|
||||
var bold, code *Entity
|
||||
for i := range entities {
|
||||
switch entities[i].Type {
|
||||
case EntityBold:
|
||||
bold = &entities[i]
|
||||
case EntityCode:
|
||||
code = &entities[i]
|
||||
}
|
||||
}
|
||||
if bold == nil || code == nil {
|
||||
t.Fatalf("want both bold and code, got %+v", entities)
|
||||
}
|
||||
if got := utf16Slice(body, bold.Offset, bold.Length); got != wantBody {
|
||||
t.Errorf("bold covers %q, want the whole body", got)
|
||||
}
|
||||
if got := utf16Slice(body, code.Offset, code.Length); got != "code" {
|
||||
t.Errorf("code covers %q, want %q", got, "code")
|
||||
}
|
||||
// Outer span must sort first so a renderer opens it before the nested one.
|
||||
if entities[0].Type != EntityBold {
|
||||
t.Errorf("entities[0] = %s, want the outer bold span first", entities[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntities_URLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantText string
|
||||
wantHref string
|
||||
}{
|
||||
{"https", "see https://prabhupadasays.org/bg/2/13 here",
|
||||
"https://prabhupadasays.org/bg/2/13", "https://prabhupadasays.org/bg/2/13"},
|
||||
{"bare www gets a scheme", "visit www.prabhupadasays.org now",
|
||||
"www.prabhupadasays.org", "https://www.prabhupadasays.org"},
|
||||
{"trailing sentence punctuation excluded", "read https://example.org/a.",
|
||||
"https://example.org/a", "https://example.org/a"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, entities := ParseEntities(tc.raw)
|
||||
var url *Entity
|
||||
for i := range entities {
|
||||
if entities[i].Type == EntityURL {
|
||||
url = &entities[i]
|
||||
}
|
||||
}
|
||||
if url == nil {
|
||||
t.Fatalf("no url entity in %+v", entities)
|
||||
}
|
||||
if got := utf16Slice(body, url.Offset, url.Length); got != tc.wantText {
|
||||
t.Errorf("covers %q, want %q", got, tc.wantText)
|
||||
}
|
||||
if url.URL != tc.wantHref {
|
||||
t.Errorf("href = %q, want %q", url.URL, tc.wantHref)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A URL inside backticks is being shown, not linked.
|
||||
func TestParseEntities_URLInsideCodeIsNotLinked(t *testing.T) {
|
||||
_, entities := ParseEntities("run `curl https://example.org` first")
|
||||
for _, e := range entities {
|
||||
if e.Type == EntityURL {
|
||||
t.Errorf("linked a URL inside a code span: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Bg 2.13" and "e.g" must not become links — the conservative pattern is what
|
||||
// keeps scripture references from turning blue.
|
||||
func TestParseEntities_NoFalsePositiveLinks(t *testing.T) {
|
||||
for _, raw := range []string{"Bg 2.13 is the verse", "e.g. this one", "version 1.2.3"} {
|
||||
_, entities := ParseEntities(raw)
|
||||
for _, e := range entities {
|
||||
if e.Type == EntityURL {
|
||||
t.Errorf("ParseEntities(%q) linked %+v", raw, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// entities is stored as jsonb; empty must round-trip as [] rather than null so
|
||||
// the column default and the parser agree.
|
||||
func TestParseEntities_JSONShape(t *testing.T) {
|
||||
_, entities := ParseEntities("plain text")
|
||||
if entities == nil {
|
||||
entities = []Entity{}
|
||||
}
|
||||
b, err := json.Marshal(entities)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "[]" {
|
||||
t.Errorf("marshalled %s, want []", b)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,203 @@
|
||||
-- Chat — DMs, group threads, and Kautilya (AI) threads.
|
||||
--
|
||||
-- One pipeline serves all three. `conversations.kind` is the only thing that
|
||||
-- differs, so the inbox's two tabs are a filter (dm/group vs ai) rather than a
|
||||
-- second implementation, and the message list, composer, pagination and
|
||||
-- context menu are written once.
|
||||
--
|
||||
-- Ordering is by a per-conversation `seq`, not by timestamp. Clocks skew and
|
||||
-- timestamps collide; a gapless integer gives stable ordering, cheap unread
|
||||
-- maths (last_seq - last_read_seq), and a sync cursor the client can resume
|
||||
-- from after a reconnect (?after_seq=N).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- dm → exactly two participants
|
||||
-- group → many participants, has a title/avatar
|
||||
-- ai → one participant; the other side is Kautilya (no user row)
|
||||
kind VARCHAR(8) NOT NULL CHECK (kind IN ('dm', 'group', 'ai')),
|
||||
title VARCHAR(120), -- groups (and AI thread titles)
|
||||
avatar_key TEXT, -- R2 key, groups only
|
||||
-- Sorted pair "least(a,b):greatest(a,b)" for DMs, NULL otherwise. Makes
|
||||
-- "one thread per pair" a database guarantee instead of a service-layer
|
||||
-- race between two people opening the chat simultaneously.
|
||||
dm_key TEXT,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Monotonic message counter. Bumped inside the send transaction, which
|
||||
-- serialises assignment per conversation and leaves no gaps.
|
||||
last_seq BIGINT NOT NULL DEFAULT 0,
|
||||
-- Denormalized purely for inbox ordering; the preview text and its sender
|
||||
-- are read with a LATERAL join on (conversation_id, seq DESC) so there is
|
||||
-- one source of truth for message content even after an edit or delete.
|
||||
last_message_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (kind <> 'dm' OR dm_key IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS conversations_dm_key_uniq
|
||||
ON conversations (dm_key) WHERE kind = 'dm';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(8) NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'admin', 'member')),
|
||||
-- Delivery/read state lives here, per participant — NOT per message. A
|
||||
-- receipt row per (message × participant) would grow with the product of
|
||||
-- both; two cursors give the same three tick states at constant size:
|
||||
-- sent → the row exists
|
||||
-- delivered → last_delivered_seq >= message.seq
|
||||
-- read → last_read_seq >= message.seq
|
||||
last_read_seq BIGINT NOT NULL DEFAULT 0,
|
||||
last_delivered_seq BIGINT NOT NULL DEFAULT 0,
|
||||
muted_until TIMESTAMPTZ,
|
||||
-- "Friends since" on the chat profile reads joined_at.
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- Non-null = left the group. Kept (rather than deleted) so their past
|
||||
-- messages still resolve to a member row when rendering history.
|
||||
left_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
-- The inbox query: this user's threads, most recent first. Partial on left_at
|
||||
-- so departed groups drop out without a filter on every read.
|
||||
CREATE INDEX IF NOT EXISTS conversation_participants_inbox_idx
|
||||
ON conversation_participants (user_id) WHERE left_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
seq BIGINT NOT NULL,
|
||||
-- NULL for messages no user wrote: 'system' (membership changes) and
|
||||
-- 'assistant' (Kautilya). SET NULL on user deletion keeps the thread
|
||||
-- readable for everyone else.
|
||||
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
kind VARCHAR(12) NOT NULL DEFAULT 'text'
|
||||
CHECK (kind IN ('text', 'system', 'assistant')),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
-- Inline formatting and detected links as offset/length spans
|
||||
-- ([{type,offset,length}]), never HTML. Storing spans instead of markup
|
||||
-- keeps rendering injection-safe and lets each client render natively.
|
||||
entities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- Client-generated id, so a retried send is deduped rather than doubled.
|
||||
client_msg_id UUID,
|
||||
reply_to_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
-- Set when this message was forwarded from another. SET NULL so deleting
|
||||
-- the original leaves the forward intact.
|
||||
forwarded_from_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
edited_at TIMESTAMPTZ,
|
||||
-- Delete-for-everyone: a tombstone, so the gap in `seq` is explainable and
|
||||
-- other participants' clients can replace the bubble in place.
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (conversation_id, seq)
|
||||
);
|
||||
|
||||
-- History paging, newest first, and the LATERAL that fetches each inbox row's
|
||||
-- last message.
|
||||
CREATE INDEX IF NOT EXISTS messages_conversation_idx
|
||||
ON messages (conversation_id, seq DESC);
|
||||
|
||||
-- Idempotent send. Partial because client_msg_id is NULL for system and
|
||||
-- assistant messages, which no client retries.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS messages_client_dedup
|
||||
ON messages (conversation_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
|
||||
|
||||
-- Delete-for-me. Distinct from messages.deleted_at (delete-for-everyone):
|
||||
-- this hides a message from one person's view while others keep seeing it.
|
||||
CREATE TABLE IF NOT EXISTS message_hides (
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
-- Denormalized from the parent message so the chat profile's "shared
|
||||
-- media" and "shared links" tabs are one index range scan, with no join
|
||||
-- back through messages.
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
ord SMALLINT NOT NULL DEFAULT 0,
|
||||
kind VARCHAR(8) NOT NULL
|
||||
CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity')),
|
||||
|
||||
-- Media (kind in image/video/audio/file). r2_key points at the PRIVATE
|
||||
-- bucket: chat media is never public-by-URL, it is read through a
|
||||
-- participant-checked presigned GET.
|
||||
r2_key TEXT,
|
||||
mime VARCHAR(128),
|
||||
size_bytes BIGINT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
duration_ms INTEGER,
|
||||
blurhash VARCHAR(64), -- instant placeholder while loading
|
||||
file_name TEXT,
|
||||
|
||||
-- Link preview (kind = 'link'). Unfurled server-side AFTER the message is
|
||||
-- persisted, so a slow remote page never delays the send. Absent when the
|
||||
-- sender dismissed the preview in the composer.
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
site_name VARCHAR(120),
|
||||
image_url TEXT,
|
||||
|
||||
-- Shared post / comment / profile / collection (kind = 'entity').
|
||||
-- entity_id is TEXT because these ids are not all UUIDs, and there is
|
||||
-- deliberately no FK: the entity may be deleted or turn private later.
|
||||
-- `snapshot` is a fallback for rendering, but permission is re-checked
|
||||
-- per viewer at read time — a collection made private must stop resolving
|
||||
-- for the recipient, so the snapshot alone must never be trusted.
|
||||
entity_type VARCHAR(16) CHECK (entity_type IS NULL OR
|
||||
entity_type IN ('post', 'comment', 'profile', 'collection')),
|
||||
entity_id TEXT,
|
||||
snapshot JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS message_attachments_message_idx
|
||||
ON message_attachments (message_id, ord);
|
||||
|
||||
-- The chat profile's media / links tabs.
|
||||
CREATE INDEX IF NOT EXISTS message_attachments_gallery_idx
|
||||
ON message_attachments (conversation_id, kind, created_at DESC);
|
||||
|
||||
-- Starred messages are per-user, not per-conversation: two people in the same
|
||||
-- thread star different things.
|
||||
CREATE TABLE IF NOT EXISTS message_stars (
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Denormalized so "starred in this chat" is a single index range scan.
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS message_stars_user_conversation_idx
|
||||
ON message_stars (user_id, conversation_id, created_at DESC);
|
||||
|
||||
-- Reported messages. `snapshot` is the whole point: it copies the reported
|
||||
-- message (and limited surrounding context) at report time so moderators read
|
||||
-- a report record and never need read access to live threads. message_id is
|
||||
-- SET NULL on delete so a report survives the sender deleting the evidence.
|
||||
CREATE TABLE IF NOT EXISTS message_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
reported_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS message_reports_unresolved_idx
|
||||
ON message_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS conversation_participants_role_idx;
|
||||
ALTER TABLE conversations DROP CONSTRAINT IF EXISTS conversations_send_policy_check;
|
||||
ALTER TABLE conversations DROP COLUMN IF EXISTS send_policy;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS chat_settings;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -0,0 +1,313 @@
|
||||
// Package unfurl fetches link preview metadata for URLs users paste into chat.
|
||||
//
|
||||
// Fetching a user-supplied URL from the server is a server-side request forgery
|
||||
// primitive: without care, "https://x" can be pointed at 169.254.169.254 (cloud
|
||||
// metadata), at a database on 10.x, or at localhost. Everything below exists to
|
||||
// make that impossible:
|
||||
//
|
||||
// - only http/https,
|
||||
// - every connection is vetted at the RESOLVED IP, so a hostname that
|
||||
// resolves to a private address is refused (and re-checked on each redirect
|
||||
// hop, which is what defeats DNS rebinding),
|
||||
// - redirects capped, response size capped, timeouts on everything,
|
||||
// - only text/html is parsed.
|
||||
//
|
||||
// The metadata is deliberately fetched by the server rather than accepted from
|
||||
// the client. A client that could supply the title and image could make any
|
||||
// link claim to be anything.
|
||||
package unfurl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrBlocked is returned for a URL that resolves somewhere we refuse to fetch.
|
||||
var ErrBlocked = errors.New("refusing to fetch that address")
|
||||
|
||||
// ErrNotHTML is returned when the response isn't a page we can read metadata
|
||||
// from — a PDF or an image has nothing to unfurl.
|
||||
var ErrNotHTML = errors.New("not an HTML page")
|
||||
|
||||
const (
|
||||
maxRedirects = 3
|
||||
maxBodyBytes = 512 << 10 // 512 KiB is far more than any <head>
|
||||
fetchTimeout = 6 * time.Second
|
||||
dialTimeout = 3 * time.Second
|
||||
maxFieldRunes = 300
|
||||
)
|
||||
|
||||
// Preview is the metadata rendered as a link card.
|
||||
type Preview struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
// Fetcher retrieves previews. Safe for concurrent use.
|
||||
type Fetcher struct {
|
||||
client *http.Client
|
||||
// allowLoopback exists only so the package's own tests can point at an
|
||||
// httptest server. It is unexported and never set outside this package.
|
||||
allowLoopback bool
|
||||
}
|
||||
|
||||
// New returns a Fetcher with every guard enabled.
|
||||
func New() *Fetcher {
|
||||
f := &Fetcher{}
|
||||
f.client = &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: dialTimeout,
|
||||
// Control runs after DNS resolution with the address actually
|
||||
// being dialled, so it sees through a hostname that points at
|
||||
// a private range — including one that changes between the
|
||||
// lookup and the connection.
|
||||
Control: func(network, address string, _ syscall.RawConn) error {
|
||||
return f.checkAddr(network, address)
|
||||
},
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: dialTimeout,
|
||||
ResponseHeaderTimeout: fetchTimeout,
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if !allowedScheme(req.URL) {
|
||||
return ErrBlocked
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Fetcher) checkAddr(network, address string) error {
|
||||
if network != "tcp4" && network != "tcp6" && network != "tcp" {
|
||||
return ErrBlocked
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return ErrBlocked
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return ErrBlocked
|
||||
}
|
||||
if f.allowLoopback && ip.IsLoopback() {
|
||||
return nil
|
||||
}
|
||||
if blockedIP(ip) {
|
||||
return ErrBlocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// blockedIP reports whether an address is one we refuse to talk to.
|
||||
func blockedIP(ip net.IP) bool {
|
||||
// Normalise IPv4-mapped IPv6 (::ffff:127.0.0.1) so the v4 rules apply.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
// Ranges Go's helpers don't cover but which still reach infrastructure.
|
||||
for _, cidr := range extraBlocked {
|
||||
if cidr.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var extraBlocked = func() []*net.IPNet {
|
||||
nets := []string{
|
||||
"100.64.0.0/10", // CGNAT
|
||||
"192.0.0.0/24", // IETF protocol assignments
|
||||
"192.0.2.0/24", // TEST-NET-1
|
||||
"198.18.0.0/15", // benchmarking
|
||||
"198.51.100.0/24", // TEST-NET-2
|
||||
"203.0.113.0/24", // TEST-NET-3
|
||||
"240.0.0.0/4", // reserved
|
||||
"::/128", // unspecified
|
||||
"64:ff9b::/96", // NAT64, can map to private v4
|
||||
}
|
||||
out := make([]*net.IPNet, 0, len(nets))
|
||||
for _, n := range nets {
|
||||
if _, cidr, err := net.ParseCIDR(n); err == nil {
|
||||
out = append(out, cidr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
func allowedScheme(u *url.URL) bool {
|
||||
return u.Scheme == "http" || u.Scheme == "https"
|
||||
}
|
||||
|
||||
// Fetch retrieves a preview for rawURL.
|
||||
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Preview, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse url: %w", err)
|
||||
}
|
||||
if !allowedScheme(u) || u.Host == "" {
|
||||
return nil, ErrBlocked
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
// Identify honestly; some sites serve richer metadata to a known crawler
|
||||
// and it gives operators someone to contact.
|
||||
req.Header.Set("User-Agent", "PrabhupadaSaysBot/1.0 (+https://prabhupadasays.org)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
// A blocked dial surfaces wrapped in a url.Error; unwrap so callers can
|
||||
// tell "refused on purpose" from "the site is down".
|
||||
if errors.Is(err, ErrBlocked) {
|
||||
return nil, ErrBlocked
|
||||
}
|
||||
return nil, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("unfurl status %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" &&
|
||||
!strings.HasPrefix(strings.ToLower(ct), "text/html") &&
|
||||
!strings.HasPrefix(strings.ToLower(ct), "application/xhtml") {
|
||||
return nil, ErrNotHTML
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
// resp.Request.URL is the FINAL url after redirects — relative images must
|
||||
// resolve against where we actually landed.
|
||||
final := resp.Request.URL
|
||||
p := extract(string(body))
|
||||
p.URL = final.String()
|
||||
if p.ImageURL != "" {
|
||||
if abs, err := final.Parse(p.ImageURL); err == nil && allowedScheme(abs) {
|
||||
p.ImageURL = abs.String()
|
||||
} else {
|
||||
p.ImageURL = ""
|
||||
}
|
||||
}
|
||||
if p.Title == "" && p.Description == "" {
|
||||
return nil, ErrNotHTML
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
var (
|
||||
metaRe = regexp.MustCompile(`(?is)<meta\s+([^>]*?)/?>`)
|
||||
attrRe = regexp.MustCompile(`(?is)([a-z0-9:_.-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`)
|
||||
titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||
headRe = regexp.MustCompile(`(?is)</head\s*>`)
|
||||
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
)
|
||||
|
||||
// extract pulls Open Graph metadata out of a document's head.
|
||||
//
|
||||
// Written against the meta tags themselves rather than a full HTML parse: the
|
||||
// fields wanted here are a handful of well-formed <meta> elements, and this
|
||||
// keeps the BFF free of another dependency.
|
||||
func extract(doc string) *Preview {
|
||||
// Metadata lives in <head>; stopping there avoids scanning a whole page.
|
||||
if loc := headRe.FindStringIndex(doc); loc != nil {
|
||||
doc = doc[:loc[0]]
|
||||
}
|
||||
|
||||
p := &Preview{}
|
||||
for _, m := range metaRe.FindAllStringSubmatch(doc, -1) {
|
||||
attrs := parseAttrs(m[1])
|
||||
key := attrs["property"]
|
||||
if key == "" {
|
||||
key = attrs["name"]
|
||||
}
|
||||
content := attrs["content"]
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "og:title", "twitter:title":
|
||||
setIfEmpty(&p.Title, content)
|
||||
case "og:description", "twitter:description", "description":
|
||||
setIfEmpty(&p.Description, content)
|
||||
case "og:site_name":
|
||||
setIfEmpty(&p.SiteName, content)
|
||||
case "og:image", "og:image:url", "og:image:secure_url", "twitter:image":
|
||||
setIfEmpty(&p.ImageURL, content)
|
||||
}
|
||||
}
|
||||
|
||||
if p.Title == "" {
|
||||
if m := titleRe.FindStringSubmatch(doc); m != nil {
|
||||
p.Title = clean(tagRe.ReplaceAllString(m[1], ""))
|
||||
}
|
||||
}
|
||||
|
||||
p.Title = truncate(p.Title, maxFieldRunes)
|
||||
p.Description = truncate(p.Description, maxFieldRunes)
|
||||
p.SiteName = truncate(p.SiteName, maxFieldRunes)
|
||||
return p
|
||||
}
|
||||
|
||||
func parseAttrs(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range attrRe.FindAllStringSubmatch(s, -1) {
|
||||
value := m[3]
|
||||
if value == "" {
|
||||
value = m[4]
|
||||
}
|
||||
if value == "" {
|
||||
value = m[5]
|
||||
}
|
||||
out[strings.ToLower(m[1])] = clean(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setIfEmpty(dst *string, v string) {
|
||||
if *dst == "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
|
||||
func clean(s string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(strings.Join(strings.Fields(s), " ")))
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string(r[:n])) + "…"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package unfurl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The blocklist is the security boundary of this package: everything else is
|
||||
// plumbing around it. A URL that resolves into any of these ranges must never
|
||||
// be fetched, or the BFF becomes a proxy into the private network.
|
||||
func TestBlockedIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", // loopback
|
||||
"::1", // loopback v6
|
||||
"::ffff:127.0.0.1", // loopback via IPv4-mapped v6
|
||||
"10.0.0.5", // RFC1918
|
||||
"172.16.4.2", // RFC1918
|
||||
"192.168.1.1", // RFC1918
|
||||
"::ffff:10.0.0.5", // RFC1918 via IPv4-mapped v6
|
||||
"169.254.169.254", // cloud instance metadata
|
||||
"fe80::1", // link-local v6
|
||||
"fd00::1", // unique local v6
|
||||
"0.0.0.0", // unspecified
|
||||
"::", // unspecified v6
|
||||
"224.0.0.1", // multicast
|
||||
"100.64.0.1", // CGNAT
|
||||
"198.18.0.1", // benchmarking
|
||||
"240.0.0.1", // reserved
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if ip := net.ParseIP(s); ip == nil || !blockedIP(ip) {
|
||||
t.Errorf("blockedIP(%s) = false, want true", s)
|
||||
}
|
||||
}
|
||||
|
||||
allowed := []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"}
|
||||
for _, s := range allowed {
|
||||
if ip := net.ParseIP(s); ip == nil || blockedIP(ip) {
|
||||
t.Errorf("blockedIP(%s) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A hostname that resolves to a private address must be refused at dial time —
|
||||
// this is what stops "http://internal.example.com" and DNS rebinding.
|
||||
func TestFetch_RefusesPrivateAddresses(t *testing.T) {
|
||||
f := New() // loopback NOT allowed
|
||||
for _, raw := range []string{
|
||||
"http://127.0.0.1:80/",
|
||||
"http://localhost/",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://10.0.0.1/",
|
||||
"http://[::1]/",
|
||||
} {
|
||||
_, err := f.Fetch(context.Background(), raw)
|
||||
if err == nil {
|
||||
t.Errorf("Fetch(%s) succeeded — it must be refused", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_RejectsNonHTTPSchemes(t *testing.T) {
|
||||
f := New()
|
||||
for _, raw := range []string{
|
||||
"file:///etc/passwd",
|
||||
"gopher://example.org/",
|
||||
"ftp://example.org/",
|
||||
"redis://10.0.0.1:6379",
|
||||
"//example.org",
|
||||
} {
|
||||
if _, err := f.Fetch(context.Background(), raw); !errors.Is(err, ErrBlocked) {
|
||||
t.Errorf("Fetch(%s) err = %v, want ErrBlocked", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testFetcher permits loopback so the fetch path can be exercised against an
|
||||
// httptest server. The flag is unexported and set only here.
|
||||
func testFetcher() *Fetcher {
|
||||
f := New()
|
||||
f.allowLoopback = true
|
||||
return f
|
||||
}
|
||||
|
||||
func TestFetch_ReadsOpenGraph(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<html><head>
|
||||
<title>Fallback title</title>
|
||||
<meta property="og:title" content="Bhagavad-gABC;tā As It Is">
|
||||
<meta property="og:description" content="Chapter 2, verse 13">
|
||||
<meta property="og:site_name" content="Prabhupāda Says">
|
||||
<meta property="og:image" content="/img/cover.jpg">
|
||||
</head><body>ignored</body></html>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := testFetcher().Fetch(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if p.Description != "Chapter 2, verse 13" {
|
||||
t.Errorf("description = %q", p.Description)
|
||||
}
|
||||
if p.SiteName != "Prabhupāda Says" {
|
||||
t.Errorf("site_name = %q", p.SiteName)
|
||||
}
|
||||
// Relative og:image must be resolved against the page it came from.
|
||||
if !strings.HasPrefix(p.ImageURL, srv.URL) || !strings.HasSuffix(p.ImageURL, "/img/cover.jpg") {
|
||||
t.Errorf("image = %q, want an absolute URL under %s", p.ImageURL, srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_FallsBackToTitleTag(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<html><head><title> Plain & simple </title>
|
||||
<meta name="description" content="From the name attribute"></head></html>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := testFetcher().Fetch(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if p.Title != "Plain & simple" {
|
||||
t.Errorf("title = %q, want entities decoded and whitespace collapsed", p.Title)
|
||||
}
|
||||
if p.Description != "From the name attribute" {
|
||||
t.Errorf("description = %q", p.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// A redirect that lands on a private address must be refused at the hop, not
|
||||
// followed because the first host looked fine.
|
||||
func TestFetch_RefusesRedirectToPrivateAddress(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := testFetcher().Fetch(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("followed a redirect into link-local space")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch_RejectsNonHTML(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
fmt.Fprint(w, "%PDF-1.7")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
|
||||
t.Errorf("err = %v, want ErrNotHTML", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A page with no metadata is not a preview — better no card than an empty one.
|
||||
func TestFetch_EmptyMetadataIsNotAPreview(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<html><head></head><body>nothing here</body></html>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := testFetcher().Fetch(context.Background(), srv.URL); !errors.Is(err, ErrNotHTML) {
|
||||
t.Errorf("err = %v, want ErrNotHTML", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A body far larger than the cap must not be read whole.
|
||||
func TestFetch_CapsBodySize(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<html><head><meta property="og:title" content="Huge"></head><body>`)
|
||||
chunk := strings.Repeat("x", 64<<10)
|
||||
for i := 0; i < 64; i++ { // 4 MiB, well past maxBodyBytes
|
||||
fmt.Fprint(w, chunk)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := testFetcher().Fetch(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch: %v", err)
|
||||
}
|
||||
if p.Title != "Huge" {
|
||||
t.Errorf("title = %q", p.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_LongFieldsAreTruncated(t *testing.T) {
|
||||
long := strings.Repeat("ā", 500)
|
||||
p := extract(`<meta property="og:title" content="` + long + `">`)
|
||||
if len([]rune(p.Title)) > maxFieldRunes+1 {
|
||||
t.Errorf("title kept %d runes, want it truncated", len([]rune(p.Title)))
|
||||
}
|
||||
if !strings.HasSuffix(p.Title, "…") {
|
||||
t.Error("truncated title should be marked with an ellipsis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_HandlesAttributeOrderAndQuotes(t *testing.T) {
|
||||
p := extract(`
|
||||
<meta content='Single quoted' property='og:title'>
|
||||
<meta content=Unquoted name=og:site_name>
|
||||
<meta property="og:description" content="Ordered normally">`)
|
||||
if p.Title != "Single quoted" {
|
||||
t.Errorf("title = %q", p.Title)
|
||||
}
|
||||
if p.SiteName != "Unquoted" {
|
||||
t.Errorf("site_name = %q", p.SiteName)
|
||||
}
|
||||
if p.Description != "Ordered normally" {
|
||||
t.Errorf("description = %q", p.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata is read from <head>; a <meta> forged in the body must not win.
|
||||
func TestExtract_IgnoresBodyContent(t *testing.T) {
|
||||
p := extract(`<html><head><meta property="og:title" content="Real"></head>
|
||||
<body><meta property="og:title" content="Forged"></body></html>`)
|
||||
if p.Title != "Real" {
|
||||
t.Errorf("title = %q, want the head's value", p.Title)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user