Initial commit: runnable Ask a Plumber First server.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
type adminUsersPage struct {
|
||||
page
|
||||
Users []store.User
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User {
|
||||
u := currentUser(r)
|
||||
if !u.Admin() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.requireAdmin(w, r) == nil {
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "could not load users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.exec(w, "admin-users", adminUsersPage{
|
||||
page: s.basePage(r, "Users"),
|
||||
Users: users,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if s.requireAdmin(w, r) == nil {
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
role := r.PostFormValue("role")
|
||||
err := s.store.SetRole(r.Context(), id, role)
|
||||
if errors.Is(err, store.ErrLastAdmin) {
|
||||
users, listErr := s.store.ListUsers(r.Context())
|
||||
if listErr != nil {
|
||||
http.Error(w, "could not demote last admin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.exec(w, "admin-users", adminUsersPage{
|
||||
page: s.basePage(r, "Users"),
|
||||
Users: users,
|
||||
Error: "Cannot demote the last admin.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "could not update role", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)
|
||||
|
||||
func safeNext(raw string) string {
|
||||
if raw == "" {
|
||||
return "/"
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.IsAbs() || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") {
|
||||
return "/"
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) != nil {
|
||||
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.exec(w, "login", authPage{
|
||||
page: s.basePage(r, "Sign in"),
|
||||
Next: r.URL.Query().Get("next"),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
next := safeNext(r.PostFormValue("next"))
|
||||
u, err := s.store.UserByUsername(r.Context(), username)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
s.exec(w, "login", authPage{
|
||||
page: s.basePage(r, "Sign in"),
|
||||
Username: username,
|
||||
Next: next,
|
||||
Error: "Wrong username or password.",
|
||||
})
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "user_id", u.ID)
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.exec(w, "register", authPage{page: s.basePage(r, "Create account")})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
p := authPage{page: s.basePage(r, "Create account"), Username: username}
|
||||
if !usernameRe.MatchString(username) {
|
||||
p.Error = "Username must be 3–20 letters, numbers, or underscores."
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(password) < 8 {
|
||||
p.Error = "Password must be at least 8 characters."
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "could not save password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
asAdmin := false
|
||||
if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) {
|
||||
n, err := s.store.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "could not create account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
asAdmin = n == 0
|
||||
}
|
||||
u, err := s.store.CreateUser(r.Context(), username, string(hash), asAdmin)
|
||||
if err != nil {
|
||||
p.Error = "That username is taken."
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "user_id", u.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthPrompt(w http.ResponseWriter, r *http.Request) {
|
||||
s.exec(w, "signin-prompt", s.basePage(r, ""))
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
// memDB is an in-memory store.DB for tests.
|
||||
type memDB struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*store.User // id -> user
|
||||
byName map[string]string // username -> id
|
||||
questions map[string]*store.RankedQuestion // id -> question
|
||||
votes map[string]int // userID|questionID -> value
|
||||
answers map[string]*store.Answer // questionID -> answer
|
||||
}
|
||||
|
||||
func newMemDB() *memDB {
|
||||
return &memDB{
|
||||
users: map[string]*store.User{},
|
||||
byName: map[string]string{},
|
||||
questions: map[string]*store.RankedQuestion{},
|
||||
votes: map[string]int{},
|
||||
answers: map[string]*store.Answer{},
|
||||
}
|
||||
}
|
||||
|
||||
func voteKey(userID, questionID string) string {
|
||||
return userID + "|" + questionID
|
||||
}
|
||||
|
||||
func (m *memDB) CreateUser(_ context.Context, username, passwordHash string, asAdmin bool) (*store.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
username = store.NormalizeUsername(username)
|
||||
if _, ok := m.byName[username]; ok {
|
||||
return nil, fmt.Errorf("username taken")
|
||||
}
|
||||
role := "user"
|
||||
if asAdmin {
|
||||
role = "admin"
|
||||
}
|
||||
u := &store.User{
|
||||
ID: uuid.NewString(),
|
||||
Username: username,
|
||||
Name: username,
|
||||
Role: role,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
m.users[u.ID] = u
|
||||
m.byName[username] = u.ID
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *memDB) UserByID(_ context.Context, id string) (*store.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
cp := *u
|
||||
cp.PasswordHash = ""
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *memDB) UserByUsername(_ context.Context, username string) (*store.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
id, ok := m.byName[store.NormalizeUsername(username)]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
cp := *m.users[id]
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *memDB) CountAdmins(_ context.Context) (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
n := 0
|
||||
for _, u := range m.users {
|
||||
if u.Role == "admin" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (m *memDB) ListUsers(_ context.Context) ([]store.User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]store.User, 0, len(m.users))
|
||||
for _, u := range m.users {
|
||||
cp := *u
|
||||
cp.PasswordHash = ""
|
||||
out = append(out, cp)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].CreatedAt < out[j].CreatedAt
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memDB) SetRole(_ context.Context, userID, role string) error {
|
||||
if role != "user" && role != "admin" {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.users[userID]
|
||||
if !ok {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if u.Role == "admin" && role == "user" {
|
||||
n := 0
|
||||
for _, x := range m.users {
|
||||
if x.Role == "admin" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n <= 1 {
|
||||
return store.ErrLastAdmin
|
||||
}
|
||||
}
|
||||
u.Role = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDB) CreateQuestion(_ context.Context, authorID, title, body, city string) (*store.RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
author, ok := m.users[authorID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown author")
|
||||
}
|
||||
q := &store.RankedQuestion{
|
||||
ID: uuid.NewString(),
|
||||
AuthorID: authorID,
|
||||
AuthorName: author.Name,
|
||||
Title: strings.TrimSpace(title),
|
||||
Body: strings.TrimSpace(body),
|
||||
City: strings.TrimSpace(city),
|
||||
HuntDate: pacific.Today(),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
m.questions[q.ID] = q
|
||||
cp := *q
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *memDB) rankedLocked(q *store.RankedQuestion, viewerID string) store.RankedQuestion {
|
||||
out := *q
|
||||
score := 0
|
||||
for k, v := range m.votes {
|
||||
_, qid, ok := strings.Cut(k, "|")
|
||||
if ok && qid == q.ID {
|
||||
score += v
|
||||
}
|
||||
}
|
||||
out.Score = score
|
||||
out.Answered = m.answers[q.ID] != nil
|
||||
if viewerID != "" {
|
||||
out.UserVote = m.votes[voteKey(viewerID, q.ID)]
|
||||
}
|
||||
if u, ok := m.users[q.AuthorID]; ok {
|
||||
out.AuthorName = u.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *memDB) ListHunt(_ context.Context, huntDate, viewerID string) ([]store.RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []store.RankedQuestion
|
||||
for _, q := range m.questions {
|
||||
if q.HuntDate != huntDate || q.Hidden {
|
||||
continue
|
||||
}
|
||||
out = append(out, m.rankedLocked(q, viewerID))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Score != out[j].Score {
|
||||
return out[i].Score > out[j].Score
|
||||
}
|
||||
return out[i].CreatedAt < out[j].CreatedAt
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memDB) GetQuestion(_ context.Context, id, viewerID string) (*store.RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
q, ok := m.questions[id]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
out := m.rankedLocked(q, viewerID)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (m *memDB) Vote(_ context.Context, userID, questionID string, value int) error {
|
||||
if value != 1 && value != -1 {
|
||||
return fmt.Errorf("invalid vote")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.questions[questionID]; !ok {
|
||||
return fmt.Errorf("unknown question")
|
||||
}
|
||||
k := voteKey(userID, questionID)
|
||||
if cur, ok := m.votes[k]; ok && cur == value {
|
||||
delete(m.votes, k)
|
||||
return nil
|
||||
}
|
||||
m.votes[k] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDB) GetAnswer(_ context.Context, questionID string) (*store.Answer, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
a, ok := m.answers[questionID]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
cp := *a
|
||||
if u, ok := m.users[a.AuthorID]; ok {
|
||||
cp.AuthorName = u.Name
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *memDB) UpsertAnswer(_ context.Context, questionID, authorID, body string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
body = strings.TrimSpace(body)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if existing, ok := m.answers[questionID]; ok {
|
||||
existing.Body = body
|
||||
existing.AuthorID = authorID
|
||||
existing.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
m.answers[questionID] = &store.Answer{
|
||||
QuestionID: questionID,
|
||||
AuthorID: authorID,
|
||||
Body: body,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDB) HideQuestion(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
q, ok := m.questions[id]
|
||||
if !ok {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
q.Hidden = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDB) UpdateProfile(_ context.Context, userID, state, avatarURL string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.users[userID]
|
||||
if !ok {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
u.State = state
|
||||
if avatarURL != "" {
|
||||
u.AvatarURL = avatarURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDB) ListQuestionsByAuthor(_ context.Context, authorID string) ([]store.RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []store.RankedQuestion
|
||||
for _, q := range m.questions {
|
||||
if q.AuthorID != authorID || q.Hidden {
|
||||
continue
|
||||
}
|
||||
out = append(out, m.rankedLocked(q, ""))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].CreatedAt > out[j].CreatedAt
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memDB) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]store.RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []store.RankedQuestion
|
||||
for qid, a := range m.answers {
|
||||
if a.AuthorID != adminID {
|
||||
continue
|
||||
}
|
||||
q, ok := m.questions[qid]
|
||||
if !ok || q.Hidden {
|
||||
continue
|
||||
}
|
||||
rq := m.rankedLocked(q, "")
|
||||
rq.Answered = true
|
||||
out = append(out, rq)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].CreatedAt > out[j].CreatedAt
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var _ store.DB = (*memDB)(nil)
|
||||
@@ -0,0 +1,142 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/geo"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
type profilePage struct {
|
||||
page
|
||||
States []struct{ Code, Name string }
|
||||
Questions []store.RankedQuestion
|
||||
QuestionsLabel string
|
||||
UploadsEnabled bool
|
||||
Error string
|
||||
StateVal string
|
||||
}
|
||||
|
||||
func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderProfile(w, r, u, "", u.State)
|
||||
}
|
||||
|
||||
func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(3 << 20); err != nil {
|
||||
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State)
|
||||
return
|
||||
}
|
||||
want := s.sessions.GetString(r.Context(), "csrf")
|
||||
got := r.FormValue("_csrf")
|
||||
if want == "" || got != want {
|
||||
http.Error(w, "invalid csrf token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
avatarURL := ""
|
||||
file, hdr, err := r.FormFile("avatar")
|
||||
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)
|
||||
return
|
||||
}
|
||||
ct := hdr.Header.Get("Content-Type")
|
||||
ext, contentType, ok := avatarType(hdr.Filename, ct)
|
||||
if !ok {
|
||||
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
|
||||
return
|
||||
}
|
||||
if hdr.Size > 2<<20 {
|
||||
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state)
|
||||
return
|
||||
}
|
||||
key := path.Join("avatars", u.ID, uuid.NewString()+ext)
|
||||
limited := io.LimitReader(file, (2<<20)+1)
|
||||
url, upErr := s.cfg.Blob.Upload(r.Context(), key, limited, contentType, hdr.Size)
|
||||
if upErr != nil {
|
||||
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state)
|
||||
return
|
||||
}
|
||||
avatarURL = url
|
||||
} else if err != http.ErrMissingFile {
|
||||
s.renderProfile(w, r, u, "Could not read avatar file.", state)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpdateProfile(r.Context(), u.ID, state, avatarURL); err != nil {
|
||||
http.Error(w, "could not save profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "flash", "Profile saved.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func avatarType(filename, contentType string) (ext, normalized string, ok bool) {
|
||||
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||
filename = strings.ToLower(filename)
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/jpeg"), strings.HasSuffix(filename, ".jpg"), strings.HasSuffix(filename, ".jpeg"):
|
||||
return ".jpg", "image/jpeg", true
|
||||
case strings.HasPrefix(contentType, "image/png"), strings.HasSuffix(filename, ".png"):
|
||||
return ".png", "image/png", true
|
||||
case strings.HasPrefix(contentType, "image/webp"), strings.HasSuffix(filename, ".webp"):
|
||||
return ".webp", "image/webp", true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) {
|
||||
var (
|
||||
questions []store.RankedQuestion
|
||||
label string
|
||||
err error
|
||||
)
|
||||
if u.Admin() {
|
||||
label = "Questions you answered"
|
||||
questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
|
||||
} else {
|
||||
label = "Your questions"
|
||||
questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if fresh, e := s.store.UserByID(r.Context(), u.ID); e == nil {
|
||||
u = fresh
|
||||
}
|
||||
p := s.basePage(r, "Profile")
|
||||
p.User = u
|
||||
s.exec(w, "profile", profilePage{
|
||||
page: p,
|
||||
States: geo.States,
|
||||
Questions: questions,
|
||||
QuestionsLabel: label,
|
||||
UploadsEnabled: s.cfg.Blob.Enabled(),
|
||||
Error: errMsg,
|
||||
StateVal: stateVal,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/geo"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AdminUsername string
|
||||
SecureCookie bool
|
||||
Blob blob.Uploader
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
store store.DB
|
||||
sessions *scs.SessionManager
|
||||
tmpl *template.Template
|
||||
cfg Config
|
||||
static http.Handler
|
||||
}
|
||||
|
||||
type page struct {
|
||||
User *store.User
|
||||
CSRF string
|
||||
Flash string
|
||||
Title string
|
||||
Today string
|
||||
Yesterday string
|
||||
}
|
||||
|
||||
type huntPage struct {
|
||||
page
|
||||
Date string
|
||||
Label string
|
||||
IsToday bool
|
||||
IsYesterday bool
|
||||
Questions []store.RankedQuestion
|
||||
}
|
||||
|
||||
type questionPage struct {
|
||||
page
|
||||
Question *store.RankedQuestion
|
||||
Answer *store.Answer
|
||||
}
|
||||
|
||||
type submitPage struct {
|
||||
page
|
||||
TitleVal string
|
||||
BodyVal string
|
||||
CityVal string
|
||||
Error string
|
||||
}
|
||||
|
||||
type authPage struct {
|
||||
page
|
||||
Username string
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
|
||||
type voteCtx struct {
|
||||
User *store.User
|
||||
CSRF string
|
||||
View string
|
||||
Date string
|
||||
Question store.RankedQuestion
|
||||
}
|
||||
|
||||
func New(st store.DB, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
|
||||
if cfg.Blob == nil {
|
||||
cfg.Blob = blob.Disabled{}
|
||||
}
|
||||
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}
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"rank": func(i int) int { return i + 1 },
|
||||
"isAdmin": func(u *store.User) bool { return u.Admin() },
|
||||
"pacificLabel": pacific.Label,
|
||||
"locationTag": func(u *store.User) string {
|
||||
if u != nil {
|
||||
if name := geo.StateName(u.State); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return "Bay Area"
|
||||
},
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html", "templates/partials/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse templates: %w", err)
|
||||
}
|
||||
sessions := scs.New()
|
||||
sessions.Store = sessionStore
|
||||
sessions.Lifetime = 30 * 24 * time.Hour
|
||||
sessions.Cookie.Name = "plumber_session"
|
||||
sessions.Cookie.HttpOnly = true
|
||||
sessions.Cookie.SameSite = http.SameSiteLaxMode
|
||||
sessions.Cookie.Secure = cfg.SecureCookie
|
||||
sessions.Cookie.Path = "/"
|
||||
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Server{
|
||||
store: st,
|
||||
sessions: sessions,
|
||||
tmpl: tmpl,
|
||||
cfg: cfg,
|
||||
static: http.StripPrefix("/static/", http.FileServer(http.FS(sub))),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 3<<20)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
r.Use(s.sessions.LoadAndSave)
|
||||
r.Use(s.withUser)
|
||||
r.Handle("/static/*", s.static)
|
||||
r.Get("/", s.handleToday)
|
||||
r.Get("/archive", s.handleArchive)
|
||||
r.Get("/hunt/{date}", s.handleHunt)
|
||||
r.Get("/submit", s.handleSubmitForm)
|
||||
r.Post("/submit", s.handleSubmit)
|
||||
r.Get("/questions/{id}", s.handleQuestion)
|
||||
r.Post("/questions/{id}/vote", s.handleVote)
|
||||
r.Post("/questions/{id}/answer", s.handleAnswer)
|
||||
r.Post("/questions/{id}/hide", s.handleHide)
|
||||
r.Get("/login", s.handleLoginForm)
|
||||
r.Post("/login", s.handleLogin)
|
||||
r.Get("/register", s.handleRegisterForm)
|
||||
r.Post("/register", s.handleRegister)
|
||||
r.Get("/auth/prompt", s.handleAuthPrompt)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Get("/admin/users", s.handleAdminUsers)
|
||||
r.Post("/admin/users/{id}/role", s.handleAdminSetRole)
|
||||
r.Get("/profile", s.handleProfileForm)
|
||||
r.Post("/profile", s.handleProfile)
|
||||
return r
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const userKey ctxKey = 1
|
||||
|
||||
func (s *Server) withUser(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sessions.GetString(r.Context(), "csrf") == "" {
|
||||
s.sessions.Put(r.Context(), "csrf", randomHex(16))
|
||||
}
|
||||
id := s.sessions.GetString(r.Context(), "user_id")
|
||||
if id != "" {
|
||||
u, err := s.store.UserByID(r.Context(), id)
|
||||
if err == nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), userKey, u))
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *store.User {
|
||||
u, _ := r.Context().Value(userKey).(*store.User)
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) basePage(r *http.Request, title string) page {
|
||||
return page{
|
||||
User: currentUser(r),
|
||||
CSRF: s.sessions.GetString(r.Context(), "csrf"),
|
||||
Flash: s.sessions.PopString(r.Context(), "flash"),
|
||||
Title: title,
|
||||
Today: pacific.Today(),
|
||||
Yesterday: pacific.Yesterday(),
|
||||
}
|
||||
}
|
||||
|
||||
func isHTMX(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
func (s *Server) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
want := s.sessions.GetString(r.Context(), "csrf")
|
||||
got := r.PostFormValue("_csrf")
|
||||
if want == "" || got != want {
|
||||
http.Error(w, "invalid csrf token", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderHunt(w, r, pacific.Today())
|
||||
}
|
||||
|
||||
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||
date := r.URL.Query().Get("date")
|
||||
if date == "" || date == pacific.Today() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := pacific.Parse(date); err != nil || date > pacific.Today() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/hunt/"+url.PathEscape(date), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleHunt(w http.ResponseWriter, r *http.Request) {
|
||||
date := chi.URLParam(r, "date")
|
||||
if _, err := pacific.Parse(date); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if date >= pacific.Today() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderHunt(w, r, date)
|
||||
}
|
||||
|
||||
func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string) {
|
||||
viewer := ""
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
label := pacific.Label(date)
|
||||
title := label
|
||||
if pacific.IsToday(date) {
|
||||
title = "Today"
|
||||
}
|
||||
s.exec(w, "hunt", huntPage{
|
||||
page: s.basePage(r, title),
|
||||
Date: date,
|
||||
Label: label,
|
||||
IsToday: pacific.IsToday(date),
|
||||
IsYesterday: pacific.IsYesterday(date),
|
||||
Questions: questions,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubmitForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) == nil {
|
||||
s.sessions.Put(r.Context(), "flash", "Sign in to ask a question.")
|
||||
http.Redirect(w, r, "/login?next=/submit", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.exec(w, "submit", submitPage{page: s.basePage(r, "Ask a question")})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login?next=/submit", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(r.PostFormValue("title"))
|
||||
body := strings.TrimSpace(r.PostFormValue("body"))
|
||||
city := strings.TrimSpace(r.PostFormValue("city"))
|
||||
if title == "" || body == "" {
|
||||
s.exec(w, "submit", submitPage{
|
||||
page: s.basePage(r, "Ask a question"),
|
||||
TitleVal: title,
|
||||
BodyVal: body,
|
||||
CityVal: city,
|
||||
Error: "Title and description are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(title) > 120 {
|
||||
title = title[:120]
|
||||
}
|
||||
if len(body) > 8000 {
|
||||
body = body[:8000]
|
||||
}
|
||||
if len(city) > 80 {
|
||||
city = city[:80]
|
||||
}
|
||||
q, err := s.store.CreateQuestion(r.Context(), u.ID, title, body, city)
|
||||
if err != nil {
|
||||
http.Error(w, "could not save question", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(q.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
viewer := ""
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, viewer)
|
||||
if err != nil || (q.Hidden && !currentUser(r).Admin()) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var ans *store.Answer
|
||||
if q.Answered {
|
||||
ans, _ = s.store.GetAnswer(r.Context(), q.ID)
|
||||
}
|
||||
s.exec(w, "question", questionPage{
|
||||
page: s.basePage(r, q.Title),
|
||||
Question: q,
|
||||
Answer: ans,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
if isHTMX(r) {
|
||||
s.exec(w, "signin-prompt", s.basePage(r, ""))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
value := 0
|
||||
switch r.PostFormValue("value") {
|
||||
case "1":
|
||||
value = 1
|
||||
case "-1":
|
||||
value = -1
|
||||
default:
|
||||
http.Error(w, "invalid vote", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
|
||||
http.Error(w, "could not vote", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view := r.PostFormValue("view")
|
||||
date := r.PostFormValue("date")
|
||||
if isHTMX(r) {
|
||||
if view == "list" {
|
||||
s.renderLeaderboard(w, r, date)
|
||||
return
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.exec(w, "vote", voteCtx{
|
||||
User: u,
|
||||
CSRF: s.sessions.GetString(r.Context(), "csrf"),
|
||||
View: "question",
|
||||
Date: q.HuntDate,
|
||||
Question: *q,
|
||||
})
|
||||
return
|
||||
}
|
||||
if view == "question" {
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if date != "" && date != pacific.Today() {
|
||||
http.Redirect(w, r, "/hunt/"+url.PathEscape(date), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date string) {
|
||||
if date == "" {
|
||||
date = pacific.Today()
|
||||
}
|
||||
viewer := ""
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.exec(w, "leaderboard", huntPage{
|
||||
page: s.basePage(r, ""),
|
||||
Date: date,
|
||||
Questions: questions,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if !u.Admin() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
body := strings.TrimSpace(r.PostFormValue("body"))
|
||||
if body == "" {
|
||||
http.Error(w, "answer required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > 12000 {
|
||||
body = body[:12000]
|
||||
}
|
||||
if err := s.store.UpsertAnswer(r.Context(), id, u.ID, body); err != nil {
|
||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ans, err := s.store.GetAnswer(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) {
|
||||
s.exec(w, "answer", questionPage{page: s.basePage(r, ""), Answer: ans})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if !u.Admin() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := s.store.HideQuestion(r.Context(), id); err != nil {
|
||||
http.Error(w, "could not hide", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) && r.PostFormValue("view") == "list" {
|
||||
s.renderLeaderboard(w, r, q.HuntDate)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
s.sessions.Remove(r.Context(), "user_id")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) exec(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template %s: %v", name, err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
|
||||
"plumber"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *memDB, scs.Store) {
|
||||
t.Helper()
|
||||
fake := newMemDB()
|
||||
sessions := scs.New()
|
||||
srv, err := New(fake, sessions.Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv, fake, sessions.Store
|
||||
}
|
||||
|
||||
func TestHomeEmptyAndViewport(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "No questions yet") {
|
||||
t.Fatal("missing empty state")
|
||||
}
|
||||
if !strings.Contains(body, "width=device-width") {
|
||||
t.Fatal("missing mobile viewport")
|
||||
}
|
||||
if !strings.Contains(body, "not a substitute for a licensed plumber") {
|
||||
t.Fatal("missing disclaimer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLoginAsk(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
cookie := rec.Result().Cookies()
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
if csrf == "" {
|
||||
t.Fatal("no csrf")
|
||||
}
|
||||
form := strings.NewReader("_csrf=" + csrf + "&username=hub&password=hunter22")
|
||||
req := httptest.NewRequest(http.MethodPost, "/register", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range cookie {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
session := rec.Result().Cookies()
|
||||
req = httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||
for _, c := range session {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("submit form %d", rec.Code)
|
||||
}
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland")
|
||||
req = httptest.NewRequest(http.MethodPost, "/submit", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range session {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != http.StatusSeeOther {
|
||||
t.Fatalf("submit %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSurvivesServerRestart(t *testing.T) {
|
||||
fake := newMemDB()
|
||||
sessionStore := scs.New().Store
|
||||
|
||||
srv1, err := New(fake, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h1 := srv1.Handler()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h1.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
preCookies := rec.Result().Cookies()
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
if csrf == "" {
|
||||
t.Fatal("no csrf")
|
||||
}
|
||||
form := strings.NewReader("_csrf=" + csrf + "&username=hub&password=hunter22")
|
||||
req := httptest.NewRequest(http.MethodPost, "/register", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range preCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h1.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
sessionCookies := mergeCookies(preCookies, rec.Result().Cookies())
|
||||
|
||||
srv2, err := New(fake, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||
for _, c := range sessionCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
srv2.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected authenticated submit form after restart, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Ask a question") {
|
||||
t.Fatal("session did not survive restart")
|
||||
}
|
||||
}
|
||||
|
||||
func registerUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
cookies := rec.Result().Cookies()
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password)
|
||||
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 != http.StatusSeeOther {
|
||||
t.Fatalf("register %s: %d %s", username, rec.Code, rec.Body.String())
|
||||
}
|
||||
return mergeCookies(cookies, rec.Result().Cookies())
|
||||
}
|
||||
|
||||
func TestAdminSeedOnlyWhenNoAdmins(t *testing.T) {
|
||||
srv, fake, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
registerUser(t, h, "hub", "hunter22")
|
||||
u, err := fake.UserByUsername(context.Background(), "hub")
|
||||
if err != nil || !u.Admin() {
|
||||
t.Fatalf("hub should be first admin: %+v %v", u, err)
|
||||
}
|
||||
registerUser(t, h, "hub2", "hunter22")
|
||||
// Create another account that also matches AdminUsername after an admin exists — use a fresh server config with AdminUsername hub2 after hub exists
|
||||
srv2, err := New(fake, scs.New().Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "lateradmin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registerUser(t, srv2.Handler(), "lateradmin", "hunter22")
|
||||
u2, err := fake.UserByUsername(context.Background(), "lateradmin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u2.Admin() {
|
||||
t.Fatal("lateradmin must stay user when an admin already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
srv, fake, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminCookies := registerUser(t, h, "hub", "hunter22")
|
||||
registerUser(t, h, "bob", "hunter22")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("admin list %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "bob") {
|
||||
t.Fatal("missing bob on admin page")
|
||||
}
|
||||
|
||||
bob, err := fake.UserByUsername(context.Background(), "bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&role=admin")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("promote %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
bob, _ = fake.UserByUsername(context.Background(), "bob")
|
||||
if !bob.Admin() {
|
||||
t.Fatal("bob should be admin")
|
||||
}
|
||||
|
||||
// Non-admin forbidden
|
||||
bobCookies := registerUser(t, h, "carol", "hunter22")
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range bobCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin expected 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Demote last remaining admin after demoting bob first — leave only hub, then demote hub
|
||||
hub, err := fake.UserByUsername(context.Background(), "hub")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&role=user")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("demote bob %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&role=user")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/users/"+hub.ID+"/role", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("demote last admin expected page with error, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") {
|
||||
t.Fatalf("missing last-admin error: %s", rec.Body.String())
|
||||
}
|
||||
hub, _ = fake.UserByUsername(context.Background(), "hub")
|
||||
if !hub.Admin() {
|
||||
t.Fatal("hub must remain admin")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBlob struct {
|
||||
calls int
|
||||
last string
|
||||
}
|
||||
|
||||
func (f *fakeBlob) Enabled() bool { return true }
|
||||
|
||||
func (f *fakeBlob) Upload(_ context.Context, key string, _ io.Reader, _ string, _ int64) (string, error) {
|
||||
f.calls++
|
||||
f.last = key
|
||||
return "https://cdn.example.com/" + key, nil
|
||||
}
|
||||
|
||||
func TestProfilePageAndState(t *testing.T) {
|
||||
srv, fake, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
cookies := registerUser(t, h, "alice", "hunter22")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("profile %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Your questions") {
|
||||
t.Fatal("expected user questions label")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "local plumbing codes") {
|
||||
t.Fatal("missing state helper copy")
|
||||
}
|
||||
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("state", "CA")
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save profile %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
u, err := fake.UserByUsername(context.Background(), "alice")
|
||||
if err != nil || u.State != "CA" {
|
||||
t.Fatalf("state not saved: %+v %v", u, err)
|
||||
}
|
||||
|
||||
// invalid state
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
buf.Reset()
|
||||
w = multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("state", "ZZ")
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "valid US state") {
|
||||
t.Fatalf("expected invalid state error, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
fake := newMemDB()
|
||||
blob := &fakeBlob{}
|
||||
sessions := scs.New()
|
||||
srv, err := New(fake, sessions.Store, plumber.TemplateFS, plumber.StaticFS, Config{
|
||||
AdminUsername: "hub",
|
||||
Blob: blob,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := srv.Handler()
|
||||
adminCookies := registerUser(t, h, "hub", "hunter22")
|
||||
userCookies := registerUser(t, h, "alice", "hunter22")
|
||||
|
||||
alice, _ := fake.UserByUsername(context.Background(), "alice")
|
||||
hub, _ := fake.UserByUsername(context.Background(), "hub")
|
||||
q, err := fake.CreateQuestion(context.Background(), alice.ID, "Drip", "Under sink", "Oakland")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fake.UpsertAnswer(context.Background(), q.ID, hub.ID, "Replace the cartridge."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("admin profile %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Questions you answered") || !strings.Contains(body, "Drip") {
|
||||
t.Fatalf("admin answered list missing: %s", body)
|
||||
}
|
||||
|
||||
csrf := csrfFrom(body)
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("state", "OR")
|
||||
part, err := w.CreateFormFile("avatar", "pic.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = part.Write([]byte("fakepngbytes"))
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("avatar upload %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if blob.calls != 1 {
|
||||
t.Fatalf("expected 1 upload, got %d", blob.calls)
|
||||
}
|
||||
hub, _ = fake.UserByUsername(context.Background(), "hub")
|
||||
if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") {
|
||||
t.Fatalf("avatar url %q", hub.AvatarURL)
|
||||
}
|
||||
_ = userCookies
|
||||
}
|
||||
|
||||
func mergeCookies(sets ...[]*http.Cookie) []*http.Cookie {
|
||||
byName := map[string]*http.Cookie{}
|
||||
for _, set := range sets {
|
||||
for _, c := range set {
|
||||
byName[c.Name] = c
|
||||
}
|
||||
}
|
||||
out := make([]*http.Cookie, 0, len(byName))
|
||||
for _, c := range byName {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func csrfFrom(html string) string {
|
||||
const needle = `name="_csrf" value="`
|
||||
i := strings.Index(html, needle)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
html = html[i+len(needle):]
|
||||
j := strings.Index(html, `"`)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return html[:j]
|
||||
}
|
||||
Reference in New Issue
Block a user