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.
219 lines
6.7 KiB
Go
219 lines
6.7 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/url"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Everything here shells out with exec.CommandContext and an argument list — never a shell string.
|
||
|
||
type probeResult struct {
|
||
Title string
|
||
Artist string
|
||
Duration time.Duration
|
||
}
|
||
|
||
type ffprobeOutput struct {
|
||
Format struct {
|
||
Duration string `json:"duration"`
|
||
Tags map[string]string `json:"tags"`
|
||
} `json:"format"`
|
||
Streams []struct {
|
||
CodecType string `json:"codec_type"`
|
||
Tags map[string]string `json:"tags"`
|
||
} `json:"streams"`
|
||
}
|
||
|
||
// probe reads duration and whatever title/artist tags the container carries. Tag keys vary in case
|
||
// by container (title, TITLE, Title), so the map is lowercased before anything is read from it.
|
||
func probe(ctx context.Context, path string) (probeResult, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
|
||
out, err := exec.CommandContext(ctx, "ffprobe",
|
||
"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path).Output()
|
||
if err != nil {
|
||
return probeResult{}, err
|
||
}
|
||
var parsed ffprobeOutput
|
||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||
return probeResult{}, err
|
||
}
|
||
|
||
tags := map[string]string{}
|
||
for _, stream := range parsed.Streams {
|
||
if stream.CodecType != "audio" {
|
||
continue
|
||
}
|
||
for k, v := range stream.Tags {
|
||
tags[strings.ToLower(k)] = v
|
||
}
|
||
}
|
||
// Container tags win over stream tags when both exist.
|
||
for k, v := range parsed.Format.Tags {
|
||
tags[strings.ToLower(k)] = v
|
||
}
|
||
|
||
var res probeResult
|
||
res.Title = clean(tags["title"], 100)
|
||
res.Artist = clean(firstOf(tags, "artist", "album_artist"), 100)
|
||
if secs, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil {
|
||
res.Duration = time.Duration(secs * float64(time.Second))
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
func firstOf(m map[string]string, keys ...string) string {
|
||
for _, k := range keys {
|
||
if v := strings.TrimSpace(m[k]); v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Tag text is attacker-controlled and arrives inside an uploaded file. html/template escapes on
|
||
// render, but a title with an embedded newline wrecks every list layout it appears in.
|
||
func clean(s string, max int) string {
|
||
s = strings.Map(func(r rune) rune {
|
||
if r == '\n' || r == '\r' || r == '\t' {
|
||
return ' '
|
||
}
|
||
if r < 0x20 || r == 0x7f {
|
||
return -1
|
||
}
|
||
return r
|
||
}, s)
|
||
s = strings.TrimSpace(strings.Join(strings.Fields(s), " "))
|
||
if r := []rune(s); len(r) > max {
|
||
s = strings.TrimSpace(string(r[:max]))
|
||
}
|
||
return s
|
||
}
|
||
|
||
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
|
||
// argument list — and it never goes through a shell.
|
||
var allowedHosts = map[string]bool{
|
||
"youtube.com": true, "www.youtube.com": true, "m.youtube.com": true,
|
||
"youtu.be": true, "www.youtu.be": true, "music.youtube.com": true,
|
||
}
|
||
|
||
func allowedYouTubeURL(raw string) (string, bool) {
|
||
u, err := url.Parse(strings.TrimSpace(raw))
|
||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||
return "", false
|
||
}
|
||
if !allowedHosts[strings.ToLower(u.Hostname())] {
|
||
return "", false
|
||
}
|
||
return u.String(), true
|
||
}
|
||
|
||
type ytOutput struct {
|
||
Title string `json:"title"`
|
||
Track string `json:"track"`
|
||
Artist string `json:"artist"`
|
||
Creator string `json:"creator"`
|
||
Uploader string `json:"uploader"`
|
||
Duration float64 `json:"duration"`
|
||
}
|
||
|
||
// youtubeMeta asks yt-dlp for metadata only — no download. A 1–3 s network call, so the handler
|
||
// gives it 15 s and renders blank fields on timeout rather than failing the submission.
|
||
func youtubeMeta(ctx context.Context, url string) (probeResult, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||
defer cancel()
|
||
|
||
out, err := exec.CommandContext(ctx, "yt-dlp", "-J", "--no-playlist", "--no-warnings", url).Output()
|
||
if err != nil {
|
||
return probeResult{}, err
|
||
}
|
||
return parseYouTubeMeta(out)
|
||
}
|
||
|
||
// track/artist exist only for Topic channels, YouTube Music entries and videos with a "Music in
|
||
// this video" panel. An ordinary upload gives a title and an uploader and nothing else — and if a
|
||
// field resolves to empty it stays empty, because a blank field prompts the submitter while a
|
||
// plausible "Unknown" does not.
|
||
func parseYouTubeMeta(jsonBytes []byte) (probeResult, error) {
|
||
var y ytOutput
|
||
if err := json.Unmarshal(jsonBytes, &y); err != nil {
|
||
return probeResult{}, err
|
||
}
|
||
title := y.Track
|
||
if title == "" {
|
||
title = y.Title
|
||
}
|
||
artist := firstOf(map[string]string{
|
||
"artist": y.Artist, "creator": y.Creator, "uploader": y.Uploader,
|
||
}, "artist", "creator", "uploader")
|
||
return probeResult{
|
||
Title: clean(title, 100),
|
||
Artist: clean(artist, 100),
|
||
Duration: time.Duration(y.Duration * float64(time.Second)),
|
||
}, nil
|
||
}
|
||
|
||
// download fetches the best audio-only stream. The extension is whatever YouTube served, so the
|
||
// caller globs for it — ffmpeg does not care which container it gets.
|
||
func downloadYouTube(ctx context.Context, url, outTemplate string) (string, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||
defer cancel()
|
||
|
||
cmd := exec.CommandContext(ctx, "yt-dlp",
|
||
"-f", "bestaudio", "--no-playlist", "--max-filesize", "100M",
|
||
"--no-warnings", "-o", outTemplate, url)
|
||
var stderr strings.Builder
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
return tail(stderr.String(), 400), err
|
||
}
|
||
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".
|
||
func convertToOpus(ctx context.Context, in, out string) (string, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||
defer cancel()
|
||
|
||
cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y",
|
||
"-i", in, "-c:a", "libopus", "-b:a", "96k", "-ac", "2", "-vn", out)
|
||
var stderr strings.Builder
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
return tail(stderr.String(), 400), err
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
func tail(s string, n int) string {
|
||
s = strings.TrimSpace(s)
|
||
lines := strings.Split(s, "\n")
|
||
if len(lines) > 3 {
|
||
lines = lines[len(lines)-3:]
|
||
}
|
||
s = strings.TrimSpace(strings.Join(lines, " "))
|
||
if r := []rune(s); len(r) > n {
|
||
s = string(r[len(r)-n:])
|
||
}
|
||
return s
|
||
}
|