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