A community based topic aggregation platform built on atproto
at main 276 lines 10 kB view raw
1package routes 2 3import ( 4 "Coves/internal/api/handlers/user" 5 "Coves/internal/api/middleware" 6 "Coves/internal/core/userblocks" 7 "Coves/internal/core/users" 8 "encoding/json" 9 "errors" 10 "log" 11 "net/http" 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 "github.com/go-chi/chi/v5" 16) 17 18// UserHandler handles user-related XRPC endpoints 19type UserHandler struct { 20 userService users.UserService 21 userBlockRepo userblocks.Repository // Optional: for hydrating viewer.blocking on profiles 22} 23 24// NewUserHandler creates a new user handler 25func NewUserHandler(userService users.UserService) *UserHandler { 26 return &UserHandler{ 27 userService: userService, 28 } 29} 30 31// SetUserBlockRepo sets the user block repository for profile viewer state hydration. 32// When set, GetProfile will include viewer.blocking in the response for authenticated viewers. 33func (h *UserHandler) SetUserBlockRepo(repo userblocks.Repository) { 34 h.userBlockRepo = repo 35} 36 37// UserRouteOptions contains optional configuration for user routes. 38// Use this to inject test dependencies like custom PDS client factories. 39type UserRouteOptions struct { 40 // PDSClientFactory overrides the default OAuth-based PDS client creation. 41 // If nil, uses OAuth with DPoP (production behavior). 42 // Set this in E2E tests to use password-based authentication. 43 PDSClientFactory user.PDSClientFactory 44 45 // UserBlockRepo provides access to user block data for profile viewer state hydration. 46 // When set, GetProfile includes viewer.blocking in the response for authenticated viewers. 47 UserBlockRepo userblocks.Repository 48} 49 50// RegisterUserRoutes registers user-related XRPC endpoints on the router 51// Implements social.coves.actor.* lexicon endpoints 52func RegisterUserRoutes(r chi.Router, service users.UserService, authMiddleware *middleware.OAuthAuthMiddleware, oauthClient *oauth.ClientApp) { 53 RegisterUserRoutesWithOptions(r, service, authMiddleware, oauthClient, nil) 54} 55 56// RegisterUserRoutesWithOptions registers user-related XRPC endpoints with optional configuration. 57// Use opts to inject test dependencies like custom PDS client factories. 58func RegisterUserRoutesWithOptions(r chi.Router, service users.UserService, authMiddleware *middleware.OAuthAuthMiddleware, oauthClient *oauth.ClientApp, opts *UserRouteOptions) { 59 h := NewUserHandler(service) 60 61 // Wire optional dependencies from options 62 if opts != nil && opts.UserBlockRepo != nil { 63 h.SetUserBlockRepo(opts.UserBlockRepo) 64 } 65 66 // /api/me - returns the authenticated user's own profile (cookie or Bearer) 67 meHandler := user.NewMeHandler(service) 68 r.With(authMiddleware.RequireAuth).Get("/api/me", meHandler.HandleMe) 69 70 // social.coves.actor.getprofile - query endpoint (public, OptionalAuth for viewer state) 71 r.With(authMiddleware.OptionalAuth).Get("/xrpc/social.coves.actor.getprofile", h.GetProfile) 72 73 // social.coves.actor.signup - procedure endpoint (public) 74 r.Post("/xrpc/social.coves.actor.signup", h.Signup) 75 76 // social.coves.actor.deleteAccount - procedure endpoint (authenticated) 77 // Deletes the authenticated user's account from the Coves AppView. 78 // This ONLY deletes AppView indexed data, NOT the user's atProto identity on their PDS. 79 deleteHandler := user.NewDeleteHandler(service) 80 r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.actor.deleteAccount", deleteHandler.HandleDeleteAccount) 81 82 // social.coves.actor.updateProfile - procedure endpoint (authenticated) 83 // Updates the authenticated user's profile on their PDS (avatar, banner, displayName, bio). 84 // This writes directly to the user's PDS and the Jetstream consumer will index the change. 85 var updateProfileHandler *user.UpdateProfileHandler 86 if opts != nil && opts.PDSClientFactory != nil { 87 // Use custom factory (for E2E tests with password auth) 88 updateProfileHandler = user.NewUpdateProfileHandlerWithFactory(opts.PDSClientFactory) 89 } else { 90 // Use OAuth client for DPoP-authenticated PDS requests (production) 91 updateProfileHandler = user.NewUpdateProfileHandler(oauthClient) 92 } 93 r.With(authMiddleware.RequireAuth).Post("/xrpc/social.coves.actor.updateProfile", updateProfileHandler.ServeHTTP) 94} 95 96// GetProfile handles social.coves.actor.getprofile 97// Query endpoint that retrieves a user profile by DID or handle 98// Returns profileViewDetailed with stats per lexicon specification 99func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { 100 ctx := r.Context() 101 102 // Get actor parameter (DID or handle) 103 actor := r.URL.Query().Get("actor") 104 if actor == "" { 105 writeXRPCError(w, "InvalidRequest", "actor parameter is required", http.StatusBadRequest) 106 return 107 } 108 109 // Resolve actor to DID 110 var did string 111 if strings.HasPrefix(actor, "did:") { 112 did = actor 113 } else { 114 // Resolve handle to DID 115 resolvedDID, err := h.userService.ResolveHandleToDID(ctx, actor) 116 if err != nil { 117 writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 118 return 119 } 120 did = resolvedDID 121 } 122 123 // Get full profile with stats 124 profile, err := h.userService.GetProfile(ctx, did) 125 if err != nil { 126 if errors.Is(err, users.ErrUserNotFound) { 127 writeXRPCError(w, "ProfileNotFound", "user not found", http.StatusNotFound) 128 return 129 } 130 log.Printf("Failed to get profile for %s: %v", did, err) 131 writeXRPCError(w, "InternalError", "failed to get profile", http.StatusInternalServerError) 132 return 133 } 134 135 // Hydrate viewer state (blocking) when authenticated 136 viewerDID := middleware.GetUserDID(r) 137 if viewerDID != "" && viewerDID != did && h.userBlockRepo != nil { 138 block, blockErr := h.userBlockRepo.GetBlock(ctx, viewerDID, did) 139 if blockErr == nil && block != nil { 140 profile.Viewer = &users.ProfileViewerState{ 141 Blocking: &block.RecordURI, 142 } 143 } else if blockErr != nil && !userblocks.IsNotFound(blockErr) { 144 // Log unexpected DB errors (connection timeout, pool exhaustion, etc.) 145 // but don't fail the profile request — viewer.blocking is best-effort 146 log.Printf("WARNING: failed to check block state for viewer %s on profile %s: %v", viewerDID, did, blockErr) 147 } 148 } 149 150 // Marshal to bytes first to avoid partial writes on encoding errors 151 responseBytes, err := json.Marshal(profile) 152 if err != nil { 153 log.Printf("Failed to marshal profile response: %v", err) 154 writeXRPCError(w, "InternalError", "failed to encode response", http.StatusInternalServerError) 155 return 156 } 157 158 w.Header().Set("Content-Type", "application/json") 159 if _, err := w.Write(responseBytes); err != nil { 160 log.Printf("Failed to write response: %v", err) 161 } 162} 163 164// writeXRPCError writes a standardized XRPC error response 165func writeXRPCError(w http.ResponseWriter, errorName, message string, statusCode int) { 166 w.Header().Set("Content-Type", "application/json") 167 w.WriteHeader(statusCode) 168 if err := json.NewEncoder(w).Encode(map[string]interface{}{ 169 "error": errorName, 170 "message": message, 171 }); err != nil { 172 log.Printf("Failed to encode error response: %v", err) 173 } 174} 175 176// Signup handles social.coves.actor.signup 177// Procedure endpoint that registers a new account on the Coves instance 178func (h *UserHandler) Signup(w http.ResponseWriter, r *http.Request) { 179 ctx := r.Context() 180 181 // Parse request body 182 var req users.RegisterAccountRequest 183 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 184 http.Error(w, "invalid request body", http.StatusBadRequest) 185 return 186 } 187 188 // Call service to register account 189 resp, err := h.userService.RegisterAccount(ctx, req) 190 if err != nil { 191 // Map service errors to lexicon error types with proper HTTP status codes 192 respondWithLexiconError(w, err) 193 return 194 } 195 196 // Return response matching lexicon output schema 197 response := map[string]interface{}{ 198 "did": resp.DID, 199 "handle": resp.Handle, 200 "accessJwt": resp.AccessJwt, 201 "refreshJwt": resp.RefreshJwt, 202 } 203 204 w.Header().Set("Content-Type", "application/json") 205 w.WriteHeader(http.StatusOK) 206 if err := json.NewEncoder(w).Encode(response); err != nil { 207 log.Printf("Failed to encode response: %v", err) 208 } 209} 210 211// respondWithLexiconError maps domain errors to lexicon error types and HTTP status codes 212// Error names match the lexicon definition in social.coves.actor.signup 213func respondWithLexiconError(w http.ResponseWriter, err error) { 214 var ( 215 statusCode int 216 errorName string 217 message string 218 ) 219 220 // Map domain errors to lexicon error types 221 var invalidHandleErr *users.InvalidHandleError 222 var handleNotAvailableErr *users.HandleNotAvailableError 223 var invalidInviteCodeErr *users.InvalidInviteCodeError 224 var invalidEmailErr *users.InvalidEmailError 225 var weakPasswordErr *users.WeakPasswordError 226 var pdsErr *users.PDSError 227 228 switch { 229 case errors.As(err, &invalidHandleErr): 230 statusCode = http.StatusBadRequest 231 errorName = "InvalidHandle" 232 message = invalidHandleErr.Error() 233 234 case errors.As(err, &handleNotAvailableErr): 235 statusCode = http.StatusBadRequest 236 errorName = "HandleNotAvailable" 237 message = handleNotAvailableErr.Error() 238 239 case errors.As(err, &invalidInviteCodeErr): 240 statusCode = http.StatusBadRequest 241 errorName = "InvalidInviteCode" 242 message = invalidInviteCodeErr.Error() 243 244 case errors.As(err, &invalidEmailErr): 245 statusCode = http.StatusBadRequest 246 errorName = "InvalidEmail" 247 message = invalidEmailErr.Error() 248 249 case errors.As(err, &weakPasswordErr): 250 statusCode = http.StatusBadRequest 251 errorName = "WeakPassword" 252 message = weakPasswordErr.Error() 253 254 case errors.As(err, &pdsErr): 255 // PDS errors get mapped based on status code 256 statusCode = pdsErr.StatusCode 257 errorName = "PDSError" 258 message = pdsErr.Message 259 260 default: 261 // Generic error handling (avoid leaking internal details) 262 statusCode = http.StatusInternalServerError 263 errorName = "InternalServerError" 264 message = "An error occurred while processing your request" 265 } 266 267 // XRPC error response format 268 w.Header().Set("Content-Type", "application/json") 269 w.WriteHeader(statusCode) 270 if err := json.NewEncoder(w).Encode(map[string]interface{}{ 271 "error": errorName, 272 "message": message, 273 }); err != nil { 274 log.Printf("Failed to encode error response: %v", err) 275 } 276}