Step 6. The surfaces around the review loop. - Nine leaderboards, ordered and limited in SQL, each with a deterministic tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews to qualify, for reviewer boards too - Profiles show counts and history-wide averages and the member's songs, never a list of their reviews — per-song opinion stays gated - Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation. No upload still means initials, and avatars are public - Changing your own password requires the current one and drops your other sessions - Palaute: free text plus the page you were on, carried in a footer link, and the user agent from the header. Reporters see their own; the admin resolves them with a timestamp rather than a status enum - Admin gained the song list with delete, the reports page, and an open-report count on the dashboard Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter because display:grid suppresses list markers, and count-based boards were printing 3.0 where they mean 3.
322 lines
9.8 KiB
Go
322 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const pageSize = 20
|
|
|
|
type songSummary struct {
|
|
ID int64
|
|
Title string
|
|
Artist string
|
|
Genre string
|
|
Duration int
|
|
CreatedAt time.Time
|
|
Submitter string
|
|
SubmitterID int64
|
|
ReviewCount int
|
|
// Nil unless the viewer has revealed the song. The reveal rule is applied in the query, not
|
|
// in the template — a hidden average is never sent.
|
|
Average *float64
|
|
Own bool
|
|
Reviewed bool
|
|
}
|
|
|
|
func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) }
|
|
func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed }
|
|
|
|
func (s *songSummary) Length() string {
|
|
return fmt.Sprintf("%d.%02d", s.Duration/60, s.Duration%60)
|
|
}
|
|
|
|
type songList struct {
|
|
Items []*songSummary
|
|
NextCursor int64 // 0 when there is no next page
|
|
Queue bool
|
|
}
|
|
|
|
// The select list is identical for both lists, so the reveal rule cannot drift between them.
|
|
const songColumns = `
|
|
s.id, s.title, s.artist, s.genre, s.duration_seconds, s.created_at, u.id, u.name,
|
|
(select count(*) from reviews r where r.song_id = s.id),
|
|
case when s.submitted_by = $1
|
|
or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
|
then (select avg(r.score)::float from reviews r where r.song_id = s.id)
|
|
end,
|
|
s.submitted_by = $1,
|
|
exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`
|
|
|
|
func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
|
|
defer rows.Close()
|
|
var out []*songSummary
|
|
for rows.Next() {
|
|
var s songSummary
|
|
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Genre, &s.Duration, &s.CreatedAt,
|
|
&s.SubmitterID, &s.Submitter, &s.ReviewCount, &s.Average, &s.Own, &s.Reviewed); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// The queue is a worklist: songs you can still review, oldest first, and never your own — you can
|
|
// never act on those, so they would sit at the front forever.
|
|
func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
|
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
|
from songs s join users u on u.id = s.submitted_by
|
|
where s.submitted_by <> $1
|
|
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
|
and ($2 = 0 or s.id > $2)
|
|
order by s.created_at, s.id
|
|
limit $3`, viewerID, cursor, pageSize+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := scanSongs(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return paginate(items, true), nil
|
|
}
|
|
|
|
// Everything, newest first. This is where a song lives once it has left the queue.
|
|
func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
|
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
|
from songs s join users u on u.id = s.submitted_by
|
|
where ($2 = 0 or s.id < $2)
|
|
order by s.created_at desc, s.id desc
|
|
limit $3`, viewerID, cursor, pageSize+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := scanSongs(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return paginate(items, false), nil
|
|
}
|
|
|
|
// One row over the page size is fetched so "is there more" needs no second count query.
|
|
func paginate(items []*songSummary, isQueue bool) *songList {
|
|
l := &songList{Items: items, Queue: isQueue}
|
|
if len(items) > pageSize {
|
|
l.Items = items[:pageSize]
|
|
l.NextCursor = l.Items[pageSize-1].ID
|
|
}
|
|
return l
|
|
}
|
|
|
|
func cursorOf(r *http.Request) int64 {
|
|
n, _ := strconv.ParseInt(r.URL.Query().Get("cursor"), 10, 64)
|
|
return n
|
|
}
|
|
|
|
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
|
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
|
if err != nil {
|
|
slog.Error("queue", "ctx", "songs", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: list})
|
|
}
|
|
|
|
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
|
list, err := a.browse(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
|
if err != nil {
|
|
slog.Error("browse", "ctx", "songs", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.render(w, r, http.StatusOK, "songs.html", page{Title: "Kappaleet", Data: list})
|
|
}
|
|
|
|
// --- detail ---
|
|
|
|
type songDetail struct {
|
|
songSummary
|
|
Description string
|
|
SourceURL *string
|
|
Reviews []*review // nil when the reveal rule is withholding them
|
|
ViewerReview *review
|
|
CanReview bool
|
|
CanEdit bool // submitter, and the song is unlocked
|
|
Genres []genre
|
|
}
|
|
|
|
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
|
|
|
|
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
|
|
var d songDetail
|
|
err := a.pool.QueryRow(ctx, `select`+songColumns+`, coalesce(s.description, ''), s.source_url
|
|
from songs s join users u on u.id = s.submitted_by
|
|
where s.id = $2`, viewerID, songID).
|
|
Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt,
|
|
&d.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed,
|
|
&d.Description, &d.SourceURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.Genres = genres
|
|
d.CanReview = !d.Own && !d.Reviewed
|
|
d.CanEdit = d.Own && d.ReviewCount == 0
|
|
|
|
if d.Reviewed {
|
|
d.ViewerReview, err = a.viewerReview(ctx, songID, viewerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// The query only runs when the song is revealed: hidden reviews are never fetched, let alone
|
|
// sent and hidden with CSS.
|
|
if d.Revealed() {
|
|
d.Reviews, err = a.reviewsFor(ctx, songID, viewerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
} else if err != nil {
|
|
slog.Error("song", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
|
|
}
|
|
|
|
// --- edit and delete ---
|
|
|
|
// The submitter may change the four text fields while the song is unlocked. Once people have
|
|
// reviewed it, the thing they reviewed stops changing under them.
|
|
func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
genre := r.FormValue("genre")
|
|
if !validGenre(genre) {
|
|
http.Error(w, "tuntematon genre", http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
title, artist := clean(r.FormValue("title"), maxTitle), clean(r.FormValue("artist"), maxArtist)
|
|
if title == "" || artist == "" {
|
|
a.flash(w, "Nimi ja esittäjä ovat pakollisia.")
|
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
tag, err := a.pool.Exec(r.Context(), `
|
|
update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '')
|
|
where id = $1 and submitted_by = $2
|
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
|
id, memberFrom(r.Context()).ID, title, artist, genre,
|
|
clean(r.FormValue("description"), maxDescription))
|
|
if err != nil {
|
|
slog.Error("edit song", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.")
|
|
} else {
|
|
a.flash(w, "Tiedot tallennettu.")
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
|
}
|
|
|
|
// The row and the file go together, always.
|
|
func (a *app) deleteSong(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 and submitted_by = $2
|
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
|
id, memberFrom(r.Context()).ID)
|
|
if err != nil {
|
|
slog.Error("delete song", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.")
|
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
|
return
|
|
}
|
|
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.
|
|
// Parsing the id as an integer is the traversal check.
|
|
func (a *app) audio(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var name string
|
|
err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.NotFound(w, r)
|
|
return
|
|
} else if err != nil {
|
|
slog.Error("audio lookup", "ctx", "songs", "error", err, "song", id)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(a.audioPath(id))
|
|
if err != nil {
|
|
slog.Error("audio open", "ctx", "songs", "error", err, "song", id)
|
|
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", "audio/ogg")
|
|
// ServeContent handles 206, 416 and If-Range correctly, which hand-rolled Range parsing does not.
|
|
http.ServeContent(w, r, name, info.ModTime(), f)
|
|
}
|