A community based topic aggregation platform built on atproto
1package users 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "io" 9 "net/http" 10 "regexp" 11 "strings" 12 "time" 13 14 "Coves/internal/atproto/identity" 15) 16 17// atProto handle validation regex (per official atProto spec: https://atproto.com/specs/handle) 18// - Must have at least one dot (domain-like structure) 19// - Each segment max 63 chars, total max 253 chars 20// - Segments: alphanumeric start/end, hyphens allowed in middle 21// - TLD (final segment) must start with letter (not digit) 22// - Case-insensitive, normalized to lowercase 23var handleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 24 25// Disallowed TLDs per atProto spec 26var disallowedTLDs = map[string]bool{ 27 ".alt": true, 28 ".arpa": true, 29 ".example": true, 30 ".internal": true, 31 ".invalid": true, 32 ".local": true, 33 ".localhost": true, 34 ".onion": true, 35 // .test is allowed for development 36} 37 38const ( 39 minPasswordLength = 8 // Reasonable minimum, though PDS may enforce stricter rules 40 maxHandleLength = 253 41) 42 43type userService struct { 44 userRepo UserRepository 45 identityResolver identity.Resolver 46 defaultPDS string // Default PDS URL for this Coves instance (used when creating new local users via registration API) 47} 48 49// NewUserService creates a new user service 50func NewUserService(userRepo UserRepository, identityResolver identity.Resolver, defaultPDS string) UserService { 51 return &userService{ 52 userRepo: userRepo, 53 identityResolver: identityResolver, 54 defaultPDS: defaultPDS, 55 } 56} 57 58// CreateUser creates a new user in the AppView database 59// This method is idempotent: if a user with the same DID already exists, it returns the existing user 60func (s *userService) CreateUser(ctx context.Context, req CreateUserRequest) (*User, error) { 61 if err := s.validateCreateRequest(req); err != nil { 62 return nil, err 63 } 64 65 // Normalize handle 66 req.Handle = strings.TrimSpace(strings.ToLower(req.Handle)) 67 req.DID = strings.TrimSpace(req.DID) 68 req.PDSURL = strings.TrimSpace(req.PDSURL) 69 70 user := &User{ 71 DID: req.DID, 72 Handle: req.Handle, 73 PDSURL: req.PDSURL, 74 } 75 76 // Try to create the user 77 createdUser, err := s.userRepo.Create(ctx, user) 78 if err != nil { 79 // If user with this DID already exists, fetch and return it (idempotent behavior) 80 if strings.Contains(err.Error(), "user with DID already exists") { 81 existingUser, getErr := s.userRepo.GetByDID(ctx, req.DID) 82 if getErr != nil { 83 return nil, fmt.Errorf("user exists but failed to fetch: %w", getErr) 84 } 85 return existingUser, nil 86 } 87 // For other errors (validation, handle conflict, etc.), return the error 88 return nil, err 89 } 90 91 return createdUser, nil 92} 93 94// GetUserByDID retrieves a user by their DID 95func (s *userService) GetUserByDID(ctx context.Context, did string) (*User, error) { 96 if strings.TrimSpace(did) == "" { 97 return nil, fmt.Errorf("DID is required") 98 } 99 100 return s.userRepo.GetByDID(ctx, did) 101} 102 103// GetUserByHandle retrieves a user by their handle 104func (s *userService) GetUserByHandle(ctx context.Context, handle string) (*User, error) { 105 handle = strings.TrimSpace(strings.ToLower(handle)) 106 if handle == "" { 107 return nil, fmt.Errorf("handle is required") 108 } 109 110 return s.userRepo.GetByHandle(ctx, handle) 111} 112 113// UpdateHandle updates the handle for a user with the given DID 114func (s *userService) UpdateHandle(ctx context.Context, did, newHandle string) (*User, error) { 115 did = strings.TrimSpace(did) 116 newHandle = strings.TrimSpace(strings.ToLower(newHandle)) 117 118 if did == "" { 119 return nil, fmt.Errorf("DID is required") 120 } 121 if newHandle == "" { 122 return nil, fmt.Errorf("handle is required") 123 } 124 125 // Validate new handle format 126 if err := validateHandle(newHandle); err != nil { 127 return nil, err 128 } 129 130 return s.userRepo.UpdateHandle(ctx, did, newHandle) 131} 132 133// ResolveHandleToDID resolves a handle to a DID 134// This is critical for login: users enter their handle, we resolve to DID 135// Uses DNS TXT record lookup and HTTPS .well-known/atproto-did resolution 136func (s *userService) ResolveHandleToDID(ctx context.Context, handle string) (string, error) { 137 handle = strings.TrimSpace(strings.ToLower(handle)) 138 if handle == "" { 139 return "", fmt.Errorf("handle is required") 140 } 141 142 // Use identity resolver to resolve handle to DID 143 did, _, err := s.identityResolver.ResolveHandle(ctx, handle) 144 if err != nil { 145 return "", fmt.Errorf("failed to resolve handle %s: %w", handle, err) 146 } 147 148 return did, nil 149} 150 151// RegisterAccount creates a new account on the PDS via XRPC 152// This is what a UI signup button would call - it handles the PDS account creation 153func (s *userService) RegisterAccount(ctx context.Context, req RegisterAccountRequest) (*RegisterAccountResponse, error) { 154 if err := s.validateRegisterRequest(req); err != nil { 155 return nil, err 156 } 157 158 // Call PDS com.atproto.server.createAccount XRPC endpoint 159 pdsURL := strings.TrimSuffix(s.defaultPDS, "/") 160 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.server.createAccount", pdsURL) 161 162 payload := map[string]string{ 163 "handle": req.Handle, 164 "email": req.Email, 165 "password": req.Password, 166 } 167 if req.InviteCode != "" { 168 payload["inviteCode"] = req.InviteCode 169 } 170 171 jsonData, err := json.Marshal(payload) 172 if err != nil { 173 return nil, fmt.Errorf("failed to marshal request: %w", err) 174 } 175 176 httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData)) 177 if err != nil { 178 return nil, fmt.Errorf("failed to create request: %w", err) 179 } 180 httpReq.Header.Set("Content-Type", "application/json") 181 182 // Set timeout to prevent hanging on slow/unavailable PDS 183 client := &http.Client{ 184 Timeout: 10 * time.Second, 185 } 186 resp, err := client.Do(httpReq) 187 if err != nil { 188 return nil, fmt.Errorf("failed to call PDS: %w", err) 189 } 190 defer resp.Body.Close() 191 192 body, err := io.ReadAll(resp.Body) 193 if err != nil { 194 return nil, fmt.Errorf("failed to read response: %w", err) 195 } 196 197 if resp.StatusCode != http.StatusOK { 198 return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) 199 } 200 201 var pdsResp RegisterAccountResponse 202 if err := json.Unmarshal(body, &pdsResp); err != nil { 203 return nil, fmt.Errorf("failed to parse PDS response: %w", err) 204 } 205 206 // Set the PDS URL in the response (PDS doesn't return this) 207 pdsResp.PDSURL = s.defaultPDS 208 209 return &pdsResp, nil 210} 211 212func (s *userService) validateCreateRequest(req CreateUserRequest) error { 213 if strings.TrimSpace(req.DID) == "" { 214 return fmt.Errorf("DID is required") 215 } 216 217 if strings.TrimSpace(req.Handle) == "" { 218 return fmt.Errorf("handle is required") 219 } 220 221 if strings.TrimSpace(req.PDSURL) == "" { 222 return fmt.Errorf("PDS URL is required") 223 } 224 225 // DID format validation 226 if !strings.HasPrefix(req.DID, "did:") { 227 return fmt.Errorf("invalid DID format: must start with 'did:'") 228 } 229 230 // Validate handle format 231 if err := validateHandle(req.Handle); err != nil { 232 return err 233 } 234 235 return nil 236} 237 238func (s *userService) validateRegisterRequest(req RegisterAccountRequest) error { 239 if strings.TrimSpace(req.Handle) == "" { 240 return fmt.Errorf("handle is required") 241 } 242 243 if strings.TrimSpace(req.Email) == "" { 244 return &InvalidEmailError{Email: req.Email} 245 } 246 247 // Basic email validation 248 if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { 249 return &InvalidEmailError{Email: req.Email} 250 } 251 252 // Password validation 253 if strings.TrimSpace(req.Password) == "" { 254 return &WeakPasswordError{Reason: "password is required"} 255 } 256 257 if len(req.Password) < minPasswordLength { 258 return &WeakPasswordError{Reason: fmt.Sprintf("password must be at least %d characters", minPasswordLength)} 259 } 260 261 // Validate handle format 262 if err := validateHandle(req.Handle); err != nil { 263 return err 264 } 265 266 return nil 267} 268 269// validateHandle validates handle per atProto spec: https://atproto.com/specs/handle 270func validateHandle(handle string) error { 271 // Normalize to lowercase (handles are case-insensitive) 272 handle = strings.TrimSpace(strings.ToLower(handle)) 273 274 if handle == "" { 275 return &InvalidHandleError{Handle: handle, Reason: "handle cannot be empty"} 276 } 277 278 // Check length 279 if len(handle) > maxHandleLength { 280 return &InvalidHandleError{Handle: handle, Reason: fmt.Sprintf("handle exceeds maximum length of %d characters", maxHandleLength)} 281 } 282 283 // Check regex pattern 284 if !handleRegex.MatchString(handle) { 285 return &InvalidHandleError{Handle: handle, Reason: "handle must be domain-like (e.g., user.bsky.social), with segments of alphanumeric/hyphens separated by dots"} 286 } 287 288 // Check for disallowed TLDs 289 for tld := range disallowedTLDs { 290 if strings.HasSuffix(handle, tld) { 291 return &InvalidHandleError{Handle: handle, Reason: fmt.Sprintf("TLD %s is not allowed", tld)} 292 } 293 } 294 295 return nil 296}