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 } os.Remove(a.audioPath(id)) slog.Info("song deleted", "ctx", "songs", "song", id) a.flash(w, "Kappale poistettu.") http.Redirect(w, r, "/songs", http.StatusSeeOther) } // --- 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) }