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