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.
233 lines
7.1 KiB
Go
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)
|
|
}
|