Files
Levyraati26_go/src/profile.go
T
Esa Kataja b01d08b1e1 Move the package into src/
Thirty-seven entries in the root, most of them .go files. The assets had to
come along: //go:embed cannot reach outside its own directory, so
templates/, static/ and migrations/ live beside the code that embeds them,
and testdata/ beside the test that reads it. storage/ stays put — runtime
data, not source.

go build now needs -o. Without it the output would be named after the
package directory and collide with src/ itself.
2026-09-05 13:53:04 +03:00

233 lines
7.1 KiB
Go

package main
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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.db.QueryRowContext(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) from reviews r where r.reviewer_id = u.id),
(select avg(r.score) 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.db.QueryContext(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, sql.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.db.ExecContext(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.db.QueryRowContext(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.db.ExecContext(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.db.ExecContext(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.db.ExecContext(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)
}