Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting. Reviewed-on: #7 Co-authored-by: codegirl-007 <s.raide@gmail.com>
552 lines
14 KiB
Go
552 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"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
|
|
Posts []*store.Post
|
|
}
|
|
|
|
type questionPage struct {
|
|
page
|
|
Question *store.Post
|
|
}
|
|
|
|
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
|
|
Post *store.Post
|
|
}
|
|
|
|
type threadPostCtx struct {
|
|
User *store.User
|
|
CSRF string
|
|
Root *store.Post
|
|
Post *store.Post
|
|
Depth int
|
|
}
|
|
|
|
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, post *store.Post) voteCtx {
|
|
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Post: post}
|
|
},
|
|
"postCtx": func(user *store.User, csrf string, root, post *store.Post, depth int) threadPostCtx {
|
|
return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth}
|
|
},
|
|
"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() },
|
|
"canReply": canReplyToThread,
|
|
"canEditPost": canEditPost,
|
|
"postLabel": postLabel,
|
|
"postDepth": postDepthClass,
|
|
"isEdited": func(post *store.Post) bool { return post != nil && post.UpdatedAt != post.CreatedAt },
|
|
"postTime": func(value string) string {
|
|
t, err := time.Parse(time.RFC3339Nano, value)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
return t.In(pacific.Loc).Format("Jan 2, 2006 · 3:04 PM")
|
|
},
|
|
"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}/hide", s.handleHide)
|
|
r.Post("/posts", s.handleCreatePost)
|
|
r.Post("/posts/{id}/edit", s.handleEditPost)
|
|
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
|
|
}
|
|
posts, err := s.store.ListRootPosts(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),
|
|
Posts: postPointers(posts),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
post := &store.Post{
|
|
AuthorID: u.ID,
|
|
Title: title,
|
|
Body: body,
|
|
City: city,
|
|
}
|
|
if err := s.store.CreatePost(r.Context(), post); err != nil {
|
|
http.Error(w, "could not save question", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/questions/"+url.PathEscape(post.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
|
|
}
|
|
post, err := s.store.GetPostThreadForViewer(r.Context(), id, viewer)
|
|
if err != nil || (post.PostState == store.PostStateHidden && !currentUser(r).Admin()) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
s.exec(w, "question", questionPage{
|
|
page: s.basePage(r, post.Title),
|
|
Question: post,
|
|
})
|
|
}
|
|
|
|
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.VotePost(r.Context(), u.ID, id, value); err != nil {
|
|
if errors.Is(err, store.ErrPostNotVotable) {
|
|
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
|
|
}
|
|
post, err := s.store.GetPostThreadForViewer(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: post.PostDate,
|
|
Post: post,
|
|
})
|
|
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
|
|
}
|
|
posts, err := s.store.ListRootPosts(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,
|
|
Posts: postPointers(posts),
|
|
})
|
|
}
|
|
|
|
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")
|
|
post, err := s.store.GetPost(r.Context(), id)
|
|
if err != nil || post.ParentID != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := s.store.SetRootPostState(r.Context(), id, store.PostStateHidden); err != nil {
|
|
http.Error(w, "could not hide", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if isHTMX(r) && r.PostFormValue("view") == "list" {
|
|
s.renderLeaderboard(w, r, post.PostDate)
|
|
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)
|
|
}
|