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, }) }