From 8e954d60d88234a33172b3d3398abe399ae20d58 Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Thu, 27 Aug 2026 00:46:37 -0700 Subject: [PATCH] Add post mutation permissions. Add authenticated post creation and body-only editing routes with root participation, hidden-thread, homeowner ownership, and admin authorship checks. --- internal/web/posts.go | 169 +++++++++++++++++++++ internal/web/posts_test.go | 293 +++++++++++++++++++++++++++++++++++++ internal/web/server.go | 2 + 3 files changed, 464 insertions(+) create mode 100644 internal/web/posts.go create mode 100644 internal/web/posts_test.go diff --git a/internal/web/posts.go b/internal/web/posts.go new file mode 100644 index 0000000..b99310f --- /dev/null +++ b/internal/web/posts.go @@ -0,0 +1,169 @@ +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 +} diff --git a/internal/web/posts_test.go b/internal/web/posts_test.go new file mode 100644 index 0000000..94bcb18 --- /dev/null +++ b/internal/web/posts_test.go @@ -0,0 +1,293 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "plumber/internal/pacific" + "plumber/internal/store" +) + +func TestCreatePostRoutePermissions(t *testing.T) { + t.Parallel() + + srv, mem := newTestServer(t, Config{}) + handler := srv.Handler() + homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser) + other := seedUser(t, mem, uniq("other"), "hunter22", store.RoleUser) + admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin) + homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22") + otherCookies := loginUser(t, handler, other.Username, "hunter22") + adminCookies := loginUser(t, handler, admin.Username, "hunter22") + homeownerCSRF := csrfForCookies(t, handler, homeownerCookies) + otherCSRF := csrfForCookies(t, handler, otherCookies) + adminCSRF := csrfForCookies(t, handler, adminCookies) + + rec := postForm(handler, "/posts", url.Values{ + "title": {"No CSRF"}, + "body": {"Body"}, + }, homeownerCookies) + if rec.Code != http.StatusForbidden { + t.Fatalf("missing CSRF status = %d, want 403", rec.Code) + } + + anonRec := httptest.NewRecorder() + handler.ServeHTTP(anonRec, httptest.NewRequest(http.MethodGet, "/login", nil)) + anonCookies := anonRec.Result().Cookies() + anonCSRF := csrfFrom(anonRec.Body.String()) + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {anonCSRF}, + "title": {"Anonymous"}, + "body": {"Body"}, + }, anonCookies) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous create status = %d, want 401", rec.Code) + } + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {homeownerCSRF}, + "title": {"Leaky sink"}, + "body": {"It drips."}, + "city": {"Oakland"}, + }, homeownerCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String()) + } + roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID) + if err != nil { + t.Fatal(err) + } + if len(roots) != 1 || + roots[0].AuthorID != homeowner.ID || + roots[0].Title != "Leaky sink" || + roots[0].PostState != store.PostStateVisible { + t.Fatalf("created root = %+v", roots) + } + root := roots[0] + if got := rec.Header().Get("Location"); got != "/questions/"+root.ID+"#post-"+root.ID { + t.Fatalf("root redirect = %q", got) + } + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {homeownerCSRF}, + "parent_id": {root.ID}, + "body": {"The model is 123."}, + }, homeownerCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("homeowner reply status = %d: %s", rec.Code, rec.Body.String()) + } + thread, err := mem.GetPostThread(context.Background(), root.ID) + if err != nil { + t.Fatal(err) + } + if len(thread.Replies) != 1 || thread.Replies[0].AuthorID != homeowner.ID { + t.Fatalf("homeowner reply missing: %+v", thread) + } + homeownerReply := thread.Replies[0] + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {adminCSRF}, + "parent_id": {homeownerReply.ID}, + "body": {"Replace the cartridge."}, + }, adminCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("admin nested reply status = %d: %s", rec.Code, rec.Body.String()) + } + thread, err = mem.GetPostThread(context.Background(), root.ID) + if err != nil { + t.Fatal(err) + } + if len(thread.Replies[0].Replies) != 1 || + thread.Replies[0].Replies[0].AuthorID != admin.ID { + t.Fatalf("admin nested reply missing: %+v", thread) + } + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {otherCSRF}, + "parent_id": {homeownerReply.ID}, + "body": {"I should not be here."}, + }, otherCookies) + if rec.Code != http.StatusForbidden { + t.Fatalf("unrelated reply status = %d, want 403", rec.Code) + } + + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {homeownerCSRF}, + "parent_id": {"missing"}, + "body": {"Missing parent"}, + }, homeownerCookies) + if rec.Code != http.StatusNotFound { + t.Fatalf("missing-parent reply status = %d, want 404", rec.Code) + } + + hidden := &store.Post{ + AuthorID: homeowner.ID, + Title: "Hidden thread", + Body: "Body", + PostDate: pacific.Today(), + PostState: store.PostStateHidden, + } + if err := mem.CreatePost(context.Background(), hidden); err != nil { + t.Fatal(err) + } + rec = postForm(handler, "/posts", url.Values{ + "_csrf": {homeownerCSRF}, + "parent_id": {hidden.ID}, + "body": {"Hidden reply"}, + }, homeownerCookies) + if rec.Code != http.StatusNotFound { + t.Fatalf("hidden-thread reply status = %d, want 404", rec.Code) + } +} + +func TestEditPostRoutePermissions(t *testing.T) { + t.Parallel() + + srv, mem := newTestServer(t, Config{}) + handler := srv.Handler() + homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser) + other := seedUser(t, mem, uniq("other"), "hunter22", store.RoleUser) + admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin) + secondAdmin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin) + if err := mem.SetUserRole(context.Background(), secondAdmin.ID, store.RoleAdmin); err != nil { + t.Fatal(err) + } + homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22") + otherCookies := loginUser(t, handler, other.Username, "hunter22") + adminCookies := loginUser(t, handler, admin.Username, "hunter22") + secondAdminCookies := loginUser(t, handler, secondAdmin.Username, "hunter22") + homeownerCSRF := csrfForCookies(t, handler, homeownerCookies) + otherCSRF := csrfForCookies(t, handler, otherCookies) + adminCSRF := csrfForCookies(t, handler, adminCookies) + secondAdminCSRF := csrfForCookies(t, handler, secondAdminCookies) + + root := &store.Post{ + AuthorID: homeowner.ID, + Title: "Leaky sink", + Body: "Original body", + PostDate: pacific.Today(), + } + if err := mem.CreatePost(context.Background(), root); err != nil { + t.Fatal(err) + } + rootID := root.ID + adminReply := &store.Post{ + ParentID: &rootID, + AuthorID: admin.ID, + Body: "Original answer", + } + if err := mem.CreatePost(context.Background(), adminReply); err != nil { + t.Fatal(err) + } + + anonRec := httptest.NewRecorder() + handler.ServeHTTP(anonRec, httptest.NewRequest(http.MethodGet, "/login", nil)) + rec := postForm(handler, "/posts/"+root.ID+"/edit", url.Values{ + "_csrf": {csrfFrom(anonRec.Body.String())}, + "body": {"Anonymous edit"}, + }, anonRec.Result().Cookies()) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous edit status = %d, want 401", rec.Code) + } + + rec = postForm(handler, "/posts/"+root.ID+"/edit", url.Values{ + "_csrf": {homeownerCSRF}, + "body": {"Updated homeowner body"}, + "parent_id": {adminReply.ID}, + "author_id": {other.ID}, + }, homeownerCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("homeowner edit status = %d: %s", rec.Code, rec.Body.String()) + } + saved, err := mem.GetPost(context.Background(), root.ID) + if err != nil { + t.Fatal(err) + } + if saved.Body != "Updated homeowner body" || + saved.ParentID != nil || + saved.AuthorID != homeowner.ID { + t.Fatalf("homeowner edit changed immutable fields: %+v", saved) + } + + for name, session := range map[string]struct { + cookies []*http.Cookie + csrf string + }{ + "other homeowner": {otherCookies, otherCSRF}, + "admin": {adminCookies, adminCSRF}, + } { + t.Run(name+" cannot edit homeowner post", func(t *testing.T) { + rec := postForm(handler, "/posts/"+root.ID+"/edit", url.Values{ + "_csrf": {session.csrf}, + "body": {"Unauthorized edit"}, + }, session.cookies) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + }) + } + + rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{ + "_csrf": {homeownerCSRF}, + "body": {"Homeowner edit"}, + }, homeownerCookies) + if rec.Code != http.StatusForbidden { + t.Fatalf("homeowner editing admin post status = %d, want 403", rec.Code) + } + + rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{ + "_csrf": {secondAdminCSRF}, + "body": {"Updated admin answer"}, + }, secondAdminCookies) + if rec.Code != http.StatusSeeOther { + t.Fatalf("admin edit status = %d: %s", rec.Code, rec.Body.String()) + } + saved, err = mem.GetPost(context.Background(), adminReply.ID) + if err != nil { + t.Fatal(err) + } + if saved.Body != "Updated admin answer" || + saved.ParentID == nil || + *saved.ParentID != root.ID || + saved.AuthorID != admin.ID { + t.Fatalf("admin edit changed immutable fields: %+v", saved) + } +} + +func csrfForCookies(t *testing.T, handler http.Handler, cookies []*http.Cookie) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/submit", nil) + for _, cookie := range cookies { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("load CSRF form status = %d", rec.Code) + } + csrf := csrfFrom(rec.Body.String()) + if csrf == "" { + t.Fatal("CSRF token missing") + } + return csrf +} + +func postForm( + handler http.Handler, + path string, + values url.Values, + cookies []*http.Cookie, +) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, cookie := range cookies { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} diff --git a/internal/web/server.go b/internal/web/server.go index bc88f5b..8c484f2 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -177,6 +177,8 @@ func (s *Server) Handler() http.Handler { r.Post("/questions/{id}/vote", s.handleVote) r.Post("/questions/{id}/answer", s.handleAnswer) r.Post("/questions/{id}/hide", s.handleHide) + r.Post("/posts", s.handleCreatePost) + r.Post("/posts/{id}/edit", s.handleEditPost) r.Get("/login", s.handleLoginForm) r.Post("/login", s.handleLogin) r.Get("/register", s.handleRegisterForm)