A community based topic aggregation platform built on atproto
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}