{{.Body}}
+ + {{if .Open}} + + {{end}} +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
Palautteet{{if .Data.OpenCount}} {{.Data.OpenCount}} avointa{{end}}
| Kappale | Lähettäjä | Arvostelut | Julkaistu | |
|---|---|---|---|---|
| {{.Title}} — {{.Artist}} | +{{.Submitter}} | +{{.Reviews}} | +{{fidate .CreatedAt}} | ++ Kuuntele + + | +
| Ei kappaleita. | ||||
{{.Body}}
+ + {{if .Open}} + + {{end}} +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ä + TilastotLiittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}
+Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.
+Ei vielä yhtään kappaletta.
+ {{end}} +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}} +{{.Body}}
+Ei vielä tarpeeksi arvosteluja.
+ {{end}} +Ei vielä tarpeeksi arvosteluja.
+ {{end}} +Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua. + Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.
+ +