A community based topic aggregation platform built on atproto
at main 360 lines 14 kB view raw
1package user 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 11 "Coves/internal/api/middleware" 12 "Coves/internal/atproto/pds" 13 14 "github.com/bluesky-social/indigo/atproto/auth/oauth" 15) 16 17// CovesProfileCollection is the atProto collection for Coves user profiles. 18// NOTE: This constant is intentionally duplicated in internal/atproto/jetstream/user_consumer.go 19// to avoid circular dependencies between packages. Keep both definitions in sync. 20const CovesProfileCollection = "social.coves.actor.profile" 21 22// PDSClientFactory creates PDS clients from session data. 23// Used to allow injection of different auth mechanisms (OAuth for production, password for E2E tests). 24type PDSClientFactory func(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) 25 26const ( 27 // MaxDisplayNameLength is the maximum allowed length for display names (per atProto lexicon) 28 MaxDisplayNameLength = 64 29 // MaxBioLength is the maximum allowed length for bio/description (per atProto lexicon) 30 MaxBioLength = 256 31 // MaxAvatarBlobSize is the maximum allowed avatar size in bytes (1MB per lexicon) 32 MaxAvatarBlobSize = 1_000_000 33 // MaxBannerBlobSize is the maximum allowed banner size in bytes (2MB per lexicon) 34 MaxBannerBlobSize = 2_000_000 35 // MaxRequestBodySize is the maximum request body size (10MB to accommodate base64 overhead) 36 MaxRequestBodySize = 10_000_000 37) 38 39// UpdateProfileRequest represents the request body for updating a user profile 40type UpdateProfileRequest struct { 41 DisplayName *string `json:"displayName,omitempty"` 42 Bio *string `json:"bio,omitempty"` 43 AvatarBlob []byte `json:"avatarBlob,omitempty"` 44 AvatarMimeType string `json:"avatarMimeType,omitempty"` 45 BannerBlob []byte `json:"bannerBlob,omitempty"` 46 BannerMimeType string `json:"bannerMimeType,omitempty"` 47} 48 49// UpdateProfileResponse represents the response from updating a profile 50type UpdateProfileResponse struct { 51 URI string `json:"uri"` 52 CID string `json:"cid"` 53} 54 55// UpdateProfileHandler handles POST /xrpc/social.coves.actor.updateProfile 56// This endpoint allows authenticated users to update their Coves profile on their PDS. 57// It validates inputs, uploads any provided blobs, and writes the profile record. 58type UpdateProfileHandler struct { 59 oauthClient *oauth.ClientApp // For creating authenticated PDS clients (production) 60 pdsClientFactory PDSClientFactory // Optional: custom factory for testing 61} 62 63// NewUpdateProfileHandler creates a new update profile handler. 64// Panics if oauthClient is nil - use NewUpdateProfileHandlerWithFactory for testing. 65func NewUpdateProfileHandler(oauthClient *oauth.ClientApp) *UpdateProfileHandler { 66 if oauthClient == nil { 67 panic("NewUpdateProfileHandler: oauthClient is required") 68 } 69 return &UpdateProfileHandler{ 70 oauthClient: oauthClient, 71 } 72} 73 74// NewUpdateProfileHandlerWithFactory creates a new update profile handler with a custom PDS client factory. 75// This is primarily for E2E testing with password-based authentication instead of OAuth. 76// Panics if factory is nil. 77func NewUpdateProfileHandlerWithFactory(factory PDSClientFactory) *UpdateProfileHandler { 78 if factory == nil { 79 panic("NewUpdateProfileHandlerWithFactory: factory is required") 80 } 81 return &UpdateProfileHandler{ 82 pdsClientFactory: factory, 83 } 84} 85 86// getPDSClient creates a PDS client from an OAuth session. 87// If a custom factory was provided (for testing), uses that. 88// Otherwise, uses DPoP authentication via indigo's ClientApp for proper OAuth token handling. 89func (h *UpdateProfileHandler) getPDSClient(ctx context.Context, session *oauth.ClientSessionData) (pds.Client, error) { 90 // Use custom factory if provided (e.g., for E2E testing with password auth) 91 if h.pdsClientFactory != nil { 92 return h.pdsClientFactory(ctx, session) 93 } 94 95 // Production path: use OAuth with DPoP 96 if h.oauthClient == nil { 97 return nil, fmt.Errorf("OAuth client not configured") 98 } 99 100 return pds.NewFromOAuthSession(ctx, h.oauthClient, session) 101} 102 103// ServeHTTP handles the update profile request 104func (h *UpdateProfileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 105 ctx := r.Context() 106 107 // Check HTTP method 108 if r.Method != http.MethodPost { 109 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 110 return 111 } 112 113 // 1. Get authenticated user from context 114 userDID := middleware.GetUserDID(r) 115 if userDID == "" { 116 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 117 return 118 } 119 120 // Get OAuth session for PDS URL and access token 121 session := middleware.GetOAuthSession(r) 122 if session == nil { 123 writeUpdateProfileError(w, http.StatusUnauthorized, "MissingSession", "Missing PDS credentials") 124 return 125 } 126 127 if session.HostURL == "" { 128 writeUpdateProfileError(w, http.StatusUnauthorized, "MissingCredentials", "Missing PDS credentials") 129 return 130 } 131 132 // 2. Parse request 133 r.Body = http.MaxBytesReader(w, r.Body, MaxRequestBodySize) 134 var req UpdateProfileRequest 135 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 136 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 137 return 138 } 139 140 // Validate displayName length 141 if req.DisplayName != nil && len(*req.DisplayName) > MaxDisplayNameLength { 142 writeUpdateProfileError(w, http.StatusBadRequest, "DisplayNameTooLong", 143 fmt.Sprintf("Display name exceeds %d character limit", MaxDisplayNameLength)) 144 return 145 } 146 147 // Validate bio length 148 if req.Bio != nil && len(*req.Bio) > MaxBioLength { 149 writeUpdateProfileError(w, http.StatusBadRequest, "BioTooLong", 150 fmt.Sprintf("Bio exceeds %d character limit", MaxBioLength)) 151 return 152 } 153 154 // 3. Validate blob sizes and mime types 155 if len(req.AvatarBlob) > 0 { 156 // Validate mime type is provided when blob is provided 157 if req.AvatarMimeType == "" { 158 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Avatar blob provided without mime type") 159 return 160 } 161 // Validate size (1MB max for avatar per lexicon) 162 if len(req.AvatarBlob) > MaxAvatarBlobSize { 163 writeUpdateProfileError(w, http.StatusBadRequest, "AvatarTooLarge", "Avatar exceeds 1MB limit") 164 return 165 } 166 if !isValidImageMimeType(req.AvatarMimeType) { 167 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidMimeType", "Invalid avatar mime type") 168 return 169 } 170 } 171 172 if len(req.BannerBlob) > 0 { 173 // Validate mime type is provided when blob is provided 174 if req.BannerMimeType == "" { 175 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidRequest", "Banner blob provided without mime type") 176 return 177 } 178 // Validate size (2MB max for banner per lexicon) 179 if len(req.BannerBlob) > MaxBannerBlobSize { 180 writeUpdateProfileError(w, http.StatusBadRequest, "BannerTooLarge", "Banner exceeds 2MB limit") 181 return 182 } 183 if !isValidImageMimeType(req.BannerMimeType) { 184 writeUpdateProfileError(w, http.StatusBadRequest, "InvalidMimeType", "Invalid banner mime type") 185 return 186 } 187 } 188 189 // 4. Create PDS client (uses factory if provided, otherwise OAuth with DPoP) 190 pdsClient, err := h.getPDSClient(ctx, session) 191 if err != nil { 192 slog.Error("failed to create PDS client", 193 slog.String("did", userDID), 194 slog.String("error", err.Error()), 195 ) 196 writeUpdateProfileError(w, http.StatusUnauthorized, "SessionError", 197 "Failed to restore session. Please sign in again.") 198 return 199 } 200 201 // 5. Build profile record 202 profile := map[string]interface{}{ 203 "$type": CovesProfileCollection, 204 } 205 206 // Add displayName if provided 207 if req.DisplayName != nil { 208 profile["displayName"] = *req.DisplayName 209 } 210 211 // Add bio (description) if provided 212 if req.Bio != nil { 213 profile["description"] = *req.Bio 214 } 215 216 // 6. Upload avatar blob if provided 217 if len(req.AvatarBlob) > 0 { 218 avatarRef, err := pdsClient.UploadBlob(ctx, req.AvatarBlob, req.AvatarMimeType) 219 if err != nil { 220 slog.Error("failed to upload avatar blob", 221 slog.String("did", userDID), 222 slog.String("error", err.Error()), 223 ) 224 // Map specific PDS errors to user-friendly messages 225 switch { 226 case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden): 227 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthExpired", "Your session may have expired. Please re-authenticate.") 228 case errors.Is(err, pds.ErrRateLimited): 229 writeUpdateProfileError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests. Please try again later.") 230 case errors.Is(err, pds.ErrPayloadTooLarge): 231 writeUpdateProfileError(w, http.StatusRequestEntityTooLarge, "AvatarTooLarge", "Avatar exceeds PDS size limit.") 232 default: 233 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Failed to upload avatar") 234 } 235 return 236 } 237 if avatarRef == nil || avatarRef.Ref == nil || avatarRef.Type == "" { 238 slog.Error("invalid blob reference returned from avatar upload", slog.String("did", userDID)) 239 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Invalid avatar blob reference") 240 return 241 } 242 profile["avatar"] = map[string]interface{}{ 243 "$type": avatarRef.Type, 244 "ref": avatarRef.Ref, 245 "mimeType": avatarRef.MimeType, 246 "size": avatarRef.Size, 247 } 248 } 249 250 // 7. Upload banner blob if provided 251 if len(req.BannerBlob) > 0 { 252 bannerRef, err := pdsClient.UploadBlob(ctx, req.BannerBlob, req.BannerMimeType) 253 if err != nil { 254 slog.Error("failed to upload banner blob", 255 slog.String("did", userDID), 256 slog.String("error", err.Error()), 257 ) 258 // Map specific PDS errors to user-friendly messages 259 switch { 260 case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden): 261 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthExpired", "Your session may have expired. Please re-authenticate.") 262 case errors.Is(err, pds.ErrRateLimited): 263 writeUpdateProfileError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests. Please try again later.") 264 case errors.Is(err, pds.ErrPayloadTooLarge): 265 writeUpdateProfileError(w, http.StatusRequestEntityTooLarge, "BannerTooLarge", "Banner exceeds PDS size limit.") 266 default: 267 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Failed to upload banner") 268 } 269 return 270 } 271 if bannerRef == nil || bannerRef.Ref == nil || bannerRef.Type == "" { 272 slog.Error("invalid blob reference returned from banner upload", slog.String("did", userDID)) 273 writeUpdateProfileError(w, http.StatusInternalServerError, "BlobUploadFailed", "Invalid banner blob reference") 274 return 275 } 276 profile["banner"] = map[string]interface{}{ 277 "$type": bannerRef.Type, 278 "ref": bannerRef.Ref, 279 "mimeType": bannerRef.MimeType, 280 "size": bannerRef.Size, 281 } 282 } 283 284 // 8. Put profile record to PDS using com.atproto.repo.putRecord 285 uri, cid, err := pdsClient.PutRecord(ctx, CovesProfileCollection, "self", profile, "") 286 if err != nil { 287 slog.Error("failed to put profile record to PDS", 288 slog.String("did", userDID), 289 slog.String("pds_url", session.HostURL), 290 slog.String("error", err.Error()), 291 ) 292 // Map PDS errors to user-friendly messages 293 switch { 294 case errors.Is(err, pds.ErrUnauthorized), errors.Is(err, pds.ErrForbidden): 295 writeUpdateProfileError(w, http.StatusUnauthorized, "AuthExpired", "Your session may have expired. Please re-authenticate.") 296 case errors.Is(err, pds.ErrRateLimited): 297 writeUpdateProfileError(w, http.StatusTooManyRequests, "RateLimited", "Too many requests. Please try again later.") 298 case errors.Is(err, pds.ErrPayloadTooLarge): 299 writeUpdateProfileError(w, http.StatusRequestEntityTooLarge, "PayloadTooLarge", "Profile data exceeds PDS size limit.") 300 default: 301 writeUpdateProfileError(w, http.StatusInternalServerError, "PDSError", "Failed to update profile") 302 } 303 return 304 } 305 306 // 9. Return success response 307 resp := UpdateProfileResponse{URI: uri, CID: cid} 308 309 // Marshal to bytes first to catch encoding errors before writing headers 310 responseBytes, err := json.Marshal(resp) 311 if err != nil { 312 slog.Error("failed to marshal update profile response", 313 slog.String("did", userDID), 314 slog.String("error", err.Error()), 315 ) 316 writeUpdateProfileError(w, http.StatusInternalServerError, "InternalError", "Failed to encode response") 317 return 318 } 319 320 w.Header().Set("Content-Type", "application/json") 321 w.WriteHeader(http.StatusOK) 322 if _, writeErr := w.Write(responseBytes); writeErr != nil { 323 slog.Warn("failed to write update profile response", 324 slog.String("did", userDID), 325 slog.String("error", writeErr.Error()), 326 ) 327 } 328} 329 330// isValidImageMimeType checks if the MIME type is allowed for profile images 331func isValidImageMimeType(mimeType string) bool { 332 switch mimeType { 333 case "image/png", "image/jpeg", "image/webp": 334 return true 335 default: 336 return false 337 } 338} 339 340// writeUpdateProfileError writes a JSON error response for update profile failures 341func writeUpdateProfileError(w http.ResponseWriter, statusCode int, errorType, message string) { 342 responseBytes, err := json.Marshal(map[string]interface{}{ 343 "error": errorType, 344 "message": message, 345 }) 346 if err != nil { 347 // Fallback to plain text if JSON encoding fails 348 slog.Error("failed to marshal error response", slog.String("error", err.Error())) 349 w.Header().Set("Content-Type", "text/plain") 350 w.WriteHeader(statusCode) 351 _, _ = w.Write([]byte(message)) 352 return 353 } 354 355 w.Header().Set("Content-Type", "application/json") 356 w.WriteHeader(statusCode) 357 if _, writeErr := w.Write(responseBytes); writeErr != nil { 358 slog.Warn("failed to write error response", slog.String("error", writeErr.Error())) 359 } 360}