A community based topic aggregation platform built on atproto
at main 265 lines 8.1 kB view raw
1package actor 2 3import ( 4 "encoding/json" 5 "errors" 6 "log" 7 "net/http" 8 "strconv" 9 "strings" 10 11 "Coves/internal/api/middleware" 12 "Coves/internal/core/comments" 13 "Coves/internal/core/users" 14 "Coves/internal/core/votes" 15) 16 17// GetCommentsHandler handles actor comment retrieval 18type GetCommentsHandler struct { 19 commentService comments.Service 20 userService users.UserService 21 voteService votes.Service 22} 23 24// NewGetCommentsHandler creates a new actor comments handler 25func NewGetCommentsHandler( 26 commentService comments.Service, 27 userService users.UserService, 28 voteService votes.Service, 29) *GetCommentsHandler { 30 return &GetCommentsHandler{ 31 commentService: commentService, 32 userService: userService, 33 voteService: voteService, 34 } 35} 36 37// HandleGetComments retrieves comments by an actor (user) 38// GET /xrpc/social.coves.actor.getComments?actor={did_or_handle}&community=...&limit=50&cursor=... 39func (h *GetCommentsHandler) HandleGetComments(w http.ResponseWriter, r *http.Request) { 40 if r.Method != http.MethodGet { 41 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 42 return 43 } 44 45 // Parse query parameters 46 req, err := h.parseRequest(r) 47 if err != nil { 48 // Check if it's an actor not found error (from handle resolution) 49 var actorNotFound *actorNotFoundError 50 if errors.As(err, &actorNotFound) { 51 writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 52 return 53 } 54 55 // Check if it's an infrastructure failure during resolution 56 // (database down, DNS failures, network errors, etc.) 57 var resolutionFailed *resolutionFailedError 58 if errors.As(err, &resolutionFailed) { 59 log.Printf("ERROR: Actor resolution infrastructure failure: %v", err) 60 writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to resolve actor identity") 61 return 62 } 63 64 writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 65 return 66 } 67 68 // Get viewer DID for populating viewer state (optional) 69 viewerDID := middleware.GetUserDID(r) 70 if viewerDID != "" { 71 req.ViewerDID = &viewerDID 72 } 73 74 // Get actor comments from service 75 response, err := h.commentService.GetActorComments(r.Context(), req) 76 if err != nil { 77 handleCommentServiceError(w, err) 78 return 79 } 80 81 // Populate viewer vote state if authenticated 82 h.populateViewerVoteState(r, response) 83 84 // Pre-encode response to buffer before writing headers 85 // This ensures we can return a proper error if encoding fails 86 responseBytes, err := json.Marshal(response) 87 if err != nil { 88 log.Printf("ERROR: Failed to encode actor comments response: %v", err) 89 writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 90 return 91 } 92 93 // Return comments 94 w.Header().Set("Content-Type", "application/json") 95 w.WriteHeader(http.StatusOK) 96 if _, err := w.Write(responseBytes); err != nil { 97 log.Printf("ERROR: Failed to write actor comments response: %v", err) 98 } 99} 100 101// parseRequest parses query parameters into GetActorCommentsRequest 102func (h *GetCommentsHandler) parseRequest(r *http.Request) (*comments.GetActorCommentsRequest, error) { 103 req := &comments.GetActorCommentsRequest{} 104 105 // Required: actor (handle or DID) 106 actor := r.URL.Query().Get("actor") 107 if actor == "" { 108 return nil, &validationError{field: "actor", message: "actor parameter is required"} 109 } 110 // Validate actor length to prevent DoS via massive strings 111 // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) 112 // Max handle length is 253 chars (DNS limit) 113 const maxActorLength = 2048 114 if len(actor) > maxActorLength { 115 return nil, &validationError{field: "actor", message: "actor parameter exceeds maximum length"} 116 } 117 118 // Resolve actor to DID if it's a handle 119 actorDID, err := h.resolveActor(r, actor) 120 if err != nil { 121 return nil, err 122 } 123 req.ActorDID = actorDID 124 125 // Optional: community (handle or DID) 126 req.Community = r.URL.Query().Get("community") 127 128 // Optional: limit (default: 50, max: 100) 129 if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 130 limit, err := strconv.Atoi(limitStr) 131 if err != nil { 132 return nil, &validationError{field: "limit", message: "limit must be a valid integer"} 133 } 134 req.Limit = limit 135 } 136 137 // Optional: cursor 138 if cursor := r.URL.Query().Get("cursor"); cursor != "" { 139 req.Cursor = &cursor 140 } 141 142 return req, nil 143} 144 145// resolveActor converts an actor identifier (handle or DID) to a DID 146func (h *GetCommentsHandler) resolveActor(r *http.Request, actor string) (string, error) { 147 // If it's already a DID, return it 148 if strings.HasPrefix(actor, "did:") { 149 return actor, nil 150 } 151 152 // It's a handle - resolve to DID using user service 153 did, err := h.userService.ResolveHandleToDID(r.Context(), actor) 154 if err != nil { 155 // Check for context errors (timeouts, cancellation) - these are infrastructure errors 156 if r.Context().Err() != nil { 157 log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) 158 return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} 159 } 160 161 // Check for common "not found" patterns in error message 162 errStr := err.Error() 163 isNotFound := strings.Contains(errStr, "not found") || 164 strings.Contains(errStr, "no rows") || 165 strings.Contains(errStr, "unable to resolve") 166 167 if isNotFound { 168 return "", &actorNotFoundError{actor: actor} 169 } 170 171 // For other errors (network, database, DNS failures), return infrastructure error 172 // This ensures users see "internal error" not "actor not found" for real problems 173 log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) 174 return "", &resolutionFailedError{actor: actor, cause: err} 175 } 176 177 return did, nil 178} 179 180// populateViewerVoteState enriches comment views with the authenticated user's vote state 181func (h *GetCommentsHandler) populateViewerVoteState(r *http.Request, response *comments.GetActorCommentsResponse) { 182 if h.voteService == nil || response == nil || len(response.Comments) == 0 { 183 return 184 } 185 186 session := middleware.GetOAuthSession(r) 187 if session == nil { 188 return 189 } 190 191 userDID := middleware.GetUserDID(r) 192 if userDID == "" { 193 return 194 } 195 196 // Ensure vote cache is populated from PDS 197 if err := h.voteService.EnsureCachePopulated(r.Context(), session); err != nil { 198 log.Printf("Warning: failed to populate vote cache for actor comments: %v", err) 199 return 200 } 201 202 // Collect comment URIs to batch lookup 203 commentURIs := make([]string, 0, len(response.Comments)) 204 for _, comment := range response.Comments { 205 if comment != nil { 206 commentURIs = append(commentURIs, comment.URI) 207 } 208 } 209 210 // Get viewer votes for all comments 211 viewerVotes := h.voteService.GetViewerVotesForSubjects(userDID, commentURIs) 212 213 // Populate viewer state on each comment 214 for _, comment := range response.Comments { 215 if comment != nil { 216 if vote, exists := viewerVotes[comment.URI]; exists { 217 comment.Viewer = &comments.CommentViewerState{ 218 Vote: &vote.Direction, 219 VoteURI: &vote.URI, 220 } 221 } 222 } 223 } 224} 225 226// handleCommentServiceError maps service errors to HTTP responses 227func handleCommentServiceError(w http.ResponseWriter, err error) { 228 if err == nil { 229 return 230 } 231 232 errStr := err.Error() 233 234 // Check for validation errors 235 if strings.Contains(errStr, "invalid request") { 236 writeError(w, http.StatusBadRequest, "InvalidRequest", errStr) 237 return 238 } 239 240 // Check for not found errors 241 if comments.IsNotFound(err) || strings.Contains(errStr, "not found") { 242 writeError(w, http.StatusNotFound, "NotFound", "Resource not found") 243 return 244 } 245 246 // Check for authorization errors 247 if errors.Is(err, comments.ErrNotAuthorized) { 248 writeError(w, http.StatusForbidden, "NotAuthorized", "Not authorized") 249 return 250 } 251 252 // Default to internal server error 253 log.Printf("ERROR: Comment service error: %v", err) 254 writeError(w, http.StatusInternalServerError, "InternalServerError", "An unexpected error occurred") 255} 256 257// validationError represents a validation error for a specific field 258type validationError struct { 259 field string 260 message string 261} 262 263func (e *validationError) Error() string { 264 return e.message 265}