diff --git a/admin.go b/admin.go index 5587363..778806f 100644 --- a/admin.go +++ b/admin.go @@ -29,8 +29,10 @@ type adminMember struct { } type dashboard struct { - Invites []adminInvite - Members []adminMember + Invites []adminInvite + Members []adminMember + Songs []adminSong + OpenCount int } func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { @@ -77,6 +79,16 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { return } + if d.Songs, err = a.adminSongs(r.Context()); err != nil { + adminError(w, "songs", err) + return + } + if err := a.pool.QueryRow(r.Context(), + `select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil { + adminError(w, "reports", err) + return + } + a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d}) } diff --git a/main.go b/main.go index adaa6d8..79e93f0 100644 --- a/main.go +++ b/main.go @@ -96,7 +96,7 @@ func main() { if err := sweep(ctx, pool); err != nil { fatal("startup sweep", "error", err) } - for _, dir := range []string{"audio", "tmp"} { + for _, dir := range []string{"audio", "tmp", "avatars"} { if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil { fatal("storage dir", "error", err, "dir", dir) } @@ -141,6 +141,15 @@ func (a *app) memberMux() *http.ServeMux { mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong)) mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong)) mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio)) + mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret + + mux.HandleFunc("GET /stats", a.requireMember(a.statsPage)) + mux.HandleFunc("GET /profile", a.requireMember(a.profilePage)) + mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage)) + mux.HandleFunc("POST /profile", a.requireMember(a.editProfile)) + + mux.HandleFunc("GET /report", a.requireMember(a.reportPage)) + mux.HandleFunc("POST /report", a.requireMember(a.createReport)) mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview)) mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview)) @@ -164,6 +173,10 @@ func (a *app) adminMux() *http.ServeMux { mux.HandleFunc("POST /admin/invites", a.createInvite) mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan) mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword) + mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong) + mux.HandleFunc("GET /admin/reports", a.adminReports) + mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport) + mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin", http.StatusSeeOther) }) diff --git a/media.go b/media.go index a89000e..e3e9abc 100644 --- a/media.go +++ b/media.go @@ -176,6 +176,17 @@ func downloadYouTube(ctx context.Context, url, outTemplate string) (string, erro return "", nil } +// toAvatarJPEG normalises any image ffmpeg understands into a 256px square JPEG. The re-encode is +// the validation and the size cap in one — webp and avif included, which stdlib image cannot read. +func toAvatarJPEG(ctx context.Context, in, out string) error { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + return exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in, + "-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256", + "-frames:v", "1", "-q:v", "3", out).Run() +} + // convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio. // No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth // showing — "Invalid data found when processing input" beats "submission failed". diff --git a/profile.go b/profile.go new file mode 100644 index 0000000..5cbf0ee --- /dev/null +++ b/profile.go @@ -0,0 +1,232 @@ +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) +} diff --git a/render.go b/render.go index e509462..dfc7a40 100644 --- a/render.go +++ b/render.go @@ -17,6 +17,16 @@ var assetFS embed.FS var funcs = template.FuncMap{ "fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") }, "score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) }, + "value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) }, + // Lets one board partial be called with a title and a list, instead of two near-identical + // partials per leaderboard. + "dict": func(pairs ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(pairs); i += 2 { + m[pairs[i].(string)] = pairs[i+1] + } + return m + }, } // Each page is parsed with the layout into its own set, so two pages may both define "content". diff --git a/reports.go b/reports.go new file mode 100644 index 0000000..416434d --- /dev/null +++ b/reports.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "strconv" + "strings" + "time" +) + +const maxReportBody = 2000 + +type report struct { + ID int64 + Body string + Page string + UserAgent string + Reporter string + ResolvedAt *time.Time + CreatedAt time.Time +} + +func (r *report) Open() bool { return r.ResolvedAt == nil } + +type reportPage struct { + From string + Mine []*report +} + +// Free text and nothing else. No category, no priority, no severity — with ten users a sentence and +// a page URL beat a taxonomy nobody fills in honestly. +func (a *app) reportPage(w http.ResponseWriter, r *http.Request) { + mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID) + if err != nil { + slog.Error("list reports", "ctx", "reports", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + from := r.URL.Query().Get("from") + if !strings.HasPrefix(from, "/") { + from = "/" // never redirect off-site on the strength of a query parameter + } + a.render(w, r, http.StatusOK, "report.html", + page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}}) +} + +// Seeing your own past reports is what stops the same bug arriving four times. +func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) { + rows, err := a.pool.Query(ctx, ` + select id, body, coalesce(page, ''), resolved_at, created_at + from reports where user_id = $1 order by created_at desc`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*report + for rows.Next() { + var rep report + if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil { + return nil, err + } + out = append(out, &rep) + } + return out, rows.Err() +} + +func (a *app) createReport(w http.ResponseWriter, r *http.Request) { + me := memberFrom(r.Context()) + body := clean(r.FormValue("body"), maxReportBody) + from := r.FormValue("from") + if !strings.HasPrefix(from, "/") { + from = "/" + } + if body == "" { + a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.") + http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther) + return + } + + // "Only on my phone" is the most common bug report and this answers it without asking. + _, err := a.pool.Exec(r.Context(), ` + insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`, + me.ID, body, from, clean(r.Header.Get("User-Agent"), 300)) + if err != nil { + slog.Error("create report", "ctx", "reports", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + slog.Info("report filed", "ctx", "reports", "user", me.ID) + a.flash(w, "Kiitos! Palaute on perillä.") + http.Redirect(w, r, from, http.StatusSeeOther) +} + +// --- admin --- + +func (a *app) adminReports(w http.ResponseWriter, r *http.Request) { + rows, err := a.pool.Query(r.Context(), ` + select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''), + u.name, rep.resolved_at, rep.created_at + from reports rep join users u on u.id = rep.user_id + order by rep.resolved_at nulls first, rep.created_at desc`) + if err != nil { + adminError(w, "reports", err) + return + } + defer rows.Close() + var out []*report + for rows.Next() { + var rep report + if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter, + &rep.ResolvedAt, &rep.CreatedAt); err != nil { + adminError(w, "reports", err) + return + } + out = append(out, &rep) + } + if err := rows.Err(); err != nil { + adminError(w, "reports", err) + return + } + a.render(w, r, http.StatusOK, "admin_reports.html", + page{Title: "Palautteet", Admin: true, Data: out}) +} + +// A nullable timestamp rather than a status enum: smaller, and it tells you *when*. +func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + if _, err := a.pool.Exec(r.Context(), + `update reports set resolved_at = case when resolved_at is null then now() end where id = $1`, + id); err != nil { + adminError(w, "reports", err) + return + } + http.Redirect(w, r, "/admin/reports", http.StatusSeeOther) +} + +// The admin deletes a song unconditionally — a separate route from the submitter's, rather than one +// route with a branch. The row and the file go together here too. +func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id) + if err != nil { + adminError(w, "songs", err) + return + } + if tag.RowsAffected() > 0 { + removeFile(a.audioPath(id)) + slog.Info("song deleted by admin", "ctx", "songs", "song", id) + a.flash(w, "Kappale poistettu.") + } + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +type adminSong struct { + ID int64 + Title string + Artist string + Submitter string + Reviews int + CreatedAt time.Time +} + +func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) { + rows, err := a.pool.Query(ctx, ` + select s.id, s.title, s.artist, u.name, + (select count(*) from reviews r where r.song_id = s.id)::int, s.created_at + from songs s join users u on u.id = s.submitted_by + order by s.created_at desc`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []adminSong + for rows.Next() { + var s adminSong + if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// Moderating a complaint means listening to the song, so the admin surface has its own audio route +// rather than branching auth inside the member handler. +func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) { + a.audio(w, r) +} diff --git a/songs.go b/songs.go index b74cd12..fde432c 100644 --- a/songs.go +++ b/songs.go @@ -269,12 +269,19 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther) return } - os.Remove(a.audioPath(id)) + removeFile(a.audioPath(id)) slog.Info("song deleted", "ctx", "songs", "song", id) a.flash(w, "Kappale poistettu.") http.Redirect(w, r, "/songs", http.StatusSeeOther) } +// A missing file is fine — the row is gone either way — but anything else is worth knowing about. +func removeFile(path string) { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + slog.Error("remove file", "ctx", "songs", "error", err, "path", path) + } +} + // --- audio --- // Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON. diff --git a/static/style.css b/static/style.css index 14032ec..45060a9 100644 --- a/static/style.css +++ b/static/style.css @@ -551,3 +551,42 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1); *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; } .songcard:hover { transform: none; } } + +/* --- stats --- */ + +.boards { display: grid; grid-template-columns: 1fr; gap: var(--space-5); } +@media (min-width: 700px) { .boards { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 1100px) { .boards { grid-template-columns: repeat(3, 1fr); } } + +.board { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius); + padding: var(--space-4); margin: 0; box-shadow: var(--shadow-card); } +.board h2 { font-size: 1.1rem; margin-bottom: var(--space-3); } +/* A counter rather than a list marker: `display: grid` on the
  • suppresses markers. */ +.board-list { list-style: none; counter-reset: rank; margin: 0; padding: 0; } +.board-list li { display: grid; grid-template-columns: 1.6rem 1fr auto; + column-gap: var(--space-2); align-items: baseline; + padding: var(--space-2) 0; border-bottom: 1px solid var(--hairline); } +.board-list li:last-child { border-bottom: 0; } +.board-list li::before { counter-increment: rank; content: counter(rank) "."; color: var(--muted); + font-family: var(--font-display); } +.board-list li a { grid-column: 2; } +.board-list li .value { grid-column: 3; grid-row: 1; font-family: var(--font-display); + font-size: 1.1rem; color: var(--gold-1); } +.board-list li .small { grid-column: 2; } +.board-list li .small:last-child { grid-column: 3; text-align: right; } + +/* --- profile --- */ + +.profilehead { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); } +.profilehead h1 { margin: 0; } +.avatar.big { width: 4.5rem; height: 4.5rem; font-size: 1.6rem; object-fit: cover; } +img.avatar { object-fit: cover; } + +.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4); + margin-bottom: var(--space-6); } +@media (min-width: 700px) { .statgrid { grid-template-columns: repeat(4, 1fr); } } + +.statcard { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius); + padding: var(--space-5); text-align: center; display: flex; flex-direction: column; + gap: var(--space-1); box-shadow: var(--shadow-card); } +.statvalue { font-family: var(--font-display); font-size: 2rem; font-weight: 700; color: var(--gold-1); } diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..8c6c2be --- /dev/null +++ b/stats.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "log/slog" + "net/http" +) + +// A song needs this many reviews to qualify for any ranking: with ten members it means a third of +// the club has weighed in, which is a real threshold rather than a formality. +const minReviews = 3 + +type songStat struct { + ID int64 + Title string + Artist string + Value float64 + ReviewCount int +} + +type userStat struct { + ID int64 + Name string + Value float64 + Count int + Avatar *string +} + +func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() } + +type stats struct { + MinReviews int + + TopSongs []songStat + BottomSongs []songStat + MostDivisive []songStat + MostUnified []songStat + MostReviewed []songStat + + Harshest []userStat + MostGenerous []userStat + MostActive []userStat + MostProlific []userStat +} + +// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break: +// ties are common in a ten-person club, and without one Postgres may return a different ten each +// time, so the page visibly reshuffles between reloads for no reason. +func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) { + rows, err := a.pool.Query(ctx, ` + select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews + from songs s join reviews r on r.song_id = s.id + group by s.id + having count(r.id) >= $1 + order by value `+direction+`, reviews desc, s.id asc + limit 10`, minReviews) + if err != nil { + return nil, err + } + defer rows.Close() + var out []songStat + for rows.Next() { + var s songStat + if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous +// member in the club forever. +func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) { + rows, err := a.pool.Query(ctx, ` + select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n + from users u join reviews r on r.reviewer_id = u.id + group by u.id + having count(r.id) >= $1 + order by value `+direction+`, n desc, u.id asc + limit 10`, minReviews) + if err != nil { + return nil, err + } + defer rows.Close() + var out []userStat + for rows.Next() { + var u userStat + if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (a *app) mostProlific(ctx context.Context) ([]userStat, error) { + rows, err := a.pool.Query(ctx, ` + select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int + from users u join songs s on s.submitted_by = u.id + group by u.id + order by count(s.id) desc, u.id asc + limit 10`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []userStat + for rows.Next() { + var u userStat + if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// The reveal rule does not apply here: leaderboards are always public. That is the whole point of +// /stats being a page you walk to deliberately. +func (a *app) statsPage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + s := stats{MinReviews: minReviews} + + var err error + for _, load := range []func() error{ + func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return }, + func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return }, + func() (err error) { + s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc") + return + }, + func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return }, + func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return }, + func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return }, + func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return }, + func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return }, + func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return }, + } { + if err = load(); err != nil { + slog.Error("stats", "ctx", "songs", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + } + + a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s}) +} diff --git a/stats_test.go b/stats_test.go new file mode 100644 index 0000000..d945c3f --- /dev/null +++ b/stats_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "testing" +) + +// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the +// same ten come back in the same order every time. +func TestLeaderboardThresholdAndOrder(t *testing.T) { + a := testApp(t) + ctx := context.Background() + submitter := a.seedMember(t, "submitter@example.com") + var reviewers []int64 + for _, e := range []string{"r1@example.com", "r2@example.com", "r3@example.com"} { + reviewers = append(reviewers, a.seedMember(t, e)) + } + + loved := a.seedSong(t, submitter, "Rakastettu") + hated := a.seedSong(t, submitter, "Vihattu") + ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu") + + for _, r := range reviewers { + a.seedReview(t, loved, r, 90) + a.seedReview(t, hated, r, 20) + } + // One short of the threshold. + a.seedReview(t, ignored, reviewers[0], 100) + a.seedReview(t, ignored, reviewers[1], 100) + + top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc") + if err != nil { + t.Fatal(err) + } + if len(top) != 2 { + t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews) + } + if top[0].ID != loved || top[1].ID != hated { + t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved) + } + if top[0].Value != 90 || top[0].ReviewCount != 3 { + t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount) + } + + bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc") + if err != nil { + t.Fatal(err) + } + if bottom[0].ID != hated { + t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated) + } + + // Identical scores everywhere means stddev 0, so unified beats divisive on the same data. + unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc") + if err != nil { + t.Fatal(err) + } + if unified[0].Value != 0 { + t.Fatalf("most unified has stddev %v, want 0", unified[0].Value) + } + + // Ties are the common case in a ten-person club: the same query must return the same order. + first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc") + second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc") + for i := range first { + if first[i].ID != second[i].ID { + t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing") + } + } +} + +// Profiles are counts and history-wide averages. They never carry per-song opinions. +func TestProfileStats(t *testing.T) { + a := testApp(t) + ctx := context.Background() + aino := a.seedMember(t, "aino@example.com") + bertta := a.seedMember(t, "bertta@example.com") + + song := a.seedSong(t, aino, "Testikappale") + a.seedReview(t, song, bertta, 80) + other := a.seedSong(t, bertta, "Berttan kappale") + a.seedReview(t, other, aino, 40) + + p, err := a.profile(ctx, bertta, aino) + if err != nil { + t.Fatal(err) + } + if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 { + t.Fatalf("counts = %d songs, %d reviews; want 1 and 1", + p.Stats.SongsSubmitted, p.Stats.ReviewsWritten) + } + if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 { + t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven) + } + if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 { + t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived) + } + // Viewing someone else's profile does not expose their email. + if p.Email != "" { + t.Fatalf("another member's email leaked: %q", p.Email) + } + // Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees + // its average here. + if len(p.Songs) != 1 { + t.Fatalf("profile lists %d songs, want 1", len(p.Songs)) + } + if p.Songs[0].Average == nil { + t.Fatal("a reviewer cannot see the average of a song they reviewed") + } + + // A third member who has reviewed nothing must not learn it from the profile page. + cecilia := a.seedMember(t, "cecilia@example.com") + p, err = a.profile(ctx, cecilia, aino) + if err != nil { + t.Fatal(err) + } + if p.Songs[0].Average != nil { + t.Fatal("profile leaked a song average to someone who has not reviewed it") + } +} diff --git a/templates/admin.html b/templates/admin.html index e888493..7017dfc 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -1,5 +1,6 @@ {{define "content"}}

    Ylläpito

    +

    Palautteet{{if .Data.OpenCount}} {{.Data.OpenCount}} avointa{{end}}

    @@ -59,4 +60,33 @@
    + +
    +

    Kappaleet

    +
    + + + + {{range .Data.Songs}} + + + + + + + + {{else}} + + {{end}} + +
    KappaleLähettäjäArvostelutJulkaistu
    {{.Title}} — {{.Artist}}{{.Submitter}}{{.Reviews}}{{fidate .CreatedAt}} + Kuuntele +
    + +
    +
    Ei kappaleita.
    +
    +
    + {{end}} diff --git a/templates/admin_reports.html b/templates/admin_reports.html new file mode 100644 index 0000000..c602edc --- /dev/null +++ b/templates/admin_reports.html @@ -0,0 +1,23 @@ +{{define "content"}} +

    Palautteet

    +

    ← Ylläpito

    + +{{range .Data}} +
    +
    + {{if .Open}}avoin{{else}}käsitelty{{end}} + {{.Reporter}} + {{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}} +
    +

    {{.Body}}

    +

    {{.UserAgent}}

    + {{if .Open}} +
    + +
    + {{end}} +
    +{{else}} +

    Ei palautteita.

    +{{end}} +{{end}} diff --git a/templates/layout.html b/templates/layout.html index c91c91b..7ca28c4 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -22,13 +22,17 @@ Jono Kappaleet Lähetä + Tilastot
    {{.Member.Name}} - {{.Member.Initials}} + + {{if .Member.Avatar}}Oma profiili + {{else}}{{.Member.Initials}}{{end}} +
    @@ -38,6 +42,8 @@ Jono Kappaleet Lähetä + Tilastot + Oma profiili
    @@ -51,7 +57,13 @@
    {{template "content" .}}
    - + {{with .Flash}}
    diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..9d4b84e --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,45 @@ +{{define "content"}} +{{$p := .Data}} +
    + {{if $p.Avatar}} + + {{else}} + {{$p.Initials}} + {{end}} +
    +

    {{$p.Name}}

    +

    Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}

    +
    +
    + +
    +
    {{$p.Stats.SongsSubmitted}}kappaletta
    +
    {{$p.Stats.ReviewsWritten}}arvostelua
    +
    {{if $p.Stats.AverageGiven}}{{score $p.Stats.AverageGiven}}{{else}}—{{end}}antanut ka.
    +
    {{if $p.Stats.AverageReceived}}{{score $p.Stats.AverageReceived}}{{else}}—{{end}}saanut ka.
    +
    + +{{if $p.Own}} +
    + Muokkaa tietoja +
    + + + + + + +
    +

    Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.

    +
    +{{end}} + +
    +

    Kappaleet

    + {{if $p.Songs}} +
    {{range $p.Songs}}{{template "songcard" .}}{{end}}
    + {{else}} +

    Ei vielä yhtään kappaletta.

    + {{end}} +
    +{{end}} diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..5bda6c4 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,30 @@ +{{define "content"}} +

    Palaute

    +

    Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi + virke riittää.

    + +
    + + + +
    +

    Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.

    + +{{with .Data.Mine}} +
    +

    Omat palautteet

    + {{range .}} +
    +
    + {{if .Open}}avoin{{else}}käsitelty{{end}} + {{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}} +
    +

    {{.Body}}

    +
    + {{end}} +
    +{{end}} +{{end}} diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..651f687 --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,56 @@ +{{define "songboard"}} +
    +

    {{.Title}}

    + {{if .Items}} +
      + {{range .Items}} +
    1. + {{.Title}} + {{.Artist}} + {{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}} + {{if not $.Count}}{{.ReviewCount}} arv.{{end}} +
    2. + {{end}} +
    + {{else}} +

    Ei vielä tarpeeksi arvosteluja.

    + {{end}} +
    +{{end}} + +{{define "userboard"}} +
    +

    {{.Title}}

    + {{if .Items}} +
      + {{range .Items}} +
    1. + {{.Name}} + {{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}} + {{if not $.Count}}{{.Count}} kpl{{end}} +
    2. + {{end}} +
    + {{else}} +

    Ei vielä tarpeeksi arvosteluja.

    + {{end}} +
    +{{end}} + +{{define "content"}} +

    Tilastot

    +

    Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua. + Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.

    + +
    + {{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}} + {{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}} + {{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive}} + {{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified}} + {{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" true}} + {{template "userboard" dict "Title" "Ankarin arvostelija" "Items" .Data.Harshest}} + {{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}} + {{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}} + {{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}} +
    +{{end}}