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