Require email at registration (editable on profile), add a Resend mailer with idempotent first-answer sends, and keep answer saves independent of delivery.
622 lines
16 KiB
Go
622 lines
16 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"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/mail"
|
|
"plumber/internal/pacific"
|
|
"plumber/internal/store"
|
|
)
|
|
|
|
type Config struct {
|
|
// AdminSetupSecret, when set, can promote the first registrant who also
|
|
// posts the matching setup_secret. It is ignored once any admin exists.
|
|
AdminSetupSecret string
|
|
SecureCookie bool
|
|
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
|
|
TrustedProxies []*net.IPNet
|
|
Blob blob.Uploader
|
|
Mail mail.Notifier
|
|
}
|
|
|
|
type Server struct {
|
|
store store.Store
|
|
sessions *scs.SessionManager
|
|
tmpl *template.Template
|
|
cfg Config
|
|
static http.Handler
|
|
loginIP *throttle
|
|
registerIP *throttle
|
|
loginFail *failureTracker
|
|
}
|
|
|
|
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
|
|
Email string
|
|
Error string
|
|
Next string
|
|
}
|
|
|
|
type voteCtx struct {
|
|
User *store.User
|
|
CSRF string
|
|
View string
|
|
Date string
|
|
Question store.RankedQuestion
|
|
}
|
|
|
|
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
|
|
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}
|
|
},
|
|
"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))),
|
|
loginIP: newThrottle(20, 15*time.Minute, defaultThrottleMaxKeys),
|
|
registerIP: newThrottle(10, 15*time.Minute, defaultThrottleMaxKeys),
|
|
loginFail: newFailureTracker(15*time.Minute, defaultThrottleMaxKeys),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
// Do not use middleware.RealIP: it rewrites RemoteAddr from client-controlled
|
|
// forwarding headers before clientIP can validate the TCP peer against
|
|
// TrustedProxies. clientIP walks X-Forwarded-For itself when the peer is trusted.
|
|
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 = truncateRunes(title, 120)
|
|
}
|
|
if len(body) > 8000 {
|
|
body = truncateRunes(body, 8000)
|
|
}
|
|
if len(city) > 80 {
|
|
city = truncateRunes(city, 80)
|
|
}
|
|
q := &store.RankedQuestion{
|
|
AuthorID: u.ID,
|
|
Title: title,
|
|
Body: body,
|
|
City: city,
|
|
}
|
|
if err := s.store.CreateQuestion(r.Context(), q); err != nil {
|
|
http.Error(w, "could not save question", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/questions/"+url.PathEscape(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, err = s.store.GetAnswer(r.Context(), q.ID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
log.Printf("question %s marked answered but answer missing", q.ID)
|
|
http.Error(w, "answer unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
log.Printf("get answer %s: %v", q.ID, err)
|
|
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
s.exec(w, "question", questionPage{
|
|
page: s.basePage(r, 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
|
|
case "0":
|
|
value = 0
|
|
default:
|
|
http.Error(w, "invalid vote", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
|
|
if errors.Is(err, store.ErrHiddenOrMissing) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
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 = 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,
|
|
Body: body,
|
|
}
|
|
if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
|
|
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)
|
|
return
|
|
}
|
|
if isHTMX(r) {
|
|
s.exec(w, "answer", questionPage{page: s.basePage(r, ""), Answer: saved})
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) notifyQuestionAnswered(q *store.RankedQuestion, answerBody, adminID string) {
|
|
if q == nil || s.cfg.Mail == nil {
|
|
return
|
|
}
|
|
author, err := s.store.UserByID(context.Background(), q.AuthorID)
|
|
if err != nil || author == nil || author.Email == "" || author.ID == adminID {
|
|
return
|
|
}
|
|
msg := mail.QuestionAnswered{
|
|
ToEmail: author.Email,
|
|
ToName: author.Name,
|
|
QuestionID: q.ID,
|
|
QuestionTitle: q.Title,
|
|
AnswerBody: answerBody,
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := s.cfg.Mail.NotifyQuestionAnswered(ctx, msg); err != nil {
|
|
log.Printf("notify answer %s: %v", q.ID, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := s.sessions.Destroy(r.Context()); err != nil {
|
|
http.Error(w, "could not sign out", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// truncateRunes shortens s to at most max runes without splitting a code point.
|
|
func truncateRunes(s string, max int) string {
|
|
if max <= 0 {
|
|
return ""
|
|
}
|
|
n := 0
|
|
for byteIdx := range s {
|
|
if n == max {
|
|
return s[:byteIdx]
|
|
}
|
|
n++
|
|
}
|
|
return s
|
|
}
|
|
|
|
func randomHex(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|