mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
0c0a71f96b
canEditComment's unconditional u.Role == "admin" bypass let a bearer-authed (PAT/CLI/MCP) platform admin edit or delete any user's comment in a workspace where they're a member, contradicting the BUG-1616/1617 bearer-suppression intent (same family as BUG-1917/1918). Gate the bypass on !isBearerAuth(r), mirroring the idiom already used in handlers_collab.go's authorizeCollabAccess. Cookie-session admins keep the existing behavior, including editing empty-user_id legacy comments; bearer-authed authors can still edit their own comments.
475 lines
14 KiB
Go
475 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/events"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// handleListComments returns all comments for an item.
|
|
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
itemSlug := chi.URLParam(r, "itemSlug")
|
|
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
if !s.requireItemVisible(w, r, workspaceID, item) {
|
|
return
|
|
}
|
|
|
|
comments, err := s.store.ListComments(item.ID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if comments == nil {
|
|
comments = []models.Comment{}
|
|
}
|
|
|
|
// Bulk-load reactions for all comments.
|
|
if len(comments) > 0 {
|
|
commentIDs := make([]string, len(comments))
|
|
for i, c := range comments {
|
|
commentIDs[i] = c.ID
|
|
}
|
|
reactionsMap, err := s.store.ListReactionsByComments(commentIDs)
|
|
if err == nil && reactionsMap != nil {
|
|
for i := range comments {
|
|
if reactions, ok := reactionsMap[comments[i].ID]; ok {
|
|
comments[i].Reactions = reactions
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, comments)
|
|
}
|
|
|
|
// handleCreateComment adds a new comment to an item.
|
|
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
itemSlug := chi.URLParam(r, "itemSlug")
|
|
item, err := s.store.ResolveItem(workspaceID, itemSlug)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if item == nil {
|
|
s.writeItemResolveError(w, r, workspaceID, itemSlug)
|
|
return
|
|
}
|
|
if !s.requireItemVisible(w, r, workspaceID, item) {
|
|
return
|
|
}
|
|
// Check edit permission (grant-aware for guests)
|
|
if !s.requireEditPermission(w, r, workspaceID, item.ID, item.CollectionID) {
|
|
return
|
|
}
|
|
|
|
var input models.CommentCreate
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
if input.Body == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "body is required")
|
|
return
|
|
}
|
|
|
|
// Set author from authenticated user if available
|
|
if u := currentUser(r); u != nil && input.Author == "" {
|
|
input.Author = u.Name
|
|
}
|
|
|
|
// Derive actor/source from auth context
|
|
actor, source := actorFromRequest(r)
|
|
if input.CreatedBy == "" {
|
|
input.CreatedBy = actor
|
|
}
|
|
if input.Source == "" {
|
|
input.Source = source
|
|
}
|
|
|
|
// Log activity first so we can link the comment to the activity record.
|
|
// This prevents duplicate timeline entries (one for the comment, one for the activity).
|
|
// Only set ActivityID on success — comments.activity_id has a FK constraint,
|
|
// and CreateActivity returns an ID even on insert failure.
|
|
if activityID, err := s.logActivityWithMetaReturningID(workspaceID, item.ID, "commented", r, ""); err == nil && activityID != "" {
|
|
input.ActivityID = activityID
|
|
}
|
|
|
|
comment, err := s.store.CreateComment(workspaceID, item.ID, currentUserID(r), input)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Publish SSE event
|
|
s.publishCommentEvent(events.CommentCreated, workspaceID, item.ID, comment.ID, item.Title, item.CollectionSlug, actor, source)
|
|
s.dispatchWebhook(workspaceID, "comment.created", comment)
|
|
|
|
writeJSON(w, http.StatusCreated, comment)
|
|
}
|
|
|
|
// handleDeleteComment removes a comment.
|
|
func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
commentID := chi.URLParam(r, "commentID")
|
|
|
|
// Verify the comment belongs to this workspace.
|
|
comment, cerr := s.store.GetComment(commentID)
|
|
if cerr != nil || comment == nil || comment.WorkspaceID != workspaceID {
|
|
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
|
|
return
|
|
}
|
|
if !s.requireCommentVisible(w, r, workspaceID, comment) {
|
|
return
|
|
}
|
|
// Check edit permission on the comment's item (grant-aware for guests)
|
|
if commentItem, ierr := s.store.GetItem(comment.ItemID); ierr == nil && commentItem != nil {
|
|
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
|
|
return
|
|
}
|
|
} else if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
|
|
if err := s.store.DeleteComment(commentID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleUpdateComment edits a comment's body. Editing is an authorship
|
|
// operation — distinct from delete, which any item editor may do — so only
|
|
// the comment author (matching user_id) or a platform admin may edit.
|
|
// Comments with no recorded user_id (created before TASK-1663, or
|
|
// agent/system comments) are admin-only. (PLAN-1662.)
|
|
func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
commentID := chi.URLParam(r, "commentID")
|
|
|
|
comment, cerr := s.store.GetComment(commentID)
|
|
if cerr != nil || comment == nil || comment.WorkspaceID != workspaceID {
|
|
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
|
|
return
|
|
}
|
|
if !s.requireCommentVisible(w, r, workspaceID, comment) {
|
|
return
|
|
}
|
|
if !s.canEditComment(r, comment) {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Only the comment author or an admin can edit this comment")
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Body string `json:"body"`
|
|
}
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
body := strings.TrimSpace(input.Body)
|
|
if body == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "body is required (use delete to remove a comment)")
|
|
return
|
|
}
|
|
|
|
updated, err := s.store.UpdateComment(commentID, body)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
actor, source := actorFromRequest(r)
|
|
var title, collSlug string
|
|
if item, ierr := s.store.GetItem(updated.ItemID); ierr == nil && item != nil {
|
|
title = item.Title
|
|
collSlug = item.CollectionSlug
|
|
}
|
|
s.publishCommentEvent(events.CommentUpdated, workspaceID, updated.ItemID, updated.ID, title, collSlug, actor, source)
|
|
s.dispatchWebhook(workspaceID, "comment.updated", updated)
|
|
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
// canEditComment reports whether the requester may edit the given comment:
|
|
// the authenticated author (matching user_id) or a platform admin. A comment
|
|
// with an empty user_id has no provable author, so only admins can edit it.
|
|
//
|
|
// The admin bypass is cookie-session only (BUG-1616/BUG-1919): a
|
|
// bearer-borne admin (PAT/CLI/MCP) falls through to the author check like
|
|
// any other member, so a bearer admin can no longer edit another user's
|
|
// comment, including empty-user_id ones. Mirrors the gate in
|
|
// handlers_collab.go's authorizeCollabAccess.
|
|
func (s *Server) canEditComment(r *http.Request, comment *models.Comment) bool {
|
|
u := currentUser(r)
|
|
if u == nil {
|
|
return false
|
|
}
|
|
if u.Role == "admin" && !isBearerAuth(r) {
|
|
return true
|
|
}
|
|
return comment.UserID != "" && comment.UserID == u.ID
|
|
}
|
|
|
|
// handleCreateReply creates a reply to an existing comment.
|
|
func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
commentID := chi.URLParam(r, "commentID")
|
|
parentComment, err := s.store.GetComment(commentID)
|
|
if err != nil || parentComment == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Parent comment not found")
|
|
return
|
|
}
|
|
if parentComment.WorkspaceID != workspaceID {
|
|
writeError(w, http.StatusNotFound, "not_found", "Parent comment not found")
|
|
return
|
|
}
|
|
if !s.requireCommentVisible(w, r, workspaceID, parentComment) {
|
|
return
|
|
}
|
|
// Check edit permission on the parent comment's item (grant-aware for guests)
|
|
if commentItem, ierr := s.store.GetItem(parentComment.ItemID); ierr == nil && commentItem != nil {
|
|
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
|
|
return
|
|
}
|
|
} else if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
|
|
var input models.CommentCreate
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(input.Body) == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "body is required")
|
|
return
|
|
}
|
|
|
|
// Set author from current user if not provided.
|
|
if input.Author == "" {
|
|
if u := currentUser(r); u != nil {
|
|
input.Author = u.Name
|
|
}
|
|
}
|
|
|
|
actor, source := actorFromRequest(r)
|
|
if input.CreatedBy == "" {
|
|
input.CreatedBy = actor
|
|
}
|
|
if input.Source == "" {
|
|
input.Source = source
|
|
}
|
|
input.ParentID = commentID
|
|
|
|
comment, err := s.store.CreateComment(workspaceID, parentComment.ItemID, currentUserID(r), input)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Comment replies don't go through logActivity (no "commented" activity
|
|
// row is emitted for replies — see handleCreateComment for the non-reply
|
|
// path that does). Bump last_write_at explicitly so engagement metrics
|
|
// reflect reply authorship too (PLAN-1542 / TASK-1543).
|
|
s.store.TouchUserWrite(r.Context(), currentUserID(r))
|
|
|
|
// Resolve the item's collection slug for SSE filtering
|
|
replyCollSlug := ""
|
|
if replyItem, err := s.store.GetItem(parentComment.ItemID); err == nil && replyItem != nil {
|
|
replyCollSlug = replyItem.CollectionSlug
|
|
}
|
|
s.publishCommentEvent(events.CommentCreated, workspaceID, parentComment.ItemID, comment.ID, parentComment.ItemTitle, replyCollSlug, actor, source)
|
|
|
|
writeJSON(w, http.StatusCreated, comment)
|
|
}
|
|
|
|
// handleAddReaction adds an emoji reaction to a comment.
|
|
func (s *Server) handleAddReaction(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
commentID := chi.URLParam(r, "commentID")
|
|
|
|
// Verify the comment belongs to this workspace.
|
|
comment, err := s.store.GetComment(commentID)
|
|
if err != nil || comment == nil || comment.WorkspaceID != workspaceID {
|
|
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
|
|
return
|
|
}
|
|
if !s.requireCommentVisible(w, r, workspaceID, comment) {
|
|
return
|
|
}
|
|
// Check edit permission on the comment's item (grant-aware for guests)
|
|
if commentItem, ierr := s.store.GetItem(comment.ItemID); ierr == nil && commentItem != nil {
|
|
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
|
|
return
|
|
}
|
|
} else if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
|
|
var input struct {
|
|
Emoji string `json:"emoji"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(input.Emoji) == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "emoji is required")
|
|
return
|
|
}
|
|
|
|
actor, _ := actorFromRequest(r)
|
|
userID := currentUserID(r)
|
|
|
|
reaction, err := s.store.AddReaction(commentID, userID, actor, input.Emoji)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Fire SSE event for the reaction.
|
|
if parentComment, cerr := s.store.GetComment(commentID); cerr == nil {
|
|
s.publishReactionEvent(events.ReactionAdded, parentComment)
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, reaction)
|
|
}
|
|
|
|
// handleRemoveReaction removes an emoji reaction from a comment.
|
|
func (s *Server) handleRemoveReaction(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
commentID := chi.URLParam(r, "commentID")
|
|
emoji := chi.URLParam(r, "emoji")
|
|
|
|
// Verify the comment belongs to this workspace.
|
|
commentObj, cerr := s.store.GetComment(commentID)
|
|
if cerr != nil || commentObj == nil || commentObj.WorkspaceID != workspaceID {
|
|
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
|
|
return
|
|
}
|
|
if !s.requireCommentVisible(w, r, workspaceID, commentObj) {
|
|
return
|
|
}
|
|
// Check edit permission on the comment's item (grant-aware for guests)
|
|
if commentItem, ierr := s.store.GetItem(commentObj.ItemID); ierr == nil && commentItem != nil {
|
|
if !s.requireEditPermission(w, r, workspaceID, commentItem.ID, commentItem.CollectionID) {
|
|
return
|
|
}
|
|
} else if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
|
|
userID := currentUserID(r)
|
|
|
|
if err := s.store.RemoveReaction(commentID, userID, emoji); err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Reaction not found")
|
|
return
|
|
}
|
|
|
|
// Fire SSE event for the reaction removal.
|
|
if parentComment, cerr := s.store.GetComment(commentID); cerr == nil {
|
|
s.publishReactionEvent(events.ReactionRemoved, parentComment)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// requireCommentVisible checks that a comment's underlying item is in a visible
|
|
// collection. Writes a 404 and returns false if not.
|
|
func (s *Server) requireCommentVisible(w http.ResponseWriter, r *http.Request, workspaceID string, comment *models.Comment) bool {
|
|
item, err := s.store.GetItem(comment.ItemID)
|
|
if err != nil || item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Comment not found")
|
|
return false
|
|
}
|
|
return s.requireItemVisible(w, r, workspaceID, item)
|
|
}
|
|
|
|
// publishCommentEvent publishes a real-time event for comment changes.
|
|
func (s *Server) publishCommentEvent(eventType, workspaceID, itemID, commentID, title, collection, actor, source string) {
|
|
if s.events == nil {
|
|
return
|
|
}
|
|
s.events.Publish(events.Event{
|
|
Type: eventType,
|
|
WorkspaceID: workspaceID,
|
|
ItemID: itemID,
|
|
Collection: collection,
|
|
Title: title,
|
|
Actor: actor,
|
|
Source: source,
|
|
})
|
|
}
|
|
|
|
// publishReactionEvent publishes a real-time event for reaction changes.
|
|
func (s *Server) publishReactionEvent(eventType string, comment *models.Comment) {
|
|
if s.events == nil || comment == nil {
|
|
return
|
|
}
|
|
// Resolve the item's collection slug so SSE filtering can scope this event
|
|
collSlug := ""
|
|
if item, err := s.store.GetItem(comment.ItemID); err == nil && item != nil {
|
|
collSlug = item.CollectionSlug
|
|
}
|
|
s.events.Publish(events.Event{
|
|
Type: eventType,
|
|
WorkspaceID: comment.WorkspaceID,
|
|
ItemID: comment.ItemID,
|
|
Collection: collSlug,
|
|
})
|
|
}
|