Files
plumber/internal/web/profile.go
codegirl007 f61b4fc7ab Notify question authors via Resend when an admin posts the first answer.
Require email at registration (editable on profile), add a Resend mailer with idempotent first-answer sends, and keep answer saves independent of delivery.
2026-08-22 12:39:43 -07:00

286 lines
7.8 KiB
Go

package web
import (
"bytes"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"net/http"
"path"
"strings"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"plumber/internal/blob"
"plumber/internal/geo"
"plumber/internal/store"
)
type profilePage struct {
page
States []struct{ Code, Name string }
Questions []store.RankedQuestion
QuestionsLabel string
UploadsEnabled bool
Error string
StateVal string
EmailVal string
}
func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if u == nil {
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
return
}
s.renderProfile(w, r, u, "", u.State, u.Email)
}
func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
if u == nil {
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
return
}
if err := r.ParseMultipartForm(3 << 20); err != nil {
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State, u.Email)
return
}
want := s.sessions.GetString(r.Context(), "csrf")
got := r.FormValue("_csrf")
if want == "" || got != want {
http.Error(w, "invalid csrf token", http.StatusForbidden)
return
}
state := geo.NormalizeState(r.FormValue("state"))
if !geo.ValidState(state) {
s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state, r.FormValue("email"))
return
}
email, emailErr := store.ValidateEmail(r.FormValue("email"))
if emailErr != "" {
s.renderProfile(w, r, u, emailErr, state, r.FormValue("email"))
return
}
avatarURL := ""
avatarKey := ""
file, hdr, err := r.FormFile("avatar")
if err == nil {
defer file.Close()
if !s.cfg.Blob.Enabled() {
s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state, email)
return
}
if hdr.Size > 2<<20 {
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state, email)
return
}
body, ext, contentType, prepErr := prepareAvatar(file, 2<<20)
if prepErr != nil {
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state, email)
return
}
prevURL := u.AvatarURL
avatarKey = path.Join("avatars", u.ID, "avatar"+ext)
url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{
Key: avatarKey,
Body: bytes.NewReader(body),
ContentType: contentType,
Size: int64(len(body)),
})
if upErr != nil {
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state, email)
return
}
avatarURL = url
u.State = state
u.Email = email
u.AvatarURL = avatarURL
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
_ = s.cfg.Blob.Delete(r.Context(), avatarKey)
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError)
return
}
if oldKey := avatarObjectKey(prevURL, u.ID); oldKey != "" && oldKey != avatarKey {
_ = s.cfg.Blob.Delete(r.Context(), oldKey)
}
s.sessions.Put(r.Context(), "flash", "Profile saved.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
} else if err != http.ErrMissingFile {
s.renderProfile(w, r, u, "Could not read avatar file.", state, email)
return
}
u.State = state
u.Email = email
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
if errors.Is(err, store.ErrDuplicateEmail) {
s.renderProfile(w, r, u, "That email is already registered.", state, email)
return
}
http.Error(w, "could not save profile", http.StatusInternalServerError)
return
}
s.sessions.Put(r.Context(), "flash", "Profile saved.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
func avatarObjectKey(publicURL, userID string) string {
marker := "/avatars/" + userID + "/"
i := strings.Index(publicURL, marker)
if i < 0 {
return ""
}
rest := publicURL[i+1:] // avatars/...
if q := strings.IndexAny(rest, "?#"); q >= 0 {
rest = rest[:q]
}
return rest
}
// prepareAvatar reads at most maxBytes, sniffs/decodes the image, resizes to a
// small avatar, and re-encodes so only bounded valid image bytes are stored.
func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType string, err error) {
limited := io.LimitReader(r, maxBytes+1)
raw, err := io.ReadAll(limited)
if err != nil {
return nil, "", "", err
}
if int64(len(raw)) > maxBytes {
return nil, "", "", fmt.Errorf("avatar too large")
}
if len(raw) == 0 {
return nil, "", "", fmt.Errorf("empty avatar")
}
sniff := http.DetectContentType(raw)
switch {
case strings.HasPrefix(sniff, "image/jpeg"),
strings.HasPrefix(sniff, "image/png"),
strings.HasPrefix(sniff, "image/webp"):
default:
return nil, "", "", fmt.Errorf("unsupported type %s", sniff)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return nil, "", "", err
}
// Cap decoded size before allocating pixel buffers (~4 MiB RGBA at 1024²).
const maxDecodeDim = 1024
const maxPixels = maxDecodeDim * maxDecodeDim
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDecodeDim || cfg.Height > maxDecodeDim {
return nil, "", "", fmt.Errorf("image dimensions out of range")
}
if int64(cfg.Width)*int64(cfg.Height) > maxPixels {
return nil, "", "", fmt.Errorf("image too many pixels")
}
img, decodedFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, "", "", err
}
if format != "" {
decodedFormat = format
}
const maxAvatarDim = 512
img = fitAvatar(img, maxAvatarDim)
var out bytes.Buffer
switch decodedFormat {
case "jpeg":
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil {
return nil, "", "", err
}
if int64(out.Len()) > maxBytes {
return nil, "", "", fmt.Errorf("encoded avatar too large")
}
return out.Bytes(), ".jpg", "image/jpeg", nil
case "png", "webp":
if err := png.Encode(&out, img); err != nil {
return nil, "", "", err
}
if int64(out.Len()) > maxBytes {
// Fall back to JPEG when PNG balloons past the upload cap.
out.Reset()
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil {
return nil, "", "", err
}
if int64(out.Len()) > maxBytes {
return nil, "", "", fmt.Errorf("encoded avatar too large")
}
return out.Bytes(), ".jpg", "image/jpeg", nil
}
return out.Bytes(), ".png", "image/png", nil
default:
return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat)
}
}
// fitAvatar scales img down so both sides are at most maxDim.
func fitAvatar(img image.Image, maxDim int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxDim && h <= maxDim {
return img
}
scale := float64(maxDim) / float64(w)
if float64(h)*scale > float64(maxDim) {
scale = float64(maxDim) / float64(h)
}
nw := int(float64(w) * scale)
nh := int(float64(h) * scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal, emailVal string) {
var (
questions []store.RankedQuestion
label string
err error
)
if u.Admin() {
label = "Questions you answered"
questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
} else {
label = "Your questions"
questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
}
if err != nil {
http.Error(w, "could not load questions", http.StatusInternalServerError)
return
}
p := s.basePage(r, "Profile")
p.User = u
s.exec(w, "profile", profilePage{
page: p,
States: geo.States,
Questions: questions,
QuestionsLabel: label,
UploadsEnabled: s.cfg.Blob.Enabled(),
Error: errMsg,
StateVal: stateVal,
EmailVal: emailVal,
})
}