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.Store 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.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))), }, 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 = 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 } 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) } } // 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) }