A community based topic aggregation platform built on atproto
at main 8.8 kB view raw
1package users 2 3import ( 4 "Coves/internal/atproto/identity" 5 "bytes" 6 "context" 7 "encoding/json" 8 "fmt" 9 "io" 10 "log" 11 "net/http" 12 "regexp" 13 "strings" 14 "time" 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 func() { 191 if closeErr := resp.Body.Close(); closeErr != nil { 192 log.Printf("Failed to close response body: %v", closeErr) 193 } 194 }() 195 196 body, err := io.ReadAll(resp.Body) 197 if err != nil { 198 return nil, fmt.Errorf("failed to read response: %w", err) 199 } 200 201 if resp.StatusCode != http.StatusOK { 202 return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) 203 } 204 205 var pdsResp RegisterAccountResponse 206 if err := json.Unmarshal(body, &pdsResp); err != nil { 207 return nil, fmt.Errorf("failed to parse PDS response: %w", err) 208 } 209 210 // Set the PDS URL in the response (PDS doesn't return this) 211 pdsResp.PDSURL = s.defaultPDS 212 213 return &pdsResp, nil 214} 215 216func (s *userService) validateCreateRequest(req CreateUserRequest) error { 217 if strings.TrimSpace(req.DID) == "" { 218 return fmt.Errorf("DID is required") 219 } 220 221 if strings.TrimSpace(req.Handle) == "" { 222 return fmt.Errorf("handle is required") 223 } 224 225 if strings.TrimSpace(req.PDSURL) == "" { 226 return fmt.Errorf("PDS URL is required") 227 } 228 229 // DID format validation 230 if !strings.HasPrefix(req.DID, "did:") { 231 return fmt.Errorf("invalid DID format: must start with 'did:'") 232 } 233 234 // Validate handle format 235 if err := validateHandle(req.Handle); err != nil { 236 return err 237 } 238 239 return nil 240} 241 242func (s *userService) validateRegisterRequest(req RegisterAccountRequest) error { 243 if strings.TrimSpace(req.Handle) == "" { 244 return fmt.Errorf("handle is required") 245 } 246 247 if strings.TrimSpace(req.Email) == "" { 248 return &InvalidEmailError{Email: req.Email} 249 } 250 251 // Basic email validation 252 if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { 253 return &InvalidEmailError{Email: req.Email} 254 } 255 256 // Password validation 257 if strings.TrimSpace(req.Password) == "" { 258 return &WeakPasswordError{Reason: "password is required"} 259 } 260 261 if len(req.Password) < minPasswordLength { 262 return &WeakPasswordError{Reason: fmt.Sprintf("password must be at least %d characters", minPasswordLength)} 263 } 264 265 // Validate handle format 266 if err := validateHandle(req.Handle); err != nil { 267 return err 268 } 269 270 return nil 271} 272 273// validateHandle validates handle per atProto spec: https://atproto.com/specs/handle 274func validateHandle(handle string) error { 275 // Normalize to lowercase (handles are case-insensitive) 276 handle = strings.TrimSpace(strings.ToLower(handle)) 277 278 if handle == "" { 279 return &InvalidHandleError{Handle: handle, Reason: "handle cannot be empty"} 280 } 281 282 // Check length 283 if len(handle) > maxHandleLength { 284 return &InvalidHandleError{Handle: handle, Reason: fmt.Sprintf("handle exceeds maximum length of %d characters", maxHandleLength)} 285 } 286 287 // Check regex pattern 288 if !handleRegex.MatchString(handle) { 289 return &InvalidHandleError{Handle: handle, Reason: "handle must be domain-like (e.g., user.bsky.social), with segments of alphanumeric/hyphens separated by dots"} 290 } 291 292 // Check for disallowed TLDs 293 for tld := range disallowedTLDs { 294 if strings.HasSuffix(handle, tld) { 295 return &InvalidHandleError{Handle: handle, Reason: fmt.Sprintf("TLD %s is not allowed", tld)} 296 } 297 } 298 299 return nil 300}