package web import ( "context" "database/sql" "errors" "fmt" "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" "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 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 { parent, 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 } 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 } http.Redirect( w, r, "/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID), http.StatusSeeOther, ) } // 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 }