A community based topic aggregation platform built on atproto
at main 185 lines 5.9 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/handlers/common" 12 "Coves/internal/api/middleware" 13 "Coves/internal/core/blueskypost" 14 "Coves/internal/core/posts" 15 "Coves/internal/core/users" 16 "Coves/internal/core/votes" 17) 18 19// GetPostsHandler handles actor post retrieval 20type GetPostsHandler struct { 21 postService posts.Service 22 userService users.UserService 23 voteService votes.Service 24 blueskyService blueskypost.Service 25} 26 27// NewGetPostsHandler creates a new actor posts handler 28func NewGetPostsHandler( 29 postService posts.Service, 30 userService users.UserService, 31 voteService votes.Service, 32 blueskyService blueskypost.Service, 33) *GetPostsHandler { 34 if blueskyService == nil { 35 log.Printf("[ACTOR-HANDLER] WARNING: blueskyService is nil - Bluesky post embeds will not be resolved") 36 } 37 return &GetPostsHandler{ 38 postService: postService, 39 userService: userService, 40 voteService: voteService, 41 blueskyService: blueskyService, 42 } 43} 44 45// HandleGetPosts retrieves posts by an actor (user) 46// GET /xrpc/social.coves.actor.getPosts?actor={did_or_handle}&filter=posts_with_replies&community=...&limit=50&cursor=... 47func (h *GetPostsHandler) HandleGetPosts(w http.ResponseWriter, r *http.Request) { 48 if r.Method != http.MethodGet { 49 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 50 return 51 } 52 53 // Parse query parameters 54 req, err := h.parseRequest(r) 55 if err != nil { 56 // Check if it's an actor not found error (from handle resolution) 57 var actorNotFound *actorNotFoundError 58 if errors.As(err, &actorNotFound) { 59 writeError(w, http.StatusNotFound, "ActorNotFound", "Actor not found") 60 return 61 } 62 writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) 63 return 64 } 65 66 // Get viewer DID for populating viewer state (optional) 67 viewerDID := middleware.GetUserDID(r) 68 req.ViewerDID = viewerDID 69 70 // Get actor posts from service 71 response, err := h.postService.GetAuthorPosts(r.Context(), req) 72 if err != nil { 73 handleServiceError(w, err) 74 return 75 } 76 77 // Populate viewer vote state if authenticated 78 common.PopulateViewerVoteState(r.Context(), r, h.voteService, response.Feed) 79 80 // Transform blob refs to URLs and resolve post embeds for all posts 81 for _, feedPost := range response.Feed { 82 if feedPost.Post != nil { 83 posts.TransformBlobRefsToURLs(feedPost.Post) 84 posts.TransformPostEmbeds(r.Context(), feedPost.Post, h.blueskyService) 85 } 86 } 87 88 // Pre-encode response to buffer before writing headers 89 // This ensures we can return a proper error if encoding fails 90 responseBytes, err := json.Marshal(response) 91 if err != nil { 92 log.Printf("ERROR: Failed to encode actor posts response: %v", err) 93 writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to encode response") 94 return 95 } 96 97 // Return feed 98 w.Header().Set("Content-Type", "application/json") 99 w.WriteHeader(http.StatusOK) 100 if _, err := w.Write(responseBytes); err != nil { 101 log.Printf("ERROR: Failed to write actor posts response: %v", err) 102 } 103} 104 105// parseRequest parses query parameters into GetAuthorPostsRequest 106func (h *GetPostsHandler) parseRequest(r *http.Request) (posts.GetAuthorPostsRequest, error) { 107 req := posts.GetAuthorPostsRequest{} 108 109 // Required: actor (handle or DID) 110 actor := r.URL.Query().Get("actor") 111 if actor == "" { 112 return req, posts.NewValidationError("actor", "actor parameter is required") 113 } 114 // Validate actor length to prevent DoS via massive strings 115 // Max DID length is ~2048 chars (did:plc: is 8 + 24 base32 = 32, but did:web: can be longer) 116 // Max handle length is 253 chars (DNS limit) 117 const maxActorLength = 2048 118 if len(actor) > maxActorLength { 119 return req, posts.NewValidationError("actor", "actor parameter exceeds maximum length") 120 } 121 122 // Resolve actor to DID if it's a handle 123 actorDID, err := h.resolveActor(r, actor) 124 if err != nil { 125 return req, err 126 } 127 req.ActorDID = actorDID 128 129 // Optional: filter (default: posts_with_replies) 130 req.Filter = r.URL.Query().Get("filter") 131 132 // Optional: community (handle or DID) 133 req.Community = r.URL.Query().Get("community") 134 135 // Optional: limit (default: 50, max: 100) 136 if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 137 limit, err := strconv.Atoi(limitStr) 138 if err != nil { 139 return req, posts.NewValidationError("limit", "limit must be a valid integer") 140 } 141 req.Limit = limit 142 } 143 144 // Optional: cursor 145 if cursor := r.URL.Query().Get("cursor"); cursor != "" { 146 req.Cursor = &cursor 147 } 148 149 return req, nil 150} 151 152// resolveActor converts an actor identifier (handle or DID) to a DID 153func (h *GetPostsHandler) resolveActor(r *http.Request, actor string) (string, error) { 154 // If it's already a DID, return it 155 if strings.HasPrefix(actor, "did:") { 156 return actor, nil 157 } 158 159 // It's a handle - resolve to DID using user service 160 did, err := h.userService.ResolveHandleToDID(r.Context(), actor) 161 if err != nil { 162 // Check for context errors (timeouts, cancellation) - these are infrastructure errors 163 if r.Context().Err() != nil { 164 log.Printf("WARN: Handle resolution failed due to context error for %s: %v", actor, err) 165 return "", &resolutionFailedError{actor: actor, cause: r.Context().Err()} 166 } 167 168 // Check for common "not found" patterns in error message 169 errStr := err.Error() 170 isNotFound := strings.Contains(errStr, "not found") || 171 strings.Contains(errStr, "no rows") || 172 strings.Contains(errStr, "unable to resolve") 173 174 if isNotFound { 175 return "", &actorNotFoundError{actor: actor} 176 } 177 178 // For other errors (network, database, DNS failures), return infrastructure error 179 // This ensures users see "internal error" not "actor not found" for real problems 180 log.Printf("WARN: Handle resolution infrastructure failure for %s: %v", actor, err) 181 return "", &resolutionFailedError{actor: actor, cause: err} 182 } 183 184 return did, nil 185}