Add polished answer notifications (#1)

Sends a branded Resend email when a question receives its first answer, records accepted and failed sends, and adds collapsed answer editing with cancel behavior.

Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-27 06:55:24 +00:00
committed by codegirl007
parent 35c8c9f391
commit 418ef93da5
28 changed files with 827 additions and 74 deletions
+5
View File
@@ -14,6 +14,11 @@ SECURE_COOKIE=0
# Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For # Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For
# (direct peer must match). Leave unset to ignore XFF and use RemoteAddr. # (direct peer must match). Leave unset to ignore XFF and use RemoteAddr.
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16 # TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16
# Resend (answer notifications). Leave RESEND_API_KEY unset to disable.
# RESEND_API_KEY=re_xxxxxxxxx
# RESEND_FROM=Ask a Plumber <notify@yourdomain.com>
# Public site origin used in email links (required when Resend is enabled):
# 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=
+8 -2
View File
@@ -17,6 +17,7 @@ import (
"plumber" "plumber"
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/store" "plumber/internal/store"
"plumber/internal/web" "plumber/internal/web"
) )
@@ -29,7 +30,11 @@ func main() {
defer sessions.Close() defer sessions.Close()
uploader := blob.FromEnv() uploader := blob.FromEnv()
handler := newHandler(db, sessions, uploader) notifier, err := mail.FromEnv()
if err != nil {
log.Fatalf("mail: %v", err)
}
handler := newHandler(db, sessions, uploader, notifier)
run(&http.Server{ run(&http.Server{
Addr: listenAddr(), Addr: listenAddr(),
Handler: handler, Handler: handler,
@@ -53,12 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions return db, sessions
} }
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler { func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")), AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(), SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")), TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader, Blob: uploader,
Mail: notifier,
}) })
if err != nil { if err != nil {
log.Fatalf("server: %v", err) log.Fatalf("server: %v", err)
+13 -12
View File
@@ -1,24 +1,25 @@
-- name: CreateUser :exec -- name: CreateUser :exec
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, '', '', $6); VALUES ($1, $2, $3, $4, $5, $6, '', '', $7);
-- name: GetUserByID :one -- name: GetUserByID :one
SELECT id, username, name, role, avatar_url, state, created_at SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users FROM users
WHERE id = $1; WHERE id = $1;
-- name: GetUserByUsername :one -- name: GetUserByUsername :one
SELECT id, username, name, role, avatar_url, state, created_at, password_hash SELECT id, username, name, role, email, avatar_url, state, created_at, password_hash
FROM users FROM users
WHERE username = $1; WHERE username = $1;
-- name: ListUsers :many -- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users FROM users
WHERE ( WHERE (
sqlc.arg(search) = '' sqlc.arg(search) = ''
OR username ILIKE '%' || sqlc.arg(search) || '%' OR username ILIKE '%' || sqlc.arg(search) || '%'
OR name ILIKE '%' || sqlc.arg(search) || '%' OR name ILIKE '%' || sqlc.arg(search) || '%'
OR email ILIKE '%' || sqlc.arg(search) || '%'
) )
AND ( AND (
sqlc.arg(cursor_created) = '' sqlc.arg(cursor_created) = ''
@@ -43,12 +44,12 @@ UPDATE users
SET role = $1 SET role = $1
WHERE id = $2; WHERE id = $2;
-- name: UpdateUserState :exec -- name: UpdateUserProfile :exec
UPDATE users UPDATE users
SET state = $1 SET state = $1, email = $2
WHERE id = $2;
-- name: UpdateUserStateAndAvatar :exec
UPDATE users
SET state = $1, avatar_url = $2
WHERE id = $3; WHERE id = $3;
-- name: UpdateUserProfileAndAvatar :exec
UPDATE users
SET state = $1, email = $2, avatar_url = $3
WHERE id = $4;
+1
View File
@@ -28,6 +28,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/resend/resend-go/v3 v3.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.41.0 // indirect
) )
+2
View File
@@ -43,6 +43,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.16.0 h1:t0Z/7k2sxnJGw8SjsCM9O8qkq3YRRHzTxWQNjhF2KhE=
github.com/resend/resend-go/v3 v3.16.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+189
View File
@@ -0,0 +1,189 @@
package mail
import (
"context"
_ "embed"
"fmt"
"html"
"os"
"strings"
"github.com/resend/resend-go/v3"
)
//go:embed mark.png
var markPNG []byte
// QuestionAnswered is the payload for notifying a question author of a reply.
type QuestionAnswered struct {
ToEmail string
ToName string
QuestionID string
QuestionTitle string
AnswerBody string
}
// Notifier sends transactional email about answered questions.
type Notifier interface {
NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error
}
// Nop is a no-op Notifier used when Resend is not configured.
type Nop struct{}
func (Nop) NotifyQuestionAnswered(context.Context, QuestionAnswered) error { return nil }
// Resend sends via the Resend HTTP API.
type Resend struct {
client *resend.Client
from string
baseURL string
}
// FromEnv builds a Notifier from RESEND_* and APP_BASE_URL.
// Returns Nop when RESEND_API_KEY is unset.
func FromEnv() (Notifier, error) {
key := strings.TrimSpace(os.Getenv("RESEND_API_KEY"))
if key == "" {
return Nop{}, nil
}
from := strings.TrimSpace(os.Getenv("RESEND_FROM"))
base := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/")
if from == "" {
return nil, fmt.Errorf("RESEND_FROM is required when RESEND_API_KEY is set")
}
if base == "" {
return nil, fmt.Errorf("APP_BASE_URL is required when RESEND_API_KEY is set")
}
return &Resend{
client: resend.NewClient(key),
from: from,
baseURL: base,
}, nil
}
func (r *Resend) NotifyQuestionAnswered(ctx context.Context, msg QuestionAnswered) error {
if r == nil || r.client == nil {
return nil
}
to := strings.TrimSpace(msg.ToEmail)
if to == "" {
return nil
}
text, htmlBody := questionAnsweredContent(r.baseURL, msg)
params := &resend.SendEmailRequest{
From: r.from,
To: []string{to},
Subject: "Your question was answered",
Text: text,
Html: htmlBody,
Attachments: []*resend.Attachment{{
Content: markPNG,
Filename: "ask-a-plumber-first.png",
ContentType: "image/png",
ContentId: "answer-notification-mark",
}},
}
opts := &resend.SendEmailOptions{
IdempotencyKey: "answer-notify:" + msg.QuestionID,
}
_, err := r.client.Emails.SendWithOptions(ctx, params, opts)
return err
}
func questionAnsweredContent(baseURL string, msg QuestionAnswered) (string, string) {
link := strings.TrimRight(baseURL, "/") + "/questions/" + msg.QuestionID
title := strings.TrimSpace(msg.QuestionTitle)
if title == "" {
title = "your question"
}
text := fmt.Sprintf(
"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),
title,
msg.AnswerBody,
link,
)
htmlBody := strings.NewReplacer(
"{{PREHEADER}}", html.EscapeString("A plumber answered "+title+"."),
"{{GREETING}}", html.EscapeString(greetingName(msg.ToName)),
"{{TITLE}}", html.EscapeString(title),
"{{ANSWER}}", html.EscapeString(msg.AnswerBody),
"{{LINK}}", html.EscapeString(link),
"{{MARK}}", "cid:answer-notification-mark",
).Replace(questionAnsweredHTML)
return text, htmlBody
}
const questionAnsweredHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>Your question was answered</title>
</head>
<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>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#161719;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="560" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:560px;background:#1e2023;border:1px solid #2e3136;border-top:3px solid #e96a26;border-radius:3px;">
<tr>
<td style="padding:24px 28px 20px;border-bottom:1px solid #2e3136;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding-right:12px;vertical-align:middle;">
<img src="{{MARK}}" width="32" height="32" alt="" style="display:block;width:32px;height:32px;border:0;">
</td>
<td style="vertical-align:middle;">
<div style="color:#ecebe7;font-size:14px;font-weight:700;line-height:1.2;letter-spacing:1px;text-transform:uppercase;">Ask a Plumber First</div>
<div style="margin-top:4px;color:#8d9096;font-family:'Courier New',monospace;font-size:10px;line-height:1.2;letter-spacing:1.4px;text-transform:uppercase;">Bay Area · Shop Dispatch</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<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;">Shop response</div>
<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}}, 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>
<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>
<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 answer</div>
<div style="margin:0;color:#ecebe7;font-size:16px;line-height:1.65;white-space:pre-wrap;">{{ANSWER}}</div>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin-top:26px;">
<tr>
<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 answer&nbsp;&rarr;</a>
</td>
</tr>
</table>
</td>
</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;">
You received this because you asked a question on Ask a Plumber First.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
func greetingName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
return " " + name
}
+76
View File
@@ -0,0 +1,76 @@
package mail
import (
"bytes"
"strings"
"testing"
)
func TestEmbeddedMarkIsPNG(t *testing.T) {
t.Parallel()
if !bytes.HasPrefix(markPNG, []byte("\x89PNG\r\n\x1a\n")) {
t.Fatal("embedded mark is not PNG data")
}
}
func TestQuestionAnsweredContent(t *testing.T) {
t.Parallel()
text, htmlBody := questionAnsweredContent("https://plumber.example/", QuestionAnswered{
ToName: `<Sam & Pat>`,
QuestionID: "question-123",
QuestionTitle: `<b>Leaky sink</b>`,
AnswerBody: "Replace the cartridge.\nThen test the handle. <script>alert('x')</script>",
})
for _, want := range []string{
"Ask a Plumber First",
"Shop response",
"cid:answer-notification-mark",
"https://plumber.example/questions/question-123",
"white-space:pre-wrap",
"&lt;Sam &amp; Pat&gt;",
"&lt;b&gt;Leaky sink&lt;/b&gt;",
"&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;",
} {
if !strings.Contains(htmlBody, want) {
t.Errorf("HTML missing %q", want)
}
}
for _, unsafe := range []string{
"<Sam & Pat>",
"<b>Leaky sink</b>",
"<script>alert('x')</script>",
} {
if strings.Contains(htmlBody, unsafe) {
t.Errorf("HTML contains unescaped content %q", unsafe)
}
}
if strings.Contains(htmlBody, "{{") {
t.Error("HTML contains an unresolved template token")
}
for _, want := range []string{
`Hi <Sam & Pat>,`,
`Your question "<b>Leaky sink</b>"`,
"Replace the cartridge.\nThen test the handle.",
"https://plumber.example/questions/question-123",
} {
if !strings.Contains(text, want) {
t.Errorf("text missing %q", want)
}
}
}
func TestQuestionAnsweredContentUsesFallbackTitle(t *testing.T) {
t.Parallel()
text, htmlBody := questionAnsweredContent("https://plumber.example", QuestionAnswered{})
if !strings.Contains(text, `"your question"`) {
t.Errorf("text missing fallback title")
}
if !strings.Contains(htmlBody, "a plumber replied to:</p>") ||
!strings.Contains(htmlBody, "“your question”") {
t.Errorf("HTML missing fallback title")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+34
View File
@@ -0,0 +1,34 @@
package mail
import (
"context"
"sync"
)
// Recording is a test Notifier that records calls.
type Recording struct {
mu sync.Mutex
Msgs []QuestionAnswered
}
func (r *Recording) NotifyQuestionAnswered(_ context.Context, msg QuestionAnswered) error {
r.mu.Lock()
defer r.mu.Unlock()
r.Msgs = append(r.Msgs, msg)
return nil
}
func (r *Recording) Len() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.Msgs)
}
// Snapshot returns a copy of recorded messages.
func (r *Recording) Snapshot() []QuestionAnswered {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]QuestionAnswered, len(r.Msgs))
copy(out, r.Msgs)
return out
}
+52
View File
@@ -0,0 +1,52 @@
package store
import (
"fmt"
"net/mail"
"strings"
"unicode/utf8"
)
const (
minEmailLen = 3
maxEmailLen = 254
)
// NormalizeEmail trims and lowercases an address for storage/comparison.
func NormalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// ValidateEmail returns a normalized address or an error message suitable for UI.
func ValidateEmail(raw string) (normalized string, errMsg string) {
normalized = NormalizeEmail(raw)
if normalized == "" {
return "", "Email is required."
}
n := utf8.RuneCountInString(normalized)
if n < minEmailLen || len(normalized) > maxEmailLen {
return "", "Enter a valid email address."
}
addr, err := mail.ParseAddress(normalized)
if err != nil || addr.Address != normalized {
return "", "Enter a valid email address."
}
at := strings.LastIndex(normalized, "@")
if at < 1 || at == len(normalized)-1 {
return "", "Enter a valid email address."
}
domain := normalized[at+1:]
if !strings.Contains(domain, ".") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return "", "Enter a valid email address."
}
return normalized, ""
}
// MustValidateEmail is like ValidateEmail but returns a Go error.
func MustValidateEmail(raw string) (string, error) {
n, msg := ValidateEmail(raw)
if msg != "" {
return "", fmt.Errorf("%s", msg)
}
return n, nil
}
+21
View File
@@ -0,0 +1,21 @@
package store
import "testing"
func TestValidateEmail(t *testing.T) {
cases := []struct {
in, want, err string
}{
{"", "", "Email is required."},
{" Alice@Example.COM ", "alice@example.com", ""},
{"not-an-email", "", "Enter a valid email address."},
{"a@b", "", "Enter a valid email address."},
{"ok@example.com", "ok@example.com", ""},
}
for _, tc := range cases {
got, msg := ValidateEmail(tc.in)
if got != tc.want || msg != tc.err {
t.Fatalf("%q: got (%q, %q) want (%q, %q)", tc.in, got, msg, tc.want, tc.err)
}
}
}
+18
View File
@@ -42,9 +42,17 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
return fmt.Errorf("invalid role") return fmt.Errorf("invalid role")
} }
u.Username = NormalizeUsername(u.Username) u.Username = NormalizeUsername(u.Username)
u.Email = NormalizeEmail(u.Email)
if _, ok := m.byName[u.Username]; ok { if _, ok := m.byName[u.Username]; ok {
return ErrDuplicateUsername return ErrDuplicateUsername
} }
if u.Email != "" {
for _, existing := range m.users {
if existing.Email == u.Email {
return ErrDuplicateEmail
}
}
}
if u.ID == "" { if u.ID == "" {
u.ID = uuid.NewString() u.ID = uuid.NewString()
} }
@@ -179,11 +187,21 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
if !ok { if !ok {
return sql.ErrNoRows return sql.ErrNoRows
} }
email := NormalizeEmail(u.Email)
if email != "" {
for id, existing := range m.users {
if id != u.ID && existing.Email == email {
return ErrDuplicateEmail
}
}
}
cur.State = strings.TrimSpace(u.State) cur.State = strings.TrimSpace(u.State)
cur.Email = email
if u.AvatarURL != "" { if u.AvatarURL != "" {
cur.AvatarURL = u.AvatarURL cur.AvatarURL = u.AvatarURL
} }
u.State = cur.State u.State = cur.State
u.Email = cur.Email
u.AvatarURL = cur.AvatarURL u.AvatarURL = cur.AvatarURL
return nil return nil
} }
+15
View File
@@ -21,6 +21,20 @@ func migrateUserProfileColumns(ctx context.Context, exec execContext) error {
return nil return nil
} }
// migrateUserEmail adds email and a partial unique index on lower(email).
func migrateUserEmail(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("add column email: %w", err)
}
if _, err := exec.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
ON users (lower(email))
WHERE email <> ''`); err != nil {
return fmt.Errorf("users_email_lower_uidx: %w", err)
}
return nil
}
type execContext interface { type execContext interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
@@ -66,6 +80,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
return applySchema(ctx, exec, schemaSQL) return applySchema(ctx, exec, schemaSQL)
}}, }},
{"002_user_profile_columns", migrateUserProfileColumns}, {"002_user_profile_columns", migrateUserProfileColumns},
{"003_user_email", migrateUserEmail},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+2
View File
@@ -29,6 +29,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
return fmt.Errorf("invalid role") return fmt.Errorf("invalid role")
} }
u.Username = NormalizeUsername(u.Username) u.Username = NormalizeUsername(u.Username)
u.Email = NormalizeEmail(u.Email)
if u.ID == "" { if u.ID == "" {
u.ID = uuid.NewString() u.ID = uuid.NewString()
} }
@@ -69,6 +70,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
Name: u.Name, Name: u.Name,
PasswordHash: u.PasswordHash, PasswordHash: u.PasswordHash,
Role: string(role), Role: string(role),
Email: u.Email,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
}); err != nil { }); err != nil {
return mapUniqueViolation(err) return mapUniqueViolation(err)
+1
View File
@@ -39,6 +39,7 @@ type User struct {
Name string Name string
PasswordHash string PasswordHash string
Role string Role string
Email string
AvatarUrl string AvatarUrl string
State string State string
CreatedAt string CreatedAt string
+54 -38
View File
@@ -24,8 +24,8 @@ func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) {
} }
const createUser = `-- name: CreateUser :exec const createUser = `-- name: CreateUser :exec
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, '', '', $6) VALUES ($1, $2, $3, $4, $5, $6, '', '', $7)
` `
type CreateUserParams struct { type CreateUserParams struct {
@@ -34,6 +34,7 @@ type CreateUserParams struct {
Name string Name string
PasswordHash string PasswordHash string
Role string Role string
Email string
CreatedAt string CreatedAt string
} }
@@ -44,13 +45,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
arg.Name, arg.Name,
arg.PasswordHash, arg.PasswordHash,
arg.Role, arg.Role,
arg.Email,
arg.CreatedAt, arg.CreatedAt,
) )
return err return err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, username, name, role, avatar_url, state, created_at SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users FROM users
WHERE id = $1 WHERE id = $1
` `
@@ -60,6 +62,7 @@ type GetUserByIDRow struct {
Username string Username string
Name string Name string
Role string Role string
Email string
AvatarUrl string AvatarUrl string
State string State string
CreatedAt string CreatedAt string
@@ -73,6 +76,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
&i.Username, &i.Username,
&i.Name, &i.Name,
&i.Role, &i.Role,
&i.Email,
&i.AvatarUrl, &i.AvatarUrl,
&i.State, &i.State,
&i.CreatedAt, &i.CreatedAt,
@@ -81,7 +85,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
} }
const getUserByUsername = `-- name: GetUserByUsername :one const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, name, role, avatar_url, state, created_at, password_hash SELECT id, username, name, role, email, avatar_url, state, created_at, password_hash
FROM users FROM users
WHERE username = $1 WHERE username = $1
` `
@@ -91,6 +95,7 @@ type GetUserByUsernameRow struct {
Username string Username string
Name string Name string
Role string Role string
Email string
AvatarUrl string AvatarUrl string
State string State string
CreatedAt string CreatedAt string
@@ -105,6 +110,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
&i.Username, &i.Username,
&i.Name, &i.Name,
&i.Role, &i.Role,
&i.Email,
&i.AvatarUrl, &i.AvatarUrl,
&i.State, &i.State,
&i.CreatedAt, &i.CreatedAt,
@@ -127,12 +133,13 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
} }
const listUsers = `-- name: ListUsers :many const listUsers = `-- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at SELECT id, username, name, role, email, avatar_url, state, created_at
FROM users FROM users
WHERE ( WHERE (
$1 = '' $1 = ''
OR username ILIKE '%' || $1 || '%' OR username ILIKE '%' || $1 || '%'
OR name ILIKE '%' || $1 || '%' OR name ILIKE '%' || $1 || '%'
OR email ILIKE '%' || $1 || '%'
) )
AND ( AND (
$2 = '' $2 = ''
@@ -155,6 +162,7 @@ type ListUsersRow struct {
Username string Username string
Name string Name string
Role string Role string
Email string
AvatarUrl string AvatarUrl string
State string State string
CreatedAt string CreatedAt string
@@ -179,6 +187,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
&i.Username, &i.Username,
&i.Name, &i.Name,
&i.Role, &i.Role,
&i.Email,
&i.AvatarUrl, &i.AvatarUrl,
&i.State, &i.State,
&i.CreatedAt, &i.CreatedAt,
@@ -196,6 +205,46 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
return items, nil return items, nil
} }
const updateUserProfile = `-- name: UpdateUserProfile :exec
UPDATE users
SET state = $1, email = $2
WHERE id = $3
`
type UpdateUserProfileParams struct {
State string
Email string
ID string
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
_, err := q.db.ExecContext(ctx, updateUserProfile, arg.State, arg.Email, arg.ID)
return err
}
const updateUserProfileAndAvatar = `-- name: UpdateUserProfileAndAvatar :exec
UPDATE users
SET state = $1, email = $2, avatar_url = $3
WHERE id = $4
`
type UpdateUserProfileAndAvatarParams struct {
State string
Email string
AvatarUrl string
ID string
}
func (q *Queries) UpdateUserProfileAndAvatar(ctx context.Context, arg UpdateUserProfileAndAvatarParams) error {
_, err := q.db.ExecContext(ctx, updateUserProfileAndAvatar,
arg.State,
arg.Email,
arg.AvatarUrl,
arg.ID,
)
return err
}
const updateUserRole = `-- name: UpdateUserRole :execresult const updateUserRole = `-- name: UpdateUserRole :execresult
UPDATE users UPDATE users
SET role = $1 SET role = $1
@@ -210,36 +259,3 @@ type UpdateUserRoleParams struct {
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) { func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) {
return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID) return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID)
} }
const updateUserState = `-- name: UpdateUserState :exec
UPDATE users
SET state = $1
WHERE id = $2
`
type UpdateUserStateParams struct {
State string
ID string
}
func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error {
_, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID)
return err
}
const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec
UPDATE users
SET state = $1, avatar_url = $2
WHERE id = $3
`
type UpdateUserStateAndAvatarParams struct {
State string
AvatarUrl string
ID string
}
func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error {
_, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID)
return err
}
+19 -9
View File
@@ -30,6 +30,7 @@ type User struct {
Username string Username string
Name string Name string
Role Role Role Role
Email string
AvatarURL string AvatarURL string
State string State string
CreatedAt string CreatedAt string
@@ -50,12 +51,13 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s)) return strings.ToLower(strings.TrimSpace(s))
} }
func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User { func toUser(db *sql.DB, id, username, name, role, email, avatarURL, state, createdAt, passwordHash string) *User {
return &User{ return &User{
ID: id, ID: id,
Username: username, Username: username,
Name: name, Name: name,
Role: Role(role), Role: Role(role),
Email: email,
AvatarURL: avatarURL, AvatarURL: avatarURL,
State: state, State: state,
CreatedAt: createdAt, CreatedAt: createdAt,
@@ -73,6 +75,7 @@ func (u *User) Create(ctx context.Context) error {
return fmt.Errorf("invalid role") return fmt.Errorf("invalid role")
} }
u.Username = NormalizeUsername(u.Username) u.Username = NormalizeUsername(u.Username)
u.Email = NormalizeEmail(u.Email)
if u.ID == "" { if u.ID == "" {
u.ID = uuid.NewString() u.ID = uuid.NewString()
} }
@@ -88,6 +91,7 @@ func (u *User) Create(ctx context.Context) error {
Name: u.Name, Name: u.Name,
PasswordHash: u.PasswordHash, PasswordHash: u.PasswordHash,
Role: string(u.Role), Role: string(u.Role),
Email: u.Email,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
})) }))
} }
@@ -149,21 +153,27 @@ func (u *User) SetRole(ctx context.Context, role Role) error {
return nil return nil
} }
// SaveProfile writes State and optionally AvatarURL. // SaveProfile writes Email, State, and optionally AvatarURL.
func (u *User) SaveProfile(ctx context.Context) error { func (u *User) SaveProfile(ctx context.Context) error {
if u == nil || u.db == nil { if u == nil || u.db == nil {
return fmt.Errorf("user: no database") return fmt.Errorf("user: no database")
} }
u.State = strings.TrimSpace(u.State) u.State = strings.TrimSpace(u.State)
u.Email = NormalizeEmail(u.Email)
q := sqlc.New(u.db) q := sqlc.New(u.db)
if u.AvatarURL == "" { if u.AvatarURL == "" {
return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID}) return mapUniqueViolation(q.UpdateUserProfile(ctx, sqlc.UpdateUserProfileParams{
}
return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
State: u.State, State: u.State,
Email: u.Email,
ID: u.ID,
}))
}
return mapUniqueViolation(q.UpdateUserProfileAndAvatar(ctx, sqlc.UpdateUserProfileAndAvatarParams{
State: u.State,
Email: u.Email,
AvatarUrl: u.AvatarURL, AvatarUrl: u.AvatarURL,
ID: u.ID, ID: u.ID,
}) }))
} }
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) { func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
@@ -187,7 +197,7 @@ func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, strin
} }
out := make([]User, 0, len(rows)) out := make([]User, 0, len(rows))
for _, r := range rows { for _, r := range rows {
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "") u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, "")
out = append(out, *u) out = append(out, *u)
} }
var nextCreated, nextID string var nextCreated, nextID string
@@ -204,7 +214,7 @@ func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
} }
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) { func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
@@ -212,5 +222,5 @@ func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil return toUser(db, r.ID, r.Username, r.Name, r.Role, r.Email, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
} }
+7
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@@ -14,6 +15,9 @@ import (
// ErrDuplicateUsername is returned when inserting a username that already exists. // ErrDuplicateUsername is returned when inserting a username that already exists.
var ErrDuplicateUsername = errors.New("username taken") var ErrDuplicateUsername = errors.New("username taken")
// ErrDuplicateEmail is returned when inserting/updating an email that already exists.
var ErrDuplicateEmail = errors.New("email taken")
// ErrHiddenOrMissing is returned when voting on a hidden or unknown question. // ErrHiddenOrMissing is returned when voting on a hidden or unknown question.
var ErrHiddenOrMissing = errors.New("question not votable") var ErrHiddenOrMissing = errors.New("question not votable")
@@ -58,6 +62,9 @@ func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int)
func mapUniqueViolation(err error) error { func mapUniqueViolation(err error) error {
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
return ErrDuplicateEmail
}
return ErrDuplicateUsername return ErrDuplicateUsername
} }
return err return err
+14 -1
View File
@@ -129,14 +129,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
username := strings.TrimSpace(r.PostFormValue("username")) username := strings.TrimSpace(r.PostFormValue("username"))
emailRaw := r.PostFormValue("email")
password := r.PostFormValue("password") password := r.PostFormValue("password")
setupSecret := r.PostFormValue("setup_secret") setupSecret := r.PostFormValue("setup_secret")
p := authPage{page: s.basePage(r, "Create account"), Username: username} p := authPage{page: s.basePage(r, "Create account"), Username: username, Email: strings.TrimSpace(emailRaw)}
if !usernameRe.MatchString(username) { if !usernameRe.MatchString(username) {
p.Error = "Username must be 320 letters, numbers, or underscores." p.Error = "Username must be 320 letters, numbers, or underscores."
s.exec(w, "register", p) s.exec(w, "register", p)
return return
} }
email, emailErr := store.ValidateEmail(emailRaw)
if emailErr != "" {
p.Error = emailErr
s.exec(w, "register", p)
return
}
if ok, msg := passwordValid(password); !ok { if ok, msg := passwordValid(password); !ok {
p.Error = msg p.Error = msg
s.exec(w, "register", p) s.exec(w, "register", p)
@@ -153,6 +160,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
} }
u := &store.User{ u := &store.User{
Username: username, Username: username,
Email: email,
PasswordHash: string(hash), PasswordHash: string(hash),
Role: role, Role: role,
} }
@@ -162,6 +170,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
s.exec(w, "register", p) s.exec(w, "register", p)
return return
} }
if errors.Is(err, store.ErrDuplicateEmail) {
p.Error = "That email is already registered."
s.exec(w, "register", p)
return
}
log.Printf("register create: %v", err) log.Printf("register create: %v", err)
http.Error(w, "could not create account", http.StatusInternalServerError) http.Error(w, "could not create account", http.StatusInternalServerError)
return return
+27 -9
View File
@@ -2,6 +2,7 @@ package web
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"image" "image"
"image/jpeg" "image/jpeg"
@@ -27,6 +28,7 @@ type profilePage struct {
UploadsEnabled bool UploadsEnabled bool
Error string Error string
StateVal string StateVal string
EmailVal string
} }
func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
@@ -35,7 +37,7 @@ func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther) http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
return return
} }
s.renderProfile(w, r, u, "", u.State) s.renderProfile(w, r, u, "", u.State, u.Email)
} }
func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
@@ -45,7 +47,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseMultipartForm(3 << 20); err != nil { if err := r.ParseMultipartForm(3 << 20); err != nil {
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State) s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State, u.Email)
return return
} }
want := s.sessions.GetString(r.Context(), "csrf") want := s.sessions.GetString(r.Context(), "csrf")
@@ -57,7 +59,12 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
state := geo.NormalizeState(r.FormValue("state")) state := geo.NormalizeState(r.FormValue("state"))
if !geo.ValidState(state) { if !geo.ValidState(state) {
s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state) s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state, r.FormValue("email"))
return
}
email, emailErr := store.ValidateEmail(r.FormValue("email"))
if emailErr != "" {
s.renderProfile(w, r, u, emailErr, state, r.FormValue("email"))
return return
} }
@@ -67,16 +74,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
if err == nil { if err == nil {
defer file.Close() defer file.Close()
if !s.cfg.Blob.Enabled() { if !s.cfg.Blob.Enabled() {
s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state) s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state, email)
return return
} }
if hdr.Size > 2<<20 { if hdr.Size > 2<<20 {
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state) s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state, email)
return return
} }
body, ext, contentType, prepErr := prepareAvatar(file, 2<<20) body, ext, contentType, prepErr := prepareAvatar(file, 2<<20)
if prepErr != nil { if prepErr != nil {
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state) s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state, email)
return return
} }
prevURL := u.AvatarURL prevURL := u.AvatarURL
@@ -88,14 +95,19 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
Size: int64(len(body)), Size: int64(len(body)),
}) })
if upErr != nil { if upErr != nil {
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state) s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state, email)
return return
} }
avatarURL = url avatarURL = url
u.State = state u.State = state
u.Email = email
u.AvatarURL = avatarURL u.AvatarURL = avatarURL
if err := s.store.SaveUserProfile(r.Context(), u); err != nil { if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
_ = s.cfg.Blob.Delete(r.Context(), avatarKey) _ = s.cfg.Blob.Delete(r.Context(), avatarKey)
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError) http.Error(w, "could not save profile", http.StatusInternalServerError)
return return
} }
@@ -106,12 +118,17 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/profile", http.StatusSeeOther) http.Redirect(w, r, "/profile", http.StatusSeeOther)
return return
} else if err != http.ErrMissingFile { } else if err != http.ErrMissingFile {
s.renderProfile(w, r, u, "Could not read avatar file.", state) s.renderProfile(w, r, u, "Could not read avatar file.", state, email)
return return
} }
u.State = state u.State = state
u.Email = email
if err := s.store.SaveUserProfile(r.Context(), u); err != nil { if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError) http.Error(w, "could not save profile", http.StatusInternalServerError)
return return
} }
@@ -236,7 +253,7 @@ func fitAvatar(img image.Image, maxDim int) image.Image {
return dst return dst
} }
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) { func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) {
var ( var (
questions []store.RankedQuestion questions []store.RankedQuestion
label string label string
@@ -263,5 +280,6 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
UploadsEnabled: s.cfg.Blob.Enabled(), UploadsEnabled: s.cfg.Blob.Enabled(),
Error: errMsg, Error: errMsg,
StateVal: stateVal, StateVal: stateVal,
EmailVal: emailVal,
}) })
} }
+46
View File
@@ -22,6 +22,7 @@ import (
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/geo" "plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific" "plumber/internal/pacific"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -34,6 +35,7 @@ type Config struct {
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer). // TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustedProxies []*net.IPNet TrustedProxies []*net.IPNet
Blob blob.Uploader Blob blob.Uploader
Mail mail.Notifier
} }
type Server struct { type Server struct {
@@ -82,6 +84,7 @@ type submitPage struct {
type authPage struct { type authPage struct {
page page
Username string Username string
Email string
Error string Error string
Next string Next string
} }
@@ -98,6 +101,9 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Blob == nil { if cfg.Blob == nil {
cfg.Blob = blob.Disabled{} cfg.Blob = blob.Disabled{}
} }
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx { "voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q} return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
@@ -482,6 +488,17 @@ 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)
} }
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
if err != nil {
http.NotFound(w, r)
return
}
_, priorErr := s.store.GetAnswer(r.Context(), id)
wasNew := errors.Is(priorErr, sql.ErrNoRows)
if priorErr != nil && !wasNew {
http.Error(w, "could not load answer", http.StatusInternalServerError)
return
}
ans := &store.Answer{ ans := &store.Answer{
QuestionID: id, QuestionID: id,
AuthorID: u.ID, AuthorID: u.ID,
@@ -491,6 +508,9 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save answer", http.StatusInternalServerError) http.Error(w, "could not save answer", http.StatusInternalServerError)
return return
} }
if wasNew {
s.notifyQuestionAnswered(q, body, u.ID)
}
saved, err := s.store.GetAnswer(r.Context(), id) saved, err := s.store.GetAnswer(r.Context(), id)
if err != nil { if err != nil {
http.Error(w, "could not load answer", http.StatusInternalServerError) http.Error(w, "could not load answer", http.StatusInternalServerError)
@@ -503,6 +523,32 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther) 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) {
if !s.requireCSRF(w, r) { if !s.requireCSRF(w, r) {
return return
+155 -1
View File
@@ -19,6 +19,7 @@ import (
"plumber" "plumber"
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/mail"
"plumber/internal/pacific" "plumber/internal/pacific"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -53,6 +54,7 @@ func seedUser(t *testing.T, st store.Store, username, password string, role stor
} }
u := &store.User{ u := &store.User{
Username: username, Username: username,
Email: username + "@example.com",
PasswordHash: string(hash), PasswordHash: string(hash),
Role: role, Role: role,
} }
@@ -114,7 +116,7 @@ func registerUser(t *testing.T, h http.Handler, username, password string, setup
pre := rec.Result().Cookies() pre := rec.Result().Cookies()
preToken := sessionValue(pre) preToken := sessionValue(pre)
csrf := csrfFrom(rec.Body.String()) csrf := csrfFrom(rec.Body.String())
form := "_csrf=" + csrf + "&username=" + username + "&password=" + password form := "_csrf=" + csrf + "&username=" + username + "&email=" + username + "%40example.com&password=" + password
if len(setupSecret) > 0 && setupSecret[0] != "" { if len(setupSecret) > 0 && setupSecret[0] != "" {
form += "&setup_secret=" + setupSecret[0] form += "&setup_secret=" + setupSecret[0]
} }
@@ -362,6 +364,7 @@ func TestProfilePageAndState(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
w := multipart.NewWriter(&buf) w := multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf) _ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", name+"@example.com")
_ = w.WriteField("state", "CA") _ = w.WriteField("state", "CA")
_ = w.Close() _ = w.Close()
req = httptest.NewRequest(http.MethodPost, "/profile", &buf) req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
@@ -389,6 +392,7 @@ func TestProfilePageAndState(t *testing.T) {
buf.Reset() buf.Reset()
w = multipart.NewWriter(&buf) w = multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf) _ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", name+"@example.com")
_ = w.WriteField("state", "ZZ") _ = w.WriteField("state", "ZZ")
_ = w.Close() _ = w.Close()
req = httptest.NewRequest(http.MethodPost, "/profile", &buf) req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
@@ -449,6 +453,7 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
w := multipart.NewWriter(&buf) w := multipart.NewWriter(&buf)
_ = w.WriteField("_csrf", csrf) _ = w.WriteField("_csrf", csrf)
_ = w.WriteField("email", hubName+"@example.com")
_ = w.WriteField("state", "OR") _ = w.WriteField("state", "OR")
part, err := w.CreateFormFile("avatar", "pic.png") part, err := w.CreateFormFile("avatar", "pic.png")
if err != nil { if err != nil {
@@ -596,10 +601,32 @@ 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("admin answer: %d %s", rec.Code, rec.Body.String()) t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
} }
if body := rec.Body.String(); !strings.Contains(body, `class="answer-editor"`) ||
!strings.Contains(body, "<summary>Edit answer</summary>") ||
!strings.Contains(body, ">Tighten the nuts.</textarea>") ||
!strings.Contains(body, `type="reset" class="btn btn-ghost"`) ||
!strings.Contains(body, `removeAttribute('open')`) ||
strings.Contains(body, `<details class="answer-editor" open`) {
t.Fatalf("admin answer editor is not collapsed and populated: %s", body)
}
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil { if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The public answer is visible to its author, but editing remains admin-only.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
for _, c := range userCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts.") {
t.Fatalf("question author cannot see answer: %d %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), `class="answer-editor"`) {
t.Fatalf("question author can see admin answer editor: %s", rec.Body.String())
}
// Hide invalid id // Hide invalid id
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil) req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
@@ -666,6 +693,133 @@ func csrfFrom(html string) string {
return html[:j] return html[:j]
} }
func TestRegisterRequiresEmail(t *testing.T) {
srv, _ := newTestServer(t, Config{})
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
csrf := csrfFrom(rec.Body.String())
cookies := rec.Result().Cookies()
form := strings.NewReader("_csrf=" + csrf + "&username=" + uniq("noem") + "&email=bad&password=hunter22")
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "valid email") {
t.Fatalf("want email validation error, got %d %s", rec.Code, rec.Body.String())
}
}
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.
+5
View File
@@ -4,11 +4,16 @@ CREATE TABLE IF NOT EXISTS users (
name TEXT NOT NULL, name TEXT NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')), role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
email TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '', avatar_url TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
ON users (lower(email))
WHERE email <> '';
CREATE TABLE IF NOT EXISTS questions ( CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
author_id TEXT NOT NULL REFERENCES users(id), author_id TEXT NOT NULL REFERENCES users(id),
+38
View File
@@ -610,6 +610,44 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
font-size: 0.72rem; font-size: 0.72rem;
} }
.answer-editor {
margin-top: 16px;
border-top: 1px solid var(--line);
}
.answer-editor summary {
display: flex;
width: fit-content;
min-height: 44px;
align-items: center;
color: var(--muted);
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
text-decoration: underline;
cursor: pointer;
list-style: none;
}
.answer-editor summary::-webkit-details-marker { display: none; }
.answer-editor summary:hover,
.answer-editor[open] summary { color: var(--signal); }
.answer-editor summary:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.answer-editor .answer-form { margin-top: 4px; }
.answer-form-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.answer-form-actions .btn { flex: 1 1 10rem; }
.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; }
.auth-wrap { .auth-wrap {
+16
View File
@@ -5,6 +5,22 @@
<h2>Answer</h2> <h2>Answer</h2>
<p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p> <p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p>
<p class="answer-body">{{.Answer.Body}}</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}} {{else}}
<p class="waiting">No answer yet. Check back after the hunt.</p> <p class="waiting">No answer yet. Check back after the hunt.</p>
{{end}} {{end}}
+4
View File
@@ -25,6 +25,10 @@
</div> </div>
</div> </div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required maxlength="254" autocomplete="email" value="{{.EmailVal}}">
<p class="hint">Required. Well email you when a plumber answers your question.</p>
<label for="state">State</label> <label for="state">State</label>
<select id="state" name="state"> <select id="state" name="state">
<option value=""{{if eq .StateVal ""}} selected{{end}}>Prefer not to say</option> <option value=""{{if eq .StateVal ""}} selected{{end}}>Prefer not to say</option>
+3 -3
View File
@@ -23,12 +23,12 @@
</div> </div>
</article> </article>
{{template "answer" .}} {{template "answer" .}}
{{if isAdmin .User}} {{if and (isAdmin .User) (not .Answer)}}
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer" <form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML"> hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="answer-body">{{if .Answer}}Edit answer{{else}}Write the answer{{end}}</label> <label for="answer-body">Write the answer</label>
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{if .Answer}}{{.Answer.Body}}{{end}}</textarea> <textarea id="answer-body" name="body" rows="8" required maxlength="12000"></textarea>
<button type="submit" class="btn btn-primary">Save answer</button> <button type="submit" class="btn btn-primary">Save answer</button>
</form> </form>
{{end}} {{end}}
+3
View File
@@ -9,6 +9,9 @@
<label for="username">Username</label> <label for="username">Username</label>
<input id="username" name="username" type="text" required minlength="3" maxlength="20" pattern="[A-Za-z0-9_]+" autocomplete="username" autocapitalize="off" spellcheck="false" value="{{.Username}}"> <input id="username" name="username" type="text" required minlength="3" maxlength="20" pattern="[A-Za-z0-9_]+" autocomplete="username" autocapitalize="off" spellcheck="false" value="{{.Username}}">
<p class="hint">320 letters, numbers, or underscores.</p> <p class="hint">320 letters, numbers, or underscores.</p>
<label for="email">Email</label>
<input id="email" name="email" type="email" required maxlength="254" autocomplete="email" value="{{.Email}}">
<p class="hint">Well email you when a plumber answers your question.</p>
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" name="password" type="password" required minlength="8" maxlength="72" autocomplete="new-password"> <input id="password" name="password" type="password" required minlength="8" maxlength="72" autocomplete="new-password">
<p class="hint">At least 8 characters (max 72 bytes).</p> <p class="hint">At least 8 characters (max 72 bytes).</p>