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) + } +}