Files
plumber/internal/web/server.go
T

519 lines
13 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 {
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)
}