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.
This commit is contained in:
+232
@@ -0,0 +1,232 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user