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