A community based topic aggregation platform built on atproto
at main 7.7 kB view raw
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}