package web import ( "context" "database/sql" "errors" "fmt" "net/http" "net/url" "strings" "github.com/go-chi/chi/v5" "plumber/internal/store" ) 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 !user.Admin() && user.ID != threadRoot.AuthorID { 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, ) } 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, ) } 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 } 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 }