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/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}