package web import ( "bytes" "fmt" "image" "image/jpeg" "image/png" "io" "net/http" "path" "strings" "github.com/google/uuid" _ "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, and re-encodes // it so only valid image bytes are stored publicly. 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 } const maxDim = 4096 const maxPixels = 4096 * 4096 if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim { 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 } var out bytes.Buffer switch decodedFormat { case "jpeg": if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil { return nil, "", "", err } return out.Bytes(), ".jpg", "image/jpeg", nil case "png", "webp": if err := png.Encode(&out, img); err != nil { return nil, "", "", err } return out.Bytes(), ".png", "image/png", nil default: return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat) } } 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 } if fresh, e := s.store.UserByID(r.Context(), u.ID); e == nil { u = fresh } 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, }) }