A community based topic aggregation platform built on atproto
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}