feat: add user centric features
This commit is contained in:
@@ -32,6 +32,7 @@ var (
|
||||
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
|
||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
ErrHandleTaken = errors.New("handle already in use")
|
||||
)
|
||||
|
||||
// AuthService bundles every flow that mutates auth state.
|
||||
@@ -91,12 +92,29 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.users.Create(ctx, email, string(hash), strings.TrimSpace(name))
|
||||
if err != nil {
|
||||
// Generate a handle and retry up to 8 times if it collides with an
|
||||
// existing user. The slug seed is derived from the email's local-part,
|
||||
// which is the closest thing to a free-form identifier the user gave us.
|
||||
base := slugForHandle(email)
|
||||
var u *model.User
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
handle := base
|
||||
if attempt > 0 {
|
||||
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||
}
|
||||
u, err = s.users.Create(ctx, email, string(hash), strings.TrimSpace(name), handle)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, repository.ErrEmailTaken) {
|
||||
return nil, ErrEmailAlreadyTaken
|
||||
}
|
||||
return nil, err
|
||||
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
||||
@@ -265,6 +283,43 @@ func newToken() (raw, hash string, err error) {
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
// slugForHandle turns the local-part of an email into a kebab-case handle.
|
||||
// Falls back to "user" for fully-stripped inputs so we always have a seed
|
||||
// the retry loop can append a suffix to.
|
||||
func slugForHandle(email string) string {
|
||||
at := strings.IndexByte(email, '@')
|
||||
local := email
|
||||
if at >= 0 {
|
||||
local = email[:at]
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(local))
|
||||
for _, r := range strings.ToLower(local) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
out = "user"
|
||||
}
|
||||
if len(out) > 32 {
|
||||
out = out[:32]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// randomHandleSuffix returns 5 hex characters (~20 bits of entropy) — plenty
|
||||
// to disambiguate names that already exist in the user base.
|
||||
func randomHandleSuffix() string {
|
||||
b := make([]byte, 3)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)[:5]
|
||||
}
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Plan caps for the social features. Updates here propagate everywhere via
|
||||
// EnforceBookmarkLimit / EnforceCollectionLimit.
|
||||
const (
|
||||
PlanFree = "free"
|
||||
PlanPremium = "premium"
|
||||
|
||||
freeBookmarkLimit = 1
|
||||
premiumBookmarkLimit = 5
|
||||
|
||||
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
|
||||
// Premium has no collection cap.
|
||||
)
|
||||
|
||||
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
|
||||
// Handlers surface this as HTTP 402 (Payment Required) so clients can show
|
||||
// an upsell. The message tells the user what cap was hit.
|
||||
var ErrPlanLimit = errors.New("plan limit reached")
|
||||
|
||||
// PlanLimitError carries detail about which limit was hit so the frontend
|
||||
// can render a helpful message instead of a generic "upgrade to premium".
|
||||
type PlanLimitError struct {
|
||||
Resource string // e.g. "bookmarks", "private collections"
|
||||
Limit int
|
||||
Plan string
|
||||
}
|
||||
|
||||
func (e *PlanLimitError) Error() string {
|
||||
return fmt.Sprintf("%s limit reached (%d on %s plan)", e.Resource, e.Limit, e.Plan)
|
||||
}
|
||||
|
||||
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||
|
||||
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
||||
// bookmark, or a PlanLimitError otherwise.
|
||||
func EnforceBookmarkLimit(plan string, currentCount int) error {
|
||||
limit := bookmarkLimitFor(plan)
|
||||
if currentCount >= limit {
|
||||
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnforceCollectionLimit returns nil if the user is allowed to create one
|
||||
// more collection of the given visibility.
|
||||
//
|
||||
// `isPublic=true` → checks against public-collection cap
|
||||
// `isPublic=false` → checks against private-collection cap
|
||||
// Premium users have no cap → always nil.
|
||||
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
|
||||
if plan == PlanPremium {
|
||||
return nil
|
||||
}
|
||||
limit := freeCollectionPerVisibilityLimit
|
||||
if currentCount >= limit {
|
||||
resource := "private collections"
|
||||
if isPublic {
|
||||
resource = "public collections"
|
||||
}
|
||||
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bookmarkLimitFor(plan string) int {
|
||||
if plan == PlanPremium {
|
||||
return premiumBookmarkLimit
|
||||
}
|
||||
return freeBookmarkLimit
|
||||
}
|
||||
|
||||
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
|
||||
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
|
||||
|
||||
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
|
||||
func CollectionLimitFor(plan string) int {
|
||||
if plan == PlanPremium {
|
||||
return -1
|
||||
}
|
||||
return freeCollectionPerVisibilityLimit
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// 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/<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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
|
||||
return &UserService{users: users, r2: r2}
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's name and/or avatar URL.
|
||||
// UpdateProfile updates the user's name, avatar URL, and/or handle.
|
||||
//
|
||||
// `avatarURL` semantics:
|
||||
//
|
||||
@@ -32,10 +32,10 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
|
||||
// "" → clear (remove avatar)
|
||||
// "x" → set to "x"
|
||||
//
|
||||
// The avatar URL is expected to point at our R2 bucket — we don't validate
|
||||
// the host because we don't want to break tests / local setups, but in
|
||||
// production you could add a check that it starts with R2_PUBLIC_URL.
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
|
||||
// `handle` is normalized to the project's slug format before being passed
|
||||
// to the DB so users can't sneak in spaces or punctuation. Empty after
|
||||
// normalization → 400.
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
|
||||
if name != nil {
|
||||
trimmed := strings.TrimSpace(*name)
|
||||
if trimmed == "" {
|
||||
@@ -46,7 +46,38 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string
|
||||
}
|
||||
name = &trimmed
|
||||
}
|
||||
return s.users.UpdateProfile(ctx, id, name, avatarURL)
|
||||
if handle != nil {
|
||||
clean := normalizeHandle(*handle)
|
||||
if len(clean) < 3 {
|
||||
return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)")
|
||||
}
|
||||
if len(clean) > 32 {
|
||||
return nil, errors.New("handle too long (max 32)")
|
||||
}
|
||||
handle = &clean
|
||||
}
|
||||
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
|
||||
}
|
||||
|
||||
// normalizeHandle lowercases and strips anything that isn't [a-z0-9-]. Same
|
||||
// rules as slugForHandle in auth_service, but exposed here because the
|
||||
// profile-update path doesn't go through auth.
|
||||
func normalizeHandle(raw string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(raw)) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-', r == '.', r == '_':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
// Collapse runs of dashes.
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AvatarPresign issues a one-shot presigned PUT URL the frontend uses to
|
||||
|
||||
Reference in New Issue
Block a user