From a41d6338f22e7ce17604627424bc7ce4397b8d56 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 4 Dec 2025 16:09:58 -0800 Subject: [PATCH] feat(comments): add HTTP handlers for create/update/delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement XRPC-style HTTP handlers for comment write operations: - CreateCommentHandler: POST /xrpc/social.coves.community.comment.create - UpdateCommentHandler: POST /xrpc/social.coves.community.comment.update - DeleteCommentHandler: POST /xrpc/social.coves.community.comment.delete Features: - Request body size limit (100KB) for DoS prevention - OAuth session extraction from middleware context - Proper error mapping to lexicon-defined error types - Labels validation with explicit error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../api/handlers/comments/create_comment.go | 130 ++++++++++++++++++ .../api/handlers/comments/delete_comment.go | 80 +++++++++++ internal/api/handlers/comments/errors.go | 36 ++++- .../api/handlers/comments/update_comment.go | 112 +++++++++++++++ 4 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 internal/api/handlers/comments/create_comment.go create mode 100644 internal/api/handlers/comments/delete_comment.go create mode 100644 internal/api/handlers/comments/update_comment.go diff --git a/internal/api/handlers/comments/create_comment.go b/internal/api/handlers/comments/create_comment.go new file mode 100644 index 0000000..b80c42c --- /dev/null +++ b/internal/api/handlers/comments/create_comment.go @@ -0,0 +1,130 @@ +package comments + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/comments" + "encoding/json" + "log" + "net/http" +) + +// CreateCommentHandler handles comment creation requests +type CreateCommentHandler struct { + service comments.Service +} + +// NewCreateCommentHandler creates a new handler for creating comments +func NewCreateCommentHandler(service comments.Service) *CreateCommentHandler { + return &CreateCommentHandler{ + service: service, + } +} + +// CreateCommentInput matches the lexicon input schema for social.coves.community.comment.create +type CreateCommentInput struct { + Reply struct { + Root struct { + URI string `json:"uri"` + CID string `json:"cid"` + } `json:"root"` + Parent struct { + URI string `json:"uri"` + CID string `json:"cid"` + } `json:"parent"` + } `json:"reply"` + Content string `json:"content"` + Facets []interface{} `json:"facets,omitempty"` + Embed interface{} `json:"embed,omitempty"` + Langs []string `json:"langs,omitempty"` + Labels interface{} `json:"labels,omitempty"` +} + +// CreateCommentOutput matches the lexicon output schema +type CreateCommentOutput struct { + URI string `json:"uri"` + CID string `json:"cid"` +} + +// HandleCreate handles comment creation requests +// POST /xrpc/social.coves.community.comment.create +// +// Request body: { "reply": { "root": {...}, "parent": {...} }, "content": "..." } +// Response: { "uri": "at://...", "cid": "..." } +func (h *CreateCommentHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { + // 1. Check method is POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments) + r.Body = http.MaxBytesReader(w, r.Body, 100*1024) + + // 3. Parse JSON body into CreateCommentInput + var input CreateCommentInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // 4. Get OAuth session from context (injected by auth middleware) + session := middleware.GetOAuthSession(r) + if session == nil { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // 5. Convert labels interface{} to *comments.SelfLabels if provided + var labels *comments.SelfLabels + if input.Labels != nil { + labelsJSON, err := json.Marshal(input.Labels) + if err != nil { + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format") + return + } + var selfLabels comments.SelfLabels + if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil { + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure") + return + } + labels = &selfLabels + } + + // 6. Convert input to CreateCommentRequest + req := comments.CreateCommentRequest{ + Reply: comments.ReplyRef{ + Root: comments.StrongRef{ + URI: input.Reply.Root.URI, + CID: input.Reply.Root.CID, + }, + Parent: comments.StrongRef{ + URI: input.Reply.Parent.URI, + CID: input.Reply.Parent.CID, + }, + }, + Content: input.Content, + Facets: input.Facets, + Embed: input.Embed, + Langs: input.Langs, + Labels: labels, + } + + // 7. Call service to create comment + response, err := h.service.CreateComment(r.Context(), session, req) + if err != nil { + handleServiceError(w, err) + return + } + + // 8. Return JSON response with URI and CID + output := CreateCommentOutput{ + URI: response.URI, + CID: response.CID, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(output); err != nil { + log.Printf("Failed to encode response: %v", err) + } +} diff --git a/internal/api/handlers/comments/delete_comment.go b/internal/api/handlers/comments/delete_comment.go new file mode 100644 index 0000000..6cf5c67 --- /dev/null +++ b/internal/api/handlers/comments/delete_comment.go @@ -0,0 +1,80 @@ +package comments + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/comments" + "encoding/json" + "log" + "net/http" +) + +// DeleteCommentHandler handles comment deletion requests +type DeleteCommentHandler struct { + service comments.Service +} + +// NewDeleteCommentHandler creates a new handler for deleting comments +func NewDeleteCommentHandler(service comments.Service) *DeleteCommentHandler { + return &DeleteCommentHandler{ + service: service, + } +} + +// DeleteCommentInput matches the lexicon input schema for social.coves.community.comment.delete +type DeleteCommentInput struct { + URI string `json:"uri"` +} + +// DeleteCommentOutput is empty per lexicon specification +type DeleteCommentOutput struct{} + +// HandleDelete handles comment deletion requests +// POST /xrpc/social.coves.community.comment.delete +// +// Request body: { "uri": "at://..." } +// Response: {} +func (h *DeleteCommentHandler) HandleDelete(w http.ResponseWriter, r *http.Request) { + // 1. Check method is POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments) + r.Body = http.MaxBytesReader(w, r.Body, 100*1024) + + // 3. Parse JSON body into DeleteCommentInput + var input DeleteCommentInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // 4. Get OAuth session from context (injected by auth middleware) + session := middleware.GetOAuthSession(r) + if session == nil { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // 5. Convert input to DeleteCommentRequest + req := comments.DeleteCommentRequest{ + URI: input.URI, + } + + // 6. Call service to delete comment + err := h.service.DeleteComment(r.Context(), session, req) + if err != nil { + handleServiceError(w, err) + return + } + + // 7. Return empty JSON object per lexicon specification + output := DeleteCommentOutput{} + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(output); err != nil { + log.Printf("Failed to encode response: %v", err) + } +} diff --git a/internal/api/handlers/comments/errors.go b/internal/api/handlers/comments/errors.go index 8038eb4..69fbb30 100644 --- a/internal/api/handlers/comments/errors.go +++ b/internal/api/handlers/comments/errors.go @@ -3,6 +3,7 @@ package comments import ( "Coves/internal/core/comments" "encoding/json" + "errors" "log" "net/http" ) @@ -30,10 +31,41 @@ func writeError(w http.ResponseWriter, statusCode int, errorType, message string func handleServiceError(w http.ResponseWriter, err error) { switch { case comments.IsNotFound(err): - writeError(w, http.StatusNotFound, "NotFound", err.Error()) + // Map specific not found errors to appropriate messages + switch { + case errors.Is(err, comments.ErrCommentNotFound): + writeError(w, http.StatusNotFound, "CommentNotFound", "Comment not found") + case errors.Is(err, comments.ErrParentNotFound): + writeError(w, http.StatusNotFound, "ParentNotFound", "Parent post or comment not found") + case errors.Is(err, comments.ErrRootNotFound): + writeError(w, http.StatusNotFound, "RootNotFound", "Root post not found") + default: + writeError(w, http.StatusNotFound, "NotFound", err.Error()) + } case comments.IsValidationError(err): - writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) + // Map specific validation errors to appropriate messages + switch { + case errors.Is(err, comments.ErrInvalidReply): + writeError(w, http.StatusBadRequest, "InvalidReply", "The reply reference is invalid or malformed") + case errors.Is(err, comments.ErrContentTooLong): + writeError(w, http.StatusBadRequest, "ContentTooLong", "Comment content exceeds 10000 graphemes") + case errors.Is(err, comments.ErrContentEmpty): + writeError(w, http.StatusBadRequest, "ContentEmpty", "Comment content is required") + default: + writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error()) + } + + case errors.Is(err, comments.ErrNotAuthorized): + writeError(w, http.StatusForbidden, "NotAuthorized", "User is not authorized to perform this action") + + case errors.Is(err, comments.ErrBanned): + writeError(w, http.StatusForbidden, "Banned", "User is banned from this community") + + // NOTE: IsConflict case removed - the PDS handles duplicate detection via CreateRecord, + // so ErrCommentAlreadyExists is never returned from the service layer. If the PDS rejects + // a duplicate record, it returns an auth/validation error which is handled by other cases. + // Keeping this code would be dead code that never executes. default: // Don't leak internal error details to clients diff --git a/internal/api/handlers/comments/update_comment.go b/internal/api/handlers/comments/update_comment.go new file mode 100644 index 0000000..a8cf24e --- /dev/null +++ b/internal/api/handlers/comments/update_comment.go @@ -0,0 +1,112 @@ +package comments + +import ( + "Coves/internal/api/middleware" + "Coves/internal/core/comments" + "encoding/json" + "log" + "net/http" +) + +// UpdateCommentHandler handles comment update requests +type UpdateCommentHandler struct { + service comments.Service +} + +// NewUpdateCommentHandler creates a new handler for updating comments +func NewUpdateCommentHandler(service comments.Service) *UpdateCommentHandler { + return &UpdateCommentHandler{ + service: service, + } +} + +// UpdateCommentInput matches the lexicon input schema for social.coves.community.comment.update +type UpdateCommentInput struct { + URI string `json:"uri"` + Content string `json:"content"` + Facets []interface{} `json:"facets,omitempty"` + Embed interface{} `json:"embed,omitempty"` + Langs []string `json:"langs,omitempty"` + Labels interface{} `json:"labels,omitempty"` +} + +// UpdateCommentOutput matches the lexicon output schema +type UpdateCommentOutput struct { + URI string `json:"uri"` + CID string `json:"cid"` +} + +// HandleUpdate handles comment update requests +// POST /xrpc/social.coves.community.comment.update +// +// Request body: { "uri": "at://...", "content": "..." } +// Response: { "uri": "at://...", "cid": "..." } +func (h *UpdateCommentHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) { + // 1. Check method is POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 2. Limit request body size to prevent DoS attacks (100KB should be plenty for comments) + r.Body = http.MaxBytesReader(w, r.Body, 100*1024) + + // 3. Parse JSON body into UpdateCommentInput + var input UpdateCommentInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") + return + } + + // 4. Get OAuth session from context (injected by auth middleware) + session := middleware.GetOAuthSession(r) + if session == nil { + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") + return + } + + // 5. Convert labels interface{} to *comments.SelfLabels if provided + var labels *comments.SelfLabels + if input.Labels != nil { + labelsJSON, err := json.Marshal(input.Labels) + if err != nil { + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels format") + return + } + var selfLabels comments.SelfLabels + if err := json.Unmarshal(labelsJSON, &selfLabels); err != nil { + writeError(w, http.StatusBadRequest, "InvalidLabels", "Invalid labels structure") + return + } + labels = &selfLabels + } + + // 6. Convert input to UpdateCommentRequest + req := comments.UpdateCommentRequest{ + URI: input.URI, + Content: input.Content, + Facets: input.Facets, + Embed: input.Embed, + Langs: input.Langs, + Labels: labels, + } + + // 7. Call service to update comment + response, err := h.service.UpdateComment(r.Context(), session, req) + if err != nil { + handleServiceError(w, err) + return + } + + // 8. Return JSON response with URI and CID + output := UpdateCommentOutput{ + URI: response.URI, + CID: response.CID, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(output); err != nil { + log.Printf("Failed to encode response: %v", err) + } +} -- 2.51.2