Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b5d6a456 | ||
|
|
007fcd0991 | ||
|
|
418ef93da5 |
@@ -14,6 +14,11 @@ SECURE_COOKIE=0
|
||||
# Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For
|
||||
# (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
|
||||
# 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.
|
||||
# SPACES_KEY=
|
||||
# SPACES_SECRET=
|
||||
|
||||
+8
-2
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/store"
|
||||
"plumber/internal/web"
|
||||
)
|
||||
@@ -29,7 +30,11 @@ func main() {
|
||||
defer sessions.Close()
|
||||
|
||||
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{
|
||||
Addr: listenAddr(),
|
||||
Handler: handler,
|
||||
@@ -53,12 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
|
||||
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{
|
||||
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
|
||||
SecureCookie: secureCookieFromEnv(),
|
||||
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
|
||||
Blob: uploader,
|
||||
Mail: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
|
||||
+13
-12
@@ -1,24 +1,25 @@
|
||||
-- name: CreateUser :exec
|
||||
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, '', '', $6);
|
||||
INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '', '', $7);
|
||||
|
||||
-- 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
|
||||
WHERE id = $1;
|
||||
|
||||
-- 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
|
||||
WHERE username = $1;
|
||||
|
||||
-- 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
|
||||
WHERE (
|
||||
sqlc.arg(search) = ''
|
||||
OR username ILIKE '%' || sqlc.arg(search) || '%'
|
||||
OR name ILIKE '%' || sqlc.arg(search) || '%'
|
||||
OR email ILIKE '%' || sqlc.arg(search) || '%'
|
||||
)
|
||||
AND (
|
||||
sqlc.arg(cursor_created) = ''
|
||||
@@ -43,12 +44,12 @@ UPDATE users
|
||||
SET role = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: UpdateUserState :exec
|
||||
-- name: UpdateUserProfile :exec
|
||||
UPDATE users
|
||||
SET state = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: UpdateUserStateAndAvatar :exec
|
||||
UPDATE users
|
||||
SET state = $1, avatar_url = $2
|
||||
SET state = $1, email = $2
|
||||
WHERE id = $3;
|
||||
|
||||
-- name: UpdateUserProfileAndAvatar :exec
|
||||
UPDATE users
|
||||
SET state = $1, email = $2, avatar_url = $3
|
||||
WHERE id = $4;
|
||||
|
||||
@@ -28,6 +28,7 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
|
||||
@@ -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 →</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
|
||||
}
|
||||
@@ -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",
|
||||
"<Sam & Pat>",
|
||||
"<b>Leaky sink</b>",
|
||||
"<script>alert('x')</script>",
|
||||
} {
|
||||
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 |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,17 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
if _, ok := m.byName[u.Username]; ok {
|
||||
return ErrDuplicateUsername
|
||||
}
|
||||
if u.Email != "" {
|
||||
for _, existing := range m.users {
|
||||
if existing.Email == u.Email {
|
||||
return ErrDuplicateEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
@@ -179,11 +187,21 @@ func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
|
||||
if !ok {
|
||||
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.Email = email
|
||||
if u.AvatarURL != "" {
|
||||
cur.AvatarURL = u.AvatarURL
|
||||
}
|
||||
u.State = cur.State
|
||||
u.Email = cur.Email
|
||||
u.AvatarURL = cur.AvatarURL
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,6 +21,89 @@ func migrateUserProfileColumns(ctx context.Context, exec execContext) error {
|
||||
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
|
||||
}
|
||||
|
||||
// migratePosts creates the unified post model and snapshots legacy content.
|
||||
// Legacy tables remain in place until the application cutover is complete.
|
||||
func migratePosts(ctx context.Context, exec execContext) error {
|
||||
steps := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"create posts", `
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
|
||||
author_id TEXT NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
post_date TEXT NOT NULL DEFAULT '',
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
||||
OR
|
||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0)
|
||||
)
|
||||
)`},
|
||||
{"index post replies", `
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
|
||||
ON posts(parent_id, created_at, id)`},
|
||||
{"index root posts", `
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||
ON posts(post_date, hidden)
|
||||
WHERE parent_id IS NULL`},
|
||||
{"create post votes", `
|
||||
CREATE TABLE IF NOT EXISTS post_votes (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||
PRIMARY KEY (user_id, post_id)
|
||||
)`},
|
||||
{"copy questions", `
|
||||
INSERT INTO posts (
|
||||
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, NULL, author_id, title, body, city, hunt_date, hidden, created_at, created_at
|
||||
FROM questions
|
||||
ON CONFLICT (id) DO NOTHING`},
|
||||
{"copy answers", `
|
||||
INSERT INTO posts (
|
||||
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'answer:' || question_id, question_id, author_id, '', body, '', '', 0, created_at, updated_at
|
||||
FROM answers
|
||||
ON CONFLICT (id) DO NOTHING`},
|
||||
{"copy votes", `
|
||||
INSERT INTO post_votes (user_id, post_id, value)
|
||||
SELECT user_id, question_id, value
|
||||
FROM votes
|
||||
ON CONFLICT (user_id, post_id) DO NOTHING`},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
|
||||
return fmt.Errorf("%s: %w", step.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type execContext interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
@@ -66,6 +149,8 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
return applySchema(ctx, exec, schemaSQL)
|
||||
}},
|
||||
{"002_user_profile_columns", migrateUserProfileColumns},
|
||||
{"003_user_email", migrateUserEmail},
|
||||
{"004_posts", migratePosts},
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if applied[m.version] {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestMigratePostsCopiesLegacyData(t *testing.T) {
|
||||
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
||||
if rawURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
dsn, err := postgresDSN(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
schemaName := "test_posts_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if _, err := conn.ExecContext(ctx, "CREATE SCHEMA "+schemaName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.Background(), "SET search_path TO public")
|
||||
_, _ = conn.ExecContext(context.Background(), "DROP SCHEMA "+schemaName+" CASCADE")
|
||||
}()
|
||||
if _, err := conn.ExecContext(ctx, "SET search_path TO "+schemaName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
legacySchema := `
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY
|
||||
);
|
||||
CREATE TABLE questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
author_id TEXT NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
hunt_date TEXT NOT NULL,
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE answers (
|
||||
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
|
||||
author_id TEXT NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE votes (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||
PRIMARY KEY (user_id, question_id)
|
||||
);`
|
||||
if err := applySchema(ctx, conn, legacySchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO users (id) VALUES ('homeowner'), ('plumber');
|
||||
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
|
||||
VALUES ('question-1', 'homeowner', 'Leaky sink', 'It drips.', 'Oakland', '2026-08-26', 0, '2026-08-26T08:00:00Z');
|
||||
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
|
||||
VALUES ('question-1', 'plumber', 'Replace the cartridge.', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z');
|
||||
INSERT INTO votes (user_id, question_id, value)
|
||||
VALUES ('homeowner', 'question-1', 1);`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := migratePosts(ctx, conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migratePosts(ctx, conn); err != nil {
|
||||
t.Fatalf("migration is not idempotent: %v", err)
|
||||
}
|
||||
|
||||
var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int
|
||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM answers").Scan(&legacyAnswerCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if postCount != 2 || voteCount != 1 || legacyQuestionCount != 1 || legacyAnswerCount != 1 {
|
||||
t.Fatalf(
|
||||
"counts posts=%d votes=%d legacy questions=%d answers=%d",
|
||||
postCount,
|
||||
voteCount,
|
||||
legacyQuestionCount,
|
||||
legacyAnswerCount,
|
||||
)
|
||||
}
|
||||
|
||||
var rootParent sql.NullString
|
||||
var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT parent_id, author_id, title, body, city, post_date, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE id = 'question-1'`).Scan(
|
||||
&rootParent,
|
||||
&rootAuthor,
|
||||
&title,
|
||||
&rootBody,
|
||||
&city,
|
||||
&postDate,
|
||||
&rootCreated,
|
||||
&rootUpdated,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rootParent.Valid ||
|
||||
rootAuthor != "homeowner" ||
|
||||
title != "Leaky sink" ||
|
||||
rootBody != "It drips." ||
|
||||
city != "Oakland" ||
|
||||
postDate != "2026-08-26" ||
|
||||
rootCreated != "2026-08-26T08:00:00Z" ||
|
||||
rootUpdated != rootCreated {
|
||||
t.Fatalf("unexpected root post")
|
||||
}
|
||||
|
||||
var replyParent, replyAuthor, replyBody, replyCreated, replyUpdated string
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT parent_id, author_id, body, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE id = 'answer:question-1'`).Scan(
|
||||
&replyParent,
|
||||
&replyAuthor,
|
||||
&replyBody,
|
||||
&replyCreated,
|
||||
&replyUpdated,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replyParent != "question-1" ||
|
||||
replyAuthor != "plumber" ||
|
||||
replyBody != "Replace the cartridge." ||
|
||||
replyCreated != "2026-08-26T09:00:00Z" ||
|
||||
replyUpdated != "2026-08-26T09:05:00Z" {
|
||||
t.Fatalf("unexpected reply post")
|
||||
}
|
||||
|
||||
var voteValue int
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT value FROM post_votes
|
||||
WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if voteValue != 1 {
|
||||
t.Fatalf("vote value = %d, want 1", voteValue)
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO posts (
|
||||
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
|
||||
) VALUES (
|
||||
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '', 0, 'now', 'now'
|
||||
)`); err == nil {
|
||||
t.Fatal("reply with root-only title unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratePostsReportsStep(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &failingMigrationExec{failAt: 5}
|
||||
err := migratePosts(context.Background(), exec)
|
||||
if err == nil || !strings.Contains(err.Error(), "copy questions") {
|
||||
t.Fatalf("error = %v, want copy questions context", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingMigrationExec struct {
|
||||
calls int
|
||||
failAt int
|
||||
}
|
||||
|
||||
func (f *failingMigrationExec) ExecContext(context.Context, string, ...any) (sql.Result, error) {
|
||||
f.calls++
|
||||
if f.calls == f.failAt {
|
||||
return nil, fmt.Errorf("boom")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*failingMigrationExec) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
@@ -69,6 +70,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
||||
Name: u.Name,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: string(role),
|
||||
Email: u.Email,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}); err != nil {
|
||||
return mapUniqueViolation(err)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -16,6 +17,25 @@ type Answer struct {
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID string
|
||||
ParentID sql.NullString
|
||||
AuthorID string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
Hidden int32
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type PostVote struct {
|
||||
UserID string
|
||||
PostID string
|
||||
Value int32
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
@@ -39,6 +59,7 @@ type User struct {
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
|
||||
@@ -24,8 +24,8 @@ func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) {
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :exec
|
||||
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, '', '', $6)
|
||||
INSERT INTO users (id, username, name, password_hash, role, email, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, '', '', $7)
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@@ -34,6 +34,7 @@ type CreateUserParams struct {
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Email string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
@@ -44,13 +45,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
|
||||
arg.Name,
|
||||
arg.PasswordHash,
|
||||
arg.Role,
|
||||
arg.Email,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -60,6 +62,7 @@ type GetUserByIDRow struct {
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -73,6 +76,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
@@ -81,7 +85,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, e
|
||||
}
|
||||
|
||||
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
|
||||
WHERE username = $1
|
||||
`
|
||||
@@ -91,6 +95,7 @@ type GetUserByUsernameRow struct {
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -105,6 +110,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
@@ -127,12 +133,13 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
|
||||
}
|
||||
|
||||
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
|
||||
WHERE (
|
||||
$1 = ''
|
||||
OR username ILIKE '%' || $1 || '%'
|
||||
OR name ILIKE '%' || $1 || '%'
|
||||
OR email ILIKE '%' || $1 || '%'
|
||||
)
|
||||
AND (
|
||||
$2 = ''
|
||||
@@ -155,6 +162,7 @@ type ListUsersRow struct {
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
Email string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -179,6 +187,7 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
@@ -196,6 +205,46 @@ func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUse
|
||||
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
|
||||
UPDATE users
|
||||
SET role = $1
|
||||
@@ -210,36 +259,3 @@ type UpdateUserRoleParams struct {
|
||||
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) {
|
||||
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
@@ -30,6 +30,7 @@ type User struct {
|
||||
Username string
|
||||
Name string
|
||||
Role Role
|
||||
Email string
|
||||
AvatarURL string
|
||||
State string
|
||||
CreatedAt string
|
||||
@@ -50,12 +51,13 @@ func NormalizeUsername(s string) string {
|
||||
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{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Role: Role(role),
|
||||
Email: email,
|
||||
AvatarURL: avatarURL,
|
||||
State: state,
|
||||
CreatedAt: createdAt,
|
||||
@@ -73,6 +75,7 @@ func (u *User) Create(ctx context.Context) error {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
@@ -88,6 +91,7 @@ func (u *User) Create(ctx context.Context) error {
|
||||
Name: u.Name,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: string(u.Role),
|
||||
Email: u.Email,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}))
|
||||
}
|
||||
@@ -149,21 +153,27 @@ func (u *User) SetRole(ctx context.Context, role Role) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveProfile writes State and optionally AvatarURL.
|
||||
// SaveProfile writes Email, State, and optionally AvatarURL.
|
||||
func (u *User) SaveProfile(ctx context.Context) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
u.State = strings.TrimSpace(u.State)
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
q := sqlc.New(u.db)
|
||||
if u.AvatarURL == "" {
|
||||
return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID})
|
||||
}
|
||||
return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
|
||||
return mapUniqueViolation(q.UpdateUserProfile(ctx, sqlc.UpdateUserProfileParams{
|
||||
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,
|
||||
ID: u.ID,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
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))
|
||||
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)
|
||||
}
|
||||
var nextCreated, nextID string
|
||||
@@ -204,7 +214,7 @@ func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -212,5 +222,5 @@ func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, er
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
@@ -14,6 +15,9 @@ import (
|
||||
// ErrDuplicateUsername is returned when inserting a username that already exists.
|
||||
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.
|
||||
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 {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
if strings.Contains(strings.ToLower(pgErr.ConstraintName), "email") {
|
||||
return ErrDuplicateEmail
|
||||
}
|
||||
return ErrDuplicateUsername
|
||||
}
|
||||
return err
|
||||
|
||||
+14
-1
@@ -129,14 +129,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
emailRaw := r.PostFormValue("email")
|
||||
password := r.PostFormValue("password")
|
||||
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) {
|
||||
p.Error = "Username must be 3–20 letters, numbers, or underscores."
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
}
|
||||
email, emailErr := store.ValidateEmail(emailRaw)
|
||||
if emailErr != "" {
|
||||
p.Error = emailErr
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
}
|
||||
if ok, msg := passwordValid(password); !ok {
|
||||
p.Error = msg
|
||||
s.exec(w, "register", p)
|
||||
@@ -153,6 +160,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
u := &store.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
}
|
||||
@@ -162,6 +170,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
s.exec(w, "register", p)
|
||||
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)
|
||||
http.Error(w, "could not create account", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+27
-9
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
@@ -27,6 +28,7 @@ type profilePage struct {
|
||||
UploadsEnabled bool
|
||||
Error string
|
||||
StateVal string
|
||||
EmailVal string
|
||||
}
|
||||
|
||||
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)
|
||||
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) {
|
||||
@@ -45,7 +47,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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"))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -67,16 +74,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
body, ext, contentType, prepErr := prepareAvatar(file, 2<<20)
|
||||
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
|
||||
}
|
||||
prevURL := u.AvatarURL
|
||||
@@ -88,14 +95,19 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
Size: int64(len(body)),
|
||||
})
|
||||
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
|
||||
}
|
||||
avatarURL = url
|
||||
u.State = state
|
||||
u.Email = email
|
||||
u.AvatarURL = avatarURL
|
||||
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
|
||||
_ = 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)
|
||||
return
|
||||
}
|
||||
@@ -106,12 +118,17 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
} 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
|
||||
}
|
||||
|
||||
u.State = state
|
||||
u.Email = email
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -236,7 +253,7 @@ func fitAvatar(img image.Image, maxDim int) image.Image {
|
||||
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 (
|
||||
questions []store.RankedQuestion
|
||||
label string
|
||||
@@ -263,5 +280,6 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
|
||||
UploadsEnabled: s.cfg.Blob.Enabled(),
|
||||
Error: errMsg,
|
||||
StateVal: stateVal,
|
||||
EmailVal: emailVal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/geo"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -34,6 +35,7 @@ type Config struct {
|
||||
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
|
||||
TrustedProxies []*net.IPNet
|
||||
Blob blob.Uploader
|
||||
Mail mail.Notifier
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -82,6 +84,7 @@ type submitPage struct {
|
||||
type authPage struct {
|
||||
page
|
||||
Username string
|
||||
Email string
|
||||
Error 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 {
|
||||
cfg.Blob = blob.Disabled{}
|
||||
}
|
||||
if cfg.Mail == nil {
|
||||
cfg.Mail = mail.Nop{}
|
||||
}
|
||||
funcMap := template.FuncMap{
|
||||
"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}
|
||||
@@ -482,6 +488,17 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
if len(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{
|
||||
QuestionID: 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)
|
||||
return
|
||||
}
|
||||
if wasNew {
|
||||
s.notifyQuestionAnswered(q, body, u.ID)
|
||||
}
|
||||
saved, err := s.store.GetAnswer(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
@@ -503,6 +523,32 @@ func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
|
||||
+155
-1
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/mail"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
@@ -53,6 +54,7 @@ func seedUser(t *testing.T, st store.Store, username, password string, role stor
|
||||
}
|
||||
u := &store.User{
|
||||
Username: username,
|
||||
Email: username + "@example.com",
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
}
|
||||
@@ -114,7 +116,7 @@ func registerUser(t *testing.T, h http.Handler, username, password string, setup
|
||||
pre := rec.Result().Cookies()
|
||||
preToken := sessionValue(pre)
|
||||
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] != "" {
|
||||
form += "&setup_secret=" + setupSecret[0]
|
||||
}
|
||||
@@ -362,6 +364,7 @@ func TestProfilePageAndState(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("email", name+"@example.com")
|
||||
_ = w.WriteField("state", "CA")
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
@@ -389,6 +392,7 @@ func TestProfilePageAndState(t *testing.T) {
|
||||
buf.Reset()
|
||||
w = multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("email", name+"@example.com")
|
||||
_ = w.WriteField("state", "ZZ")
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
@@ -449,6 +453,7 @@ func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("email", hubName+"@example.com")
|
||||
_ = w.WriteField("state", "OR")
|
||||
part, err := w.CreateFormFile("avatar", "pic.png")
|
||||
if err != nil {
|
||||
@@ -596,10 +601,32 @@ func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
|
||||
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 {
|
||||
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
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
@@ -666,6 +693,133 @@ func csrfFrom(html string) string {
|
||||
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
|
||||
// cannot bypass rate limits when the direct peer is outside TrustedProxies.
|
||||
// This must go through Handler() so middleware ordering bugs are caught.
|
||||
|
||||
+37
@@ -4,11 +4,16 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
author_id TEXT NOT NULL REFERENCES users(id),
|
||||
@@ -37,6 +42,38 @@ CREATE TABLE IF NOT EXISTS answers (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
|
||||
author_id TEXT NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
post_date TEXT NOT NULL DEFAULT '',
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
||||
OR
|
||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
|
||||
ON posts(parent_id, created_at, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||
ON posts(post_date, hidden)
|
||||
WHERE parent_id IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_votes (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||
PRIMARY KEY (user_id, post_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
data BYTEA NOT NULL,
|
||||
|
||||
@@ -610,6 +610,44 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
|
||||
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; }
|
||||
|
||||
.auth-wrap {
|
||||
|
||||
@@ -5,6 +5,22 @@
|
||||
<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}}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
</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. We’ll email you when a plumber answers your question.</p>
|
||||
|
||||
<label for="state">State</label>
|
||||
<select id="state" name="state">
|
||||
<option value=""{{if eq .StateVal ""}} selected{{end}}>Prefer not to say</option>
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
</div>
|
||||
</article>
|
||||
{{template "answer" .}}
|
||||
{{if isAdmin .User}}
|
||||
{{if and (isAdmin .User) (not .Answer)}}
|
||||
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
|
||||
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label for="answer-body">{{if .Answer}}Edit answer{{else}}Write the answer{{end}}</label>
|
||||
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{if .Answer}}{{.Answer.Body}}{{end}}</textarea>
|
||||
<label for="answer-body">Write the answer</label>
|
||||
<textarea id="answer-body" name="body" rows="8" required maxlength="12000"></textarea>
|
||||
<button type="submit" class="btn btn-primary">Save answer</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<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}}">
|
||||
<p class="hint">3–20 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">We’ll email you when a plumber answers your question.</p>
|
||||
<label for="password">Password</label>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user