Replace username-based admin bootstrap with a one-time setup secret, rate-limit login/register, equalize login bcrypt timing, cap passwords at 72 bytes, destroy sessions on logout, and require Secure cookies when PORT is set.
559 lines
14 KiB
Go
559 lines
14 KiB
Go
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 {
|
|
// 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
|
|
// TrustProxy enables X-Forwarded-For / RealIP only behind a known proxy.
|
|
TrustProxy bool
|
|
Blob blob.Uploader
|
|
}
|
|
|
|
type Server struct {
|
|
store store.Store
|
|
sessions *scs.SessionManager
|
|
tmpl *template.Template
|
|
cfg Config
|
|
static http.Handler
|
|
loginIP *throttle
|
|
loginUser *throttle
|
|
registerIP *throttle
|
|
}
|
|
|
|
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.Store, 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))),
|
|
loginIP: newThrottle(20, 15*time.Minute),
|
|
loginUser: newThrottle(10, 15*time.Minute),
|
|
registerIP: newThrottle(10, 15*time.Minute),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
if s.cfg.TrustProxy {
|
|
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 = 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, _ = 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 = truncateRunes(body, 12000)
|
|
}
|
|
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
|
|
}
|
|
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) 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)
|
|
}
|