691 lines
23 KiB
Go
691 lines
23 KiB
Go
// Package service — social bits of the BFF.
|
||
//
|
||
// `social_service.go` bundles three lightweight services (Post, Bookmark,
|
||
// Collection) because each is small (10–40 LOC of logic) and they share the
|
||
// same R2 + UserRepo dependencies. Keeping them together keeps `server.go`'s
|
||
// init-wiring tidy without sprawling into 4 separate files.
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"path"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||
)
|
||
|
||
// Sentinels surfaced to handlers.
|
||
var (
|
||
ErrForbidden = errors.New("forbidden")
|
||
ErrNotFound = errors.New("not found")
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PostService
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type PostService struct {
|
||
posts *repository.PostRepo
|
||
r2 *repository.R2
|
||
likes *repository.LikeRepo
|
||
mentions *repository.MentionRepo
|
||
comments *repository.CommentRepo
|
||
notif *NotificationService // optional; set via SetNotifier
|
||
}
|
||
|
||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo) *PostService {
|
||
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments}
|
||
}
|
||
|
||
// SetNotifier wires the notification seam after construction (the
|
||
// NotificationService is built later in server wiring than PostService).
|
||
func (s *PostService) SetNotifier(n *NotificationService) { s.notif = n }
|
||
|
||
// Create persists a new post. The caption is treated as opaque — we trust
|
||
// the frontend to send the exact text the user selected. Source URL must be
|
||
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
|
||
// was provided so links can't be hijacked.
|
||
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs, mentionUserIDs []string) (*model.Post, error) {
|
||
caption = strings.TrimSpace(caption)
|
||
if caption == "" {
|
||
return nil, errors.New("caption is required")
|
||
}
|
||
if len(caption) > 4000 {
|
||
return nil, errors.New("caption too long (max 4000)")
|
||
}
|
||
sourceURL = stripHost(strings.TrimSpace(sourceURL))
|
||
if sourceURL == "" || !strings.HasPrefix(sourceURL, "/") {
|
||
return nil, errors.New("source_url must be an absolute path starting with /")
|
||
}
|
||
if len(imageURLs) > 5 {
|
||
return nil, errors.New("a post can hold up to 5 images")
|
||
}
|
||
p, err := s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Tag mentioned users (best-effort, additive — a bad id never fails the
|
||
// post itself). Reflect the count on the returned post, then notify the
|
||
// tagged people (fire-and-forget on a detached context).
|
||
if len(mentionUserIDs) > 0 {
|
||
_ = s.mentions.AddMany(ctx, p.ID, mentionUserIDs)
|
||
_ = s.mentions.HydrateCounts(ctx, []*model.Post{p})
|
||
go s.notifyMentions(context.Background(), p.ID, userID, mentionUserIDs)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// Get returns one post (public). `viewerID` is "" for anonymous callers;
|
||
// when set, the returned post carries the viewer's reshare state so the
|
||
// frontend can render the right action button without a second request.
|
||
func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post, error) {
|
||
p, err := s.posts.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.hydrateReshareStates(ctx, viewerID, []*model.Post{p}); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.comments.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// Reshare materialises a reshare as a brand-new posts row owned by the
|
||
// caller, with reshared_from_post_id pointing to the canonical original.
|
||
// Idempotent — calling it twice returns the same reshare row. Resharing
|
||
// one's own post (canonical or chained) is rejected at the repo layer.
|
||
//
|
||
// Returns the new post so the frontend can drop it into local state
|
||
// without a round-trip.
|
||
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) {
|
||
return s.posts.Reshare(ctx, postID, callerID)
|
||
}
|
||
|
||
// Unreshare removes the caller's reshare of a canonical post by deleting
|
||
// their own reshare posts row. Idempotent — no-ops when nothing to delete.
|
||
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error {
|
||
return s.posts.Unreshare(ctx, postID, callerID)
|
||
}
|
||
|
||
// hydrateReshareStates fills in ViewerHasReshared on each post via one
|
||
// batched lookup. Skips the query when the viewer is anonymous (the
|
||
// answer is uniformly false) or the slice is empty.
|
||
func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
|
||
if viewerID == "" || len(posts) == 0 {
|
||
return nil
|
||
}
|
||
ids := make([]string, 0, len(posts))
|
||
for _, p := range posts {
|
||
if p == nil {
|
||
continue
|
||
}
|
||
ids = append(ids, p.ID)
|
||
}
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, p := range posts {
|
||
if p == nil {
|
||
continue
|
||
}
|
||
if states[p.ID] {
|
||
p.ViewerHasReshared = true
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// hydrateReshareStatesSlice is the value-slice variant of
|
||
// hydrateReshareStates. Repository list methods return []model.Post
|
||
// (not pointers) — we wrap each element so the helper can mutate in
|
||
// place via a stable pointer.
|
||
func (s *PostService) hydrateReshareStatesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||
if len(posts) == 0 {
|
||
return nil
|
||
}
|
||
ptrs := make([]*model.Post, len(posts))
|
||
for i := range posts {
|
||
ptrs[i] = &posts[i]
|
||
}
|
||
return s.hydrateReshareStates(ctx, viewerID, ptrs)
|
||
}
|
||
|
||
// Delete checks ownership and removes the post.
|
||
func (s *PostService) Delete(ctx context.Context, postID, callerID string) error {
|
||
return s.posts.Delete(ctx, postID, callerID)
|
||
}
|
||
|
||
// ReplaceImages swaps the image set on a post. Owner-only — every
|
||
// reshare is its own posts row, owned by the resharer, so this works
|
||
// uniformly for canonical posts and reshares.
|
||
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
|
||
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.posts.FindByID(ctx, postID)
|
||
}
|
||
|
||
// ListByHandle is the public feed view. `viewerID` is "" for anonymous
|
||
// callers; when set, each returned post carries the viewer's reshare
|
||
// state.
|
||
func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, limit, offset int) ([]model.Post, int, error) {
|
||
if limit <= 0 || limit > 50 {
|
||
limit = 20
|
||
}
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
posts, total, err := s.posts.ListByHandle(ctx, handle, limit, offset)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return posts, total, nil
|
||
}
|
||
|
||
// hydratePinnedSlice fills PinnedComment on a value slice.
|
||
func (s *PostService) hydratePinnedSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||
if len(posts) == 0 {
|
||
return nil
|
||
}
|
||
ptrs := make([]*model.Post, len(posts))
|
||
for i := range posts {
|
||
ptrs[i] = &posts[i]
|
||
}
|
||
return s.comments.Hydrate(ctx, viewerID, ptrs)
|
||
}
|
||
|
||
// hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice.
|
||
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||
if len(posts) == 0 {
|
||
return nil
|
||
}
|
||
ptrs := make([]*model.Post, len(posts))
|
||
for i := range posts {
|
||
ptrs[i] = &posts[i]
|
||
}
|
||
return s.likes.Hydrate(ctx, viewerID, ptrs)
|
||
}
|
||
|
||
// hydrateMentionsSlice fills MentionsCount on a value slice.
|
||
func (s *PostService) hydrateMentionsSlice(ctx context.Context, posts []model.Post) error {
|
||
if len(posts) == 0 {
|
||
return nil
|
||
}
|
||
ptrs := make([]*model.Post, len(posts))
|
||
for i := range posts {
|
||
ptrs[i] = &posts[i]
|
||
}
|
||
return s.mentions.HydrateCounts(ctx, ptrs)
|
||
}
|
||
|
||
// SuggestMentions returns prioritised mention suggestions for the composer.
|
||
func (s *PostService) SuggestMentions(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
|
||
return s.mentions.Suggest(ctx, viewerID, q, limit, offset)
|
||
}
|
||
|
||
// Mentions lists the users tagged on a post.
|
||
func (s *PostService) Mentions(ctx context.Context, postID string) ([]model.UserSummary, error) {
|
||
return s.mentions.ListForPost(ctx, postID)
|
||
}
|
||
|
||
// AddMention / RemoveMention edit a post's tags — author only.
|
||
func (s *PostService) AddMention(ctx context.Context, viewerID, postID, targetUserID string) error {
|
||
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
|
||
return err
|
||
}
|
||
if err := s.mentions.Add(ctx, postID, targetUserID); err != nil {
|
||
return err
|
||
}
|
||
if s.notif != nil {
|
||
go s.notif.NotifyPostMention(context.Background(), targetUserID, viewerID, postID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// notifyMentions notifies each freshly-tagged user (deduped, never the author).
|
||
func (s *PostService) notifyMentions(ctx context.Context, postID, authorID string, userIDs []string) {
|
||
if s.notif == nil {
|
||
return
|
||
}
|
||
seen := make(map[string]bool, len(userIDs))
|
||
for _, uid := range userIDs {
|
||
if uid == "" || uid == authorID || seen[uid] {
|
||
continue
|
||
}
|
||
seen[uid] = true
|
||
s.notif.NotifyPostMention(ctx, uid, authorID, postID)
|
||
}
|
||
}
|
||
|
||
func (s *PostService) RemoveMention(ctx context.Context, viewerID, postID, targetUserID string) error {
|
||
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
|
||
return err
|
||
}
|
||
return s.mentions.Remove(ctx, postID, targetUserID)
|
||
}
|
||
|
||
func (s *PostService) requireAuthor(ctx context.Context, viewerID, postID string) error {
|
||
p, err := s.posts.FindByID(ctx, postID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if p.UserID != viewerID {
|
||
return ErrForbidden
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Like / Unlike toggle the caller's like on a post and return the effective
|
||
// total (likes_count + pending Redis delta).
|
||
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
|
||
changed, err := s.likes.Like(ctx, postID, userID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
count, err := s.likes.EffectiveCount(ctx, postID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
// Notify the author on a genuinely new like by someone else. Fire-and-
|
||
// forget on a detached context so it isn't cancelled when the request
|
||
// returns; NotifyPostLike re-checks self-like internally too.
|
||
if changed && s.notif != nil {
|
||
if authorID, aerr := s.likes.PostAuthor(ctx, postID); aerr == nil && authorID != userID {
|
||
go s.notif.NotifyPostLike(context.Background(), postID, authorID, userID)
|
||
}
|
||
}
|
||
return count, nil
|
||
}
|
||
|
||
func (s *PostService) Unlike(ctx context.Context, userID, postID string) (int, error) {
|
||
if _, err := s.likes.Unlike(ctx, postID, userID); err != nil {
|
||
return 0, err
|
||
}
|
||
return s.likes.EffectiveCount(ctx, postID)
|
||
}
|
||
|
||
// Likers returns one page of users who liked the post + the total count.
|
||
func (s *PostService) Likers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||
return s.likes.ListLikers(ctx, postID, q, limit, offset)
|
||
}
|
||
|
||
// ListMine is the owner's view of their own profile feed. Every post
|
||
// they own — authored or reshared — comes from posts.user_id, so the
|
||
// viewer is by construction the owner. ViewerHasReshared is true for
|
||
// reshare rows (the user IS the resharer) and false for canonical
|
||
// rows (you don't "reshare your own post").
|
||
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||
if limit <= 0 || limit > 50 {
|
||
limit = 20
|
||
}
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
posts, total, err := s.posts.ListByUser(ctx, userID, limit, offset)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for i := range posts {
|
||
if posts[i].ResharedFromPostID != nil {
|
||
posts[i].ViewerHasReshared = true
|
||
}
|
||
}
|
||
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return posts, total, nil
|
||
}
|
||
|
||
// ImagePresign returns a one-shot R2 PUT URL for a post image.
|
||
// The key is namespaced under post-images/<user_id>/<random>.<ext>. We
|
||
// don't tie it to a post_id at presign time because the post might not
|
||
// exist yet — the user uploads images first, then submits the post with
|
||
// the public URLs.
|
||
func (s *PostService) ImagePresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||
return presignImage(ctx, s.r2, "post-images", userID, contentType)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BookmarkService
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type BookmarkService struct {
|
||
users *repository.UserRepo
|
||
bookmarks *repository.BookmarkRepo
|
||
}
|
||
|
||
func NewBookmarkService(users *repository.UserRepo, bookmarks *repository.BookmarkRepo) *BookmarkService {
|
||
return &BookmarkService{users: users, bookmarks: bookmarks}
|
||
}
|
||
|
||
// Create enforces the plan cap and inserts a new bookmark.
|
||
//
|
||
// If the same URL is already bookmarked (UNIQUE constraint), we return the
|
||
// existing row as a 200 idempotent response instead of an error — clicking
|
||
// "Bookmark" twice on the same passage should feel like a no-op.
|
||
func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
|
||
u, err := s.users.FindByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
current, err := s.bookmarks.CountByUser(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
b, err := s.bookmarks.Create(ctx, userID, stripHost(sourceURL), sourceTitle, excerpt, note)
|
||
if err == nil {
|
||
return b, nil
|
||
}
|
||
if errors.Is(err, repository.ErrBookmarkExists) {
|
||
// Idempotent: return whatever is already there.
|
||
list, lerr := s.bookmarks.ListByUser(ctx, userID)
|
||
if lerr != nil {
|
||
return nil, lerr
|
||
}
|
||
for _, existing := range list {
|
||
if existing.SourceURL == stripHost(sourceURL) {
|
||
return &existing, nil
|
||
}
|
||
}
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
// List returns the caller's bookmarks.
|
||
func (s *BookmarkService) List(ctx context.Context, userID string) ([]model.Bookmark, error) {
|
||
return s.bookmarks.ListByUser(ctx, userID)
|
||
}
|
||
|
||
// Delete removes a bookmark belonging to the caller.
|
||
func (s *BookmarkService) Delete(ctx context.Context, id, userID string) error {
|
||
return s.bookmarks.Delete(ctx, id, userID)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CollectionService
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type CollectionService struct {
|
||
users *repository.UserRepo
|
||
collections *repository.CollectionRepo
|
||
posts *repository.PostRepo
|
||
r2 *repository.R2
|
||
}
|
||
|
||
func NewCollectionService(users *repository.UserRepo, collections *repository.CollectionRepo, posts *repository.PostRepo, r2 *repository.R2) *CollectionService {
|
||
return &CollectionService{users: users, collections: collections, posts: posts, r2: r2}
|
||
}
|
||
|
||
// Create enforces per-visibility plan caps and inserts.
|
||
func (s *CollectionService) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
|
||
if title = strings.TrimSpace(title); title == "" {
|
||
return nil, errors.New("title is required")
|
||
}
|
||
if len(title) > 120 {
|
||
return nil, errors.New("title too long (max 120)")
|
||
}
|
||
u, err := s.users.FindByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
current, err := s.collections.CountByUserAndVisibility(ctx, userID, isPublic)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
||
}
|
||
|
||
// Update — only the owner may edit. Visibility changes that would exceed
|
||
// the plan cap are blocked.
|
||
func (s *CollectionService) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
|
||
c, err := s.collections.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if c.UserID != userID {
|
||
return nil, ErrForbidden
|
||
}
|
||
// If the caller is flipping visibility, make sure they wouldn't blow the cap.
|
||
if isPublic != nil && *isPublic != c.IsPublic {
|
||
u, err := s.users.FindByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
current, err := s.collections.CountByUserAndVisibility(ctx, userID, *isPublic)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return s.collections.Update(ctx, id, userID, title, description, coverURL, isPublic)
|
||
}
|
||
|
||
// Delete — only the owner may delete.
|
||
func (s *CollectionService) Delete(ctx context.Context, id, userID string) error {
|
||
c, err := s.collections.FindByID(ctx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if c.UserID != userID {
|
||
return ErrForbidden
|
||
}
|
||
return s.collections.Delete(ctx, id, userID)
|
||
}
|
||
|
||
// ListMine returns the caller's collections (both public and private).
|
||
func (s *CollectionService) ListMine(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
|
||
return s.collections.ListByUser(ctx, userID)
|
||
}
|
||
|
||
// AddItem inserts a post into a collection. Caller must own the collection.
|
||
// We don't require ownership of the *post* — collections can curate other
|
||
// users' public posts in a future iteration; for v1 the toolbar always
|
||
// creates a fresh post owned by the caller before calling this.
|
||
func (s *CollectionService) AddItem(ctx context.Context, collectionID, postID, callerID string) error {
|
||
c, err := s.collections.FindByID(ctx, collectionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if c.UserID != callerID {
|
||
return ErrForbidden
|
||
}
|
||
if _, err := s.posts.FindByID(ctx, postID); err != nil {
|
||
return err
|
||
}
|
||
return s.collections.AddItem(ctx, collectionID, postID)
|
||
}
|
||
|
||
// SetItemOrder updates positions for the given post ids so the new order
|
||
// matches the input array. Owner-only.
|
||
func (s *CollectionService) SetItemOrder(ctx context.Context, collectionID, callerID string, postIDs []string) error {
|
||
c, err := s.collections.FindByID(ctx, collectionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if c.UserID != callerID {
|
||
return ErrForbidden
|
||
}
|
||
return s.collections.SetItemOrder(ctx, collectionID, postIDs)
|
||
}
|
||
|
||
// CollectionsContaining returns the ids of the caller's collections
|
||
// that already include the given post. Used by the save-to-collection
|
||
// drawer to disable rows the post is already in.
|
||
func (s *CollectionService) CollectionsContaining(ctx context.Context, postID, callerID string) ([]string, error) {
|
||
return s.collections.CollectionsContaining(ctx, postID, callerID)
|
||
}
|
||
|
||
// RemoveItem detaches a post from a collection (owner only).
|
||
func (s *CollectionService) RemoveItem(ctx context.Context, collectionID, postID, callerID string) error {
|
||
c, err := s.collections.FindByID(ctx, collectionID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if c.UserID != callerID {
|
||
return ErrForbidden
|
||
}
|
||
return s.collections.RemoveItem(ctx, collectionID, postID)
|
||
}
|
||
|
||
// Get returns one collection. Visibility is enforced by the caller —
|
||
// `viewerID` is the calling user id ("" if anonymous). If the collection is
|
||
// private and the viewer isn't the owner, returns ErrForbidden.
|
||
func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*model.CollectionRecord, []model.Post, error) {
|
||
c, err := s.collections.FindByID(ctx, id)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if !c.IsPublic && c.UserID != viewerID {
|
||
return nil, nil, ErrForbidden
|
||
}
|
||
posts, err := s.collections.PostsIn(ctx, id)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
// PostsIn returns posts without images (single-table boundary). Hydrate
|
||
// them in one batched query so callers — and the post cards rendered
|
||
// inside collection carousels — can show the post's first image as
|
||
// background instead of falling back to the empty quote panel.
|
||
if len(posts) > 0 {
|
||
ids := make([]string, len(posts))
|
||
for i := range posts {
|
||
ids[i] = posts[i].ID
|
||
}
|
||
imgs, err := s.posts.ImagesFor(ctx, ids)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
for i := range posts {
|
||
posts[i].Images = imgs[posts[i].ID]
|
||
}
|
||
// Same batched approach for reshare states so the action footer
|
||
// on each card swaps Reshare↔Send correctly. Anonymous viewers
|
||
// (viewerID == "") get all-false at zero cost.
|
||
if viewerID != "" {
|
||
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
for i := range posts {
|
||
if states[posts[i].ID] {
|
||
posts[i].ViewerHasReshared = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return c, posts, nil
|
||
}
|
||
|
||
// CoverPresign returns a one-shot R2 PUT URL for a collection cover image.
|
||
func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||
return presignImage(ctx, s.r2, "collection-covers", userID, contentType)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// shared helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// presignImage builds an R2 presign for a user-scoped media key under the
|
||
// given prefix. The random suffix invalidates browser caches when the image
|
||
// is replaced.
|
||
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||
if r2 == nil {
|
||
return "", "", errors.New("media uploads are not configured")
|
||
}
|
||
ext, ok := imageExtensionFor(contentType)
|
||
if !ok {
|
||
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||
}
|
||
suffix := make([]byte, 8)
|
||
if _, err := rand.Read(suffix); err != nil {
|
||
return "", "", err
|
||
}
|
||
key := path.Join(prefix, userID, hex.EncodeToString(suffix)+ext)
|
||
return r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||
}
|
||
|
||
func imageExtensionFor(ct string) (string, bool) {
|
||
switch ct {
|
||
case "image/png":
|
||
return ".png", true
|
||
case "image/jpeg":
|
||
return ".jpg", true
|
||
case "image/webp":
|
||
return ".webp", true
|
||
case "image/gif":
|
||
return ".gif", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
// stripHost reduces "https://prabhupadasays.org/books/bg/1/1?hl=x" to
|
||
// "/books/bg/1/1?hl=x" so we never store arbitrary external URLs.
|
||
// Anything that doesn't parse cleanly is returned unchanged (and rejected
|
||
// later by the prefix-check in the service).
|
||
func stripHost(u string) string {
|
||
u = strings.TrimSpace(u)
|
||
if u == "" {
|
||
return u
|
||
}
|
||
if strings.HasPrefix(u, "/") {
|
||
return u
|
||
}
|
||
for _, prefix := range []string{"http://", "https://"} {
|
||
if strings.HasPrefix(u, prefix) {
|
||
rest := u[len(prefix):]
|
||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||
return rest[i:]
|
||
}
|
||
return "/"
|
||
}
|
||
}
|
||
return u
|
||
}
|