// 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 } func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService { return &PostService{posts: posts, r2: r2} } // 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 []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") } return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs) } // Get returns one post (public). func (s *PostService) Get(ctx context.Context, id string) (*model.Post, error) { return s.posts.FindByID(ctx, id) } // 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. The caller must own the // post. Returns the fresh post payload so the client can reconcile its // local state without a separate fetch. 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. func (s *PostService) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) { if limit <= 0 || limit > 50 { limit = 20 } if offset < 0 { offset = 0 } return s.posts.ListByHandle(ctx, handle, limit, offset) } // ListMine is the owner's view of their own posts (drafts feature isn't a // thing yet, so this is equivalent to ListByHandle minus the handle lookup). 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 } return s.posts.ListByUser(ctx, userID, limit, offset) } // ImagePresign returns a one-shot R2 PUT URL for a post image. // The key is namespaced under post-images//.. 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 } 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 }