3 Commits
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
22 changed files with 349 additions and 1503 deletions
-58
View File
@@ -44,16 +44,6 @@ FROM thread
JOIN users u ON u.id = thread.author_id
ORDER BY thread.created_at, thread.id;
-- name: GetRootPostVoteSummary :one
SELECT
COALESCE(SUM(value), 0)::bigint AS score,
COALESCE(
MAX(value) FILTER (WHERE user_id = sqlc.arg(viewer_id)),
0
)::bigint AS user_vote
FROM post_votes
WHERE post_id = sqlc.arg(root_id);
-- name: UpdatePost :execrows
UPDATE posts
SET
@@ -61,14 +51,6 @@ SET
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id);
-- name: UpdateRootPostState :execrows
UPDATE posts
SET
post_state = sqlc.arg(post_state),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND parent_id IS NULL;
-- name: ListRootPosts :many
WITH RECURSIVE roots AS (
SELECT p.*
@@ -118,46 +100,6 @@ LEFT JOIN post_votes viewer_vote
ORDER BY score DESC, roots.created_at, roots.id
LIMIT sqlc.arg(row_limit);
-- name: ListRootPostsByAuthor :many
SELECT
p.id, p.parent_id, p.author_id,
u.name AS author_name, u.role AS author_role,
p.title, p.body, p.city, p.post_date,
p.post_state, p.created_at, p.updated_at
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.parent_id IS NULL
AND p.author_id = sqlc.arg(author_id)
AND p.post_state <> sqlc.arg(hidden_state)
ORDER BY p.created_at DESC, p.id DESC
LIMIT sqlc.arg(row_limit);
-- name: ListRootPostsAnsweredBy :many
WITH RECURSIVE ancestors AS (
SELECT p.id, p.parent_id
FROM posts p
WHERE p.author_id = sqlc.arg(admin_id)
AND p.parent_id IS NOT NULL
UNION
SELECT parent.id, parent.parent_id
FROM posts parent
JOIN ancestors child ON child.parent_id = parent.id
)
SELECT DISTINCT
root.id, root.parent_id, root.author_id,
u.name AS author_name, u.role AS author_role,
root.title, root.body, root.city, root.post_date,
root.post_state, root.created_at, root.updated_at
FROM posts root
JOIN ancestors ON ancestors.id = root.id
JOIN users u ON u.id = root.author_id
WHERE root.parent_id IS NULL
AND root.post_state <> sqlc.arg(hidden_state)
ORDER BY root.created_at DESC, root.id DESC
LIMIT sqlc.arg(row_limit);
-- name: PostIsVisibleRoot :one
SELECT EXISTS(
SELECT 1
-82
View File
@@ -438,21 +438,6 @@ func (m *Memory) GetPostThread(_ context.Context, rootID string) (*Post, error)
return buildPostTree(posts, rootID)
}
func (m *Memory) GetPostThreadForViewer(ctx context.Context, rootID, viewerID string) (*Post, error) {
root, err := m.GetPostThread(ctx, rootID)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
for _, value := range m.postVotes[rootID] {
root.Score += value
}
root.UserVote = m.postVotes[rootID][viewerID]
root.Answered = m.threadContainsAdminReply(rootID)
return root, nil
}
func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
@@ -504,73 +489,6 @@ func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]
return posts, nil
}
func (m *Memory) ListRootPostsByAuthor(_ context.Context, authorID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, post := range m.posts {
if post.ParentID != nil ||
post.AuthorID != authorID ||
post.PostState == PostStateHidden {
continue
}
posts = append(posts, *clonePostWithAuthor(post, m.users))
}
return sortProfilePosts(posts), nil
}
func (m *Memory) ListRootPostsAnsweredBy(_ context.Context, adminID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, root := range m.posts {
if root.ParentID != nil || root.PostState == PostStateHidden {
continue
}
participated := false
for _, post := range m.posts {
if post.AuthorID == adminID && m.postIsDescendantOf(post, root.ID) {
participated = true
break
}
}
if participated {
posts = append(posts, *clonePostWithAuthor(root, m.users))
}
}
return sortProfilePosts(posts), nil
}
func (m *Memory) SetRootPostState(_ context.Context, id string, state PostState) error {
switch state {
case PostStateVisible, PostStateHidden, PostStateLocked:
default:
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
}
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[id]
if !ok || post.ParentID != nil {
return sql.ErrNoRows
}
post.PostState = state
post.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
return nil
}
func sortProfilePosts(posts []Post) []Post {
sort.Slice(posts, func(i, j int) bool {
if posts[i].CreatedAt != posts[j].CreatedAt {
return posts[i].CreatedAt > posts[j].CreatedAt
}
return posts[i].ID > posts[j].ID
})
if len(posts) > ProfileListLimit {
posts = posts[:ProfileListLimit]
}
return posts
}
func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
-13
View File
@@ -64,9 +64,6 @@ CREATE TABLE IF NOT EXISTS posts (
{name: "index post replies", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
ON posts(parent_id, created_at, id)`},
{name: "index post authors", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_author_created
ON posts(author_id, created_at DESC, id DESC)`},
{name: "index root posts", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state)
@@ -120,15 +117,6 @@ CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
return nil
}
func migratePostAuthorIndex(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_posts_author_created
ON posts(author_id, created_at DESC, id DESC)`); err != nil {
return fmt.Errorf("idx_posts_author_created: %w", err)
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -318,7 +306,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"005_post_vote_post_id_index", migratePostVoteIndex},
{"006_post_date", migratePostDate},
{"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex},
}
for _, m := range migrations {
if applied[m.version] {
+1 -46
View File
@@ -138,18 +138,6 @@ WHERE schemaname = current_schema()
if postVoteIndexCount != 1 {
t.Fatalf("post vote index count = %d, want 1", postVoteIndexCount)
}
var postAuthorIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'posts'
AND indexname = 'idx_posts_author_created'`).Scan(&postAuthorIndexCount); err != nil {
t.Fatal(err)
}
if postAuthorIndexCount != 1 {
t.Fatalf("post author index count = %d, want 1", postAuthorIndexCount)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'question-1', -1)`); err == nil {
@@ -288,39 +276,6 @@ INSERT INTO posts (
roots[0].UserVote != 1 {
t.Fatalf("root annotations = %+v", roots)
}
summary, err := queries.GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: "plumber",
RootID: "question-1",
})
if err != nil || summary.Score != 2 || summary.UserVote != 1 {
t.Fatalf("root vote summary = %+v, %v", summary, err)
}
byAuthor, err := queries.ListRootPostsByAuthor(ctx, sqlc.ListRootPostsByAuthorParams{
AuthorID: "homeowner",
HiddenState: string(PostStateHidden),
RowLimit: 50,
})
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != "question-1" {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := queries.ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
HiddenState: string(PostStateHidden),
AdminID: "plumber",
RowLimit: 50,
})
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != "question-1" {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
for _, state := range []PostState{PostStateLocked, PostStateVisible} {
n, err := queries.UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state),
UpdatedAt: "2026-08-26T10:10:00Z",
ID: "question-1",
})
if err != nil || n != 1 {
t.Fatalf("set root state %q rows=%d error=%v", state, n, err)
}
}
if _, err := conn.ExecContext(ctx, `
DROP INDEX idx_posts_root_date;
@@ -458,7 +413,7 @@ WHERE schemaname = current_schema()
func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel()
exec := &failingMigrationExec{failAt: 6}
exec := &failingMigrationExec{failAt: 5}
err := migratePosts(context.Background(), exec)
if err == nil || !strings.Contains(err.Error(), "copy questions") {
t.Fatalf("error = %v, want copy questions context", err)
-116
View File
@@ -258,39 +258,6 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error
return buildPostTree(posts, rootID)
}
// GetPostThreadForViewer includes root voting and answered annotations.
func GetPostThreadForViewer(
ctx context.Context,
db *sql.DB,
rootID string,
viewerID string,
) (*Post, error) {
root, err := GetPostThread(ctx, db, rootID)
if err != nil {
return nil, err
}
summary, err := sqlc.New(db).GetRootPostVoteSummary(ctx, sqlc.GetRootPostVoteSummaryParams{
ViewerID: viewerID,
RootID: rootID,
})
if err != nil {
return nil, err
}
root.Score = int(summary.Score)
root.UserVote = int(summary.UserVote)
root.Answered = postTreeContainsRole(root, RoleAdmin)
return root, nil
}
func postTreeContainsRole(post *Post, role Role) bool {
for _, reply := range post.Replies {
if reply.AuthorRole == role || postTreeContainsRole(reply, role) {
return true
}
}
return false
}
func buildPostTree(posts []Post, rootID string) (*Post, error) {
byID := make(map[string]*Post, len(posts))
for i := range posts {
@@ -367,89 +334,6 @@ func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) (
return posts, nil
}
// ListRootPostsByAuthor returns visible roots created by an author, newest first.
func ListRootPostsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]Post, error) {
rows, err := sqlc.New(db).ListRootPostsByAuthor(ctx, sqlc.ListRootPostsByAuthorParams{
AuthorID: authorID,
HiddenState: string(PostStateHidden),
RowLimit: ProfileListLimit,
})
if err != nil {
return nil, err
}
posts := make([]Post, 0, len(rows))
for _, r := range rows {
posts = append(posts, postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
))
}
return posts, nil
}
// ListRootPostsAnsweredBy returns visible roots containing a reply by adminID.
func ListRootPostsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]Post, error) {
rows, err := sqlc.New(db).ListRootPostsAnsweredBy(ctx, sqlc.ListRootPostsAnsweredByParams{
HiddenState: string(PostStateHidden),
AdminID: adminID,
RowLimit: ProfileListLimit,
})
if err != nil {
return nil, err
}
posts := make([]Post, 0, len(rows))
for _, r := range rows {
posts = append(posts, postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
))
}
return posts, nil
}
// SetRootPostState changes a root post's state.
func SetRootPostState(ctx context.Context, db *sql.DB, id string, state PostState) error {
switch state {
case PostStateVisible, PostStateHidden, PostStateLocked:
default:
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
}
n, err := sqlc.New(db).UpdateRootPostState(ctx, sqlc.UpdateRootPostStateParams{
PostState: string(state),
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
ID: id,
})
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// SetPostVote sets value to 1, -1, or 0 on a visible root post.
func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value int) error {
if value != 1 && value != -1 && value != 0 {
-20
View File
@@ -141,26 +141,6 @@ func TestMemoryPostLifecycle(t *testing.T) {
if err := mem.VotePost(ctx, voter.ID, later.ID, 1); !errors.Is(err, ErrPostNotVotable) {
t.Fatalf("reply vote error = %v", err)
}
byAuthor, err := mem.ListRootPostsByAuthor(ctx, homeowner.ID)
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != root.ID {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := mem.ListRootPostsAnsweredBy(ctx, plumber.ID)
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != root.ID {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
if err := mem.SetRootPostState(ctx, later.ID, PostStateHidden); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("reply state error = %v, want sql.ErrNoRows", err)
}
if err := mem.SetRootPostState(ctx, root.ID, PostStateHidden); err != nil {
t.Fatal(err)
}
if roots, err := mem.ListRootPostsByAuthor(ctx, homeowner.ID); err != nil || len(roots) != 0 {
t.Fatalf("hidden author roots = %+v, %v", roots, err)
}
if roots, err := mem.ListRootPostsAnsweredBy(ctx, plumber.ID); err != nil || len(roots) != 0 {
t.Fatalf("hidden answered roots = %+v, %v", roots, err)
}
}
func TestMemoryPostValidation(t *testing.T) {
-16
View File
@@ -157,10 +157,6 @@ func (p *Postgres) GetPostThread(ctx context.Context, rootID string) (*Post, err
return GetPostThread(ctx, p.db, rootID)
}
func (p *Postgres) GetPostThreadForViewer(ctx context.Context, rootID, viewerID string) (*Post, error) {
return GetPostThreadForViewer(ctx, p.db, rootID, viewerID)
}
func (p *Postgres) UpdatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Update(ctx)
@@ -170,18 +166,6 @@ func (p *Postgres) ListRootPosts(ctx context.Context, postDate, viewerID string)
return ListRootPosts(ctx, p.db, postDate, viewerID)
}
func (p *Postgres) ListRootPostsByAuthor(ctx context.Context, authorID string) ([]Post, error) {
return ListRootPostsByAuthor(ctx, p.db, authorID)
}
func (p *Postgres) ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error) {
return ListRootPostsAnsweredBy(ctx, p.db, adminID)
}
func (p *Postgres) SetRootPostState(ctx context.Context, id string, state PostState) error {
return SetRootPostState(ctx, p.db, id, state)
}
func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
return SetPostVote(ctx, p.db, userID, postID, value)
}
-207
View File
@@ -117,34 +117,6 @@ func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
return i, err
}
const getRootPostVoteSummary = `-- name: GetRootPostVoteSummary :one
SELECT
COALESCE(SUM(value), 0)::bigint AS score,
COALESCE(
MAX(value) FILTER (WHERE user_id = $1),
0
)::bigint AS user_vote
FROM post_votes
WHERE post_id = $2
`
type GetRootPostVoteSummaryParams struct {
ViewerID string
RootID string
}
type GetRootPostVoteSummaryRow struct {
Score int64
UserVote int64
}
func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVoteSummaryParams) (GetRootPostVoteSummaryRow, error) {
row := q.db.QueryRowContext(ctx, getRootPostVoteSummary, arg.ViewerID, arg.RootID)
var i GetRootPostVoteSummaryRow
err := row.Scan(&i.Score, &i.UserVote)
return i, err
}
const listPostThread = `-- name: ListPostThread :many
WITH RECURSIVE thread AS (
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
@@ -337,162 +309,6 @@ func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([
return items, nil
}
const listRootPostsAnsweredBy = `-- name: ListRootPostsAnsweredBy :many
WITH RECURSIVE ancestors AS (
SELECT p.id, p.parent_id
FROM posts p
WHERE p.author_id = $3
AND p.parent_id IS NOT NULL
UNION
SELECT parent.id, parent.parent_id
FROM posts parent
JOIN ancestors child ON child.parent_id = parent.id
)
SELECT DISTINCT
root.id, root.parent_id, root.author_id,
u.name AS author_name, u.role AS author_role,
root.title, root.body, root.city, root.post_date,
root.post_state, root.created_at, root.updated_at
FROM posts root
JOIN ancestors ON ancestors.id = root.id
JOIN users u ON u.id = root.author_id
WHERE root.parent_id IS NULL
AND root.post_state <> $1
ORDER BY root.created_at DESC, root.id DESC
LIMIT $2
`
type ListRootPostsAnsweredByParams struct {
HiddenState string
RowLimit int32
AdminID string
}
type ListRootPostsAnsweredByRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) ListRootPostsAnsweredBy(ctx context.Context, arg ListRootPostsAnsweredByParams) ([]ListRootPostsAnsweredByRow, error) {
rows, err := q.db.QueryContext(ctx, listRootPostsAnsweredBy, arg.HiddenState, arg.RowLimit, arg.AdminID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootPostsAnsweredByRow{}
for rows.Next() {
var i ListRootPostsAnsweredByRow
if err := rows.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRootPostsByAuthor = `-- name: ListRootPostsByAuthor :many
SELECT
p.id, p.parent_id, p.author_id,
u.name AS author_name, u.role AS author_role,
p.title, p.body, p.city, p.post_date,
p.post_state, p.created_at, p.updated_at
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.parent_id IS NULL
AND p.author_id = $1
AND p.post_state <> $2
ORDER BY p.created_at DESC, p.id DESC
LIMIT $3
`
type ListRootPostsByAuthorParams struct {
AuthorID string
HiddenState string
RowLimit int32
}
type ListRootPostsByAuthorRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) ListRootPostsByAuthor(ctx context.Context, arg ListRootPostsByAuthorParams) ([]ListRootPostsByAuthorRow, error) {
rows, err := q.db.QueryContext(ctx, listRootPostsByAuthor, arg.AuthorID, arg.HiddenState, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootPostsByAuthorRow{}
for rows.Next() {
var i ListRootPostsByAuthorRow
if err := rows.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one
SELECT EXISTS(
SELECT 1
@@ -537,29 +353,6 @@ func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64,
return result.RowsAffected()
}
const updateRootPostState = `-- name: UpdateRootPostState :execrows
UPDATE posts
SET
post_state = $1,
updated_at = $2
WHERE id = $3
AND parent_id IS NULL
`
type UpdateRootPostStateParams struct {
PostState string
UpdatedAt string
ID string
}
func (q *Queries) UpdateRootPostState(ctx context.Context, arg UpdateRootPostStateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateRootPostState, arg.PostState, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execrows
INSERT INTO post_votes (user_id, post_id, value)
SELECT $1, $2, $3
-4
View File
@@ -40,12 +40,8 @@ type Store interface {
CreatePost(ctx context.Context, post *Post) error
GetPost(ctx context.Context, id string) (*Post, error)
GetPostThread(ctx context.Context, rootID string) (*Post, error)
GetPostThreadForViewer(ctx context.Context, rootID, viewerID string) (*Post, error)
UpdatePost(ctx context.Context, post *Post) error
ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error)
ListRootPostsByAuthor(ctx context.Context, authorID string) ([]Post, error)
ListRootPostsAnsweredBy(ctx context.Context, adminID string) ([]Post, error)
SetRootPostState(ctx context.Context, id string, state PostState) error
VotePost(ctx context.Context, userID, postID string, value int) error
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
-216
View File
@@ -1,216 +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 !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
}
-380
View File
@@ -1,380 +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 TestQuestionPageRendersNestedPostControls(t *testing.T) {
t.Parallel()
srv, mem := newTestServer(t, Config{})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
root := &store.Post{
AuthorID: homeowner.ID,
Title: "Leaky sink",
Body: "Water under the cabinet.",
City: "Oakland",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
homeownerReply := &store.Post{
ParentID: &root.ID,
AuthorID: homeowner.ID,
Body: "The model number is 123.",
}
if err := mem.CreatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
}
adminReply := &store.Post{
ParentID: &homeownerReply.ID,
AuthorID: admin.ID,
Body: "Replace the cartridge.",
}
if err := mem.CreatePost(context.Background(), adminReply); err != nil {
t.Fatal(err)
}
homeownerReply.Body = "The model number is 123A."
if err := mem.UpdatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil)
for _, cookie := range homeownerCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("question page status = %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
`id="post-` + root.ID + `"`,
`id="post-` + homeownerReply.ID + `"`,
`id="post-` + adminReply.ID + `"`,
`class="thread-post thread-post-branch`,
`class="thread-post thread-post-deep is-shop"`,
"Homeowner",
"Shop response",
"Edited",
`action="/posts"`,
`action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`,
`>The model number is 123A.</textarea>`,
`removeAttribute('open')`,
} {
if !strings.Contains(body, want) {
t.Fatalf("question page missing %q: %s", want, body)
}
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil)
for _, cookie := range adminCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `action="/posts/`+adminReply.ID+`/edit"`) ||
strings.Contains(rec.Body.String(), `action="/posts/`+root.ID+`/edit"`) {
t.Fatalf("admin edit controls are incorrect: %d %s", rec.Code, rec.Body.String())
}
}
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
}
+7 -7
View File
@@ -23,7 +23,7 @@ import (
type profilePage struct {
page
States []struct{ Code, Name string }
Posts []store.Post
Questions []store.RankedQuestion
QuestionsLabel string
UploadsEnabled bool
Error string
@@ -255,16 +255,16 @@ func fitAvatar(img image.Image, maxDim int) image.Image {
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) {
var (
posts []store.Post
label string
err error
questions []store.RankedQuestion
label string
err error
)
if u.Admin() {
label = "Questions you answered"
posts, err = s.store.ListRootPostsAnsweredBy(r.Context(), u.ID)
questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
} else {
label = "Your questions"
posts, err = s.store.ListRootPostsByAuthor(r.Context(), u.ID)
questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
}
if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError)
@@ -275,7 +275,7 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
s.exec(w, "profile", profilePage{
page: p,
States: geo.States,
Posts: posts,
Questions: questions,
QuestionsLabel: label,
UploadsEnabled: s.cfg.Blob.Enabled(),
Error: errMsg,
+104 -74
View File
@@ -3,6 +3,7 @@ package web
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
@@ -63,12 +64,13 @@ type huntPage struct {
Label string
IsToday bool
IsYesterday bool
Posts []*store.Post
Questions []store.RankedQuestion
}
type questionPage struct {
page
Question *store.Post
Question *store.RankedQuestion
Answer *store.Answer
}
type submitPage struct {
@@ -88,19 +90,11 @@ type authPage struct {
}
type voteCtx struct {
User *store.User
CSRF string
View string
Date string
Post *store.Post
}
type threadPostCtx struct {
User *store.User
CSRF string
Root *store.Post
Post *store.Post
Depth int
User *store.User
CSRF string
View string
Date string
Question store.RankedQuestion
}
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
@@ -111,27 +105,12 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
cfg.Mail = mail.Nop{}
}
funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Post: post}
},
"postCtx": func(user *store.User, csrf string, root, post *store.Post, depth int) threadPostCtx {
return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth}
},
"add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() },
"canReply": canReplyToThread,
"canEditPost": canEditPost,
"postLabel": postLabel,
"postDepth": postDepthClass,
"isEdited": func(post *store.Post) bool { return post != nil && post.UpdatedAt != post.CreatedAt },
"postTime": func(value string) string {
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return value
}
return t.In(pacific.Loc).Format("Jan 2, 2006 · 3:04 PM")
"voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
},
"add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() },
"pacificLabel": pacific.Label,
"locationTag": func(u *store.User) string {
if u != nil {
@@ -198,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)
@@ -302,7 +279,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
if u := currentUser(r); u != nil {
viewer = u.ID
}
posts, err := s.store.ListRootPosts(r.Context(), date, viewer)
questions, err := s.store.ListHunt(r.Context(), date, viewer)
if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError)
return
@@ -318,7 +295,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
Label: label,
IsToday: pacific.IsToday(date),
IsYesterday: pacific.IsYesterday(date),
Posts: postPointers(posts),
Questions: questions,
})
}
@@ -362,17 +339,17 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
if len(city) > 80 {
city = truncateRunes(city, 80)
}
post := &store.Post{
q := &store.RankedQuestion{
AuthorID: u.ID,
Title: title,
Body: body,
City: city,
}
if err := s.store.CreatePost(r.Context(), post); err != nil {
if err := s.store.CreateQuestion(r.Context(), q); err != nil {
http.Error(w, "could not save question", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther)
http.Redirect(w, r, "/questions/"+url.PathEscape(q.ID), http.StatusSeeOther)
}
func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
@@ -381,14 +358,29 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
if u := currentUser(r); u != nil {
viewer = u.ID
}
post, err := s.store.GetPostThreadForViewer(r.Context(), id, viewer)
if err != nil || (post.PostState == store.PostStateHidden && !currentUser(r).Admin()) {
q, err := s.store.GetQuestion(r.Context(), id, viewer)
if err != nil || (q.Hidden && !currentUser(r).Admin()) {
http.NotFound(w, r)
return
}
var ans *store.Answer
if q.Answered {
ans, err = s.store.GetAnswer(r.Context(), q.ID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Printf("question %s marked answered but answer missing", q.ID)
http.Error(w, "answer unavailable", http.StatusInternalServerError)
return
}
log.Printf("get answer %s: %v", q.ID, err)
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
}
s.exec(w, "question", questionPage{
page: s.basePage(r, post.Title),
Question: post,
page: s.basePage(r, q.Title),
Question: q,
Answer: ans,
})
}
@@ -418,8 +410,8 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid vote", http.StatusBadRequest)
return
}
if err := s.store.VotePost(r.Context(), u.ID, id, value); err != nil {
if errors.Is(err, store.ErrPostNotVotable) {
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
if errors.Is(err, store.ErrHiddenOrMissing) {
http.Error(w, "not found", http.StatusNotFound)
return
}
@@ -433,17 +425,17 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
s.renderLeaderboard(w, r, date)
return
}
post, err := s.store.GetPostThreadForViewer(r.Context(), id, u.ID)
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s.exec(w, "vote", voteCtx{
User: u,
CSRF: s.sessions.GetString(r.Context(), "csrf"),
View: "question",
Date: post.PostDate,
Post: post,
User: u,
CSRF: s.sessions.GetString(r.Context(), "csrf"),
View: "question",
Date: q.HuntDate,
Question: *q,
})
return
}
@@ -466,15 +458,15 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
if u := currentUser(r); u != nil {
viewer = u.ID
}
posts, err := s.store.ListRootPosts(r.Context(), date, viewer)
questions, err := s.store.ListHunt(r.Context(), date, viewer)
if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError)
return
}
s.exec(w, "leaderboard", huntPage{
page: s.basePage(r, ""),
Date: date,
Posts: postPointers(posts),
page: s.basePage(r, ""),
Date: date,
Questions: questions,
})
}
@@ -496,27 +488,65 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
if len(body) > 12000 {
body = truncateRunes(body, 12000)
}
root, err := s.store.GetPost(r.Context(), id)
if err != nil || root.ParentID != nil || root.PostState == store.PostStateHidden {
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil {
http.NotFound(w, r)
return
}
reply := &store.Post{
ParentID: &root.ID,
AuthorID: u.ID,
Body: body,
_, priorErr := s.store.GetAnswer(r.Context(), id)
wasNew := errors.Is(priorErr, sql.ErrNoRows)
if priorErr != nil && !wasNew {
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
if err := s.store.CreatePost(r.Context(), reply); err != nil {
ans := &store.Answer{
QuestionID: id,
AuthorID: u.ID,
Body: body,
}
if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
http.Error(w, "could not save answer", http.StatusInternalServerError)
return
}
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID)
if isHTMX(r) {
w.Header().Set("HX-Redirect", location)
w.WriteHeader(http.StatusSeeOther)
if wasNew {
s.notifyQuestionAnswered(q, body, u.ID)
}
saved, err := s.store.GetAnswer(r.Context(), id)
if err != nil {
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
http.Redirect(w, r, location, http.StatusSeeOther)
if isHTMX(r) {
s.exec(w, "answer", questionPage{page: s.basePage(r, ""), Answer: saved})
return
}
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
}
func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adminID string) {
if q == nil || s.cfg.Mail == nil {
return
}
author, err := s.store.UserByID(context.Background(), q.AuthorID)
if err != nil || author == nil || author.Email == "" || author.ID == adminID {
return
}
msg := mail.QuestionAnswered{
ToEmail: author.Email,
ToName: author.Name,
QuestionID: q.ID,
QuestionTitle: q.Title,
AnswerBody: answerBody,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
log.Printf("notify answer %s: %v", q.ID, err)
return
}
log.Printf("notify answer %s: accepted", q.ID)
}()
}
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
@@ -529,17 +559,17 @@ func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
return
}
id := chi.URLParam(r, "id")
post, err := s.store.GetPost(r.Context(), id)
if err != nil || post.ParentID != nil {
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.store.SetRootPostState(r.Context(), id, store.PostStateHidden); err != nil {
if err := s.store.HideQuestion(r.Context(), id); err != nil {
http.Error(w, "could not hide", http.StatusInternalServerError)
return
}
if isHTMX(r) && r.PostFormValue("view") == "list" {
s.renderLeaderboard(w, r, post.PostDate)
s.renderLeaderboard(w, r, q.HuntDate)
return
}
if isHTMX(r) {
+133 -40
View File
@@ -19,6 +19,7 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -416,21 +417,21 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, hubName, "hunter22")
root := &store.Post{
q := &store.RankedQuestion{
AuthorID: alice.ID,
Title: "Drip",
Body: "Under sink",
City: "Oakland",
}
if err := mem.CreatePost(context.Background(), root); err != nil {
if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err)
}
reply := &store.Post{
ParentID: &root.ID,
AuthorID: hub.ID,
Body: "Replace the cartridge.",
ans := &store.Answer{
QuestionID: q.ID,
AuthorID: hub.ID,
Body: "Replace the cartridge.",
}
if err := mem.CreatePost(context.Background(), reply); err != nil {
if err := mem.UpsertAnswer(context.Background(), ans); err != nil {
t.Fatal(err)
}
@@ -492,14 +493,14 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
adminCookies := loginUser(t, h, adminName, "hunter22")
userCookies := loginUser(t, h, userName, "hunter22")
q := &store.Post{
q := &store.RankedQuestion{
AuthorID: user.ID,
Title: "Pipe noise",
Body: "Clanking",
City: "SF",
PostDate: pacific.Today(),
HuntDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), q); err != nil {
if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err)
}
@@ -555,7 +556,7 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String())
}
got, err := mem.GetPostThreadForViewer(context.Background(), q.ID, user.ID)
got, err := mem.GetQuestion(context.Background(), q.ID, user.ID)
if err != nil || got.UserVote != 1 || got.Score != 1 {
t.Fatalf("vote not applied: %+v %v", got, err)
}
@@ -580,7 +581,7 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
t.Fatalf("non-admin answer want 403, got %d", rec.Code)
}
// Admin answer compatibility route creates a reply and redirects the thread.
// Admin answer success (HTMX)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
@@ -597,37 +598,22 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), q.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("admin reply missing: %+v %v", thread, err)
}
adminReply := thread.Replies[0]
if adminReply.AuthorID != admin.ID || adminReply.Body != "Tighten the nuts." {
t.Fatalf("unexpected admin reply: %+v", adminReply)
}
if got := rec.Header().Get("HX-Redirect"); got != "/questions/"+q.ID+"#post-"+adminReply.ID {
t.Fatalf("admin answer redirect = %q", got)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
if body := rec.Body.String(); !strings.Contains(body, "Tighten the nuts.") ||
!strings.Contains(body, "<summary>Edit</summary>") ||
if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) ||
!strings.Contains(body, "<summary>Edit answer</summary>") ||
!strings.Contains(body, ">Tighten the nuts.</textarea>") ||
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
!strings.Contains(body, `removeAttribute('open')`) ||
strings.Contains(body, `<details class="post-composer" open`) {
t.Fatalf("admin reply editor is not collapsed and populated: %s", body)
strings.Contains(body, `<details class="answer-editor" open`) {
t.Fatalf("admin answer editor is not collapsed and populated: %s", body)
}
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
t.Fatal(err)
}
// The public reply is visible to the root author, but editing remains admin-only.
// The public answer is visible to its author, but editing remains admin-only.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
@@ -637,8 +623,8 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") {
t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), `/posts/`+adminReply.ID+`/edit`) {
t.Fatalf("question author can edit admin reply: %s", rec.Body.String())
if strings.Contains(rec.Body.String(), `class="answer-editor"`) {
t.Fatalf("question author can see admin answer editor: %s", rec.Body.String())
}
// Hide invalid id
@@ -673,8 +659,8 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != http.StatusSeeOther {
t.Fatalf("hide %d %s", rec.Code, rec.Body.String())
}
hidden, err := mem.GetPost(context.Background(), q.ID)
if err != nil || hidden.PostState != store.PostStateHidden {
hidden, err := mem.GetQuestion(context.Background(), q.ID, admin.ID)
if err != nil || !hidden.Hidden {
t.Fatalf("question not hidden: %+v %v", hidden, err)
}
}
@@ -727,6 +713,113 @@ func TestRegisterRequiresEmail(t *testing.T) {
}
}
func TestAnswerNotifyFirstOnly(t *testing.T) {
recMail := &mail.Recording{}
srv, mem := newTestServer(t, Config{Mail: recMail})
h := srv.Handler()
adminName := uniq("adm")
askName := uniq("ask")
admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
asker := seedUser(t, mem, askName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, adminName, "hunter22")
q := &store.RankedQuestion{
AuthorID: asker.ID,
Title: "Leaky sink",
Body: "Drip",
City: "Oakland",
HuntDate: pacific.Today(),
}
if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err)
}
postAnswer := func(body string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies {
req.AddCookie(c)
}
h.ServeHTTP(w, req)
csrf := csrfFrom(w.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&body=" + body)
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range adminCookies {
req.AddCookie(c)
}
w = httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("answer %d %s", w.Code, w.Body.String())
}
}
postAnswer("First+reply")
deadline := time.Now().Add(2 * time.Second)
var msgs []mail.QuestionAnswered
for time.Now().Before(deadline) {
msgs = recMail.Snapshot()
if len(msgs) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if len(msgs) != 1 {
t.Fatalf("first answer notifies once, got %d", len(msgs))
}
if msgs[0].ToEmail != asker.Email || msgs[0].QuestionID != q.ID {
t.Fatalf("unexpected notify: %+v", msgs[0])
}
if msgs[0].AnswerBody != "First reply" {
t.Fatalf("answer body %q", msgs[0].AnswerBody)
}
postAnswer("Edited+reply")
time.Sleep(50 * time.Millisecond)
if recMail.Len() != 1 {
t.Fatalf("edit must not notify again, got %d", recMail.Len())
}
// Author without email is skipped
recMail2 := &mail.Recording{}
srv2, mem2 := newTestServer(t, Config{Mail: recMail2})
h2 := srv2.Handler()
admin2 := seedUser(t, mem2, uniq("adm2"), "hunter22", store.RoleAdmin)
noMail := &store.User{Username: uniq("silent"), PasswordHash: admin.PasswordHash, Role: store.RoleUser, Email: ""}
hash, _ := bcrypt.GenerateFromPassword([]byte("hunter22"), bcrypt.MinCost)
noMail.PasswordHash = string(hash)
if err := mem2.CreateUser(context.Background(), noMail); err != nil {
t.Fatal(err)
}
q2 := &store.RankedQuestion{AuthorID: noMail.ID, Title: "Quiet", Body: "x", HuntDate: pacific.Today()}
if err := mem2.CreateQuestion(context.Background(), q2); err != nil {
t.Fatal(err)
}
cookies := loginUser(t, h2, admin2.Username, "hunter22")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+q2.ID, nil)
for _, c := range cookies {
req.AddCookie(c)
}
h2.ServeHTTP(w, req)
csrf := csrfFrom(w.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&body=Hello")
req = httptest.NewRequest(http.MethodPost, "/questions/"+q2.ID+"/answer", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
w = httptest.NewRecorder()
h2.ServeHTTP(w, req)
time.Sleep(50 * time.Millisecond)
if recMail2.Len() != 0 {
t.Fatalf("empty email must skip notify, got %d", recMail2.Len())
}
}
// TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For
// cannot bypass rate limits when the direct peer is outside TrustedProxies.
// This must go through Handler() so middleware ordering bugs are caught.
-3
View File
@@ -63,9 +63,6 @@ CREATE TABLE IF NOT EXISTS posts (
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
ON posts(parent_id, created_at, id);
CREATE INDEX IF NOT EXISTS idx_posts_author_created
ON posts(author_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state)
WHERE parent_id IS NULL;
+28 -102
View File
@@ -552,14 +552,12 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
padding: 18px 14px;
}
.post-content { min-width: 0; }
.question-page h1 {
font-size: clamp(1.5rem, 3.5vw, 2.1rem);
line-height: 1.15;
}
.post-body {
.q-body, .answer-body {
white-space: pre-wrap;
margin: 14px 0 0;
text-wrap: pretty;
@@ -576,7 +574,18 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
}
.crumb a:hover { color: var(--signal); }
.post-kicker {
.answer {
margin-top: 16px;
padding: 20px 18px;
background: var(--panel);
border: 1px solid var(--line);
}
.answer.is-in {
border-color: var(--signal);
}
.answer-kicker {
margin: 0 0 6px;
font-family: var(--mono);
text-transform: uppercase;
@@ -586,99 +595,27 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
color: var(--signal);
}
.conversation {
margin-top: 32px;
}
.conversation-head {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.conversation h2 {
.answer h2 {
margin: 0;
font-size: 1.25rem;
font-size: 1.15rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.conversation-head .eyebrow {
margin-bottom: 5px;
}
.thread {
display: grid;
gap: 12px;
}
.thread-post {
position: relative;
padding: 16px;
background: var(--panel);
border: 1px solid var(--line);
border-left: 2px solid var(--zinc);
overflow-wrap: anywhere;
}
.thread-post.is-shop {
border-left-color: var(--signal);
}
.thread-post:target,
.q-detail:target {
outline: 2px solid var(--signal);
outline-offset: 3px;
}
.thread-post-branch,
.thread-post-deep {
margin-left: clamp(12px, 4vw, 28px);
}
.thread-post-deep .thread-post-deep {
margin-left: 0;
}
.post-replies {
display: grid;
gap: 12px;
margin-top: 12px;
}
.post-meta {
.byline {
margin: 6px 0 0;
color: var(--muted);
font-family: var(--mono);
font-size: 0.72rem;
}
.edited {
display: inline-block;
margin-left: 8px;
color: var(--zinc);
font-size: 0.65rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.post-actions {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0 16px;
margin-top: 10px;
.answer-editor {
margin-top: 16px;
border-top: 1px solid var(--line);
}
.post-composer {
min-width: 0;
}
.post-composer[open] {
flex: 1 0 100%;
}
.post-composer summary {
.answer-editor summary {
display: flex;
width: fit-content;
min-height: 44px;
@@ -693,34 +630,23 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
list-style: none;
}
.post-composer summary::-webkit-details-marker { display: none; }
.post-composer summary::marker { content: ""; }
.post-composer summary:hover,
.post-composer[open] summary { color: var(--signal); }
.post-composer summary:focus-visible {
.answer-editor summary::-webkit-details-marker { display: none; }
.answer-editor summary:hover,
.answer-editor[open] summary { color: var(--signal); }
.answer-editor summary:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.post-form {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin: 0 0 14px;
}
.answer-editor .answer-form { margin-top: 4px; }
.post-form-actions {
.answer-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.post-form-actions .btn { flex: 1 1 10rem; }
.post-hide {
margin: 0;
}
.answer-form-actions .btn { flex: 1 1 10rem; }
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; }
+28
View File
@@ -0,0 +1,28 @@
{{define "answer"}}
<section id="answer-block" class="answer{{if .Answer}} is-in{{end}}">
{{if .Answer}}
<p class="answer-kicker">Shop response</p>
<h2>Answer</h2>
<p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p>
<p class="answer-body">{{.Answer.Body}}</p>
{{if isAdmin .User}}
<details class="answer-editor">
<summary>Edit answer</summary>
<form class="answer-form" method="post" action="/questions/{{.Answer.QuestionID}}/answer"
hx-post="/questions/{{.Answer.QuestionID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="answer-body">Edit answer</label>
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{.Answer.Body}}</textarea>
<div class="answer-form-actions">
<button type="submit" class="btn btn-primary">Save answer</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
</form>
</details>
{{end}}
{{else}}
<p class="waiting">No answer yet. Check back after the hunt.</p>
{{end}}
</section>
{{end}}
+9 -9
View File
@@ -1,6 +1,6 @@
{{define "leaderboard"}}
<ol id="leaderboard" class="board" start="1">
{{if not .Posts}}
{{if not .Questions}}
<li class="empty">
{{if eq .Date .Today}}
<p class="empty-kicker">Queue empty</p>
@@ -10,20 +10,20 @@
{{end}}
</li>
{{else}}
{{range $i, $post := .Posts}}
{{range $i, $q := .Questions}}
<li class="row">
<span class="rank" aria-hidden="true">{{rank $i}}</span>
{{template "vote" (voteCtx $.User $.CSRF "list" $.Date $post)}}
{{template "vote" (voteCtx $.User $.CSRF "list" $.Date $q)}}
<div class="row-body">
<a class="q-title" href="/questions/{{$post.ID}}">{{$post.Title}}</a>
<a class="q-title" href="/questions/{{$q.ID}}">{{$q.Title}}</a>
<p class="meta">
<span>{{$post.AuthorName}}</span>
{{if $post.City}}<span class="dot" aria-hidden="true">·</span><span>{{$post.City}}</span>{{end}}
{{if $post.Answered}}<span class="badge">Answered</span>{{end}}
<span>{{$q.AuthorName}}</span>
{{if $q.City}}<span class="dot" aria-hidden="true">·</span><span>{{$q.City}}</span>{{end}}
{{if $q.Answered}}<span class="badge">Answered</span>{{end}}
</p>
{{if isAdmin $.User}}
<form class="inline-hide" method="post" action="/questions/{{$post.ID}}/hide"
hx-post="/questions/{{$post.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML">
<form class="inline-hide" method="post" action="/questions/{{$q.ID}}/hide"
hx-post="/questions/{{$q.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
<input type="hidden" name="view" value="list">
<button type="submit" class="linkish">Hide</button>
-67
View File
@@ -1,67 +0,0 @@
{{define "postActions"}}
<div class="post-actions">
{{if canReply .User .Root}}
<details class="post-composer">
<summary>Reply</summary>
<form class="post-form" method="post" action="/posts">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="parent_id" value="{{.Post.ID}}">
<label for="reply-{{.Post.ID}}">Reply to {{.Post.AuthorName}}</label>
<textarea id="reply-{{.Post.ID}}" name="body" rows="5" required maxlength="12000"></textarea>
<div class="post-form-actions">
<button type="submit" class="btn btn-primary">Post reply</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
</form>
</details>
{{end}}
{{if canEditPost .User .Post}}
<details class="post-composer">
<summary>Edit</summary>
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="edit-{{.Post.ID}}">Edit post</label>
<textarea id="edit-{{.Post.ID}}" name="body" rows="5" required
maxlength="12000">{{.Post.Body}}</textarea>
<div class="post-form-actions">
<button type="submit" class="btn btn-primary">Save changes</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
</form>
</details>
{{end}}
{{if and (not .Post.ParentID) (isAdmin .User)}}
<form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="linkish">Hide</button>
</form>
{{end}}
</div>
{{end}}
{{define "threadReply"}}
{{$ctx := .}}
<article id="post-{{.Post.ID}}"
class="thread-post thread-post-{{postDepth .Depth}}{{if eq .Post.AuthorRole "admin"}} is-shop{{end}}">
<header class="post-head">
<p class="post-kicker">{{postLabel .Post}}</p>
<p class="post-meta">
<span>{{.Post.AuthorName}}</span>
<span class="dot" aria-hidden="true">·</span>
<time datetime="{{.Post.CreatedAt}}">{{postTime .Post.CreatedAt}}</time>
{{if isEdited .Post}}<span class="edited">Edited</span>{{end}}
</p>
</header>
<p class="post-body">{{.Post.Body}}</p>
{{template "postActions" .}}
{{if .Post.Replies}}
<div class="post-replies">
{{range .Post.Replies}}
{{template "threadReply" (postCtx $ctx.User $ctx.CSRF $ctx.Root . (add $ctx.Depth 1))}}
{{end}}
</div>
{{end}}
</article>
{{end}}
+13 -13
View File
@@ -1,26 +1,26 @@
{{define "vote"}}
<div id="vote-{{.Post.ID}}" class="vote">
<div id="vote-{{.Question.ID}}" class="vote">
{{if .User}}
<form method="post" action="/questions/{{.Post.ID}}/vote"
hx-post="/questions/{{.Post.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Post.ID}}" hx-swap="outerHTML"{{end}}>
<form method="post" action="/questions/{{.Question.ID}}/vote"
hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if eq .Post.UserVote 1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="1">{{end}}
{{if eq .Question.UserVote 1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="1">{{end}}
<input type="hidden" name="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}">
<button type="submit" class="vote-btn{{if eq .Post.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Post.UserVote 1}}true{{else}}false{{end}}">
<button type="submit" class="vote-btn{{if eq .Question.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Question.UserVote 1}}true{{else}}false{{end}}">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
</button>
</form>
<span class="score" aria-label="Net score {{.Post.Score}}">{{.Post.Score}}</span>
<form method="post" action="/questions/{{.Post.ID}}/vote"
hx-post="/questions/{{.Post.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Post.ID}}" hx-swap="outerHTML"{{end}}>
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
<form method="post" action="/questions/{{.Question.ID}}/vote"
hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if eq .Post.UserVote -1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="-1">{{end}}
{{if eq .Question.UserVote -1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="-1">{{end}}
<input type="hidden" name="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}">
<button type="submit" class="vote-btn{{if eq .Post.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Post.UserVote -1}}true{{else}}false{{end}}">
<button type="submit" class="vote-btn{{if eq .Question.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Question.UserVote -1}}true{{else}}false{{end}}">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
</button>
</form>
@@ -28,7 +28,7 @@
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to upvote">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
</a>
<span class="score" aria-label="Net score {{.Post.Score}}">{{.Post.Score}}</span>
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to downvote">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
</a>
+3 -3
View File
@@ -43,12 +43,12 @@
<section class="profile-questions" aria-labelledby="profile-q-heading">
<h2 id="profile-q-heading">{{.QuestionsLabel}}</h2>
{{if .Posts}}
{{if .Questions}}
<ul class="profile-q-list">
{{range .Posts}}
{{range .Questions}}
<li>
<a href="/questions/{{.ID}}">{{.Title}}</a>
<span class="meta">{{.PostDate}}</span>
<span class="meta">{{.HuntDate}}</span>
</li>
{{end}}
</ul>
+23 -27
View File
@@ -1,41 +1,37 @@
{{define "question"}}
{{template "header" .}}
<main id="main" class="wrap question-page">
<p class="crumb"><a href="{{if eq .Question.PostDate .Today}}/{{else}}/hunt/{{.Question.PostDate}}{{end}}">← {{pacificLabel .Question.PostDate}}</a></p>
<article id="post-{{.Question.ID}}" class="q-detail">
{{template "vote" (voteCtx .User .CSRF "question" .Question.PostDate .Question)}}
<div class="post-content">
<p class="post-kicker">Question</p>
<p class="crumb"><a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">← {{pacificLabel .Question.HuntDate}}</a></p>
<article class="q-detail">
{{template "vote" (voteCtx .User .CSRF "question" .Question.HuntDate .Question)}}
<div>
<h1>{{.Question.Title}}</h1>
<p class="meta">
<span>{{.Question.AuthorName}}</span>
{{if .Question.City}}<span class="dot" aria-hidden="true">·</span><span>{{.Question.City}}</span>{{end}}
<span class="dot" aria-hidden="true">·</span>
<a href="{{if eq .Question.PostDate .Today}}/{{else}}/hunt/{{.Question.PostDate}}{{end}}">{{.Question.PostDate}}</a>
{{if eq .Question.PostState "locked"}}<span class="badge">Locked</span>{{end}}
{{if eq .Question.PostState "hidden"}}<span class="badge">Hidden</span>{{end}}
{{if isEdited .Question}}<span class="edited">Edited</span>{{end}}
<a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">{{.Question.HuntDate}}</a>
</p>
<p class="post-body">{{.Question.Body}}</p>
{{template "postActions" (postCtx .User .CSRF .Question .Question 0)}}
<p class="q-body">{{.Question.Body}}</p>
{{if isAdmin .User}}
<form method="post" action="/questions/{{.Question.ID}}/hide"
hx-post="/questions/{{.Question.ID}}/hide" hx-target="body">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="linkish">Hide this question</button>
</form>
{{end}}
</div>
</article>
<section class="conversation" aria-labelledby="conversation-heading">
<div class="conversation-head">
<p class="eyebrow">Thread</p>
<h2 id="conversation-heading">Conversation</h2>
</div>
{{if .Question.Replies}}
<div class="thread">
{{$page := .}}
{{range .Question.Replies}}
{{template "threadReply" (postCtx $page.User $page.CSRF $page.Question . 1)}}
{{end}}
</div>
{{else}}
<p class="waiting">No replies yet.</p>
{{end}}
</section>
{{template "answer" .}}
{{if and (isAdmin .User) (not .Answer)}}
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="answer-body">Write the answer</label>
<textarea id="answer-body" name="body" rows="8" required maxlength="12000"></textarea>
<button type="submit" class="btn btn-primary">Save answer</button>
</form>
{{end}}
</main>
{{template "footer" .}}
{{end}}