A community based topic aggregation platform built on atproto
1package aggregator
2
3import (
4 "Coves/internal/atproto/identity"
5 "Coves/internal/core/users"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "log"
11 "net/http"
12 "strings"
13 "time"
14)
15
16const (
17 // maxWellKnownSize limits the response body size when fetching .well-known/atproto-did.
18 // DIDs are typically ~60 characters. A 4KB limit leaves ample room for whitespace or
19 // future metadata while still preventing attackers from streaming unbounded data.
20 maxWellKnownSize = 4 * 1024 // bytes
21)
22
23// RegisterHandler handles aggregator registration
24type RegisterHandler struct {
25 userService users.UserService
26 identityResolver identity.Resolver
27 httpClient *http.Client // Allows test injection
28}
29
30// NewRegisterHandler creates a new registration handler
31func NewRegisterHandler(userService users.UserService, identityResolver identity.Resolver) *RegisterHandler {
32 return &RegisterHandler{
33 userService: userService,
34 identityResolver: identityResolver,
35 httpClient: &http.Client{Timeout: 10 * time.Second},
36 }
37}
38
39// SetHTTPClient allows overriding the HTTP client (for testing with self-signed certs)
40func (h *RegisterHandler) SetHTTPClient(client *http.Client) {
41 h.httpClient = client
42}
43
44// RegisterRequest represents the registration request
45type RegisterRequest struct {
46 DID string `json:"did"`
47 Domain string `json:"domain"`
48}
49
50// RegisterResponse represents the registration response
51type RegisterResponse struct {
52 DID string `json:"did"`
53 Handle string `json:"handle"`
54 Message string `json:"message"`
55}
56
57// HandleRegister handles aggregator registration
58// POST /xrpc/social.coves.aggregator.register
59//
60// Architecture Note: This handler contains business logic for domain verification.
61// This is intentional for the following reasons:
62// 1. Registration is a one-time setup operation, not core aggregator business logic
63// 2. It primarily delegates to UserService (proper service layer)
64// 3. Domain verification is an infrastructure concern (like TLS verification)
65// 4. Moving to AggregatorService would create circular dependency (aggregators table has FK to users)
66// 5. Similar pattern used in Bluesky's PDS for account creation
67func (h *RegisterHandler) HandleRegister(w http.ResponseWriter, r *http.Request) {
68 if r.Method != http.MethodPost {
69 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
70 return
71 }
72
73 // Parse request body
74 var req RegisterRequest
75 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
76 writeError(w, http.StatusBadRequest, "InvalidDID", "Invalid request body: JSON decode failed")
77 return
78 }
79
80 // Validate input
81 if err := validateRegistrationRequest(req); err != nil {
82 writeError(w, http.StatusBadRequest, "InvalidDID", err.Error())
83 return
84 }
85
86 // Normalize inputs
87 req.DID = strings.TrimSpace(req.DID)
88 req.Domain = strings.TrimSpace(req.Domain)
89
90 // Reject HTTP explicitly (HTTPS required for domain verification)
91 if strings.HasPrefix(req.Domain, "http://") {
92 writeError(w, http.StatusBadRequest, "InvalidDID", "Domain must use HTTPS, not HTTP")
93 return
94 }
95
96 req.Domain = strings.TrimPrefix(req.Domain, "https://")
97 req.Domain = strings.TrimSuffix(req.Domain, "/")
98
99 // Re-validate after normalization to catch edge cases like " " or "https://"
100 if req.Domain == "" {
101 writeError(w, http.StatusBadRequest, "InvalidDID", "Domain cannot be empty")
102 return
103 }
104
105 // Verify domain ownership via .well-known
106 if err := h.verifyDomainOwnership(r.Context(), req.DID, req.Domain); err != nil {
107 log.Printf("Domain verification failed for DID %s, domain %s: %v", req.DID, req.Domain, err)
108 writeError(w, http.StatusUnauthorized, "DomainVerificationFailed",
109 "Could not verify domain ownership. Ensure .well-known/atproto-did serves your DID over HTTPS")
110 return
111 }
112
113 // Check if user already exists (before CreateUser since it's idempotent)
114 existingUser, err := h.userService.GetUserByDID(r.Context(), req.DID)
115 if err == nil && existingUser != nil {
116 writeError(w, http.StatusConflict, "AlreadyRegistered",
117 "This aggregator is already registered with this instance")
118 return
119 }
120
121 // Resolve DID to get handle and PDS URL
122 identityInfo, err := h.identityResolver.Resolve(r.Context(), req.DID)
123 if err != nil {
124 writeError(w, http.StatusBadRequest, "DIDResolutionFailed",
125 "Could not resolve DID. Please verify it exists in the PLC directory")
126 return
127 }
128
129 // Register the aggregator in the users table
130 createReq := users.CreateUserRequest{
131 DID: req.DID,
132 Handle: identityInfo.Handle,
133 PDSURL: identityInfo.PDSURL,
134 }
135
136 user, err := h.userService.CreateUser(r.Context(), createReq)
137 if err != nil {
138 log.Printf("Failed to create user for aggregator DID %s: %v", req.DID, err)
139 writeError(w, http.StatusInternalServerError, "RegistrationFailed",
140 "Failed to register aggregator")
141 return
142 }
143
144 // Return success response
145 response := RegisterResponse{
146 DID: user.DID,
147 Handle: user.Handle,
148 Message: fmt.Sprintf("Aggregator registered successfully. Next step: create a service declaration record at at://%s/social.coves.aggregator.service/self", user.DID),
149 }
150
151 w.Header().Set("Content-Type", "application/json")
152 w.WriteHeader(http.StatusOK)
153 if err := json.NewEncoder(w).Encode(response); err != nil {
154 http.Error(w, "Failed to encode response", http.StatusInternalServerError)
155 }
156}
157
158// validateRegistrationRequest validates the registration request
159func validateRegistrationRequest(req RegisterRequest) error {
160 // Validate DID format
161 if req.DID == "" {
162 return fmt.Errorf("did is required")
163 }
164
165 if !strings.HasPrefix(req.DID, "did:") {
166 return fmt.Errorf("did must start with 'did:' prefix")
167 }
168
169 // We support did:plc for now (most common for aggregators)
170 if !strings.HasPrefix(req.DID, "did:plc:") && !strings.HasPrefix(req.DID, "did:web:") {
171 return fmt.Errorf("only did:plc and did:web formats are currently supported")
172 }
173
174 // Validate domain
175 if req.Domain == "" {
176 return fmt.Errorf("domain is required")
177 }
178
179 return nil
180}
181
182// verifyDomainOwnership verifies that the domain serves the correct DID in .well-known/atproto-did
183func (h *RegisterHandler) verifyDomainOwnership(ctx context.Context, expectedDID, domain string) error {
184 // Construct .well-known URL
185 wellKnownURL := fmt.Sprintf("https://%s/.well-known/atproto-did", domain)
186
187 // Create request with context
188 req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil)
189 if err != nil {
190 return fmt.Errorf("failed to create request: %w", err)
191 }
192
193 // Perform request
194 resp, err := h.httpClient.Do(req)
195 if err != nil {
196 return fmt.Errorf("failed to fetch .well-known/atproto-did from %s: %w", domain, err)
197 }
198 defer func() { _ = resp.Body.Close() }()
199
200 // Check status code
201 if resp.StatusCode != http.StatusOK {
202 return fmt.Errorf(".well-known/atproto-did returned status %d (expected 200)", resp.StatusCode)
203 }
204
205 // Read body with size limit to prevent DoS attacks from malicious servers
206 // streaming arbitrarily large responses. Read one extra byte so we can detect
207 // when the response exceeded the allowed size instead of silently truncating.
208 limitedReader := io.LimitReader(resp.Body, maxWellKnownSize+1)
209 body, err := io.ReadAll(limitedReader)
210 if err != nil {
211 return fmt.Errorf("failed to read .well-known/atproto-did response: %w", err)
212 }
213
214 if len(body) > maxWellKnownSize {
215 return fmt.Errorf(".well-known/atproto-did response exceeds %d bytes", maxWellKnownSize)
216 }
217
218 // Parse DID from response
219 actualDID := strings.TrimSpace(string(body))
220
221 // Verify DID matches
222 if actualDID != expectedDID {
223 return fmt.Errorf("DID mismatch: .well-known/atproto-did contains '%s', expected '%s'", actualDID, expectedDID)
224 }
225
226 return nil
227}