Author SHA1 Message Date
codegirl007 b481dd2925 Replace hidden flag with post state.
CI / test (pull_request) Successful in 6m16s
Store post state as text so Go owns the allowed values and future states such as locked remain representable without a database enum migration.
2026-08-27 00:37:17 -07:00
codegirl007 e918e5bd1d Simplify post listing queries.
CI / test (pull_request) Successful in 6m17s
Use named sqlc arguments, scope vote aggregation to selected roots, join viewer votes directly, and add the indexes and drift migration required by the resulting access paths.
2026-08-27 00:22:07 -07:00
codegirl007 fc8f286c34 Add unified post storage.
CI / test (pull_request) Successful in 6m17s
Provide one creation path for roots and replies, recursive thread loading, body-only updates, root listings, and post voting across PostgreSQL and the in-memory test store.
2026-08-27 00:10:47 -07:00
3 changed files with 0 additions and 472 deletions
-177
View File
@@ -1,177 +0,0 @@
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 !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,
)
}
// 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
}
-293
View File
@@ -1,293 +0,0 @@
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
}
-2
View File
@@ -177,8 +177,6 @@ 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)