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" ) // UserService handles profile-edit + avatar-upload flows. type UserService struct { users *repository.UserRepo r2 *repository.R2 } 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. // // `avatarURL` semantics: // // nil → don't touch // "" → 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) { if name != nil { trimmed := strings.TrimSpace(*name) if trimmed == "" { return nil, errors.New("name cannot be empty") } if len(trimmed) > 120 { return nil, errors.New("name too long (max 120)") } name = &trimmed } return s.users.UpdateProfile(ctx, id, name, avatarURL) } // AvatarPresign issues a one-shot presigned PUT URL the frontend uses to // upload an avatar directly to R2. // // The key is namespaced under `avatars//.` so: // - we can list / clean up a user's avatars with one prefix scan // - the random suffix forces cache invalidation when the avatar changes // - a malicious user can't overwrite someone else's avatar (their JWT // scopes the user_id portion of the key) // // Returns (uploadURL, publicURL, error). The frontend then PUTs to uploadURL // and POSTs publicURL back as the new avatar. func (s *UserService) AvatarPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) { if s.r2 == nil { return "", "", errors.New("avatar uploads are not configured") } ext, ok := extensionFor(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("avatars", userID, hex.EncodeToString(suffix)+ext) return s.r2.PresignPut(ctx, key, contentType, 5*time.Minute) } // extensionFor whitelists the image content types we accept. Restricting // here is the cheap-but-effective defence against users uploading arbitrary // binaries to our R2 bucket. func extensionFor(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 } }