Add authenticated post creation and body-only editing routes with root participation, hidden-thread, homeowner ownership, and admin authorship checks.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user