package web import ( "context" "database/sql" "errors" "fmt" "log" "net/http" "net/url" "strings" "time" "github.com/go-chi/chi/v5" "plumber/internal/mail" "plumber/internal/store" ) // handleCreatePost creates either a root question or a reply. Replies are // limited to the root author and admins, and cannot be added to hidden threads. func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { if !s.requireCSRF(w, r) { return } user := currentUser(r) if user == nil { http.Error(w, "authentication required", http.StatusUnauthorized) return } parentID := strings.TrimSpace(r.PostFormValue("parent_id")) body := strings.TrimSpace(r.PostFormValue("body")) if body == "" { http.Error(w, "post body required", http.StatusBadRequest) return } post := &store.Post{ AuthorID: user.ID, Body: truncateRunes(body, 12000), } var parent, root *store.Post if parentID == "" { post.Title = truncateRunes(strings.TrimSpace(r.PostFormValue("title")), 120) post.City = truncateRunes(strings.TrimSpace(r.PostFormValue("city")), 80) if post.Title == "" { http.Error(w, "post title required", http.StatusBadRequest) return } } else { loadedParent, threadRoot, err := s.postAndRoot(r.Context(), parentID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return } http.Error(w, "could not load thread", http.StatusInternalServerError) return } if threadRoot.PostState == store.PostStateHidden { http.NotFound(w, r) return } if !canReplyToThread(user, threadRoot) { http.Error(w, "forbidden", http.StatusForbidden) return } parent = loadedParent post.ParentID = &parent.ID root = threadRoot } if err := s.store.CreatePost(r.Context(), post); err != nil { if errors.Is(err, store.ErrInvalidPost) { http.Error(w, "invalid post", http.StatusBadRequest) return } http.Error(w, "could not save post", http.StatusInternalServerError) return } if root == nil { root = post } if parent != nil { s.notifyPostReply(parent, root, post, user) } http.Redirect( w, r, "/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID), http.StatusSeeOther, ) } // notifyPostReply asynchronously emails the direct parent post's author. func (s *Server) notifyPostReply( parent *store.Post, root *store.Post, reply *store.Post, replyAuthor *store.User, ) { if parent == nil || root == nil || reply == nil || replyAuthor == nil || s.cfg.Mail == nil || parent.AuthorID == replyAuthor.ID { return } if _, disabled := s.cfg.Mail.(mail.Nop); disabled { return } msg := mail.PostReply{ RootID: root.ID, RootTitle: root.Title, ReplyID: reply.ID, ReplyBody: reply.Body, ReplyAuthorName: replyAuthor.Name, } recipientID := parent.AuthorID go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() recipient, err := s.store.UserByID(ctx, recipientID) if err != nil { log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err) return } if recipient == nil || strings.TrimSpace(recipient.Email) == "" { return } msg.ToEmail = recipient.Email msg.ToName = recipient.Name if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil { log.Printf("notify reply %s: %v", msg.ReplyID, err) return } log.Printf("notify reply %s: accepted", msg.ReplyID) }() } // handleEditPost updates only a post's body after verifying that the current // homeowner owns it or that an admin is editing an admin-authored post. func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) { if !s.requireCSRF(w, r) { return } user := currentUser(r) if user == nil { http.Error(w, "authentication required", http.StatusUnauthorized) return } post, root, err := s.postAndRoot(r.Context(), chi.URLParam(r, "id")) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return } http.Error(w, "could not load post", http.StatusInternalServerError) return } if !canEditPost(user, post) { http.Error(w, "forbidden", http.StatusForbidden) return } body := strings.TrimSpace(r.PostFormValue("body")) if body == "" { http.Error(w, "post body required", http.StatusBadRequest) return } post.Body = truncateRunes(body, 12000) if err := s.store.UpdatePost(r.Context(), post); err != nil { if errors.Is(err, store.ErrInvalidPost) { http.Error(w, "invalid post", http.StatusBadRequest) return } if errors.Is(err, sql.ErrNoRows) { http.NotFound(w, r) return } http.Error(w, "could not save post", http.StatusInternalServerError) return } http.Redirect( w, r, "/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID), http.StatusSeeOther, ) } // postAndRoot loads a post and follows its immutable parent chain to the root. // It returns both so callers can authorize against the thread and redirect to it. func (s *Server) postAndRoot(ctx context.Context, postID string) (*store.Post, *store.Post, error) { postID = strings.TrimSpace(postID) if postID == "" { return nil, nil, sql.ErrNoRows } post, err := s.store.GetPost(ctx, postID) if err != nil { return nil, nil, err } current := post seen := map[string]bool{} for current.ParentID != nil { if seen[current.ID] { return nil, nil, fmt.Errorf("post ancestry cycle at %s", current.ID) } seen[current.ID] = true current, err = s.store.GetPost(ctx, *current.ParentID) if err != nil { return nil, nil, err } } return post, current, nil } // canEditPost keeps homeowner posts owner-only while allowing admins to edit // posts authored by an admin. func canEditPost(user *store.User, post *store.Post) bool { if user == nil || post == nil { return false } if post.AuthorRole == store.RoleAdmin { return user.Admin() } return user.ID == post.AuthorID } func canReplyToThread(user *store.User, root *store.Post) bool { return user != nil && root != nil && root.PostState != store.PostStateHidden && (user.Admin() || user.ID == root.AuthorID) } func postLabel(post *store.Post) string { if post == nil { return "" } if post.ParentID == nil { return "Question" } if post.AuthorRole == store.RoleAdmin { return "Shop response" } return "Homeowner" } func postDepthClass(depth int) string { switch depth { case 0: return "root" case 1: return "branch" default: return "deep" } } func postPointers(posts []store.Post) []*store.Post { out := make([]*store.Post, len(posts)) for i := range posts { out[i] = &posts[i] } return out }