package web import ( "io" "net/http" "path" "strings" "github.com/google/uuid" "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 } ct := hdr.Header.Get("Content-Type") ext, contentType, ok := avatarType(hdr.Filename, ct) if !ok { s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state) return } if hdr.Size > 2<<20 { s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state) return } key := path.Join("avatars", u.ID, uuid.NewString()+ext) limited := io.LimitReader(file, (2<<20)+1) url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{ Key: key, Body: limited, ContentType: contentType, Size: hdr.Size, }) 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 } if err := s.store.UpdateProfile(r.Context(), u.ID, state, avatarURL); 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) } func avatarType(filename, contentType string) (ext, normalized string, ok bool) { contentType = strings.ToLower(strings.TrimSpace(contentType)) filename = strings.ToLower(filename) switch { case strings.HasPrefix(contentType, "image/jpeg"), strings.HasSuffix(filename, ".jpg"), strings.HasSuffix(filename, ".jpeg"): return ".jpg", "image/jpeg", true case strings.HasPrefix(contentType, "image/png"), strings.HasSuffix(filename, ".png"): return ".png", "image/png", true case strings.HasPrefix(contentType, "image/webp"), strings.HasSuffix(filename, ".webp"): return ".webp", "image/webp", true default: return "", "", false } } 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, }) }