package main import ( "context" "errors" "io" "log/slog" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) const maxAvatarBytes = 5 << 20 type profileStats struct { SongsSubmitted int ReviewsWritten int AverageGiven *float64 AverageReceived *float64 } type profileView struct { ID int64 Name string Email string // only filled for your own profile Avatar *string CreatedAt time.Time Own bool Stats profileStats Songs []*songSummary Errors map[string]string } func (p *profileView) Initials() string { m := member{Name: p.Name}; return m.Initials() } func (a *app) avatarPath(userID int64) string { return filepath.Join(a.cfg.storageDir, "avatars", strconv.FormatInt(userID, 10)+".jpg") } // Counts and history-wide averages only. A member's per-song opinions stay on the song pages — // per-song opinion is gated, whole-history aggregate is public. func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) { var p profileView err := a.pool.QueryRow(ctx, ` select u.id, u.name, u.email, u.avatar, u.created_at, (select count(*) from songs s where s.submitted_by = u.id), (select count(*) from reviews r where r.reviewer_id = u.id), (select avg(r.score)::float from reviews r where r.reviewer_id = u.id), (select avg(r.score)::float from reviews r join songs s on s.id = r.song_id where s.submitted_by = u.id) from users u where u.id = $1`, userID). Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt, &p.Stats.SongsSubmitted, &p.Stats.ReviewsWritten, &p.Stats.AverageGiven, &p.Stats.AverageReceived) if err != nil { return nil, err } p.Own = viewerID == userID if !p.Own { p.Email = "" } // Their songs, with the viewer's own reveal rule applied to each average. rows, err := a.pool.Query(ctx, `select`+songColumns+` from songs s join users u on u.id = s.submitted_by where s.submitted_by = $2 order by s.created_at desc`, viewerID, userID) if err != nil { return nil, err } p.Songs, err = scanSongs(rows) return &p, err } func (a *app) profilePage(w http.ResponseWriter, r *http.Request) { me := memberFrom(r.Context()) id := me.ID if raw := r.PathValue("id"); raw != "" { parsed, err := strconv.ParseInt(raw, 10, 64) if err != nil { http.NotFound(w, r) return } id = parsed } p, err := a.profile(r.Context(), me.ID, id) if errors.Is(err, pgx.ErrNoRows) { http.NotFound(w, r) return } else if err != nil { slog.Error("profile", "ctx", "auth", "error", err, "user", id) http.Error(w, "virhe", http.StatusInternalServerError) return } a.render(w, r, http.StatusOK, "profile.html", page{Title: p.Name, Data: p}) } // Editing your own profile: name, email, password, avatar. Changing the password requires the // current one and drops your other sessions. func (a *app) editProfile(w http.ResponseWriter, r *http.Request) { me := memberFrom(r.Context()) if err := r.ParseMultipartForm(maxAvatarBytes); err != nil && !errors.Is(err, http.ErrNotMultipart) { a.flash(w, "Kuva on liian suuri. Enintään 5 MB.") http.Redirect(w, r, "/profile", http.StatusSeeOther) return } name := clean(r.FormValue("name"), 50) email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) if name == "" || !strings.Contains(email, "@") { a.flash(w, "Tarkista nimi ja sähköpostiosoite.") http.Redirect(w, r, "/profile", http.StatusSeeOther) return } if _, err := a.pool.Exec(r.Context(), `update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) { a.flash(w, "Sähköpostiosoite on jo käytössä.") http.Redirect(w, r, "/profile", http.StatusSeeOther) return } else if err != nil { slog.Error("edit profile", "ctx", "auth", "error", err, "user", me.ID) http.Error(w, "virhe", http.StatusInternalServerError) return } if newPassword := r.FormValue("new_password"); newPassword != "" { if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) { return } } if file, _, err := r.FormFile("avatar"); err == nil { defer file.Close() if err := a.saveAvatar(r, me.ID, file); err != nil { slog.Error("avatar", "ctx", "auth", "error", err, "user", me.ID) a.flash(w, "Kuvaa ei voitu käsitellä. Onko se varmasti kuva?") http.Redirect(w, r, "/profile", http.StatusSeeOther) return } } a.flash(w, "Tiedot tallennettu.") http.Redirect(w, r, "/profile", http.StatusSeeOther) } func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool { var hash string if err := a.pool.QueryRow(r.Context(), `select password_hash from users where id = $1`, userID).Scan(&hash); err != nil { http.Error(w, "virhe", http.StatusInternalServerError) return false } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(current)) != nil { a.flash(w, "Nykyinen salasana ei täsmää.") http.Redirect(w, r, "/profile", http.StatusSeeOther) return false } newHash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost) if err != nil { http.Error(w, "virhe", http.StatusInternalServerError) return false } if _, err := a.pool.Exec(r.Context(), `update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil { http.Error(w, "virhe", http.StatusInternalServerError) return false } // Every other session dies; this browser keeps its own. if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil { slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID) } slog.Info("password changed", "ctx", "auth", "user", userID) return true } // The re-encode through ffmpeg is the validation, the same trick as audio: it handles webp and // avif (stdlib image does not), and it caps what ends up on disk. func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error { tmp := filepath.Join(a.cfg.storageDir, "tmp", "avatar-"+strconv.FormatInt(userID, 10)) dst, err := os.Create(tmp) if err != nil { return err } _, err = io.Copy(dst, io.LimitReader(file, maxAvatarBytes)) dst.Close() if err != nil { os.Remove(tmp) return err } defer os.Remove(tmp) out := a.avatarPath(userID) if err := toAvatarJPEG(r.Context(), tmp, out); err != nil { return err } _, err = a.pool.Exec(r.Context(), `update users set avatar = $2 where id = $1`, userID, filepath.Base(out)) return err } // Avatars are public: they are not secret, and gating them buys nothing. func (a *app) avatar(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.NotFound(w, r) return } f, err := os.Open(a.avatarPath(id)) if err != nil { // No upload: the template renders initials instead, and a client sees avatar_url null. http.NotFound(w, r) return } defer f.Close() info, err := f.Stat() if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "public, max-age=300") http.ServeContent(w, r, "avatar.jpg", info.ModTime(), f) }