The app is about operating something — playing a track and setting a level on it — but every screen looked like a form. One metaphor now does three jobs. - Reviewing: a vertical fader beside the text, so the two things you do at once stop being a screen apart. Native range input, so keyboard, focus and form submission are unchanged; on mobile it lies down and the ticks reverse - The reveal: everyone's scores as a row of channels. The silhouette of that row is the spread, which the stats page can only tell you as a number - Profiles: given versus received as two faders, the one comparison that says something about a person The player is now a transport: play/pause, a range input for seeking so arrow keys come free, and a stereo level meter driven by a real AnalyserNode. It is progressive enhancement — the page ships native audio controls and the script takes over, so no JS means the browser's own player. The meter is dark until audio actually plays and stops when it does; reduced motion skips it entirely. Also: hidden scores are hatched rather than blank, the nav carries the queue count, "Seuraava jonossa" keeps the loop going after a review, leaderboards gained level bars and a range bar where divisive is the point, durations read 3:54, both lists can get back to the start, and the admin invite table lists unused codes instead of silently truncating at 50. Slogan restored from the original app, three decades on.
194 lines
5.4 KiB
Go
194 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type adminInvite struct {
|
|
ID int64
|
|
Code string
|
|
IsValid bool
|
|
CreatedAt time.Time
|
|
Link string
|
|
}
|
|
|
|
type adminMember struct {
|
|
ID int64
|
|
Name string
|
|
Email string
|
|
Banned bool
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type dashboard struct {
|
|
Invites []adminInvite
|
|
SpentCount int
|
|
Members []adminMember
|
|
Songs []adminSong
|
|
OpenCount int
|
|
}
|
|
|
|
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|
var d dashboard
|
|
|
|
// Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating
|
|
// a list silently reads as "that's all of them".
|
|
if err := a.pool.QueryRow(r.Context(),
|
|
`select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
rows, err := a.pool.Query(r.Context(),
|
|
`select id, code, is_valid, created_at from invites where is_valid order by created_at desc`)
|
|
if err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
for rows.Next() {
|
|
var i adminInvite
|
|
if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
i.Link = a.inviteLink(i.Code)
|
|
d.Invites = append(d.Invites, i)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
|
|
rows, err = a.pool.Query(r.Context(),
|
|
`select id, name, email, banned, created_at from users order by created_at`)
|
|
if err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var m adminMember
|
|
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
d.Members = append(d.Members, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
adminError(w, "users", err)
|
|
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})
|
|
}
|
|
|
|
// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining.
|
|
func inviteCode() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// The link is what actually gets sent to someone: the register form reads ?code= and prefills it,
|
|
// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path,
|
|
// which is enough locally.
|
|
func (a *app) inviteLink(code string) string {
|
|
return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code)
|
|
}
|
|
|
|
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
|
|
code := inviteCode()
|
|
if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
|
|
adminError(w, "invites", err)
|
|
return
|
|
}
|
|
slog.Info("invite minted", "ctx", "invites")
|
|
// The dashboard lists it as a clickable link immediately below, newest first, so the flash
|
|
// doesn't repeat the URL as unclickable text.
|
|
a.flash(w, "Uusi kutsulinkki luotu.")
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login
|
|
// would leave a banned member browsing until their session expired.
|
|
func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
var banned bool
|
|
err = a.pool.QueryRow(r.Context(),
|
|
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
|
if err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
if banned {
|
|
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
|
adminError(w, "users", err)
|
|
return
|
|
}
|
|
a.flash(w, "Jäsen estetty.")
|
|
} else {
|
|
a.flash(w, "Esto poistettu.")
|
|
}
|
|
slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned)
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
// The admin reset is the only password recovery there is, so it also drops the member's sessions.
|
|
func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
password := r.FormValue("password")
|
|
if password == "" {
|
|
a.flash(w, "Salasana on pakollinen.")
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
adminError(w, "auth", err)
|
|
return
|
|
}
|
|
if _, err := a.pool.Exec(r.Context(),
|
|
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
|
|
adminError(w, "auth", err)
|
|
return
|
|
}
|
|
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
|
adminError(w, "auth", err)
|
|
return
|
|
}
|
|
slog.Info("password reset by admin", "ctx", "auth", "user", id)
|
|
a.flash(w, "Salasana vaihdettu.")
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
func adminError(w http.ResponseWriter, ctx string, err error) {
|
|
slog.Error("admin", "ctx", ctx, "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
}
|