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}