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