Switch avatar resize to ApproxBiLinear, cap hunt/profile/admin list queries, drop redundant admin/profile lookups, dedupe CI on app PRs, and refresh stale todo.md notes.
244 lines
6.5 KiB
Go
244 lines
6.5 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
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)
|
|
return
|
|
}
|
|
|
|
avatarURL := ""
|
|
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)
|
|
return
|
|
}
|
|
if hdr.Size > 2<<20 {
|
|
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state)
|
|
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)
|
|
return
|
|
}
|
|
key := path.Join("avatars", u.ID, uuid.NewString()+ext)
|
|
url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{
|
|
Key: key,
|
|
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)
|
|
return
|
|
}
|
|
avatarURL = url
|
|
} else if err != http.ErrMissingFile {
|
|
s.renderProfile(w, r, u, "Could not read avatar file.", state)
|
|
return
|
|
}
|
|
|
|
u.State = state
|
|
if avatarURL != "" {
|
|
u.AvatarURL = avatarURL
|
|
}
|
|
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
|
|
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)
|
|
}
|
|
|
|
// 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 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,
|
|
})
|
|
}
|