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