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, avatar URL, and/or handle. // // `avatarURL` semantics: // // nil → don't touch // "" → clear (remove avatar) // "x" → set to "x" // // `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 == "" { return nil, errors.New("name cannot be empty") } if len(trimmed) > 120 { return nil, errors.New("name too long (max 120)") } name = &trimmed } 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) } // Search returns users matching the query (handle or name substring). // Used by the public user-search endpoint on the /search page. func (s *UserService) Search(ctx context.Context, q string, limit int) ([]model.User, error) { return s.users.Search(ctx, q, limit) } // GetReadingPrefs returns the user's reading prefs blob (opaque JSON). // Used by the frontend to hydrate ReadingSettings on sign-in so the // theme/palette/font choices roam across devices. func (s *UserService) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) { return s.users.GetReadingPrefs(ctx, id) } // UpdateReadingPrefs overwrites the user's reading prefs blob. The // handler re-marshals the decoded request before calling in here so we // know `prefsJSON` is well-formed by the time it hits the DB. func (s *UserService) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error { return s.users.UpdateReadingPrefs(ctx, id, prefsJSON) } // 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 // 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 } }