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
26 changed files with 406 additions and 1780 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ SECURE_COOKIE=0
# RESEND_API_KEY=re_xxxxxxxxx # RESEND_API_KEY=re_xxxxxxxxx
# RESEND_FROM=Ask a Plumber <notify@yourdomain.com> # RESEND_FROM=Ask a Plumber <notify@yourdomain.com>
# Public site origin used in email links (required when Resend is enabled): # Public site origin used in email links (required when Resend is enabled):
# APP_BASE_URL=https://www.askaplumberfirst.com # APP_BASE_URL=https://askaplumber.example
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads. # DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
# SPACES_KEY= # SPACES_KEY=
# SPACES_SECRET= # SPACES_SECRET=
-58
View File
@@ -44,16 +44,6 @@ FROM thread
JOIN users u ON u.id = thread.author_id JOIN users u ON u.id = thread.author_id
ORDER BY thread.created_at, thread.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 -- name: UpdatePost :execrows
UPDATE posts UPDATE posts
SET SET
@@ -61,14 +51,6 @@ SET
updated_at = sqlc.arg(updated_at) updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id); 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 -- name: ListRootPosts :many
WITH RECURSIVE roots AS ( WITH RECURSIVE roots AS (
SELECT p.* SELECT p.*
@@ -118,46 +100,6 @@ LEFT JOIN post_votes viewer_vote
ORDER BY score DESC, roots.created_at, roots.id ORDER BY score DESC, roots.created_at, roots.id
LIMIT sqlc.arg(row_limit); 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 -- name: PostIsVisibleRoot :one
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
+33 -49
View File
@@ -5,7 +5,6 @@ import (
_ "embed" _ "embed"
"fmt" "fmt"
"html" "html"
"net/url"
"os" "os"
"strings" "strings"
@@ -15,26 +14,24 @@ import (
//go:embed mark.png //go:embed mark.png
var markPNG []byte var markPNG []byte
// PostReply is the payload for notifying a post author of a direct reply. // QuestionAnswered is the payload for notifying a question author of a reply.
type PostReply struct { type QuestionAnswered struct {
ToEmail string ToEmail string
ToName string ToName string
RootID string QuestionID string
RootTitle string QuestionTitle string
ReplyID string AnswerBody string
ReplyBody string
ReplyAuthorName string
} }
// Notifier sends transactional email about post replies. // Notifier sends transactional email about answered questions.
type Notifier interface { type Notifier interface {
NotifyPostReply(ctx context.Context, msg PostReply) error NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error
} }
// Nop is a no-op Notifier used when Resend is not configured. // Nop is a no-op Notifier used when Resend is not configured.
type Nop struct{} type Nop struct{}
func (Nop) NotifyPostReply(context.Context, PostReply) error { return nil } func (Nop) NotifyQuestionAnswered(context.Context, QuestionAnswered) error { return nil }
// Resend sends via the Resend HTTP API. // Resend sends via the Resend HTTP API.
type Resend struct { type Resend struct {
@@ -65,7 +62,7 @@ func FromEnv() (Notifier, error) {
}, nil }, nil
} }
func (r *Resend) NotifyPostReply(ctx context.Context, msg PostReply) error { func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error {
if r == nil || r.client == nil { if r == nil || r.client == nil {
return nil return nil
} }
@@ -73,72 +70,59 @@ func (r *Resend) NotifyPostReply(ctx context.Context, msg PostReply) error {
if to == "" { if to == "" {
return nil return nil
} }
text, htmlBody := postReplyContent(r.baseURL, msg) text, htmlBody := questionAnsweredContent(r.baseURL, msg)
params := &resend.SendEmailRequest{ params := &resend.SendEmailRequest{
From: r.from, From: r.from,
To: []string{to}, To: []string{to},
Subject: "New reply to your post", Subject: "Your question was answered",
Text: text, Text: text,
Html: htmlBody, Html: htmlBody,
Attachments: []*resend.Attachment{{ Attachments: []*resend.Attachment{{
Content: markPNG, Content: markPNG,
Filename: "ask-a-plumber-first.png", Filename: "ask-a-plumber-first.png",
ContentType: "image/png", ContentType: "image/png",
ContentId: "reply-notification-mark", ContentId: "answer-notification-mark",
}}, }},
} }
opts := &resend.SendEmailOptions{ opts := &resend.SendEmailOptions{
IdempotencyKey: "post-reply:" + msg.ReplyID, IdempotencyKey: "answer-notify:" + msg.QuestionID,
} }
_, err := r.client.Emails.SendWithOptions(ctx, params, opts) _, err := r.client.Emails.SendWithOptions(ctx, params, opts)
return err return err
} }
func postReplyContent(baseURL string, msg PostReply) (string, string) { func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) {
link := strings.TrimRight(baseURL, "/") + link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID
"/questions/" + url.PathEscape(msg.RootID) + title := strings.TrimSpace(msg.QuestionTitle)
"#post-" + url.PathEscape(msg.ReplyID) if title == "" {
title := replyRootTitle(msg.RootTitle) title = "your question"
author := strings.TrimSpace(msg.ReplyAuthorName)
if author == "" {
author = "Someone"
} }
text := fmt.Sprintf( text := fmt.Sprintf(
"Hi%s,\n\n%s replied in %q:\n\n%s\n\nView the reply:\n%s\n", "Hi%s,\n\nYour question %q has an answer from a plumber:\n\n%s\n\nView it here:\n%s\n",
greetingName(msg.ToName), greetingName(msg.ToName),
author,
title, title,
msg.ReplyBody, msg.AnswerBody,
link, link,
) )
htmlBody := strings.NewReplacer( htmlBody := strings.NewReplacer(
"{{PREHEADER}}", html.EscapeString(author+" replied in "+title+"."), "{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)), "{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
"{{TITLE}}", html.EscapeString(title), "{{TITLE}}", html.EscapeString(title),
"{{AUTHOR}}", html.EscapeString(author), "{{ANSWER}}", html.EscapeString(msg.AnswerBody),
"{{REPLY}}", html.EscapeString(msg.ReplyBody),
"{{LINK}}", html.EscapeString(link), "{{LINK}}", html.EscapeString(link),
"{{MARK}}", "cid:reply-notification-mark", "{{MARK}}", "cid:answer-notification-mark",
).Replace(postReplyHTML) ).Replace(questionAnsweredHTML)
return text, htmlBody return text, htmlBody
} }
func replyRootTitle(title string) string { const questionAnsweredHTML = `<!doctype html>
title = strings.TrimSpace(title)
if title == "" {
return "your conversation"
}
return title
}
const postReplyHTML = `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark"> <meta name="supported-color-schemes" content="dark">
<title>New reply to your conversation</title> <title>Your question was answered</title>
</head> </head>
<body style="margin:0;padding:0;background:#161719;color:#ecebe7;font-family:Arial,'Helvetica Neue',sans-serif;"> <body style="margin:0;padding:0;background:#161719;color:#ecebe7;font-family:Arial,'Helvetica Neue',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{PREHEADER}}</div> <div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{PREHEADER}}</div>
@@ -163,22 +147,22 @@ const postReplyHTML = `<!doctype html>
</tr> </tr>
<tr> <tr>
<td style="padding:30px 28px 32px;"> <td style="padding:30px 28px 32px;">
<div style="margin:0 0 10px;color:#e96a26;font-family:'Courier New',monospace;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:1.8px;text-transform:uppercase;">New reply</div> <div style="margin:0 0 10px;color:#e96a26;font-family:'Courier New',monospace;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:1.8px;text-transform:uppercase;">Shop response</div>
<h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">The conversation has a new reply.</h1> <h1 style="margin:0;color:#ecebe7;font-size:28px;font-weight:600;line-height:1.2;letter-spacing:-0.4px;">Your question has an answer.</h1>
<p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, {{AUTHOR}} replied in:</p> <p style="margin:18px 0 0;color:#b8babf;font-size:16px;line-height:1.6;">Hi{{GREETING}}, a plumber replied to:</p>
<p style="margin:8px 0 0;color:#ecebe7;font-size:17px;font-weight:600;line-height:1.45;">“{{TITLE}}”</p> <p style="margin:8px 0 0;color:#ecebe7;font-size:17px;font-weight:600;line-height:1.45;">“{{TITLE}}”</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;margin-top:24px;background:#161719;border:1px solid #2e3136;border-radius:3px;"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;margin-top:24px;background:#161719;border:1px solid #2e3136;border-radius:3px;">
<tr> <tr>
<td style="padding:20px 18px;"> <td style="padding:20px 18px;">
<div style="margin:0 0 10px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;font-weight:700;line-height:1.4;letter-spacing:1.5px;text-transform:uppercase;">The reply</div> <div style="margin:0 0 10px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;font-weight:700;line-height:1.4;letter-spacing:1.5px;text-transform:uppercase;">The answer</div>
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{REPLY}}</div> <div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{ANSWER}}</div>
</td> </td>
</tr> </tr>
</table> </table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;">
<tr> <tr>
<td bgcolor="#e96a26" style="border-radius:3px;"> <td bgcolor="#e96a26" style="border-radius:3px;">
<a href="{{LINK}}" style="display:inline-block;padding:13px 18px;color:#161719;font-family:'Courier New',monospace;font-size:12px;font-weight:700;line-height:1;text-decoration:none;letter-spacing:0.8px;text-transform:uppercase;">View the reply&nbsp;&rarr;</a> <a href="{{LINK}}" style="display:inline-block;padding:13px 18px;color:#161719;font-family:'Courier New',monospace;font-size:12px;font-weight:700;line-height:1;text-decoration:none;letter-spacing:0.8px;text-transform:uppercase;">View the answer&nbsp;&rarr;</a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -186,7 +170,7 @@ const postReplyHTML = `<!doctype html>
</tr> </tr>
<tr> <tr>
<td style="padding:18px 28px;border-top:1px solid #2e3136;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.6;letter-spacing:0.4px;"> <td style="padding:18px 28px;border-top:1px solid #2e3136;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.6;letter-spacing:0.4px;">
You received this because someone replied to your post on Ask a Plumber First. You received this because you asked a question on Ask a Plumber First.
</td> </td>
</tr> </tr>
</table> </table>
+16 -20
View File
@@ -14,27 +14,24 @@ func TestEmbeddedMarkIsPNG(t *testing.T) {
} }
} }
func TestPostReplyContent(t *testing.T) { func TestQuestionAnsweredContent(t *testing.T) {
t.Parallel() t.Parallel()
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com/", PostReply{ text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{
ToName: `<Sam & Pat>`, ToName: `<Sam & Pat>`,
RootID: "question-123", QuestionID: "question-123",
RootTitle: `<b>Leaky sink</b>`, QuestionTitle: `<b>Leaky sink</b>`,
ReplyID: "reply-456", AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
ReplyBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
ReplyAuthorName: `<Jo & Co>`,
}) })
for _, want := range []string{ for _, want := range []string{
"Ask a Plumber First", "Ask a Plumber First",
"New reply", "Shop response",
"cid:reply-notification-mark", "cid:answer-notification-mark",
"https://www.askaplumberfirst.com/questions/question-123#post-reply-456", "https://plumber.example/questions/question-123",
"white-space:pre-wrap", "white-space:pre-wrap",
"&lt;Sam &amp; Pat&gt;", "&lt;Sam &amp; Pat&gt;",
"&lt;b&gt;Leaky sink&lt;/b&gt;", "&lt;b&gt;Leaky sink&lt;/b&gt;",
"&lt;Jo &amp; Co&gt;",
"&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;", "&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;",
} { } {
if !strings.Contains(htmlBody, want) { if !strings.Contains(htmlBody, want) {
@@ -44,7 +41,6 @@ func TestPostReplyContent(t *testing.T) {
for _, unsafe := range []string{ for _, unsafe := range []string{
"<Sam & Pat>", "<Sam & Pat>",
"<b>Leaky sink</b>", "<b>Leaky sink</b>",
"<Jo & Co>",
"<script>alert('x')</script>", "<script>alert('x')</script>",
} { } {
if strings.Contains(htmlBody, unsafe) { if strings.Contains(htmlBody, unsafe) {
@@ -56,9 +52,9 @@ func TestPostReplyContent(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
`Hi <Sam & Pat>,`, `Hi <Sam & Pat>,`,
`<Jo & Co> replied in "<b>Leaky sink</b>"`, `Your question "<b>Leaky sink</b>"`,
"Replace the cartridge.\nThen test the handle.", "Replace the cartridge.\nThen test the handle.",
"https://www.askaplumberfirst.com/questions/question-123#post-reply-456", "https://plumber.example/questions/question-123",
} { } {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Errorf("text missing %q", want) t.Errorf("text missing %q", want)
@@ -66,15 +62,15 @@ func TestPostReplyContent(t *testing.T) {
} }
} }
func TestPostReplyContentUsesFallbacks(t *testing.T) { func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) {
t.Parallel() t.Parallel()
text, htmlBody := postReplyContent("https://www.askaplumberfirst.com", PostReply{}) text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{})
if !strings.Contains(text, `Someone replied in "your conversation"`) { if !strings.Contains(text, `"your question"`) {
t.Errorf("text missing fallback title") t.Errorf("text missing fallback title")
} }
if !strings.Contains(htmlBody, "Someone replied in:</p>") || if !strings.Contains(htmlBody, "a plumber replied to:</p>") ||
!strings.Contains(htmlBody, "“your conversation”") { !strings.Contains(htmlBody, "“your question”") {
t.Errorf("HTML missing fallbacks") t.Errorf("HTML missing fallback title")
} }
} }
+4 -4
View File
@@ -8,10 +8,10 @@ import (
// Recording is a test Notifier that records calls. // Recording is a test Notifier that records calls.
type Recording struct { type Recording struct {
mu sync.Mutex mu sync.Mutex
Msgs []PostReply Msgs []QuestionAnswered
} }
func (r *Recording) NotifyPostReply(_ context.Context, msg PostReply) error { func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.Msgs = append(r.Msgs, msg) r.Msgs = append(r.Msgs, msg)
@@ -25,10 +25,10 @@ func (r *Recording) Len() int {
} }
// Snapshot returns a copy of recorded messages. // Snapshot returns a copy of recorded messages.
func (r *Recording) Snapshot() []PostReply { func (r *Recording) Snapshot() []QuestionAnswered {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
out := make([]PostReply, len(r.Msgs)) out := make([]QuestionAnswered, len(r.Msgs))
copy(out, r.Msgs) copy(out, r.Msgs)
return out return out
} }
-82
View File
@@ -438,21 +438,6 @@ func (m *Memory) GetPostThread(_ context.Context, rootID string) (*Post, error)
return buildPostTree(posts, rootID) 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 { func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if post == nil { if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost) return fmt.Errorf("%w: post is nil", ErrInvalidPost)
@@ -504,73 +489,6 @@ func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]
return posts, nil 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 { func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
-13
View File
@@ -64,9 +64,6 @@ CREATE TABLE IF NOT EXISTS posts (
{name: "index post replies", sql: ` {name: "index post replies", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_parent_created CREATE INDEX IF NOT EXISTS idx_posts_parent_created
ON posts(parent_id, created_at, id)`}, 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: ` {name: "index root posts", sql: `
CREATE INDEX IF NOT EXISTS idx_posts_root_date CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state) ON posts(post_date, post_state)
@@ -120,15 +117,6 @@ CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
return nil 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 { func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct { steps := []struct {
name string name string
@@ -318,7 +306,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"005_post_vote_post_id_index", migratePostVoteIndex}, {"005_post_vote_post_id_index", migratePostVoteIndex},
{"006_post_date", migratePostDate}, {"006_post_date", migratePostDate},
{"007_post_state", migratePostState}, {"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+1 -46
View File
@@ -138,18 +138,6 @@ WHERE schemaname = current_schema()
if postVoteIndexCount != 1 { if postVoteIndexCount != 1 {
t.Fatalf("post vote index count = %d, want 1", postVoteIndexCount) 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, ` if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value) INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'question-1', -1)`); err == nil { VALUES ('homeowner', 'question-1', -1)`); err == nil {
@@ -288,39 +276,6 @@ INSERT INTO posts (
roots[0].UserVote != 1 { roots[0].UserVote != 1 {
t.Fatalf("root annotations = %+v", roots) 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, ` if _, err := conn.ExecContext(ctx, `
DROP INDEX idx_posts_root_date; DROP INDEX idx_posts_root_date;
@@ -458,7 +413,7 @@ WHERE schemaname = current_schema()
func TestMigratePostsReportsStep(t *testing.T) { func TestMigratePostsReportsStep(t *testing.T) {
t.Parallel() t.Parallel()
exec := &failingMigrationExec{failAt: 6} exec := &failingMigrationExec{failAt: 5}
err := migratePosts(context.Background(), exec) err := migratePosts(context.Background(), exec)
if err == nil || !strings.Contains(err.Error(), "copy questions") { if err == nil || !strings.Contains(err.Error(), "copy questions") {
t.Fatalf("error = %v, want copy questions context", err) 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) 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) { func buildPostTree(posts []Post, rootID string) (*Post, error) {
byID := make(map[string]*Post, len(posts)) byID := make(map[string]*Post, len(posts))
for i := range posts { for i := range posts {
@@ -367,89 +334,6 @@ func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) (
return posts, nil 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. // 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 { func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value int) error {
if value != 1 && value != -1 && value != 0 { 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) { if err := mem.VotePost(ctx, voter.ID, later.ID, 1); !errors.Is(err, ErrPostNotVotable) {
t.Fatalf("reply vote error = %v", err) 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) { 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) 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 { func (p *Postgres) UpdatePost(ctx context.Context, post *Post) error {
post.db = p.db post.db = p.db
return post.Update(ctx) 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) 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 { func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
return SetPostVote(ctx, p.db, userID, postID, value) 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 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 const listPostThread = `-- name: ListPostThread :many
WITH RECURSIVE thread AS ( 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 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 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 const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
@@ -537,29 +353,6 @@ func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64,
return result.RowsAffected() 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 const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execrows
INSERT INTO post_votes (user_id, post_id, value) INSERT INTO post_votes (user_id, post_id, value)
SELECT $1, $2, $3 SELECT $1, $2, $3
-4
View File
@@ -40,12 +40,8 @@ type Store interface {
CreatePost(ctx context.Context, post *Post) error CreatePost(ctx context.Context, post *Post) error
GetPost(ctx context.Context, id string) (*Post, error) GetPost(ctx context.Context, id string) (*Post, error)
GetPostThread(ctx context.Context, rootID 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 UpdatePost(ctx context.Context, post *Post) error
ListRootPosts(ctx context.Context, postDate, viewerID string) ([]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 VotePost(ctx context.Context, userID, postID string, value int) error
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question. // Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
-270
View File
@@ -1,270 +0,0 @@
package web
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"plumber/internal/mail"
"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 parent, 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 {
loadedParent, 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
}
parent = loadedParent
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
}
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
http.Redirect(
w,
r,
"/questions/"+url.PathEscape(root.ID)+"#post-"+url.PathEscape(post.ID),
http.StatusSeeOther,
)
}
// notifyPostReply asynchronously emails the direct parent post's author.
func (s *Server) notifyPostReply(
parent *store.Post,
root *store.Post,
reply *store.Post,
replyAuthor *store.User,
) {
if parent == nil ||
root == nil ||
reply == nil ||
replyAuthor == nil ||
s.cfg.Mail == nil ||
parent.AuthorID == replyAuthor.ID {
return
}
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
return
}
msg := mail.PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: replyAuthor.Name,
}
recipientID := parent.AuthorID
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := s.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
}
// 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
}
-516
View File
@@ -1,516 +0,0 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"plumber/internal/mail"
"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 TestPostReplyNotifications(t *testing.T) {
t.Parallel()
recording := &mail.Recording{}
srv, mem := newTestServer(t, Config{Mail: recording})
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")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
rec := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Leaky sink"},
"body": {"Water under the cabinet."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root create status = %d: %s", rec.Code, rec.Body.String())
}
if recording.Len() != 0 {
t.Fatalf("root create sent %d notifications", recording.Len())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 1 {
t.Fatalf("created roots = %+v, %v", roots, err)
}
root := roots[0]
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {root.ID},
"body": {"Replace the cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("admin reply status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("admin reply thread = %+v, %v", thread, err)
}
adminReply := thread.Replies[0]
msgs := waitForMail(t, recording, 1)
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.RootTitle != root.Title ||
msg.ReplyID != adminReply.ID ||
msg.ReplyBody != adminReply.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("admin reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {adminReply.ID},
"body": {"That fixed the drip."},
}, 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 || len(thread.Replies[0].Replies) != 1 {
t.Fatalf("homeowner nested reply thread = %+v, %v", thread, err)
}
homeownerReply := thread.Replies[0].Replies[0]
msgs = waitForMail(t, recording, 2)
if msg := msgs[1]; msg.ToEmail != admin.Email ||
msg.RootID != root.ID ||
msg.ReplyID != homeownerReply.ID ||
msg.ReplyAuthorName != homeowner.Name {
t.Fatalf("homeowner reply notification = %+v", msg)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {root.ID},
"body": {"A note to myself."},
}, homeownerCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("self reply status = %d: %s", rec.Code, rec.Body.String())
}
rec = postForm(handler, "/posts/"+adminReply.ID+"/edit", url.Values{
"_csrf": {adminCSRF},
"body": {"Replace the ceramic cartridge."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("edit status = %d: %s", rec.Code, rec.Body.String())
}
noEmail := &store.User{
Username: uniq("no-email"),
PasswordHash: homeowner.PasswordHash,
Role: store.RoleUser,
}
if err := mem.CreateUser(context.Background(), noEmail); err != nil {
t.Fatal(err)
}
noEmailRoot := &store.Post{
AuthorID: noEmail.ID,
Title: "Quiet thread",
Body: "No email configured.",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), noEmailRoot); err != nil {
t.Fatal(err)
}
rec = postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {noEmailRoot.ID},
"body": {"This should not send."},
}, adminCookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("no-email reply status = %d: %s", rec.Code, rec.Body.String())
}
time.Sleep(50 * time.Millisecond)
if recording.Len() != 2 {
t.Fatalf("self, edit, or no-email action sent a notification: %+v", recording.Snapshot())
}
}
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 waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
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
}
+5 -5
View File
@@ -23,7 +23,7 @@ import (
type profilePage struct { type profilePage struct {
page page
States []struct{ Code, Name string } States []struct{ Code, Name string }
Posts []store.Post Questions []store.RankedQuestion
QuestionsLabel string QuestionsLabel string
UploadsEnabled bool UploadsEnabled bool
Error string 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) { func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) {
var ( var (
posts []store.Post questions []store.RankedQuestion
label string label string
err error err error
) )
if u.Admin() { if u.Admin() {
label = "Questions you answered" label = "Questions you answered"
posts, err = s.store.ListRootPostsAnsweredBy(r.Context(), u.ID) questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
} else { } else {
label = "Your questions" label = "Your questions"
posts, err = s.store.ListRootPostsByAuthor(r.Context(), u.ID) questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
} }
if err != nil { if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError) 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{ s.exec(w, "profile", profilePage{
page: p, page: p,
States: geo.States, States: geo.States,
Posts: posts, Questions: questions,
QuestionsLabel: label, QuestionsLabel: label,
UploadsEnabled: s.cfg.Blob.Enabled(), UploadsEnabled: s.cfg.Blob.Enabled(),
Error: errMsg, Error: errMsg,
+90 -61
View File
@@ -3,6 +3,7 @@ package web
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -63,12 +64,13 @@ type huntPage struct {
Label string Label string
IsToday bool IsToday bool
IsYesterday bool IsYesterday bool
Posts []*store.Post Questions []store.RankedQuestion
} }
type questionPage struct { type questionPage struct {
page page
Question *store.Post Question *store.RankedQuestion
Answer *store.Answer
} }
type submitPage struct { type submitPage struct {
@@ -92,15 +94,7 @@ type voteCtx struct {
CSRF string CSRF string
View string View string
Date string Date string
Post *store.Post Question store.RankedQuestion
}
type threadPostCtx struct {
User *store.User
CSRF string
Root *store.Post
Post *store.Post
Depth int
} }
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) { 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{} cfg.Mail = mail.Nop{}
} }
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx { "voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Post: post} return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
},
"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 }, "add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 }, "rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() }, "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")
},
"pacificLabel": pacific.Label, "pacificLabel": pacific.Label,
"locationTag": func(u *store.User) string { "locationTag": func(u *store.User) string {
if u != nil { if u != nil {
@@ -198,8 +177,6 @@ func (s *Server) Handler() http.Handler {
r.Post("/questions/{id}/vote", s.handleVote) r.Post("/questions/{id}/vote", s.handleVote)
r.Post("/questions/{id}/answer", s.handleAnswer) r.Post("/questions/{id}/answer", s.handleAnswer)
r.Post("/questions/{id}/hide", s.handleHide) 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.Get("/login", s.handleLoginForm)
r.Post("/login", s.handleLogin) r.Post("/login", s.handleLogin)
r.Get("/register", s.handleRegisterForm) 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 { if u := currentUser(r); u != nil {
viewer = u.ID viewer = u.ID
} }
posts, err := s.store.ListRootPosts(r.Context(), date, viewer) questions, err := s.store.ListHunt(r.Context(), date, viewer)
if err != nil { if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError) http.Error(w, "could not load questions", http.StatusInternalServerError)
return return
@@ -318,7 +295,7 @@ func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string)
Label: label, Label: label,
IsToday: pacific.IsToday(date), IsToday: pacific.IsToday(date),
IsYesterday: pacific.IsYesterday(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 { if len(city) > 80 {
city = truncateRunes(city, 80) city = truncateRunes(city, 80)
} }
post := &store.Post{ q := &store.RankedQuestion{
AuthorID: u.ID, AuthorID: u.ID,
Title: title, Title: title,
Body: body, Body: body,
City: city, 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) http.Error(w, "could not save question", http.StatusInternalServerError)
return 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) { 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 { if u := currentUser(r); u != nil {
viewer = u.ID viewer = u.ID
} }
post, err := s.store.GetPostThreadForViewer(r.Context(), id, viewer) q, err := s.store.GetQuestion(r.Context(), id, viewer)
if err != nil || (post.PostState == store.PostStateHidden && !currentUser(r).Admin()) { if err != nil || (q.Hidden && !currentUser(r).Admin()) {
http.NotFound(w, r) http.NotFound(w, r)
return 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{ s.exec(w, "question", questionPage{
page: s.basePage(r, post.Title), page: s.basePage(r, q.Title),
Question: post, 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) http.Error(w, "invalid vote", http.StatusBadRequest)
return return
} }
if err := s.store.VotePost(r.Context(), u.ID, id, value); err != nil { if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
if errors.Is(err, store.ErrPostNotVotable) { if errors.Is(err, store.ErrHiddenOrMissing) {
http.Error(w, "not found", http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
@@ -433,7 +425,7 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
s.renderLeaderboard(w, r, date) s.renderLeaderboard(w, r, date)
return return
} }
post, err := s.store.GetPostThreadForViewer(r.Context(), id, u.ID) q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil { if err != nil {
http.Error(w, "not found", http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
@@ -442,8 +434,8 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
User: u, User: u,
CSRF: s.sessions.GetString(r.Context(), "csrf"), CSRF: s.sessions.GetString(r.Context(), "csrf"),
View: "question", View: "question",
Date: post.PostDate, Date: q.HuntDate,
Post: post, Question: *q,
}) })
return return
} }
@@ -466,7 +458,7 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
if u := currentUser(r); u != nil { if u := currentUser(r); u != nil {
viewer = u.ID viewer = u.ID
} }
posts, err := s.store.ListRootPosts(r.Context(), date, viewer) questions, err := s.store.ListHunt(r.Context(), date, viewer)
if err != nil { if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError) http.Error(w, "could not load questions", http.StatusInternalServerError)
return return
@@ -474,7 +466,7 @@ func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date
s.exec(w, "leaderboard", huntPage{ s.exec(w, "leaderboard", huntPage{
page: s.basePage(r, ""), page: s.basePage(r, ""),
Date: date, Date: date,
Posts: postPointers(posts), Questions: questions,
}) })
} }
@@ -496,28 +488,65 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
if len(body) > 12000 { if len(body) > 12000 {
body = truncateRunes(body, 12000) body = truncateRunes(body, 12000)
} }
root, err := s.store.GetPost(r.Context(), id) q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil || root.ParentID != nil || root.PostState == store.PostStateHidden { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
reply := &store.Post{ _, priorErr := s.store.GetAnswer(r.Context(), id)
ParentID: &root.ID, wasNew := errors.Is(priorErr, sql.ErrNoRows)
if priorErr != nil && !wasNew {
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
ans := &store.Answer{
QuestionID: id,
AuthorID: u.ID, AuthorID: u.ID,
Body: body, Body: body,
} }
if err := s.store.CreatePost(r.Context(), reply); err != nil { if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
http.Error(w, "could not save answer", http.StatusInternalServerError) http.Error(w, "could not save answer", http.StatusInternalServerError)
return return
} }
s.notifyPostReply(root, root, reply, u) if wasNew {
location := "/questions/" + url.PathEscape(id) + "#post-" + url.PathEscape(reply.ID) s.notifyQuestionAnswered(q, body, u.ID)
if isHTMX(r) { }
w.Header().Set("HX-Redirect", location) saved, err := s.store.GetAnswer(r.Context(), id)
w.WriteHeader(http.StatusSeeOther) if err != nil {
http.Error(w, "could not load answer", http.StatusInternalServerError)
return 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) { func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
@@ -530,17 +559,17 @@ func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
return return
} }
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
post, err := s.store.GetPost(r.Context(), id) q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil || post.ParentID != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return 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) http.Error(w, "could not hide", http.StatusInternalServerError)
return return
} }
if isHTMX(r) && r.PostFormValue("view") == "list" { if isHTMX(r) && r.PostFormValue("view") == "list" {
s.renderLeaderboard(w, r, post.PostDate) s.renderLeaderboard(w, r, q.HuntDate)
return return
} }
if isHTMX(r) { if isHTMX(r) {
+131 -47
View File
@@ -417,21 +417,21 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser) alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
adminCookies := loginUser(t, h, hubName, "hunter22") adminCookies := loginUser(t, h, hubName, "hunter22")
root := &store.Post{ q := &store.RankedQuestion{
AuthorID: alice.ID, AuthorID: alice.ID,
Title: "Drip", Title: "Drip",
Body: "Under sink", Body: "Under sink",
City: "Oakland", City: "Oakland",
} }
if err := mem.CreatePost(context.Background(), root); err != nil { if err := mem.CreateQuestion(context.Background(), q); err != nil {
t.Fatal(err) t.Fatal(err)
} }
reply := &store.Post{ ans := &store.Answer{
ParentID: &root.ID, QuestionID: q.ID,
AuthorID: hub.ID, AuthorID: hub.ID,
Body: "Replace the cartridge.", 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) t.Fatal(err)
} }
@@ -484,8 +484,7 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
} }
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) { func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
recording := &mail.Recording{} srv, mem := newTestServer(t, Config{})
srv, mem := newTestServer(t, Config{Mail: recording})
h := srv.Handler() h := srv.Handler()
adminName := uniq("admin") adminName := uniq("admin")
userName := uniq("user") userName := uniq("user")
@@ -494,14 +493,14 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
adminCookies := loginUser(t, h, adminName, "hunter22") adminCookies := loginUser(t, h, adminName, "hunter22")
userCookies := loginUser(t, h, userName, "hunter22") userCookies := loginUser(t, h, userName, "hunter22")
q := &store.Post{ q := &store.RankedQuestion{
AuthorID: user.ID, AuthorID: user.ID,
Title: "Pipe noise", Title: "Pipe noise",
Body: "Clanking", Body: "Clanking",
City: "SF", 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) t.Fatal(err)
} }
@@ -557,7 +556,7 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != 200 { if rec.Code != 200 {
t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String()) 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 { if err != nil || got.UserVote != 1 || got.Score != 1 {
t.Fatalf("vote not applied: %+v %v", got, err) t.Fatalf("vote not applied: %+v %v", got, err)
} }
@@ -582,7 +581,7 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
t.Fatalf("non-admin answer want 403, got %d", rec.Code) 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() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range adminCookies { for _, c := range adminCookies {
@@ -599,44 +598,22 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
} }
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
h.ServeHTTP(rec, req) 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()) t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
} }
thread, err := mem.GetPostThread(context.Background(), q.ID) if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) ||
if err != nil || len(thread.Replies) != 1 { !strings.Contains(body, "<summary>Edit answer</summary>") ||
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)
}
msgs := waitForMail(t, recording, 1)
if msg := msgs[0]; msg.ToEmail != user.Email ||
msg.RootID != q.ID ||
msg.ReplyID != adminReply.ID ||
msg.ReplyBody != adminReply.Body {
t.Fatalf("compatibility reply notification = %+v", msg)
}
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>") ||
!strings.Contains(body, ">Tighten the nuts.</textarea>") || !strings.Contains(body, ">Tighten the nuts.</textarea>") ||
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) || !strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
!strings.Contains(body, `removeAttribute('open')`) || !strings.Contains(body, `removeAttribute('open')`) ||
strings.Contains(body, `<details class="post-composer" open`) { strings.Contains(body, `<details class="answer-editor" open`) {
t.Fatalf("admin reply editor is not collapsed and populated: %s", body) 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() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies { for _, c := range userCookies {
@@ -646,8 +623,8 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") { 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()) t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
} }
if strings.Contains(rec.Body.String(), `/posts/`+adminReply.ID+`/edit`) { if strings.Contains(rec.Body.String(), `class="answer-editor"`) {
t.Fatalf("question author can edit admin reply: %s", rec.Body.String()) t.Fatalf("question author can see admin answer editor: %s", rec.Body.String())
} }
// Hide invalid id // Hide invalid id
@@ -682,8 +659,8 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
if rec.Code != http.StatusSeeOther { if rec.Code != http.StatusSeeOther {
t.Fatalf("hide %d %s", rec.Code, rec.Body.String()) t.Fatalf("hide %d %s", rec.Code, rec.Body.String())
} }
hidden, err := mem.GetPost(context.Background(), q.ID) hidden, err := mem.GetQuestion(context.Background(), q.ID, admin.ID)
if err != nil || hidden.PostState != store.PostStateHidden { if err != nil || !hidden.Hidden {
t.Fatalf("question not hidden: %+v %v", hidden, err) t.Fatalf("question not hidden: %+v %v", hidden, err)
} }
} }
@@ -736,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 // TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For
// cannot bypass rate limits when the direct peer is outside TrustedProxies. // cannot bypass rate limits when the direct peer is outside TrustedProxies.
// This must go through Handler() so middleware ordering bugs are caught. // 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 CREATE INDEX IF NOT EXISTS idx_posts_parent_created
ON posts(parent_id, created_at, id); 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 CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state) ON posts(post_date, post_state)
WHERE parent_id IS NULL; 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; padding: 18px 14px;
} }
.post-content { min-width: 0; }
.question-page h1 { .question-page h1 {
font-size: clamp(1.5rem, 3.5vw, 2.1rem); font-size: clamp(1.5rem, 3.5vw, 2.1rem);
line-height: 1.15; line-height: 1.15;
} }
.post-body { .q-body, .answer-body {
white-space: pre-wrap; white-space: pre-wrap;
margin: 14px 0 0; margin: 14px 0 0;
text-wrap: pretty; 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); } .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; margin: 0 0 6px;
font-family: var(--mono); font-family: var(--mono);
text-transform: uppercase; text-transform: uppercase;
@@ -586,99 +595,27 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
color: var(--signal); color: var(--signal);
} }
.conversation { .answer h2 {
margin-top: 32px;
}
.conversation-head {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.conversation h2 {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.15rem;
font-weight: 500; font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
} }
.conversation-head .eyebrow { .byline {
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 {
margin: 6px 0 0; margin: 6px 0 0;
color: var(--muted); color: var(--muted);
font-family: var(--mono); font-family: var(--mono);
font-size: 0.72rem; font-size: 0.72rem;
} }
.edited { .answer-editor {
display: inline-block; margin-top: 16px;
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;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.post-composer { .answer-editor summary {
min-width: 0;
}
.post-composer[open] {
flex: 1 0 100%;
}
.post-composer summary {
display: flex; display: flex;
width: fit-content; width: fit-content;
min-height: 44px; min-height: 44px;
@@ -693,34 +630,23 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
list-style: none; list-style: none;
} }
.post-composer summary::-webkit-details-marker { display: none; } .answer-editor summary::-webkit-details-marker { display: none; }
.post-composer summary::marker { content: ""; } .answer-editor summary:hover,
.post-composer summary:hover, .answer-editor[open] summary { color: var(--signal); }
.post-composer[open] summary { color: var(--signal); } .answer-editor summary:focus-visible {
.post-composer summary:focus-visible {
outline: 2px solid var(--signal); outline: 2px solid var(--signal);
outline-offset: 2px; outline-offset: 2px;
} }
.post-form { .answer-editor .answer-form { margin-top: 4px; }
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin: 0 0 14px;
}
.post-form-actions { .answer-form-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.post-form-actions .btn { flex: 1 1 10rem; } .answer-form-actions .btn { flex: 1 1 10rem; }
.post-hide {
margin: 0;
}
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; } .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"}} {{define "leaderboard"}}
<ol id="leaderboard" class="board" start="1"> <ol id="leaderboard" class="board" start="1">
{{if not .Posts}} {{if not .Questions}}
<li class="empty"> <li class="empty">
{{if eq .Date .Today}} {{if eq .Date .Today}}
<p class="empty-kicker">Queue empty</p> <p class="empty-kicker">Queue empty</p>
@@ -10,20 +10,20 @@
{{end}} {{end}}
</li> </li>
{{else}} {{else}}
{{range $i, $post := .Posts}} {{range $i, $q := .Questions}}
<li class="row"> <li class="row">
<span class="rank" aria-hidden="true">{{rank $i}}</span> <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"> <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"> <p class="meta">
<span>{{$post.AuthorName}}</span> <span>{{$q.AuthorName}}</span>
{{if $post.City}}<span class="dot" aria-hidden="true">·</span><span>{{$post.City}}</span>{{end}} {{if $q.City}}<span class="dot" aria-hidden="true">·</span><span>{{$q.City}}</span>{{end}}
{{if $post.Answered}}<span class="badge">Answered</span>{{end}} {{if $q.Answered}}<span class="badge">Answered</span>{{end}}
</p> </p>
{{if isAdmin $.User}} {{if isAdmin $.User}}
<form class="inline-hide" method="post" action="/questions/{{$post.ID}}/hide" <form class="inline-hide" method="post" action="/questions/{{$q.ID}}/hide"
hx-post="/questions/{{$post.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML"> hx-post="/questions/{{$q.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"> <input type="hidden" name="_csrf" value="{{$.CSRF}}">
<input type="hidden" name="view" value="list"> <input type="hidden" name="view" value="list">
<button type="submit" class="linkish">Hide</button> <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"}} {{define "vote"}}
<div id="vote-{{.Post.ID}}" class="vote"> <div id="vote-{{.Question.ID}}" class="vote">
{{if .User}} {{if .User}}
<form method="post" action="/questions/{{.Post.ID}}/vote" <form method="post" action="/questions/{{.Question.ID}}/vote"
hx-post="/questions/{{.Post.ID}}/vote" hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Post.ID}}" hx-swap="outerHTML"{{end}}> {{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}}"> <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="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}"> <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> <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
</button> </button>
</form> </form>
<span class="score" aria-label="Net score {{.Post.Score}}">{{.Post.Score}}</span> <span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
<form method="post" action="/questions/{{.Post.ID}}/vote" <form method="post" action="/questions/{{.Question.ID}}/vote"
hx-post="/questions/{{.Post.ID}}/vote" hx-post="/questions/{{.Question.ID}}/vote"
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Post.ID}}" hx-swap="outerHTML"{{end}}> {{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}}"> <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="view" value="{{.View}}">
<input type="hidden" name="date" value="{{.Date}}"> <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> <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
</button> </button>
</form> </form>
@@ -28,7 +28,7 @@
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to upvote"> <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> <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
</a> </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"> <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> <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
</a> </a>
+3 -3
View File
@@ -43,12 +43,12 @@
<section class="profile-questions" aria-labelledby="profile-q-heading"> <section class="profile-questions" aria-labelledby="profile-q-heading">
<h2 id="profile-q-heading">{{.QuestionsLabel}}</h2> <h2 id="profile-q-heading">{{.QuestionsLabel}}</h2>
{{if .Posts}} {{if .Questions}}
<ul class="profile-q-list"> <ul class="profile-q-list">
{{range .Posts}} {{range .Questions}}
<li> <li>
<a href="/questions/{{.ID}}">{{.Title}}</a> <a href="/questions/{{.ID}}">{{.Title}}</a>
<span class="meta">{{.PostDate}}</span> <span class="meta">{{.HuntDate}}</span>
</li> </li>
{{end}} {{end}}
</ul> </ul>
+22 -26
View File
@@ -1,41 +1,37 @@
{{define "question"}} {{define "question"}}
{{template "header" .}} {{template "header" .}}
<main id="main" class="wrap question-page"> <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> <p class="crumb"><a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">← {{pacificLabel .Question.HuntDate}}</a></p>
<article id="post-{{.Question.ID}}" class="q-detail"> <article class="q-detail">
{{template "vote" (voteCtx .User .CSRF "question" .Question.PostDate .Question)}} {{template "vote" (voteCtx .User .CSRF "question" .Question.HuntDate .Question)}}
<div class="post-content"> <div>
<p class="post-kicker">Question</p>
<h1>{{.Question.Title}}</h1> <h1>{{.Question.Title}}</h1>
<p class="meta"> <p class="meta">
<span>{{.Question.AuthorName}}</span> <span>{{.Question.AuthorName}}</span>
{{if .Question.City}}<span class="dot" aria-hidden="true">·</span><span>{{.Question.City}}</span>{{end}} {{if .Question.City}}<span class="dot" aria-hidden="true">·</span><span>{{.Question.City}}</span>{{end}}
<span class="dot" aria-hidden="true">·</span> <span class="dot" aria-hidden="true">·</span>
<a href="{{if eq .Question.PostDate .Today}}/{{else}}/hunt/{{.Question.PostDate}}{{end}}">{{.Question.PostDate}}</a> <a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">{{.Question.HuntDate}}</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}}
</p> </p>
<p class="post-body">{{.Question.Body}}</p> <p class="q-body">{{.Question.Body}}</p>
{{template "postActions" (postCtx .User .CSRF .Question .Question 0)}} {{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> </div>
</article> </article>
<section class="conversation" aria-labelledby="conversation-heading"> {{template "answer" .}}
<div class="conversation-head"> {{if and (isAdmin .User) (not .Answer)}}
<p class="eyebrow">Thread</p> <form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
<h2 id="conversation-heading">Conversation</h2> hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
</div> <input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if .Question.Replies}} <label for="answer-body">Write the answer</label>
<div class="thread"> <textarea id="answer-body" name="body" rows="8" required maxlength="12000"></textarea>
{{$page := .}} <button type="submit" class="btn btn-primary">Save answer</button>
{{range .Question.Replies}} </form>
{{template "threadReply" (postCtx $page.User $page.CSRF $page.Question . 1)}}
{{end}} {{end}}
</div>
{{else}}
<p class="waiting">No replies yet.</p>
{{end}}
</section>
</main> </main>
{{template "footer" .}} {{template "footer" .}}
{{end}} {{end}}