Release 2026.07.31-1

First release of the Go rewrite: invite-only membership, the submission
pipeline for uploads and YouTube, the queue and review loop with the reveal
rule, stats, profiles, avatars and palaute.
This commit is contained in:
Esa Kataja
2026-07-31 23:39:03 +03:00
52 changed files with 6367 additions and 21 deletions
+11
View File
@@ -0,0 +1,11 @@
# Copy to .env and edit. Neither password has a default.
POSTGRES_PASSWORD=
ADMIN_USER=admin
ADMIN_PASSWORD=
# Set to false only for local development over plain HTTP.
SECURE_COOKIES=true
# Public address of the member site. Used to build pasteable invite links in the admin panel.
# Unset falls back to a relative link, which is fine locally.
PUBLIC_URL=https://levyraati.example.com
+1
View File
@@ -1,4 +1,5 @@
/levyraati
/levyraati26-go
/storage/
/pgdata/
.env
+17
View File
@@ -0,0 +1,17 @@
FROM golang:1.26-alpine AS build
# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=…
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati .
FROM alpine:3.24
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates
COPY --from=build /levyraati /usr/local/bin/levyraati
ENV STORAGE_DIR=/storage
EXPOSE 8080
ENTRYPOINT ["levyraati"]
+32 -7
View File
@@ -16,15 +16,30 @@ Invite-only, no public registration. Built for about ten friends.
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish |
| [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema |
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only |
| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff |
| [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
## Branches
## Branches and releases
- **`main`** — released code only. Every commit on it is something that ran in production, or is
meant to. Tagged at each release.
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and
reaches `main` by merge at release time.
Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1.
The version is injected at build time, so no file in the repo carries it:
```sh
git switch main && git merge --no-ff dev
git tag 2026.07.31-1
VERSION=$(git describe --tags --exact-match) docker compose build app
docker compose up -d app
```
A plain `go build` reports `dev`, which is the honest answer for a local binary. The running
version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is
actually deployed" is answerable without an SSH session.
The app never sends email — there is no verification, no password reset link, and no notifications.
Members have an address because it is their login and because mail is a planned feature.
@@ -49,24 +64,33 @@ creates no users: log into the admin panel and mint an invite.
| Variable | Default | Notes |
|---|---|---|
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
| `ADMIN_USER` | `admin` | Admin panel username |
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
| `ADDR` | `:8080` | Member-facing listener |
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback |
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
### Local development
```sh
docker compose up -d postgres
export DATABASE_URL=postgres://levyraati:levyraati@localhost:5432/levyraati
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
go run .
```
Requires Go 1.22+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the
migration test drops and recreates the `public` schema, so never point it at anything you care about.
```sh
go test ./...
```
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
template changes. `go build && ./levyraati` is the loop.
@@ -91,8 +115,8 @@ endpoint and no recovery key — the credentials are the environment.
### yt-dlp goes stale
yt-dlp needs regular updates to keep working against YouTube. It is installed with
`pip install -U yt-dlp` at image build time, so rebuilding is how you update it:
yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community
repository, whose active branch tracks upstream closely, so rebuilding is how you update it:
```sh
docker compose build --no-cache app && docker compose up -d app
@@ -114,7 +138,8 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app
Two paths hold everything:
- `./pgdata` — the database
- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data`
- `./storage` — audio files and avatars
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
+193
View File
@@ -0,0 +1,193 @@
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)
}
+319
View File
@@ -0,0 +1,319 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "session"
idleShort = 24 * time.Hour
idleRemember = 30 * 24 * time.Hour
// Skip the extending UPDATE unless the session has aged at least this much, so a sliding
// session is not a write on every request.
extendAfter = time.Minute
)
type member struct {
ID int64
Name string
Email string
Avatar *string
Banned bool
CreatedAt time.Time
}
// Initials for the avatar circle: no default image on disk, no identicon generator.
func (m *member) Initials() string {
out := ""
for _, f := range strings.Fields(m.Name) {
out += strings.ToUpper(string([]rune(f)[0]))
if len(out) == 2 {
break
}
}
return out
}
type ctxKey int
const memberKey ctxKey = 0
func memberFrom(ctx context.Context) *member {
m, _ := ctx.Value(memberKey).(*member)
return m
}
func token() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// A bearer header as well as the cookie, so something that isn't a browser can authenticate
// without a second concept. SameSite=Lax still guards the cookie path, and a cross-origin page
// cannot set Authorization without CORS, which is not enabled.
func sessionToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
if c, err := r.Cookie(sessionCookie); err == nil {
return c.Value
}
return ""
}
func (a *app) startSession(ctx context.Context, userID int64, remember bool) (string, time.Time, error) {
ttl := idleShort
if remember {
ttl = idleRemember
}
tok := token()
expires := time.Now().Add(ttl)
_, err := a.pool.Exec(ctx,
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
tok, userID, ttl, expires)
return tok, expires, err
}
func (a *app) setSessionCookie(w http.ResponseWriter, tok string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/", Expires: expires,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
}
// session loads the member behind a token, extends the idle timeout, and treats a banned or
// expired session as no session at all.
func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
tok := sessionToken(r)
if tok == "" {
return nil
}
var (
m member
expires time.Time
ttl time.Duration
ttlMicros int64
)
err := a.pool.QueryRow(r.Context(), `
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
from sessions s join users u on u.id = s.user_id
where s.token = $1 and s.expires_at > now()`, tok).
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
slog.Error("session lookup", "ctx", "auth", "error", err)
}
return nil
}
if m.Banned {
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
return nil
}
ttl = time.Duration(ttlMicros) * time.Microsecond
if time.Until(expires) < ttl-extendAfter {
newExpiry := time.Now().Add(ttl)
if _, err := a.pool.Exec(r.Context(),
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
a.setSessionCookie(w, tok, newExpiry)
}
}
return &m
}
func (a *app) withMember(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if m := a.session(w, r); m != nil {
r = r.WithContext(context.WithValue(r.Context(), memberKey, m))
}
next.ServeHTTP(w, r)
})
}
func (a *app) requireMember(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if memberFrom(r.Context()) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// --- pages ---
type authForm struct {
Name, Email, Code string
Errors map[string]string
}
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}})
}
func (a *app) login(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
form := authForm{Email: email, Errors: map[string]string{}}
if a.logins.locked(email) {
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return
}
var (
id int64
hash string
banned bool
)
err := a.pool.QueryRow(r.Context(),
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
a.logins.fail(email)
// One message for both cases: a distinct "no such account" tells anyone who asks which
// addresses are members.
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return
}
if banned {
form.Errors["form"] = "Tunnus on estetty."
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return
}
tok, expires, err := a.startSession(r.Context(), id, r.FormValue("remember") != "")
if err != nil {
slog.Error("start session", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.logins.succeed(email)
a.setSessionCookie(w, tok, expires)
slog.Info("login", "ctx", "auth", "user", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "register.html",
page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}})
}
// register spends the invite only when the account is actually created: both statements are in one
// transaction, so a failed signup leaves the code usable.
func (a *app) register(w http.ResponseWriter, r *http.Request) {
form := authForm{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(strings.ToLower(r.FormValue("email"))),
Code: strings.TrimSpace(r.FormValue("code")),
Errors: map[string]string{},
}
password := r.FormValue("password")
if form.Name == "" || len([]rune(form.Name)) > 50 {
form.Errors["name"] = "Nimi on pakollinen, enintään 50 merkkiä."
}
if !strings.Contains(form.Email, "@") {
form.Errors["email"] = "Tarkista sähköpostiosoite."
}
// ponytail: no length policy. Invite-only, ten friends, bcrypt, and the admin is the reset
// path — a minimum buys nothing here and makes dev accounts tedious.
if password == "" {
form.Errors["password"] = "Salasana on pakollinen."
}
if form.Code == "" {
form.Errors["code"] = "Kutsukoodi on pakollinen."
}
if len(form.Errors) > 0 {
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
slog.Error("hash password", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
tx, err := a.pool.Begin(r.Context())
if err != nil {
slog.Error("begin", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
var inviteID int64
err = tx.QueryRow(r.Context(),
`update invites set is_valid = false where code = $1 and is_valid returning id`,
form.Code).Scan(&inviteID)
if errors.Is(err, pgx.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
} else if err != nil {
slog.Error("burn invite", "ctx", "invites", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
var userID int64
err = tx.QueryRow(r.Context(),
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
form.Name, form.Email, string(hash)).Scan(&userID)
if isUnique(err) {
// Rolls back, so the invite is still valid.
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
} else if err != nil {
slog.Error("create user", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
slog.Error("commit registration", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("registered", "ctx", "auth", "user", userID, "invite", inviteID)
tok, expires, err := a.startSession(r.Context(), userID, false)
if err != nil {
slog.Error("start session", "ctx", "auth", "error", err)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
a.setSessionCookie(w, tok, expires)
a.flash(w, "Tervetuloa mukaan!")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func isUnique(err error) bool {
var pgErr interface{ SQLState() string }
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
}
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
func testApp(t *testing.T) *app {
t.Helper()
dbURL := os.Getenv("TEST_DATABASE_URL")
if dbURL == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
t.Fatal(err)
}
if err := migrate(ctx, pool); err != nil {
t.Fatal(err)
}
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool}
}
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func (a *app) inviteValid(t *testing.T, code string) bool {
t.Helper()
var valid bool
if err := a.pool.QueryRow(context.Background(),
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
t.Fatal(err)
}
return valid
}
// A failed registration must leave the code usable; a successful one must not.
func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
t.Fatal(err)
}
if _, err := a.pool.Exec(ctx,
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
t.Fatal(err)
}
// Taken email — the insert fails after the invite has already been marked spent in the tx.
w := post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Toinen"},
"email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("duplicate email: status = %d, want 422", w.Code)
}
if !a.inviteValid(t, "kutsu1") {
t.Fatal("failed registration spent the invite")
}
// Missing password — rejected before the invite is touched at all.
w = post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {""},
})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("short password: status = %d, want 422", w.Code)
}
if !a.inviteValid(t, "kutsu1") {
t.Fatal("rejected registration spent the invite")
}
w = post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusSeeOther {
t.Fatalf("valid registration: status = %d, want 303", w.Code)
}
if a.inviteValid(t, "kutsu1") {
t.Fatal("successful registration left the invite usable")
}
// And it cannot be used twice.
w = post(t, mux, "/register", url.Values{
"code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("reused invite: status = %d, want 422", w.Code)
}
}
// The limiter has its own unit test; this covers the wiring into the handler.
func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
a := testApp(t)
mux := a.withMember(a.memberMux())
a.seedMember(t, "[email protected]")
bad := url.Values{"email": {"[email protected]"}, "password": {"väärin"}}
for i := range loginMaxFailures {
if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
}
}
if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests {
t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code)
}
}
func (a *app) seedMember(t *testing.T, email string) int64 {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(),
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
email).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func (a *app) sessionFor(t *testing.T, token string) *member {
t.Helper()
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
return a.session(httptest.NewRecorder(), r)
}
func TestSessionIdleTimeout(t *testing.T) {
a := testApp(t)
ctx := context.Background()
id := a.seedMember(t, "[email protected]")
live, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, live); m == nil || m.ID != id {
t.Fatal("fresh session did not resolve to its member")
}
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, live); m != nil {
t.Fatal("expired session still resolved")
}
// A session used inside the window slides forward.
fresh, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, fresh); m == nil {
t.Fatal("session inside the window did not resolve")
}
var expires time.Time
if err := a.pool.QueryRow(ctx,
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
t.Fatal(err)
}
if time.Until(expires) < 23*time.Hour {
t.Fatalf("session was not extended: expires in %s", time.Until(expires))
}
}
func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
t.Fatal(err)
}
w := post(t, mux, "/register", url.Values{
"code": {"kutsu2"}, "name": {"Esa"}, "email": {"[email protected]"}, "password": {"salasana1"},
})
if w.Code != http.StatusSeeOther {
t.Fatalf("registration: status = %d, want 303", w.Code)
}
var id int64
if err := a.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
t.Fatal(err)
}
adminMux := a.adminMux()
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
t.Fatalf("ban: status = %d, want 303", w.Code)
}
var sessions int
if err := a.pool.QueryRow(ctx,
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
t.Fatal(err)
}
if sessions != 0 {
t.Fatalf("banned member kept %d sessions", sessions)
}
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
if w.Code != http.StatusForbidden {
t.Fatalf("banned login: status = %d, want 403", w.Code)
}
// Reversible: unban, and the same credentials work again.
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
t.Fatalf("unban: status = %d, want 303", w.Code)
}
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("login after unban: status = %d, want 303", w.Code)
}
}
+42
View File
@@ -0,0 +1,42 @@
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: levyraati
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: levyraati
volumes:
# Postgres 18 keeps its data in /var/lib/postgresql/<version>/docker, so the mount is the
# parent directory, not the old /var/lib/postgresql/data.
- ./pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U levyraati"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
app:
build:
context: .
args:
VERSION: ${VERSION:-dev}
environment:
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
ADDR: ":8080"
# Inside the container the admin listener must bind the container's own interface; it is not
# published below, so it stays unreachable from outside without a tunnel or the proxy.
ADMIN_ADDR: ":8081"
SECURE_COOKIES: ${SECURE_COOKIES:-true}
PUBLIC_URL: ${PUBLIC_URL:-}
volumes:
- ./storage:/storage
ports:
- "8080:8080"
- "8081:8081"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
+35 -2
View File
@@ -66,8 +66,12 @@ and `storage` was test data, so **the schema has no legacy to respect.**
18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed
is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth
tracking.
19. **yt-dlp: `pip install -U yt-dlp` at image build, rebuild monthly.** Pinning only schedules the
breakage for a moment you did not choose.
19. **yt-dlp is updated by rebuilding the image, monthly.** Pinning a version only schedules the
breakage for a moment you did not choose. Originally `pip install -U yt-dlp`; changed on
2026-07-31 to `apk add yt-dlp` once Alpine 3.24 turned out to carry the current release
(2026.07.04, four weeks old) — which drops python3 and pip from the image entirely. The apk
route inherits Alpine's packaging lag, so pip is the fallback if it ever goes stale at a bad
moment. Note that this only holds on the *active* branch: 3.21 was 16 months behind.
20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the
unbuilt-stats list ships in v1.
@@ -158,3 +162,32 @@ says so.
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
`dev`.
42. **The theme handoff is implemented as CSS custom properties, not a Tailwind config.** Its
palette, spacing, shadows, motion and component shapes are followed as written; the parts that
assumed Tailwind, Pico or cover artwork are adapted rather than dropped, and each adaptation is
listed in [theme.md](./theme.md). Oswald's phantom weight 900 resolved to 700 — loading a weight
you do not have is what made the brand render differently per platform. The custom audio player
stays deferred: `color-scheme: dark` makes the native control fit the palette, which was the
actual complaint.
41. **No password minimum; rate limit logins instead.** A length policy protects against guessing,
and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute
lockout, cleared by a correct password. The floor was rejected because typing an 8-character
password on every dev account is friction with nothing behind it — there is no public
registration to spray, and the admin is the reset path. It was also deliberately *not* made
configurable: settings like this belong in code, not in an env file that grows a line per
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
else out by trying.
43. **The JSON API is deferred entirely, not built on demand.** Decision 17 kept the contract fixed
and expected handlers to appear one at a time; in practice nothing consumes `/api` at all, so
even that trickle would be handlers with no callers, plus golden tests guarding shapes nothing
reads. The contract in [spec.md](./spec.md) stays as the design — it is what stops the shape
changing under a future client — and the first endpoint gets built the day something actually
calls it. Both surfaces being thin adapters over one data function is already true of the page
handlers, so adding the JSON side later stays a one-line-per-route job.
44. **CalVer, `YYYY.MM.DD-N`, injected at build time.** The date is the useful part: this app ships
when there is something to ship and gets rebuilt monthly for yt-dlp anyway, so a semantic
version would communicate nothing a date does not. `N` is the build number within that day,
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches
the binary through `-ldflags`, so no file in the repo has to be bumped and a local build
honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`.
+38
View File
@@ -130,6 +130,44 @@ Also here: pruning the count-based leaderboards once the queue has drained and t
---
## Review form as a mixer channel
Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**,
with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form
field and becomes the instrument the app is actually about, and the two things you do at once —
decide a number, write why — stop being stacked a screenful apart.
Notes for whoever builds it:
- A vertical `<input type="range">` is native now: `writing-mode: vertical-lr; direction: rtl` gives
bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free.
- Keep the live `<output>` — on a fader it wants to sit at the top of the track, reading like a
channel's gain display.
- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside
it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal
one, so measure before choosing.
- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are
what make it read as equipment rather than decoration.
---
## The JSON API
Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes,
pagination — and deliberately not implemented, because nothing calls it (decision 43).
When something does:
- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data
functions the pages already use, so the domain rules cannot diverge between the surfaces.
- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed
field a test failure rather than a silent break in a client you cannot update.
- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before.
- The most likely first consumer is a native client (see above), and the endpoints it needs are
login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one.
---
## Filters on the browse list
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
+39 -12
View File
@@ -49,7 +49,12 @@ and a cross-origin page cannot set `Authorization` without CORS, which is not en
### 1.2 Security behaviours
- Changing your own password requires the current password.
- Passwords are bcrypt.
- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration,
ten members, and an admin-only reset path leave a length policy nothing to protect.
- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock
*that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email
rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in
memory, so a restart clears it. Registration is not limited — an invite code is 128 bits.
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
@@ -91,10 +96,20 @@ zero reviews, it is editable again.
### 2.2 Genres
Fixed list, `text` column, validated app-side:
Fixed list, `text` column, validated app-side. The **stored value is the English code** and the
Finnish label is display only — the same split the statuses use, so rewording a genre never touches
a song row:
Rock, Metal, Punk, Blues, Jazz, Electronic, Hip Hop, Pop, Folk / Country, Classical, Soundtrack,
Experimental, Finnish, Just Plain Weird, Other
| Code | Label | | Code | Label |
|---|---|---|---|---|
| Rock | Rock | | Soundtrack | Elokuvamusiikki |
| Metal | Metal | | Experimental | Kokeellinen |
| Punk | Punk | | Classical | Klassinen |
| Blues | Blues | | Electronic | Elektroninen |
| Jazz | Jazz | | Hip Hop | Hip hop |
| Pop | Pop | | Finnish | Kotimainen |
| Folk / Country | Folk / Country | | Just Plain Weird | Ihan outoa |
| | | | Other | Muu |
---
@@ -196,9 +211,17 @@ States: `queued` → (`downloading`, URL only) → `converting` → `ready` | `f
Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the
introduction rather than watching a spinner.
The button reads *Muunnetaan…* and is disabled until `status = 'ready'`, when it becomes
**Julkaise**. Publishing is always an explicit click — firing it automatically would race the
submitter mid-sentence.
**One button, at the bottom of the form: Julkaise**, disabled until `status = 'ready'`. Publishing
is always an explicit click — firing it automatically would race the submitter mid-sentence.
There is no separate save button: two buttons made it unclear which one committed the text.
- The metadata form **autosaves**`hx-post` on `input changed delay:1.2s` and on `change`,
answering with a quiet "Tallennettu 21.37" line and nothing else.
- Julkaise lives outside the form and is bound to it with the HTML `form=` attribute, so pressing
it submits the metadata *and* publishes in one request. The last keystrokes therefore arrive with
the click even if the autosave never fired — which is also what makes the page work with no JS at
all.
The live part is HTMX polling a fragment:
@@ -206,7 +229,7 @@ The live part is HTMX polling a fragment:
<!-- {{define "submission-status"}} — included by the page, returned alone by the poll -->
<div hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML">
<p>{{.Label}}</p>
<button {{if not .Ready}}disabled{{end}}>Julkaise</button>
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
</div>
```
@@ -265,7 +288,9 @@ but its submitter, so a broken pipeline has no other way of announcing itself.
### 4.7 Operational notes
- **yt-dlp rots.** `pip install -U yt-dlp` at image build; rebuild monthly.
- **yt-dlp rots.** Installed with `apk add yt-dlp` from Alpine's active branch, which tracks
upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment,
`pip install -U yt-dlp` is the fallback — at the cost of python3 and pip in the image.
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
decision is deliberate rather than accidental.
@@ -484,8 +509,10 @@ file.
## 8. API contract
Fixed before implementation, because the shape is the expensive thing to change once a client is
installed somewhere you cannot update.
**Not built.** Nothing consumes `/api` — the browser talks HTML to the page surface — so this
section is a design, not a description of running code (decision 43). It stays here because the
shape is the expensive thing to change once a client is installed somewhere you cannot update, and
the first endpoint is one line over a data function that already exists.
**Conventions**
@@ -702,4 +729,4 @@ panel, so the admin surface comes first — before a single member can exist.
this step.**
5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works.
6. **Stats, profiles, avatars, palaute.**
7. **API endpoints and golden tests**, once something wants them.
7. **API endpoints and golden tests** — deferred until something wants them (decision 43).
+53
View File
@@ -0,0 +1,53 @@
# Theme
Dark-only. Rock/metal club poster, not SaaS dashboard: near-black surfaces, warm bronze/amber
accents, condensed uppercase display type, one-tone-lighter surfaces instead of borders everywhere.
Restrained motion — 150 ms, one easing curve, no bounce. **There is no light theme and none is
wanted.**
The tokens themselves live in [`static/style.css`](../static/style.css) as CSS custom properties, and
that file is the source of truth. This page records the decisions behind them and the places the
implementation deliberately differs from the theme handoff it came from.
## Rules
- **Nothing outside `:root` invents a value.** No colour, spacing step, radius or duration appears in
a rule unless it is declared as a token first. Six spacing steps (432 px), one radius (4 px, plus
6 px for toasts and a pill), one duration, one curve.
- **Headings step downward in brightness with level** — h1 lightest gold, h3 the primary bronze.
- **Status colours are desaturated on purpose.** A pure red error would break the palette.
- **`color-scheme: dark`** is set on `:root`, which is what keeps the native `<audio>` element,
checkboxes, range inputs and scrollbars from rendering as white slabs.
- **One focus treatment, everywhere:** a soft gold ring via `box-shadow` on `:focus-visible` only.
Not optional — keyboard navigation is the only way through some admin tables.
- **`prefers-reduced-motion`** drops the card lift and the panel slide, and keeps the fades.
## Type
Body is a system stack; no webfont for body text. Display is **Oswald**, vendored as a variable
`.woff2` (latin subset, 21 KB) in `static/fonts/` — no CDN, matching the no-npm rule for HTMX.
The handoff asked for weight 900 in five places while loading only 400/600/700, so browsers were
synthesising a fake bold that differed per platform. **Resolved as 700 being the top weight.** The
variable font covers 400700 and nothing asks for more.
## The unreviewed state
A song the viewer has not reviewed gets a red-brown border — it is the single most important state
in the app, since it is how you see what still needs a review. It is **never carried by colour
alone**: the card also shows an `arvostelematta` badge.
## Deviations from the handoff
| Handoff said | Here | Why |
|---|---|---|
| Tailwind theme config, utility classes | CSS custom properties, semantic classes | Decisions 4 and 8 — no Tailwind, no bundler, no npm |
| Song cards are 16:9 tiles with cover artwork and a gradient scrim | Text cards, same borders, badges, hover glow and score badge | Songs have no artwork. There is no upload for one and nothing to derive it from |
| Fixed bottom audio player bar | Player inline on the song page | Nothing plays across navigation, so a persistent bar would be an empty bar on every other page |
| A styleguide page rendering every variant | Not built | The real pages cover every component; a second copy of them would drift |
| Custom audio player skin | Native `<audio controls>` | Decision 14. It is keyboard-operable, screen-reader labelled and media-key aware for free, and replacing it later is one template partial — see [later.md](./later.md) |
| Nav dropdown for the user block, hamburger with animated bars | User block is a plain row; mobile menu is `<details>` | No JS for either. The app has no dropdown-worthy menu yet: logout is one button |
Everything else — the palette, spacing, radii, shadows, motion, the 1450 px content width, the
420 px auth column, badges, review cards, the admin section cards, bottom-right toasts — follows the
handoff as written.
+14
View File
@@ -0,0 +1,14 @@
module git.kessinen.com/kessinen/levyraati26-go
go 1.24
require github.com/jackc/pgx/v5 v5.7.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"context"
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
var version = "dev"
type config struct {
databaseURL string
adminUser string
adminPass string
addr string
adminAddr string
storageDir string
secureCookies bool
// Public address of the member site, so admin-side invite links are pasteable. The admin
// listener's own Host is a tunnel, not the site, so it cannot be derived.
publicURL string
}
func loadConfig() config {
c := config{
databaseURL: os.Getenv("DATABASE_URL"),
adminUser: env("ADMIN_USER", "admin"),
adminPass: os.Getenv("ADMIN_PASSWORD"),
addr: env("ADDR", ":8080"),
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
storageDir: env("STORAGE_DIR", "./storage"),
secureCookies: env("SECURE_COOKIES", "true") != "false",
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
}
if c.databaseURL == "" {
fatal("DATABASE_URL is not set")
}
// An admin panel that silently opens is worse than one that won't boot.
if c.adminPass == "" {
fatal("ADMIN_PASSWORD is not set")
}
return c
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func fatal(msg string, args ...any) {
slog.Error(msg, args...)
os.Exit(1)
}
type app struct {
cfg config
pool *pgxpool.Pool
logins limiter // zero value is ready to use
}
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig()
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.databaseURL)
if err != nil {
fatal("database connect", "error", err)
}
defer pool.Close()
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
for i := 0; ; i++ {
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
err = pool.Ping(pingCtx)
cancel()
if err == nil {
break
}
if i == 10 {
fatal("database unreachable", "error", err)
}
time.Sleep(time.Second)
}
if err := migrate(ctx, pool); err != nil {
fatal("migrations", "error", err)
}
if err := sweep(ctx, pool); err != nil {
fatal("startup sweep", "error", err)
}
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)
}
}
a := &app{cfg: cfg, pool: pool}
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
// or the reverse proxy. A separate binary would need its own deploy and would race the
// startup migrations; it buys nothing else.
go func() {
slog.Info("admin listening", "ctx", "startup", "addr", cfg.adminAddr)
err := http.ListenAndServe(cfg.adminAddr, a.requireAdmin(a.adminMux()))
fatal("admin listener", "error", err)
}()
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
}
func (a *app) memberMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := a.pool.Ping(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
// The version answers "what is actually running out there" without an SSH session.
fmt.Fprintf(w, "ok %s\n", version)
})
mux.HandleFunc("GET /login", a.loginPage)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("GET /register", a.registerPage)
mux.HandleFunc("POST /register", a.register)
mux.HandleFunc("POST /logout", a.logout)
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
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))
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
return mux
}
func (a *app) adminMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /admin", a.adminDashboard)
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)
})
return mux
}
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
// (close the browser). Add a cookie session if a second admin ever needs one.
//
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the
// env file next to the Postgres password already. The constant-time compare is the part that matters.
func (a *app) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1
if !ok || !userOK || !passOK {
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestRequireAdmin(t *testing.T) {
a := &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}}
h := a.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
for _, tc := range []struct {
name, user, pass string
auth bool
want int
}{
{name: "no credentials", want: http.StatusUnauthorized},
{name: "wrong password", user: "admin", pass: "hunter2", auth: true, want: http.StatusUnauthorized},
{name: "wrong user", user: "root", pass: "s3cret", auth: true, want: http.StatusUnauthorized},
{name: "correct", user: "admin", pass: "s3cret", auth: true, want: http.StatusTeapot},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "/admin", nil)
if tc.auth {
r.SetBasicAuth(tc.user, tc.pass)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("status = %d, want %d", w.Code, tc.want)
}
})
}
}
// Set TEST_DATABASE_URL to run this against a throwaway database.
func TestMigrateIsIdempotent(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
t.Fatal(err)
}
for i := range 2 {
if err := migrate(ctx, pool); err != nil {
t.Fatalf("migrate run %d: %v", i+1, err)
}
}
if err := sweep(ctx, pool); err != nil {
t.Fatalf("sweep: %v", err)
}
var n int
if err := pool.QueryRow(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("applied migrations = %d, want 1", n)
}
}
+218
View File
@@ -0,0 +1,218 @@
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 13 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
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"os"
"testing"
"time"
)
func TestAllowedYouTubeURL(t *testing.T) {
for _, ok := range []string{
"https://www.youtube.com/watch?v=XnfMBo4IQ-g",
"https://youtu.be/XnfMBo4IQ-g",
"https://music.youtube.com/watch?v=XnfMBo4IQ-g",
"http://m.youtube.com/watch?v=XnfMBo4IQ-g",
} {
if _, allowed := allowedYouTubeURL(ok); !allowed {
t.Errorf("%q was rejected", ok)
}
}
for _, bad := range []string{
"",
"not a url",
"file:///etc/passwd",
"https://evil.example.com/watch?v=x",
// The allowlist is on the host, so a lookalike path or userinfo must not pass.
"https://evil.example.com/www.youtube.com/watch?v=x",
"https://youtube.com.evil.example.com/watch?v=x",
"https://[email protected]/",
"-oExecuteMe",
} {
if _, allowed := allowedYouTubeURL(bad); allowed {
t.Errorf("%q was allowed", bad)
}
}
}
// A real dump of an ordinary upload: no track, no artist, no creator, no album — just a title with
// double spaces and an uploader. This is why prefill must never invent an "Unknown".
func TestYouTubeMetaFromOrdinaryUpload(t *testing.T) {
raw, err := os.ReadFile("testdata/ytdlp-noose.json")
if err != nil {
t.Skipf("fixture missing: %v", err)
}
meta, err := parseYouTubeMeta(raw)
if err != nil {
t.Fatal(err)
}
if meta.Title != "Sentenced Noose" {
t.Errorf("title = %q, want the cleaned video title", meta.Title)
}
if meta.Artist != "Heikki Rokkonen" {
t.Errorf("artist = %q, want the uploader as the last fallback", meta.Artist)
}
if meta.Duration != 245*time.Second {
t.Errorf("duration = %v, want 4m5s", meta.Duration)
}
}
func TestYouTubeMetaPrefersMusicFields(t *testing.T) {
meta, err := parseYouTubeMeta([]byte(`{
"title": "Sentenced - Noose (Official Video)",
"track": "Noose", "artist": "Sentenced", "creator": "ignored",
"uploader": "SentencedVEVO", "duration": 245.0}`))
if err != nil {
t.Fatal(err)
}
if meta.Title != "Noose" || meta.Artist != "Sentenced" {
t.Errorf("got %q by %q, want the track/artist fields to win", meta.Title, meta.Artist)
}
}
// Empty stays empty: a blank field prompts the submitter, a plausible "Unknown" does not.
func TestYouTubeMetaLeavesBlanksBlank(t *testing.T) {
meta, err := parseYouTubeMeta([]byte(`{"duration": 10.0}`))
if err != nil {
t.Fatal(err)
}
if meta.Title != "" || meta.Artist != "" {
t.Errorf("got %q by %q, want both empty", meta.Title, meta.Artist)
}
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"embed"
"fmt"
"log/slog"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own
// transaction. Applied names are the record — a file that changes after it ran is not re-applied.
func migrate(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `create table if not exists schema_migrations (
name text primary key,
applied_at timestamptz not null default now()
)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied := map[string]bool{}
rows, err := pool.Query(ctx, `select name from schema_migrations`)
if err != nil {
return fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return err
}
applied[name] = true
}
if err := rows.Err(); err != nil {
return err
}
rows.Close()
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
if applied[name] {
continue
}
sql, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return err
}
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, string(sql)); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("migration %s: %w", name, err)
}
if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil {
tx.Rollback(ctx)
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("migration %s: %w", name, err)
}
slog.Info("migration applied", "ctx", "startup", "name", name)
}
return nil
}
// sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies
// with the process, so without this those rows say "converting" forever.
func sweep(ctx context.Context, pool *pgxpool.Pool) error {
tag, err := pool.Exec(ctx, `update submissions
set status = 'failed', status_msg = 'interrupted by restart'
where status in ('queued', 'downloading', 'converting')`)
if err != nil {
return err
}
if n := tag.RowsAffected(); n > 0 {
slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n)
}
// ponytail: temp files of swept submissions are unlinked with the row in step 3, once the
// pipeline exists and there is something to unlink.
if _, err := pool.Exec(ctx,
`delete from submissions where created_at < now() - interval '7 days'`); err != nil {
return err
}
if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil {
return err
}
var failed int
err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed)
if err != nil {
return err
}
if failed > 0 {
// A failed submission is invisible to everyone but its submitter, so a broken pipeline
// has no other way of announcing itself.
slog.Warn("failed submissions present", "ctx", "startup", "count", failed)
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
create table users (
id bigserial primary key,
name text not null,
email text not null unique,
password_hash text not null,
avatar text,
banned boolean not null default false,
created_at timestamptz not null default now()
);
create table sessions (
token text primary key,
user_id bigint not null references users (id) on delete cascade,
idle_ttl interval not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create index on sessions (user_id);
create table invites (
id bigserial primary key,
code text not null unique,
is_valid boolean not null default true,
created_at timestamptz not null default now()
);
create table songs (
id bigserial primary key,
title text not null,
artist text not null,
genre text not null,
description text,
audio_file text not null,
duration_seconds integer not null,
source_url text,
submitted_by bigint not null references users (id),
created_at timestamptz not null default now()
);
create index on songs (created_at desc);
create table submissions (
id bigserial primary key,
user_id bigint not null references users (id) on delete cascade,
status text not null default 'queued',
status_msg text,
source_url text,
tmp_path text,
title text,
artist text,
genre text,
description text,
created_at timestamptz not null default now(),
constraint submissions_status check (
status in ('queued', 'downloading', 'converting', 'ready', 'failed')
)
);
-- The submission quota (5 per rolling 24h, failures excluded) reads this.
create index on submissions (user_id, created_at desc);
create table reviews (
id bigserial primary key,
song_id bigint not null references songs (id) on delete cascade,
reviewer_id bigint not null references users (id),
score integer not null check (score between 1 and 100),
text text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (song_id, reviewer_id)
);
create index on reviews (song_id);
-- The queue asks "songs this member has not reviewed" — that lookup is by reviewer.
create index on reviews (reviewer_id, song_id);
create table reports (
id bigserial primary key,
user_id bigint not null references users (id) on delete cascade,
body text not null,
page text,
user_agent text,
resolved_at timestamptz,
created_at timestamptz not null default now()
);
+232
View File
@@ -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)
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"sync"
"time"
)
// Login attempts are limited per email address, which is the thing under attack — and the only key
// available without parsing X-Forwarded-For and maintaining a trusted-proxy list.
//
// ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an
// attacker can mount. A shared store is the upgrade if this ever runs as more than one process.
const (
loginMaxFailures = 10
loginWindow = 15 * time.Minute
loginLockout = 15 * time.Minute
)
type attempts struct {
count int
first time.Time
until time.Time // zero unless locked
}
type limiter struct {
mu sync.Mutex
by map[string]*attempts
}
// locked reports whether the key is currently refused. It does not count as an attempt.
func (l *limiter) locked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
a := l.by[key]
return a != nil && time.Now().Before(a.until)
}
func (l *limiter) fail(key string) {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if l.by == nil {
l.by = map[string]*attempts{}
}
l.sweep(now)
a := l.by[key]
if a == nil || now.Sub(a.first) > loginWindow {
l.by[key] = &attempts{count: 1, first: now}
return
}
a.count++
if a.count >= loginMaxFailures {
a.until = now.Add(loginLockout)
}
}
// A correct password clears the record: the limit is on guessing, not on the account. Locking the
// account itself would let anyone lock its owner out by trying.
func (l *limiter) succeed(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.by, key)
}
// Called under the lock, on failures only — there is nothing to grow the map otherwise.
func (l *limiter) sweep(now time.Time) {
for k, a := range l.by {
if now.Sub(a.first) > loginWindow && now.After(a.until) {
delete(l.by, k)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"testing"
"time"
)
func TestLoginRateLimit(t *testing.T) {
var l limiter
for i := range loginMaxFailures - 1 {
l.fail("[email protected]")
if l.locked("[email protected]") {
t.Fatalf("locked after %d failures, limit is %d", i+1, loginMaxFailures)
}
}
l.fail("[email protected]")
if !l.locked("[email protected]") {
t.Fatalf("not locked after %d failures", loginMaxFailures)
}
// The limit is per email: locking one address must not lock anyone else out.
if l.locked("[email protected]") {
t.Fatal("a different address was locked too")
}
// A correct password clears it, so a member who mistypes nine times and then gets it right
// starts from zero.
l.succeed("[email protected]")
if l.locked("[email protected]") {
t.Fatal("still locked after a successful login")
}
// Failures older than the window don't accumulate.
for range loginMaxFailures - 1 {
l.fail("[email protected]")
}
l.by["[email protected]"].first = time.Now().Add(-loginWindow - time.Minute)
l.fail("[email protected]")
if l.locked("[email protected]") {
t.Fatal("failures outside the window were counted")
}
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"bytes"
"embed"
"html/template"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
)
//go:embed templates static
var assetFS embed.FS
var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
// Date without the clock: the minute a song was published is noise.
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
"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".
var pages = map[string]*template.Template{}
func init() {
entries, err := assetFS.ReadDir("templates")
if err != nil {
panic(err)
}
for _, e := range entries {
if e.Name() == "layout.html" {
continue
}
if e.IsDir() {
continue
}
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
ParseFS(assetFS, "templates/layout.html", "templates/partials/*.html", "templates/"+e.Name()))
}
}
// page is everything the layout needs, plus whatever the page itself wants in Data.
type page struct {
Title string
Member *member
Admin bool
Flash string
Path string
Narrow bool // auth pages are a 420px column
Queued int // songs still owed a review, shown in the nav
Version string
Data any
}
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
t, ok := pages[name]
if !ok {
slog.Error("unknown template", "name", name)
http.Error(w, "template", http.StatusInternalServerError)
return
}
p.Member = memberFrom(r.Context())
p.Path = r.URL.Path
p.Version = version
if p.Member != nil {
// The queue is a worklist, so its size belongs in the nav.
a.pool.QueryRow(r.Context(), `
select count(*)::int from songs s
where s.submitted_by <> $1
and not exists (select 1 from reviews r
where r.song_id = s.id and r.reviewer_id = $1)`,
p.Member.ID).Scan(&p.Queued)
}
p.Flash = a.takeFlash(w, r)
// Render to memory first: a template that fails halfway must not leave a half-written 200.
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
slog.Error("render", "name", name, "error", err)
http.Error(w, "template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
buf.WriteTo(w)
}
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
func (a *app) flash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
}
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie("flash")
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: "flash", Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
msg, err := url.QueryUnescape(c.Value)
if err != nil {
return ""
}
return msg
}
+197
View File
@@ -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)
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
const (
editWindow = 30 * time.Minute
maxReview = 5000
)
type review struct {
ID int64
SongID int64
ReviewerID int64
Reviewer string
Score int
Text string
CreatedAt time.Time
UpdatedAt time.Time
Own bool
}
// The window is measured from updated_at, so an edit extends it. It gates deletion as well as
// editing: for 30 minutes a review is yours to change or withdraw, after that it is on the record.
func (r *review) EditableUntil() time.Time { return r.UpdatedAt.Add(editWindow) }
func (r *review) CanEdit() bool { return r.Own && time.Now().Before(r.EditableUntil()) }
func (r *review) Initials() string {
m := member{Name: r.Reviewer}
return m.Initials()
}
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
rows, err := a.pool.Query(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at,
r.reviewer_id = $2
from reviews r join users u on u.id = r.reviewer_id
where r.song_id = $1
order by r.created_at`, songID, viewerID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*review
for rows.Next() {
var v review
if err := rows.Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
&v.CreatedAt, &v.UpdatedAt, &v.Own); err != nil {
return nil, err
}
out = append(out, &v)
}
return out, rows.Err()
}
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
var v review
err := a.pool.QueryRow(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true
from reviews r join users u on u.id = r.reviewer_id
where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID).
Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
&v.CreatedAt, &v.UpdatedAt, &v.Own)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return &v, err
}
func reviewInput(r *http.Request) (int, string, string) {
score, _ := strconv.Atoi(r.FormValue("score"))
text := clean(r.FormValue("text"), maxReview)
switch {
case score < 1 || score > 100:
return 0, "", "Pisteiden tulee olla 1100."
case text == "":
return 0, "", "Kirjoita muutama sana."
}
return score, text, ""
}
func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
songID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
me := memberFrom(r.Context())
score, text, problem := reviewInput(r)
if problem != "" {
a.flash(w, problem)
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
return
}
// You cannot review your own song, and the unique constraint is what stops a second review —
// no read-then-write race to lose.
var submitter int64
err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
slog.Error("load song", "ctx", "reviews", "error", err, "song", songID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if submitter == me.ID {
http.Error(w, "omaa kappaletta ei voi arvostella", http.StatusForbidden)
return
}
_, err = a.pool.Exec(r.Context(),
`insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`,
songID, me.ID, score, text)
if isUnique(err) {
a.flash(w, "Olet jo arvostellut tämän kappaleen.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("create review", "ctx", "reviews", "error", err, "song", songID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("review written", "ctx", "reviews", "song", songID, "user", me.ID)
a.flash(w, "Arvostelu tallennettu. Nyt näet muidenkin arvostelut.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
// Both edit and delete are gated by the same window, in the same WHERE clause — the database
// decides, so there is no clock-check in Go to get subtly wrong.
func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
score, text, problem := reviewInput(r)
if problem != "" {
a.flash(w, problem)
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
}
var songID int64
err = a.pool.QueryRow(r.Context(), `
update reviews set score = $3, text = $4, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval
returning song_id`,
id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("edit review", "ctx", "reviews", "error", err, "review", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.flash(w, "Arvostelu päivitetty.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
// Deleting the last review unlocks the song for its submitter again — locked is a live state, and
// the 30-minute window is what keeps that from being a rug-pull months later.
func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
var songID int64
err = a.pool.QueryRow(r.Context(), `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning song_id`,
id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
} else if err != nil {
slog.Error("delete review", "ctx", "reviews", "error", err, "review", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("review deleted", "ctx", "reviews", "review", id, "song", songID)
a.flash(w, "Arvostelu poistettu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
}
+345
View File
@@ -0,0 +1,345 @@
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
Cursor int64 // the cursor this page was fetched with; 0 means the first page
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, cursor, 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, cursor, false), nil
}
// One row over the page size is fetched so "is there more" needs no second count query.
func paginate(items []*songSummary, cursor int64, isQueue bool) *songList {
l := &songList{Items: items, Cursor: cursor, 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
NextInQueue int64 // 0 when the queue is empty — keeps the loop moving after a review
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
}
}
if !d.CanReview {
d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID)
if err != nil {
return nil, err
}
}
return &d, nil
}
// The oldest song the viewer still owes a review on. Offered right after they finish one, so
// draining the queue never means navigating back to it.
func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) {
var id int64
err := a.pool.QueryRow(ctx, `
select s.id from songs s
where s.submitted_by <> $1 and s.id <> $2
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
order by s.created_at, s.id
limit 1`, viewerID, exceptID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return id, err
}
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)
}
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"context"
"testing"
)
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(), `
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`,
title, submitter).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(), `
insert into reviews (song_id, reviewer_id, score, text)
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
// A member who hasn't reviewed a song must not receive other reviews *in the result set*, and must
// not receive the average either — not merely fail to render them.
func TestRevealRuleWithholdsReviewsAndAverage(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
cecilia := a.seedMember(t, "[email protected]")
songID := a.seedSong(t, aino, "Testikappale")
a.seedReview(t, songID, bertta, 88)
// Cecilia has not reviewed it.
d, err := a.song(ctx, cecilia, songID)
if err != nil {
t.Fatal(err)
}
if d.Revealed() {
t.Fatal("song is revealed to a member who has not reviewed it")
}
if d.Reviews != nil {
t.Fatalf("withheld reviews were still fetched: %d of them", len(d.Reviews))
}
if d.Average != nil {
t.Fatalf("withheld average was still sent: %v", *d.Average)
}
if d.ReviewCount != 1 {
t.Fatalf("review count = %d, want 1 — the count is not secret", d.ReviewCount)
}
// Writing her own review unlocks both.
a.seedReview(t, songID, cecilia, 60)
d, err = a.song(ctx, cecilia, songID)
if err != nil {
t.Fatal(err)
}
if !d.Revealed() || len(d.Reviews) != 2 {
t.Fatalf("after reviewing: revealed = %v, reviews = %d, want true and 2",
d.Revealed(), len(d.Reviews))
}
if d.Average == nil || *d.Average != 74 {
t.Fatalf("average = %v, want 74", d.Average)
}
// The submitter sees everything without reviewing — they cannot review their own song.
d, err = a.song(ctx, aino, songID)
if err != nil {
t.Fatal(err)
}
if !d.Revealed() || len(d.Reviews) != 2 || d.Average == nil {
t.Fatal("the submitter cannot see the reviews of their own song")
}
if d.CanReview {
t.Fatal("the submitter is offered a review form for their own song")
}
// And the same rule holds in the list query, which is a different SQL path.
list, err := a.browse(ctx, cecilia, 0)
if err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 || list.Items[0].Average == nil {
t.Fatal("browse withheld the average from someone who has reviewed the song")
}
list, err = a.browse(ctx, a.seedMember(t, "[email protected]"), 0)
if err != nil {
t.Fatal(err)
}
if list.Items[0].Average != nil {
t.Fatal("browse leaked the average to someone who has not reviewed the song")
}
}
// The queue excludes your own songs and anything you have already reviewed, oldest first.
func TestQueueContents(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
own := a.seedSong(t, aino, "Oma kappale")
reviewed := a.seedSong(t, bertta, "Jo arvosteltu")
fresh := a.seedSong(t, bertta, "Arvostelematon")
a.seedReview(t, reviewed, aino, 50)
list, err := a.queue(ctx, aino, 0)
if err != nil {
t.Fatal(err)
}
if len(list.Items) != 1 {
var titles []string
for _, s := range list.Items {
titles = append(titles, s.Title)
}
t.Fatalf("queue = %v, want just the unreviewed song", titles)
}
if list.Items[0].ID != fresh {
t.Fatalf("queue holds song %d, want %d", list.Items[0].ID, fresh)
}
_ = own
// Oldest first: a second unreviewed song comes after the first.
older := a.seedSong(t, bertta, "Vanhempi")
if _, err := a.pool.Exec(ctx,
`update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil {
t.Fatal(err)
}
list, err = a.queue(ctx, aino, 0)
if err != nil {
t.Fatal(err)
}
if list.Items[0].ID != older {
t.Fatal("queue is not oldest first")
}
}
// Locked is a live state: deleting the only review makes the song editable again.
func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
songID := a.seedSong(t, aino, "Testikappale")
d, _ := a.song(ctx, aino, songID)
if !d.CanEdit {
t.Fatal("a song with no reviews is not editable by its submitter")
}
reviewID := a.seedReview(t, songID, bertta, 88)
d, _ = a.song(ctx, aino, songID)
if d.CanEdit {
t.Fatal("a reviewed song is still editable")
}
if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil {
t.Fatal(err)
}
d, _ = a.song(ctx, aino, songID)
if !d.CanEdit {
t.Fatal("song did not unlock after its only review was deleted")
}
}
// The window is measured from updated_at, so an edit extends it — and it gates delete too.
func TestEditWindow(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
songID := a.seedSong(t, aino, "Testikappale")
reviewID := a.seedReview(t, songID, bertta, 88)
v, err := a.viewerReview(ctx, songID, bertta)
if err != nil {
t.Fatal(err)
}
if !v.CanEdit() {
t.Fatal("a fresh review is not editable")
}
// Just inside the window.
if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = now() - interval '29 minutes' where id = $1`,
reviewID); err != nil {
t.Fatal(err)
}
v, _ = a.viewerReview(ctx, songID, bertta)
if !v.CanEdit() {
t.Fatal("a 29-minute-old review is not editable")
}
// Past it.
if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = now() - interval '31 minutes' where id = $1`,
reviewID); err != nil {
t.Fatal(err)
}
v, _ = a.viewerReview(ctx, songID, bertta)
if v.CanEdit() {
t.Fatal("a 31-minute-old review is still editable")
}
// The database is the authority, not the Go clock: the update and the delete both refuse.
var n int64
err = a.pool.QueryRow(ctx, `
update reviews set score = 1, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil {
t.Fatal("an expired review was edited")
}
err = a.pool.QueryRow(ctx, `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil {
t.Fatal("an expired review was deleted")
}
}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1024"
height="1024"
viewBox="0 0 1024 1024"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<linearGradient
id="linearGradient1">
<stop
style="stop-color:#131313;stop-opacity:1;"
offset="0"
id="stop1" />
<stop
style="stop-color:#1e1e1e;stop-opacity:1;"
offset="1"
id="stop2" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient1"
id="linearGradient2"
x1="-126.14488"
y1="1023.127"
x2="-1038.4041"
y2="94.281311"
gradientUnits="userSpaceOnUse" />
</defs>
<g
id="g1"
transform="translate(1112.6069,-41.466324)">
<rect
style="font-variation-settings:'wght' 400;opacity:1;fill:url(#linearGradient2);stroke:none;stroke-width:1.50385"
id="rect1"
width="1024"
height="1024"
x="-1112.6069"
y="41.466324" />
<path
style="fill:#a96832;fill-opacity:1"
d="m -256.32286,859.80613 c -0.5587,-1.0439 -5.998,-14.34542 -12.0874,-29.55893 -6.0894,-15.21351 -12.6981,-31.36028 -14.6861,-35.88171 -1.9879,-4.52143 -6.2446,-15.32745 -9.4591,-24.01338 -9.8775,-26.68952 -17.0559,-44.11196 -19.469,-47.25279 -1.2676,-1.65 -6.3824,-7.275 -11.3661,-12.5 -4.9837,-5.225 -9.2794,-10.4 -9.546,-11.5 -0.2667,-1.1 -1.0926,-9.875 -1.8354,-19.5 l -1.3507,-17.5 -8.7081,-21 c -19.3876,-46.75383 -39.6764,-97.57184 -39.2382,-98.28098 0.2562,-0.41453 9.0751,-6.5959 19.5975,-13.73636 10.5224,-7.14046 25.298,-17.36069 32.8348,-22.71161 7.5367,-5.35093 16.5609,-11.49239 20.0538,-13.64769 l 6.3507,-3.91873 -0.138,-14.35231 c -0.088,-9.11047 0.4034,-17.17033 1.3445,-22.06808 1.2363,-6.43377 1.4019,-16.5691 0.9969,-61 -0.2672,-29.30633 -0.7305,-55.53424 -1.0295,-58.28424 -0.6352,-5.84093 9.315,3.05716 -80.2267,-71.74374 l -20.3514,-17.00104 -159.3986,-0.43563 c -93.9669,-0.2568 -159.3986,-0.0666 -159.3986,0.46325 0,0.79387 14.1728,39.5009 32.3012,88.21716 l 6.14,16.5 -0.4706,20 c -0.5906,25.10141 -0.61,106.08069 -0.035,147 0.2396,17.05 -0.058,42.25 -0.6606,56 -0.6029,13.75 -1.4093,49.4125 -1.7922,79.25 l -0.696,54.25 h 34.4369 34.4369 l 17.6574,-12.25 c 9.7116,-6.7375 23.2724,-16.075 30.1351,-20.75 l 12.4776,-8.5 0.045,-28 0.045,-28 23.7399,-10.46084 c 13.0569,-5.75347 28.8714,-12.84696 35.1432,-15.76332 6.2719,-2.91637 11.4981,-5.18399 11.6139,-5.03916 0.1159,0.14483 1.3683,3.86332 2.7832,8.26332 1.415,4.4 8.1816,25.1 15.0368,46 11.4894,35.02826 12.867,38.58653 17.6148,45.5 2.8328,4.125 7.6915,11.24468 10.797,15.8215 l 5.6464,8.3215 0.8322,11.6785 c 2.1376,29.99811 3.0902,34.82716 12.5368,63.55422 l 8.8379,26.87572 5.9488,2.32255 c 3.2719,1.27739 12.0239,4.62345 19.4489,7.43567 7.425,2.81223 18.4886,7.11051 24.5858,9.55174 6.0972,2.44123 11.7197,4.4386 12.4945,4.4386 2.6086,0 23.1976,8.18948 27.9673,11.12431 2.5995,1.59952 12.5398,5.78582 22.0894,9.30289 9.5496,3.51707 23.888,8.84253 31.863,11.83435 14.6625,5.50065 16.6188,5.6317 14.1107,0.94526 z m -303.1107,-418.51779 v -57.34908 l -23.4418,-17.91997 c -12.893,-9.85598 -23.468,-18.20969 -23.5,-18.56379 -0.032,-0.3541 34.315,-0.53781 76.3268,-0.40824 l 76.3849,0.23557 7.3651,5.99943 c 4.0507,3.29968 10.74,8.39168 14.865,11.31555 4.125,2.92387 9.2498,6.8043 11.3884,8.62319 l 3.8884,3.30707 -0.1932,29.78562 c -0.1063,16.3821 -0.4246,30.68563 -0.7073,31.78563 -0.2828,1.1 -4.6655,5.22906 -9.7393,9.17568 -5.0739,3.94663 -12.9359,10.38223 -17.4712,14.30134 l -8.246,7.12566 -37.2099,10.49833 c -20.4655,5.77409 -43.2849,12.21526 -50.7099,14.31371 -7.425,2.09845 -14.7375,4.10967 -16.25,4.46938 l -2.75,0.654 z m 45.446,417.1983 c 0.2839,-0.28388 1.4247,-7.59187 2.5351,-16.23998 1.8573,-14.46552 2.0189,-21.36257 2.0189,-86.18559 0,-40.49991 -0.3784,-70.46175 -0.89,-70.46175 -0.4894,0 -6.9019,4.40065 -14.25,9.77923 -11.1102,8.13247 -58.8721,42.62513 -78.1649,56.44912 l -5.2033,3.72836 -111.246,0.27164 c -61.1854,0.14941 -111.246,0.21437 -111.246,0.14437 10e-5,-0.07 4.1627,-3.4641 9.2502,-7.54242 11.3961,-9.13555 33.5431,-26.93368 35.3917,-28.44206 1.1443,-0.93375 1.3221,-39.12151 1.1104,-238.5 l -0.2521,-237.38824 -79.75,-0.25599 c -65.2629,-0.20948 -79.75,-0.0177 -79.75,1.05568 0,0.72141 1.6361,5.33661 3.6358,10.25598 1.9997,4.91938 6.4668,16.14433 9.9269,24.94433 3.4601,8.8 10.0502,25.225 14.6447,36.5 l 8.3537,20.5 -0.1377,207.42507 -0.1377,207.42508 -18.8328,19.343 -18.8327,19.34299 0.206,33.73193 c 0.1133,18.55256 0.1987,34.00382 0.1899,34.33613 -0.02,0.73234 430.6972,0.51582 431.4299,-0.21688 z"
id="path1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.
+1
View File
File diff suppressed because one or more lines are too long
+143
View File
@@ -0,0 +1,143 @@
// Progressive enhancement: the page ships <audio controls>. If this script runs, it takes the
// controls off and drives the same element itself — playback, buffering, seeking and Range
// requests are untouched, because the element never changes.
(function () {
'use strict'
const fmt = (s) => {
if (!isFinite(s)) return ':––'
const m = Math.floor(s / 60)
return m + ':' + String(Math.floor(s % 60)).padStart(2, '0')
}
const SEGMENTS = 18
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
function enhance(wrap) {
const audio = wrap.querySelector('audio')
if (!audio) return
audio.removeAttribute('controls')
const total = Number(wrap.dataset.duration) || 0
wrap.insertAdjacentHTML('beforeend', `
<div class="transport">
<button type="button" class="tp-play" aria-label="Toista">
<span class="tp-icon" aria-hidden="true"></span>
</button>
<div class="tp-mid">
<input type="range" class="tp-seek" min="0" max="${total || 100}" step="0.1" value="0"
aria-label="Kelaus">
<div class="meter" aria-hidden="true">
<div class="meter-row"><span class="lbl">L</span><span class="segs"></span></div>
<div class="meter-row"><span class="lbl">R</span><span class="segs"></span></div>
</div>
</div>
<span class="tp-time"><b>0:00</b> / ${fmt(total)}</span>
</div>`)
const play = wrap.querySelector('.tp-play')
const seek = wrap.querySelector('.tp-seek')
const time = wrap.querySelector('.tp-time b')
const rows = wrap.querySelectorAll('.meter .segs')
for (const row of rows) {
row.innerHTML = '<span class="seg"></span>'.repeat(SEGMENTS)
}
const segs = [...rows].map((r) => [...r.children])
// --- transport ---
play.addEventListener('click', () => (audio.paused ? audio.play() : audio.pause()))
const setPlaying = (playing) => {
wrap.classList.toggle('playing', playing)
play.setAttribute('aria-label', playing ? 'Tauko' : 'Toista')
}
audio.addEventListener('play', () => { setPlaying(true); startMeter() })
audio.addEventListener('pause', () => setPlaying(false))
audio.addEventListener('ended', () => setPlaying(false))
audio.addEventListener('loadedmetadata', () => {
if (isFinite(audio.duration)) {
seek.max = audio.duration
wrap.querySelector('.tp-time').lastChild.textContent = ' / ' + fmt(audio.duration)
}
})
let scrubbing = false
seek.addEventListener('input', () => {
scrubbing = true
time.textContent = fmt(Number(seek.value))
})
seek.addEventListener('change', () => {
audio.currentTime = Number(seek.value)
scrubbing = false
})
audio.addEventListener('timeupdate', () => {
if (scrubbing) return
seek.value = audio.currentTime
time.textContent = fmt(audio.currentTime)
seek.style.setProperty('--pct', (audio.currentTime / (Number(seek.max) || 1)) * 100 + '%')
})
// --- meter ---
//
// A real analyser, not a decorative loop: it is dark until the audio actually plays, and it
// stops the moment playback does. The AudioContext can only start from a gesture, so it is
// created on first play. MediaElementSource reroutes the audio, so the graph must reach the
// destination or the sound stops.
let ctx, analysers, raf
function startMeter() {
if (quiet || raf) return
if (!ctx) {
try {
ctx = new (window.AudioContext || window.webkitAudioContext)()
const src = ctx.createMediaElementSource(audio)
const split = ctx.createChannelSplitter(2)
analysers = [ctx.createAnalyser(), ctx.createAnalyser()]
analysers.forEach((a, i) => {
a.fftSize = 256
split.connect(a, i)
})
src.connect(split)
src.connect(ctx.destination)
} catch (e) {
return // no Web Audio: the transport still works, the meter simply never lights
}
}
ctx.resume()
const buf = new Uint8Array(analysers[0].fftSize)
const held = [0, 0]
const draw = () => {
if (audio.paused) {
segs.forEach((row) => row.forEach((s) => (s.className = 'seg')))
raf = null
return
}
analysers.forEach((a, ch) => {
a.getByteTimeDomainData(buf)
let peak = 0
for (let i = 0; i < buf.length; i++) {
const v = Math.abs(buf[i] - 128) / 128
if (v > peak) peak = v
}
// Fall slower than it rises, the way a real meter behaves.
held[ch] = peak > held[ch] ? peak : held[ch] * 0.88
const lit = Math.round(held[ch] * SEGMENTS)
segs[ch].forEach((s, i) => {
s.className = 'seg' +
(i < lit ? ' on' + (i >= SEGMENTS - 3 ? ' peak' : i >= SEGMENTS - 7 ? ' hot' : '') : '')
})
})
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.playerwrap').forEach(enhance)
})
})()
+982
View File
@@ -0,0 +1,982 @@
/* Levyraati — dark-only rock club poster. Tokens first; nothing below invents a colour, a spacing
step, a radius or a duration that isn't declared here. */
/* Oswald variable, latin subset, vendored — no CDN, and no webfont for body text. 400700 only:
asking for 900 makes the browser synthesise a fake bold that differs per platform. */
@font-face {
font-family: "Oswald";
src: url("/static/fonts/oswald.woff2") format("woff2");
font-weight: 400 700;
font-display: swap;
font-style: normal;
}
:root {
/* Surfaces */
--bg: #111111;
--surface: #1a1a1a;
--surface-raised: #222222;
--surface-header: #1e1e1e;
--bar: #0d0d0d;
--hairline: #2a2a2a;
--divider: #333333;
--input-border: #444444;
--track: #2a2a2a;
/* Text */
--text: #e0e0e0;
--muted: #888888;
--text-strong: #f0f0f0;
/* Accent — bronze/amber, not neon */
--primary: #a0693a;
--primary-hover: #b57848;
--primary-wash: rgba(160, 105, 58, 0.2);
--primary-inverse: #111111;
--gold-1: #c4a96a;
--gold-2: #b89558;
/* Status, deliberately desaturated */
--error: #e05959;
--error-bg: rgba(224, 89, 89, 0.08);
--success: #7db07d;
--success-bg: rgba(125, 176, 125, 0.08);
--pending: #8f4a44;
--pending-bg: #2e2020;
--unreviewed: #7a3a36;
--unreviewed-hover: #a04540;
/* Scale — 4px base, six steps, nothing in between */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius: 4px;
--radius-toast: 6px;
--radius-pill: 99px;
--duration-fast: 150ms;
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03);
--shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 24px rgba(180, 120, 72, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
--shadow-focus: 0 0 0 3px rgba(196, 169, 106, 0.35);
/* Native controls (audio, checkbox, range, scrollbars) follow this — it is what stops the
<audio> element rendering as a white slab against the dark page. */
color-scheme: dark;
--content: 1450px;
--font-display: "Oswald", Impact, system-ui, sans-serif;
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
line-height: 1.5;
}
/* One focus treatment everywhere, on :focus-visible only. Keyboard navigation is the only way
through some admin tables — this ring is not optional. */
a:focus-visible, button:focus-visible, input:focus-visible,
textarea:focus-visible, select:focus-visible, summary:focus-visible,
[role="button"]:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius);
}
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 var(--space-3);
}
/* Headings step downward in brightness with level. */
h1 { font-size: 2rem; color: var(--gold-1); }
h2 { font-size: 1.5rem; color: var(--gold-2); }
h3 { font-size: 1.2rem; color: var(--primary); }
h4 { font-family: var(--font-body); font-weight: 800; letter-spacing: -0.02em; }
a { color: var(--primary); text-decoration-color: var(--primary-wash); }
a:hover { color: var(--primary-hover); }
/* --- top bar --- */
header.topbar {
background: var(--bar);
border-bottom: 2px solid var(--divider);
padding: var(--space-3) var(--space-5);
position: relative;
}
/* Three columns, not space-between: this is what keeps the links optically centred whatever the
brand and user block weigh. */
.topbar-inner {
max-width: var(--content);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--space-4);
}
.brand {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.03em;
text-transform: uppercase;
color: var(--text);
text-decoration: none;
}
.navlinks { display: flex; gap: var(--space-2); justify-content: center; }
.navlinks a {
padding: 0.4rem var(--space-3);
border-radius: var(--radius);
color: var(--text);
text-decoration: none;
font-size: 0.9rem;
transition: background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
/* Hover and current route look the same, on purpose. */
.navlinks a:hover, .navlinks a[aria-current="page"] {
background: var(--surface-raised);
color: var(--primary);
}
.userblock {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
line-height: 1.2;
}
.userblock .lines { text-align: right; display: flex; flex-direction: column; }
.userblock .name { font-size: 0.85rem; font-weight: 600; }
.userblock .email { font-size: 0.7rem; color: var(--muted); }
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
flex: none;
border-radius: 50%;
background: var(--surface-raised);
border: 1px solid var(--input-border);
color: var(--gold-1);
font-family: var(--font-display);
font-size: 0.9rem;
font-weight: 600;
}
.avatar.small { width: 1.8rem; height: 1.8rem; font-size: 0.75rem; }
/* --- layout --- */
main { max-width: var(--content); margin: 0 auto; padding: var(--space-6) var(--space-5); }
main.narrow { max-width: 420px; padding-top: 8vh; }
section { margin-bottom: var(--space-6); }
footer.sitefooter {
text-align: center;
color: var(--muted);
font-size: 0.8rem;
padding: var(--space-6) var(--space-4) var(--space-4);
}
.muted { color: var(--muted); }
.small { font-size: 0.8rem; }
.nowrap { white-space: nowrap; }
.error { color: var(--error); display: block; font-size: 0.85rem; }
.empty { font-family: var(--font-display); color: var(--muted); padding: var(--space-6) 0; }
/* --- badges --- */
.badge {
display: inline-block;
border-radius: var(--radius-pill);
padding: 0.15rem var(--space-2);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.badge.genre { background: var(--surface-raised); color: var(--primary); border: 1px solid var(--input-border); }
.badge.admin { background: #3a1a1a; color: var(--gold-2); }
.badge.reviewed { background: var(--success-bg); color: var(--success); border: 1px solid var(--success); }
.badge.pending { background: var(--pending-bg); color: var(--pending); }
/* --- song grid --- */
.songgrid { display: grid; grid-template-columns: 1fr; gap: var(--space-4); }
@media (min-width: 640px) { .songgrid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .songgrid { grid-template-columns: repeat(3, 1fr); } }
.songcard {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-1);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: var(--space-4);
padding-right: var(--space-6);
text-decoration: none;
color: var(--text);
box-shadow: var(--shadow-card);
transition: transform var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.songcard:hover {
transform: translateY(-2px);
border-color: var(--primary);
box-shadow: var(--shadow-card-hover);
}
/* The single most important state in the app: what still needs a review. Carried by the border
AND the pending badge — never by colour alone. */
.songcard.unreviewed { border-color: var(--unreviewed); }
.songcard.unreviewed:hover { border-color: var(--unreviewed-hover); }
.songcard .title { font-size: 1.1rem; font-weight: 700; color: var(--text-strong); }
.songcard .artist { font-size: 0.9rem; color: #cccccc; }
.songcard .meta { font-size: 0.75rem; color: var(--muted); display: flex; gap: var(--space-2);
align-items: center; flex-wrap: wrap; margin-top: var(--space-2); }
.songcard .scorebadge {
position: absolute;
top: var(--space-3);
right: var(--space-3);
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--gold-1);
background: rgba(0, 0, 0, 0.5);
border-radius: var(--radius);
padding: 0 var(--space-2);
}
/* --- song page and reviews --- */
/* The artist is the second most important thing on the page, so it gets the display face and a
line of its own rather than being one bold word inside a run of metadata. */
.by {
font-family: var(--font-display);
font-size: 1.35rem;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text);
margin: 0 0 var(--space-3);
}
/* Four kinds of fact, four labelled cells — the spine of a cassette insert. */
.spec {
display: flex;
flex-wrap: wrap;
margin: 0 0 var(--space-5);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
overflow: hidden;
}
.spec > div {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 6rem;
padding: var(--space-2) var(--space-4);
border-right: 1px solid var(--hairline);
}
.spec > div:last-child { border-right: 0; }
.spec dt {
font-family: var(--font-display);
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.spec dd { margin: 0; font-size: 0.95rem; }
.intro { white-space: pre-wrap; background: var(--surface); padding: var(--space-4);
border-left: 3px solid var(--hairline); border-radius: 0 var(--radius) var(--radius) 0; }
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
.review {
padding: var(--space-4);
border-left: 3px solid var(--input-border);
background: var(--surface);
border-radius: 0 var(--radius) var(--radius) 0;
margin-bottom: var(--space-2);
}
.review.own { border-left-color: var(--primary); }
.review header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-2); }
.review .who { font-weight: 700; color: var(--primary); }
.review .score { font-family: var(--font-display); font-size: 1.8rem; font-weight: 700; color: var(--gold-1); }
.review p { white-space: pre-wrap; line-height: 1.6; margin: 0; }
.review .meta { font-size: 0.8rem; color: var(--muted); }
.player { width: 100%; margin: var(--space-3) 0 var(--space-5); }
/* --- forms --- */
form.stack { display: flex; flex-direction: column; gap: var(--space-4); }
form.stack label { display: flex; flex-direction: column; gap: var(--space-1); }
form.stack label.row { flex-direction: row; align-items: center; gap: var(--space-2); }
/* Buttons size to their label rather than stretching across the column. */
form.stack > button, form.stack > .actions { align-self: flex-start; }
input, select, textarea {
background: var(--surface);
color: var(--text);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: var(--space-2) var(--space-3);
font: inherit;
width: 100%;
transition: border-color var(--duration-fast) var(--ease-out);
}
input[type="checkbox"], input[type="range"] { width: auto; }
input:hover, select:hover, textarea:hover { border-color: var(--muted); }
input:focus, select:focus, textarea:focus { border-color: var(--primary); }
textarea { resize: vertical; line-height: 1.6; }
button, .btn {
background: var(--primary);
color: var(--primary-inverse);
border: 1px solid var(--primary);
border-radius: var(--radius);
padding: var(--space-2) var(--space-4);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
button:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
button:disabled { opacity: 0.5; cursor: not-allowed; background: var(--surface-raised);
border-color: var(--input-border); color: var(--muted); }
button.ghost {
background: transparent;
border-color: var(--input-border);
color: var(--text);
font-size: 0.85rem;
}
button.ghost:hover { border-color: var(--primary); color: var(--primary);
background: rgba(180, 120, 72, 0.05); }
button.danger { background: transparent; border-color: var(--pending); color: var(--pending); }
button.danger:hover { background: var(--pending-bg); border-color: var(--unreviewed-hover);
color: var(--error); }
button.link { background: none; border: 0; color: var(--primary); padding: 0;
font-weight: 400; font-size: 0.9rem; text-decoration: underline; }
button.link:hover { background: none; color: var(--primary-hover); }
/* --- the channel strip --- */
/* Score and text side by side: the two things you do at once stop being a screen apart. */
.strip {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-5);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: var(--space-5);
box-shadow: var(--shadow-card);
margin-bottom: var(--space-5);
}
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
.deck .grow { flex: 1; }
.deck .player { margin: 0; }
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.fader { display: grid; grid-template-columns: auto auto; grid-template-rows: 1fr auto;
gap: var(--space-2); align-items: stretch; }
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
font-size: 0.7rem; color: var(--muted); font-family: var(--font-display); }
/* Vertical is native: writing-mode plus rtl gives bottom-to-top travel with no custom widget. */
.fader input[type="range"] {
writing-mode: vertical-lr;
direction: rtl;
-webkit-appearance: none;
appearance: none;
width: 2rem;
height: 100%;
min-height: 15rem;
padding: 0;
border: 0;
background: transparent;
cursor: ns-resize;
}
.fader input[type="range"]::-webkit-slider-runnable-track {
width: 8px;
background: var(--track);
border: 1px solid var(--input-border);
border-radius: var(--radius-pill);
}
.fader input[type="range"]::-moz-range-track {
width: 8px;
background: var(--track);
border: 1px solid var(--input-border);
border-radius: var(--radius-pill);
}
/* A cap, not a knob: this is a fader. */
.fader input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 2rem;
height: 14px;
margin-left: -12px;
border-radius: 2px;
background: linear-gradient(var(--gold-1), var(--gold-2));
border: 1px solid var(--bar);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
}
.fader input[type="range"]::-moz-range-thumb {
width: 2rem;
height: 14px;
border-radius: 2px;
background: linear-gradient(var(--gold-1), var(--gold-2));
border: 1px solid var(--bar);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
}
.readout {
grid-column: 1 / -1;
font-family: var(--font-display);
font-size: 2rem;
font-weight: 700;
color: var(--gold-1);
text-align: center;
background: var(--bar);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: 0 var(--space-2);
}
/* --- the reveal: one channel per reviewer --- */
.channels { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: flex-end;
margin-bottom: var(--space-5); }
.chan { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); width: 2.6rem; }
.chan-track {
position: relative;
width: 8px;
height: 8rem;
background: var(--track);
border: 1px solid var(--input-border);
border-radius: var(--radius-pill);
animation: chan-rise var(--duration-fast) var(--ease-out) backwards;
animation-delay: calc(var(--i, 0) * 40ms);
}
/* The cap sits at the score: the row's silhouette is the spread. */
.chan-cap {
position: absolute;
left: 50%;
bottom: calc(var(--v) * 1%);
transform: translate(-50%, 50%);
width: 1.8rem;
height: 10px;
border-radius: 2px;
background: var(--primary);
border: 1px solid var(--bar);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
/* The filled part below the cap reads as level. */
.chan-cap::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
width: 4px;
height: 8rem;
background: linear-gradient(var(--primary), transparent);
opacity: 0.5;
}
.chan.own .chan-cap { background: var(--gold-1); }
.chan.own .chan-cap::after { background: linear-gradient(var(--gold-1), transparent); }
.chan.own .chan-score { color: var(--gold-1); }
.chan.avg .chan-track { background: transparent; border-left: 1px dashed var(--input-border); width: 1px; }
.chan.avg .chan-cap { background: var(--gold-2); width: 2rem; height: 2px; border: 0; }
.chan.avg .chan-cap::after { display: none; }
.chan-score { font-family: var(--font-display); font-size: 0.95rem; color: var(--text); }
.chan-who { font-size: 0.7rem; color: var(--muted); }
@keyframes chan-rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
/* Hidden scores are hatched rather than absent: "locked", not "no data". */
.sealed {
display: inline-block;
width: 2.2rem;
height: 1rem;
vertical-align: -2px;
border-radius: 2px;
background: repeating-linear-gradient(-45deg, rgba(224, 224, 224, 0.18) 0 6px, transparent 6px 12px),
var(--bar);
border: 1px solid var(--input-border);
}
.sealed-note { color: var(--muted); display: flex; align-items: center; gap: var(--space-2); }
.reviewtext { white-space: pre-wrap; line-height: 1.6; }
.nextup { font-family: var(--font-display); text-transform: uppercase; font-size: 1.1rem; }
/* --- drop zone --- */
.dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-6) var(--space-4);
border: 2px dashed var(--input-border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
text-align: center;
transition: border-color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
}
.dropzone:hover { border-color: var(--primary); }
.dropzone.over { border-color: var(--primary); background: var(--surface-raised); }
.dropzone.has-file { border-style: solid; border-color: var(--primary); }
.dropzone input[type="file"] { position: absolute; width: 1px; height: 1px; opacity: 0; }
.dropzone:focus-within { box-shadow: var(--shadow-focus); }
.dz-title { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.02em; }
.filename { color: var(--primary); word-break: break-all; }
.or { text-align: center; color: var(--muted); text-transform: uppercase;
font-family: var(--font-display); margin: 0; }
/* --- submission status --- */
.status { background: var(--surface); border-left: 3px solid var(--primary);
border-radius: 0 var(--radius) var(--radius) 0; padding: var(--space-4);
margin-bottom: var(--space-5); }
.status p { margin: 0 0 var(--space-2); }
.status.failed { border-left-color: var(--pending); }
.saved { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.editbox { background: var(--surface); border: 1px solid var(--hairline);
border-radius: var(--radius); padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-5); }
.editbox summary { cursor: pointer; font-family: var(--font-display);
text-transform: uppercase; color: var(--primary); }
.editbox form { margin: var(--space-4) 0; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: flex-start; }
.actions form { display: flex; gap: var(--space-2); }
.actions input { width: 12rem; }
/* --- tables and admin --- */
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: var(--space-2); border-bottom: 1px solid var(--hairline); }
th { font-family: var(--font-display); text-transform: uppercase; font-size: 0.8rem;
font-weight: 600; color: var(--muted); }
tr.banned { opacity: 0.55; }
td.break { word-break: break-all; font-size: 0.8rem; }
.adminsection { background: var(--surface); border: 1px solid var(--hairline);
border-radius: var(--radius); margin-bottom: var(--space-6); }
.adminsection > header { background: var(--surface-header); padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--hairline); display: flex;
align-items: center; justify-content: space-between; gap: var(--space-4); }
.adminsection > header h2 { margin: 0; }
.adminsection .body { padding: var(--space-4) var(--space-5); }
.dot { display: inline-block; width: 0.9rem; height: 0.9rem; border-radius: 50%; }
.dot.on { background: #5a8f5a; }
.dot.off { background: #8f4a44; }
code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
border-radius: var(--radius); font-size: 0.85rem; }
/* --- toasts --- */
.toasts { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 1000;
display: flex; flex-direction: column; gap: var(--space-2);
max-width: min(360px, 100vw - 24px); pointer-events: none; }
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-toast);
border-left: 4px solid var(--success);
background: var(--surface);
box-shadow: var(--shadow-card);
animation: toast-in var(--duration-fast) var(--ease-out);
}
.toast p { margin: 0; }
.toast .dismiss { background: none; border: 0; color: var(--text); font-size: 1.5rem;
line-height: 1; padding: 0; opacity: 0.7; cursor: pointer; }
.toast .dismiss:hover { opacity: 1; background: none; }
@keyframes toast-in {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: none; }
}
/* --- mobile --- */
.mobilenav { display: none; }
@media (max-width: 768px) {
.topbar-inner { grid-template-columns: 1fr auto; }
.navlinks, .userblock .lines { display: none; }
.mobilenav { display: block; justify-self: end; }
.mobilenav > summary {
list-style: none;
cursor: pointer;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--input-border);
border-radius: var(--radius);
font-size: 1.4rem;
color: var(--text);
transition: border-color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
}
.mobilenav > summary::-webkit-details-marker { display: none; }
.mobilenav[open] > summary, .mobilenav > summary:hover {
border-color: var(--primary);
background: rgba(180, 120, 72, 0.05);
}
.mobilenav .panel {
position: absolute;
left: 0;
right: 0;
top: 100%;
background: var(--surface);
border-bottom: 2px solid var(--primary);
box-shadow: var(--shadow-card-hover);
padding: var(--space-5) var(--space-4) var(--space-4);
display: flex;
flex-direction: column;
z-index: 95;
animation: panel-down var(--duration-fast) var(--ease-out);
}
.mobilenav .panel a, .mobilenav .panel button {
font-family: var(--font-display);
text-transform: uppercase;
font-size: 1.15rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: var(--space-4);
text-align: left;
background: none;
border: 0;
color: var(--text);
text-decoration: none;
}
main { padding: var(--space-5) var(--space-4); }
.toasts { left: var(--space-4); max-width: none; }
/* A 200px fader on a phone is worse than a horizontal one. */
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
width: 100%; height: auto; min-height: 0; }
/* row-reverse: the markup is top-to-bottom (100 first), a horizontal fader runs 1 → 100. */
.ticks { flex-direction: row-reverse; justify-content: space-between; order: 2;
grid-column: 1 / -1; }
.readout { grid-column: auto; font-size: 1.5rem; }
}
@keyframes panel-down {
from { transform: translateY(-100%); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* Keep the fades, drop the movement. */
@media (prefers-reduced-motion: reduce) {
*, *::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 <li> suppresses markers. */
.board-list { list-style: none; counter-reset: rank; margin: 0; padding: 0; }
.board-list li { display: grid; grid-template-columns: 1.6rem 1fr auto;
column-gap: var(--space-2); align-items: baseline;
padding: var(--space-2) 0; border-bottom: 1px solid var(--hairline); }
.board-list li:last-child { border-bottom: 0; }
.board-list li::before { counter-increment: rank; content: counter(rank) "."; color: var(--muted);
font-family: var(--font-display); }
.board-list li a { grid-column: 2; }
.board-list li .value { grid-column: 3; grid-row: 1; font-family: var(--font-display);
font-size: 1.1rem; color: var(--gold-1); }
.board-list li .small { grid-column: 2; }
.board-list li .small:last-child { grid-column: 3; text-align: right; }
/* --- profile --- */
.profilehead { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); }
.profilehead h1 { margin: 0; }
.avatar.big { width: 4.5rem; height: 4.5rem; font-size: 1.6rem; object-fit: cover; }
img.avatar { object-fit: cover; }
.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4);
margin-bottom: var(--space-6); }
@media (min-width: 700px) { .statgrid { grid-template-columns: repeat(4, 1fr); } }
.statcard { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: var(--space-5); text-align: center; display: flex; flex-direction: column;
gap: var(--space-1); box-shadow: var(--shadow-card); }
.statvalue { font-family: var(--font-display); font-size: 2rem; font-weight: 700; color: var(--gold-1); }
/* --- leaderboard bars --- */
.bar { grid-column: 2 / -1; position: relative; height: 4px; margin-top: var(--space-1);
background: var(--track); border-radius: var(--radius-pill); overflow: hidden; }
.bar .fill { position: absolute; top: 0; bottom: 0; left: 0; background: var(--primary);
border-radius: var(--radius-pill); }
.bar.range .fill { background: linear-gradient(90deg, var(--primary), var(--gold-1)); }
/* --- nav queue count --- */
.count { display: inline-block; min-width: 1.4em; padding: 0 0.35em; border-radius: var(--radius-pill);
background: var(--primary); color: var(--primary-inverse); font-size: 0.75rem;
font-weight: 700; text-align: center; }
/* --- profile comparison --- */
.statcard.wide { grid-column: span 2; }
.channels.compare { justify-content: center; gap: var(--space-5); margin: 0; }
.channels.compare .chan-track { height: 5rem; }
.channels.compare .chan-cap::after { height: 5rem; }
.channels.compare .chan { width: 4rem; }
/* --- submission progress --- */
.segments { display: flex; gap: var(--space-1); list-style: none; margin: 0 0 var(--space-3);
padding: 0; font-family: var(--font-display); text-transform: uppercase;
font-size: 0.75rem; letter-spacing: 0.04em; }
.segments li { flex: 1; padding: var(--space-1) var(--space-2); text-align: center;
color: var(--muted); background: var(--bar); border: 1px solid var(--hairline);
border-radius: var(--radius); }
.segments li.now { color: var(--primary-inverse); background: var(--primary); border-color: var(--primary); }
.segments li.done { color: var(--gold-1); border-color: var(--input-border); }
.segments li.failed { color: var(--pending); border-color: var(--pending); background: var(--pending-bg); }
/* The score chip on a card, sealed: the score exists, you just haven't earned it yet.
The specificity has to beat .songcard .scorebadge, which sets its own background. */
.songcard .scorebadge.sealed {
display: block;
width: 2.4rem;
height: 1.4rem;
padding: 0;
background: repeating-linear-gradient(-45deg, rgba(224, 224, 224, 0.22) 0 5px, transparent 5px 10px),
var(--bar);
border: 1px solid var(--input-border);
}
/* --- transport --- */
.playerwrap { margin: 0 0 var(--space-3); }
.playerwrap .player { width: 100%; }
/* Once enhanced the native element is only a source of sound. */
.playerwrap:has(.transport) .player { display: none; }
.transport {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-4);
background: var(--bar);
border: 1px solid var(--divider);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
}
.tp-play {
width: 3rem;
height: 3rem;
flex: none;
padding: 0;
border-radius: 50%;
background: var(--primary);
border: 0;
display: grid;
place-items: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}
.tp-play:hover { background: var(--primary-hover); }
/* Drawn shapes, not glyphs: ▶ and ❚❚ render differently on every platform. */
.tp-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 9px 0 9px 14px;
border-color: transparent transparent transparent var(--primary-inverse);
margin-left: 3px;
}
.playing .tp-icon {
width: 14px;
height: 16px;
margin-left: 0;
border: 0;
background: linear-gradient(90deg, var(--primary-inverse) 0 5px, transparent 5px 9px,
var(--primary-inverse) 9px 14px);
}
.tp-mid { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
/* A range input for seeking, so arrow keys and the focus ring come free. */
.tp-seek {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 14px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.tp-seek::-webkit-slider-runnable-track {
height: 6px;
border-radius: var(--radius-pill);
background: linear-gradient(90deg, var(--primary) 0 var(--pct, 0%), var(--track) var(--pct, 0%));
border: 1px solid var(--input-border);
}
.tp-seek::-moz-range-track {
height: 6px;
border-radius: var(--radius-pill);
background: var(--track);
border: 1px solid var(--input-border);
}
.tp-seek::-moz-range-progress { height: 6px; border-radius: var(--radius-pill); background: var(--primary); }
.tp-seek::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
margin-top: -4px;
border-radius: 50%;
background: var(--gold-1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
.tp-seek::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: var(--gold-1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
.tp-time { font-family: var(--font-display); font-size: 0.95rem; color: var(--muted); white-space: nowrap; }
.tp-time b { color: var(--text); font-weight: 700; }
/* --- level meter --- */
.meter { display: flex; flex-direction: column; gap: 3px; }
.meter-row { display: flex; gap: var(--space-2); align-items: center; }
.meter-row .lbl { font-family: var(--font-display); font-size: 0.65rem; color: var(--muted); width: 0.8rem; }
.meter .segs { display: flex; gap: 2px; }
.seg { width: 6px; height: 11px; border-radius: 1px; background: var(--track);
border: 1px solid rgba(0, 0, 0, 0.4); }
.seg.on { background: var(--gold-2); }
.seg.on.hot { background: var(--primary); }
.seg.on.peak { background: #c0392b; }
@media (max-width: 768px) {
.transport { gap: var(--space-3); padding: var(--space-3); }
.tp-play { width: 2.6rem; height: 2.6rem; }
.seg { width: 4px; }
}
.pager { display: flex; gap: var(--space-5); margin-top: var(--space-5); }
/* The slogan is a mark, so it is set in the display face rather than the body face. */
.slogan { font-family: var(--font-display); letter-spacing: 0.02em; }
.slogan.hero { color: var(--gold-2); font-size: 1.1rem; text-transform: uppercase;
margin: calc(-1 * var(--space-2)) 0 var(--space-5); }
.copyright { color: var(--muted); }
.copyright::before { content: "·"; margin: 0 var(--space-2); }
.version { color: var(--muted); font-family: var(--font-display); }
.version::before { content: "·"; margin: 0 var(--space-2); }
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"context"
"log/slog"
"net/http"
)
// A song needs this many reviews to qualify for any ranking: with ten members it means a third of
// the club has weighed in, which is a real threshold rather than a formality.
const minReviews = 3
type songStat struct {
ID int64
Title string
Artist string
Value float64
ReviewCount int
Min int // the spread, which is what "divisive" actually means
Max int
}
// Percentages for the bars, so the templates hold no arithmetic.
func (s songStat) Pct() float64 { return s.Value }
func (s songStat) MinPct() float64 { return float64(s.Min) }
func (s songStat) SpanPct() float64 {
if s.Max <= s.Min {
return 1
}
return float64(s.Max - s.Min)
}
type userStat struct {
ID int64
Name string
Value float64
Count int
Avatar *string
}
func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() }
type stats struct {
MinReviews int
TopSongs []songStat
BottomSongs []songStat
MostDivisive []songStat
MostUnified []songStat
MostReviewed []songStat
Harshest []userStat
MostGenerous []userStat
MostActive []userStat
MostProlific []userStat
}
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
// ties are common in a ten-person club, and without one Postgres may return a different ten each
// time, so the page visibly reshuffles between reloads for no reason.
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
rows, err := a.pool.Query(ctx, `
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews,
min(r.score)::int, max(r.score)::int
from songs s join reviews r on r.song_id = s.id
group by s.id
having count(r.id) >= $1
order by value `+direction+`, reviews desc, s.id asc
limit 10`, minReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var out []songStat
for rows.Next() {
var s songStat
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount,
&s.Min, &s.Max); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
// member in the club forever.
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
from users u join reviews r on r.reviewer_id = u.id
group by u.id
having count(r.id) >= $1
order by value `+direction+`, n desc, u.id asc
limit 10`, minReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var out []userStat
for rows.Next() {
var u userStat
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
from users u join songs s on s.submitted_by = u.id
group by u.id
order by count(s.id) desc, u.id asc
limit 10`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []userStat
for rows.Next() {
var u userStat
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// The reveal rule does not apply here: leaderboards are always public. That is the whole point of
// /stats being a page you walk to deliberately.
func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s := stats{MinReviews: minReviews}
var err error
for _, load := range []func() error{
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) {
s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
return
},
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return },
func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return },
} {
if err = load(); err != nil {
slog.Error("stats", "ctx", "songs", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
}
a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s})
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"context"
"testing"
)
// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the
// same ten come back in the same order every time.
func TestLeaderboardThresholdAndOrder(t *testing.T) {
a := testApp(t)
ctx := context.Background()
submitter := a.seedMember(t, "[email protected]")
var reviewers []int64
for _, e := range []string{"[email protected]", "[email protected]", "[email protected]"} {
reviewers = append(reviewers, a.seedMember(t, e))
}
loved := a.seedSong(t, submitter, "Rakastettu")
hated := a.seedSong(t, submitter, "Vihattu")
ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu")
for _, r := range reviewers {
a.seedReview(t, loved, r, 90)
a.seedReview(t, hated, r, 20)
}
// One short of the threshold.
a.seedReview(t, ignored, reviewers[0], 100)
a.seedReview(t, ignored, reviewers[1], 100)
top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc")
if err != nil {
t.Fatal(err)
}
if len(top) != 2 {
t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews)
}
if top[0].ID != loved || top[1].ID != hated {
t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved)
}
if top[0].Value != 90 || top[0].ReviewCount != 3 {
t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount)
}
bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
if bottom[0].ID != hated {
t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated)
}
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
if unified[0].Value != 0 {
t.Fatalf("most unified has stddev %v, want 0", unified[0].Value)
}
// Ties are the common case in a ten-person club: the same query must return the same order.
first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
for i := range first {
if first[i].ID != second[i].ID {
t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing")
}
}
}
// Profiles are counts and history-wide averages. They never carry per-song opinions.
func TestProfileStats(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
song := a.seedSong(t, aino, "Testikappale")
a.seedReview(t, song, bertta, 80)
other := a.seedSong(t, bertta, "Berttan kappale")
a.seedReview(t, other, aino, 40)
p, err := a.profile(ctx, bertta, aino)
if err != nil {
t.Fatal(err)
}
if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 {
t.Fatalf("counts = %d songs, %d reviews; want 1 and 1",
p.Stats.SongsSubmitted, p.Stats.ReviewsWritten)
}
if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 {
t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven)
}
if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 {
t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived)
}
// Viewing someone else's profile does not expose their email.
if p.Email != "" {
t.Fatalf("another member's email leaked: %q", p.Email)
}
// Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees
// its average here.
if len(p.Songs) != 1 {
t.Fatalf("profile lists %d songs, want 1", len(p.Songs))
}
if p.Songs[0].Average == nil {
t.Fatal("a reviewer cannot see the average of a song they reviewed")
}
// A third member who has reviewed nothing must not learn it from the profile page.
cecilia := a.seedMember(t, "[email protected]")
p, err = a.profile(ctx, cecilia, aino)
if err != nil {
t.Fatal(err)
}
if p.Songs[0].Average != nil {
t.Fatal("profile leaked a song average to someone who has not reviewed it")
}
}
+625
View File
@@ -0,0 +1,625 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
maxUploadBytes = 50 << 20
maxDuration = 15 * time.Minute
maxPerDay = 5
maxTitle = 100
maxArtist = 100
maxDescription = 2000
quotaWindowText = "24 tunnin"
)
// Fixed list, validated app-side. Not a table: it never changes without a code change anyway.
//
// The stored value is the English code and the Finnish label is display only — the same split the
// statuses use, so rewording a genre never touches a song row.
type genre struct {
Code string
Label string
}
var genres = []genre{
{"Rock", "Rock"},
{"Metal", "Metal"},
{"Punk", "Punk"},
{"Blues", "Blues"},
{"Jazz", "Jazz"},
{"Electronic", "Elektroninen"},
{"Hip Hop", "Hip hop"},
{"Pop", "Pop"},
{"Folk / Country", "Folk / Country"},
{"Classical", "Klassinen"},
{"Soundtrack", "Elokuvamusiikki"},
{"Experimental", "Kokeellinen"},
{"Finnish", "Kotimainen"},
{"Just Plain Weird", "Ihan outoa"},
{"Other", "Muu"},
}
func validGenre(code string) bool {
return genreLabel(code) != ""
}
func genreLabel(code string) string {
for _, g := range genres {
if g.Code == code {
return g.Label
}
}
return ""
}
// ponytail: in-process goroutines, 2 at a time. A real queue is the upgrade if this ever needs to
// survive a restart mid-conversion or run on another box. Unbounded goroutines shelling out to
// ffmpeg is how one enthusiastic evening fork-bombs a small VPS.
var slots = make(chan struct{}, 2)
// The nullable metadata columns are read with coalesce and held as plain strings: templates
// indirect pointers when printing, so a nil *string would render as "<nil>" inside a form field.
type submission struct {
ID int64
UserID int64
Status string
StatusMsg *string
SourceURL *string
TmpPath string
Title string
Artist string
Genre string
Description string
CreatedAt time.Time
}
func (s *submission) Ready() bool { return s.Status == "ready" }
func (s *submission) Failed() bool { return s.Status == "failed" }
func (s *submission) Done() bool { return s.Ready() || s.Failed() }
// Every status ships with its Finnish label, so the strings never leave Go.
func (s *submission) Label() string {
switch s.Status {
case "queued":
return "Jonossa…"
case "downloading":
return "Ladataan…"
case "converting":
return "Muunnetaan…"
case "ready":
return "Valmis julkaistavaksi"
case "failed":
return "Epäonnistui"
}
return s.Status
}
func (s *submission) Genres() []genre { return genres }
// Only URL submissions can retry: an upload's temp file is gone, so that case offers re-upload.
func (s *submission) CanRetry() bool { return s.Failed() && s.SourceURL != nil }
// The chosen genre's Finnish label, for pages that show it rather than offer it.
func (s *submission) GenreLabel() string { return genreLabel(s.Genre) }
func (a *app) tmpPath(id int64, ext string) string {
return filepath.Join(a.cfg.storageDir, "tmp", strconv.FormatInt(id, 10)+ext)
}
func (a *app) audioPath(songID int64) string {
return filepath.Join(a.cfg.storageDir, "audio", strconv.FormatInt(songID, 10)+".ogg")
}
// --- submit ---
// The submit page also lists your own submissions still in flight, so one is reachable by
// something other than its URL.
func (a *app) submitPage(w http.ResponseWriter, r *http.Request) {
a.submitError(w, r, http.StatusOK, "")
}
func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, msg string) {
subs, err := a.mySubmissions(r.Context(), memberFrom(r.Context()).ID)
if err != nil {
slog.Error("list submissions", "ctx", "submissions", "error", err)
}
a.render(w, r, status, "submit.html",
page{Title: "Lähetä kappale", Data: map[string]any{"Error": msg, "Submissions": subs}})
}
// Five submissions per rolling 24 hours. Failed ones never count: no audio, no submission, and
// yt-dlp breaking is not the submitter's fault. Published songs do count, so the row being gone
// from `submissions` is why this also looks at `songs`.
func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) {
var n int
err := a.pool.QueryRow(ctx, `
select (select count(*) from submissions
where user_id = $1 and status <> 'failed'
and created_at > now() - interval '24 hours')
+ (select count(*) from songs
where submitted_by = $1 and created_at > now() - interval '24 hours')`,
userID).Scan(&n)
return n >= maxPerDay, err
}
func (a *app) submit(w http.ResponseWriter, r *http.Request) {
m := memberFrom(r.Context())
over, err := a.overQuota(r.Context(), m.ID)
if err != nil {
slog.Error("quota", "ctx", "submissions", "error", err)
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
return
}
if over {
a.submitError(w, r, http.StatusTooManyRequests,
fmt.Sprintf("Olet lähettänyt jo %d kappaletta viimeisen %s aikana. Yritä huomenna.",
maxPerDay, quotaWindowText))
return
}
// A YouTube song is not a different kind of song — it just has an extra download step and a
// source_url. Both paths converge on the same worker.
if raw := r.FormValue("url"); strings.TrimSpace(raw) != "" {
a.submitURL(w, r, m.ID, raw)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
file, header, err := r.FormFile("audio")
if errors.Is(err, http.ErrMissingFile) {
// Neither field filled. HTML cannot express "one of these two", so the server says it.
a.submitError(w, r, http.StatusUnprocessableEntity,
"Valitse äänitiedosto tai anna YouTube-linkki.")
return
}
if err != nil {
a.submitError(w, r, http.StatusRequestEntityTooLarge,
"Tiedostoa ei voitu lukea. Enintään 50 MB.")
return
}
defer file.Close()
var subID int64
err = a.pool.QueryRow(r.Context(),
`insert into submissions (user_id, status) values ($1, 'queued') returning id`,
m.ID).Scan(&subID)
if err != nil {
slog.Error("create submission", "ctx", "submissions", "error", err)
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
return
}
// The row exists before the file does, so nothing on disk is ever unaccounted for.
src := a.tmpPath(subID, filepath.Ext(header.Filename))
dst, err := os.Create(src)
if err == nil {
_, err = io.Copy(dst, file)
dst.Close()
}
if err != nil {
slog.Error("save upload", "ctx", "submissions", "error", err, "submission", subID)
a.discardSubmission(r.Context(), subID, src)
a.submitError(w, r, http.StatusRequestEntityTooLarge,
"Tiedostoa ei voitu tallentaa. Enintään 50 MB.")
return
}
// Metadata is read synchronously: arriving later, it would land in a form the submitter is
// already typing into and race their keystrokes.
meta, err := probe(r.Context(), src)
if err != nil {
a.discardSubmission(r.Context(), subID, src)
a.submitError(w, r, http.StatusUnprocessableEntity,
"Tiedostosta ei löytynyt ääntä. Onko se varmasti äänitiedosto?")
return
}
if meta.Duration > maxDuration {
a.discardSubmission(r.Context(), subID, src)
a.submitError(w, r, http.StatusUnprocessableEntity,
"Kappale on yli 15 minuuttia pitkä.")
return
}
if _, err := a.pool.Exec(r.Context(),
`update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '')
where id = $1`, subID, src, meta.Title, meta.Artist); err != nil {
slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID)
}
slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID)
go a.process(subID, "", src)
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
}
func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, raw string) {
// The allowlist is checked before yt-dlp is invoked at all.
link, ok := allowedYouTubeURL(raw)
if !ok {
a.submitError(w, r, http.StatusUnprocessableEntity,
"Vain YouTube-linkit kelpaavat (youtube.com, youtu.be, music.youtube.com).")
return
}
// Metadata first, so an over-long track is refused before a byte is downloaded. A timeout is
// not fatal: blank fields are a fine outcome, since the submitter fills them in anyway.
meta, err := youtubeMeta(r.Context(), link)
if err != nil {
slog.Warn("yt-dlp metadata", "ctx", "submissions", "error", err)
}
if meta.Duration > maxDuration {
a.submitError(w, r, http.StatusUnprocessableEntity, "Kappale on yli 15 minuuttia pitkä.")
return
}
var subID int64
err = a.pool.QueryRow(r.Context(), `
insert into submissions (user_id, status, source_url, title, artist)
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
userID, link, meta.Title, meta.Artist).Scan(&subID)
if err != nil {
slog.Error("create submission", "ctx", "submissions", "error", err)
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
return
}
slog.Info("url submission received", "ctx", "submissions", "submission", subID, "user", userID)
go a.process(subID, link, "")
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
}
// Retry re-queues a failed URL submission with the typed title and introduction intact. An upload
// cannot retry — its temp file is gone — so that case offers re-upload instead.
func (a *app) retry(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
if !s.CanRetry() {
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
return
}
if _, err := a.pool.Exec(r.Context(),
`update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil {
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("submission retried", "ctx", "submissions", "submission", s.ID)
go a.process(s.ID, *s.SourceURL, "")
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
}
func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
if path != "" {
os.Remove(path)
}
if _, err := a.pool.Exec(ctx, `delete from submissions where id = $1`, subID); err != nil {
slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID)
}
}
// --- convert ---
// process is the whole background half of the pipeline: download when the source is a URL, then
// convert. Everything past the two slots waits in `queued`.
func (a *app) process(subID int64, sourceURL, src string) {
slots <- struct{}{}
defer func() { <-slots }()
// Detached from the request: the submitter's browser is long gone by now.
ctx := context.Background()
if sourceURL != "" {
a.setStatus(ctx, subID, "downloading", "")
msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
if err != nil {
if msg == "" {
msg = err.Error()
}
a.setStatus(ctx, subID, "failed", msg)
slog.Warn("download failed", "ctx", "submissions", "submission", subID, "error", err)
return
}
// yt-dlp names the file after whatever container YouTube served.
matches, _ := filepath.Glob(a.tmpPath(subID, ".*"))
for _, m := range matches {
if filepath.Ext(m) != ".ogg" {
src = m
break
}
}
if src == "" {
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa")
return
}
if _, err := a.pool.Exec(ctx,
`update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil {
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
}
}
a.setStatus(ctx, subID, "converting", "")
out := a.tmpPath(subID, ".ogg")
msg, err := convertToOpus(ctx, src, out)
if err != nil {
os.Remove(out)
if msg == "" {
msg = err.Error()
}
a.setStatus(ctx, subID, "failed", msg)
slog.Warn("conversion failed", "ctx", "submissions", "submission", subID, "error", err)
return
}
// The original is discarded as soon as the Opus exists.
os.Remove(src)
if _, err := a.pool.Exec(ctx,
`update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`,
subID, out); err != nil {
slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID)
return
}
slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
}
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
if _, err := a.pool.Exec(ctx,
`update submissions set status = $2, status_msg = nullif($3, '') where id = $1`,
subID, status, msg); err != nil {
slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID)
}
}
// --- the waiting page ---
// Submitter-only: a submission is invisible to everyone else, including a failed one.
func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return nil
}
var s submission
err = a.pool.QueryRow(r.Context(), `
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), created_at
from submissions where id = $1`, id).
Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return nil
} else if err != nil {
slog.Error("load submission", "ctx", "submissions", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return nil
}
if s.UserID != memberFrom(r.Context()).ID {
http.NotFound(w, r)
return nil
}
return &s
}
func (a *app) submissionPage(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
a.render(w, r, http.StatusOK, "submission.html", page{Title: "Lähetys", Data: s})
}
// The same partial the page includes on first paint, returned alone for the HTMX poll — so the
// markup exists once and arrives already populated. HTMX stops polling when the fragment drops
// hx-trigger, which it does on a terminal status.
func (a *app) submissionStatus(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := pages["submission.html"].ExecuteTemplate(w, "submission-status", s); err != nil {
slog.Error("render status", "ctx", "submissions", "error", err)
}
}
// Metadata is editable while the conversion runs — that is the point of the waiting page. There is
// no save button: HTMX posts here after a pause in typing, and pressing Julkaise posts the same
// fields to publish, so a browser without JS loses nothing.
func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error {
genre := r.FormValue("genre")
if genre != "" && !validGenre(genre) {
return fmt.Errorf("unknown genre %q", genre)
}
_, err := a.pool.Exec(ctx, `
update submissions set title = nullif($2, ''), artist = nullif($3, ''),
genre = nullif($4, ''), description = nullif($5, '')
where id = $1`,
subID,
clean(r.FormValue("title"), maxTitle),
clean(r.FormValue("artist"), maxArtist),
genre,
clean(r.FormValue("description"), maxDescription))
return err
}
func (a *app) saveSubmission(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
if err := a.saveMetadata(r.Context(), s.ID, r); err != nil {
slog.Error("save submission", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusUnprocessableEntity)
return
}
// The autosave answers with the "saved at" line and nothing else; a plain POST (no JS) goes
// back to the page.
if r.Header.Get("HX-Request") == "" {
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := pages["submission.html"].ExecuteTemplate(w, "saved",
time.Now().Local().Format("15.04")); err != nil {
slog.Error("render saved", "ctx", "submissions", "error", err)
}
}
// --- publish ---
func (a *app) publish(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
if !s.Ready() {
http.Error(w, "ei vielä valmis", http.StatusConflict)
return
}
// Julkaise submits the metadata form, so the last keystrokes arrive with it — the autosave is
// a convenience, not the only path.
if r.FormValue("title") != "" || r.FormValue("artist") != "" || r.FormValue("genre") != "" {
if err := a.saveMetadata(r.Context(), s.ID, r); err != nil {
slog.Error("save before publish", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusUnprocessableEntity)
return
}
if s = a.loadSubmission(w, r); s == nil {
return
}
}
// Title, artist and genre are required here rather than at submit: the form is meant to be
// filled while the conversion runs, and prefill can legitimately produce nothing.
title, artist, genre := clean(s.Title, maxTitle), clean(s.Artist, maxArtist), s.Genre
if title == "" || artist == "" || !validGenre(genre) {
a.flash(w, "Täytä nimi, esittäjä ja genre ennen julkaisua.")
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
return
}
src := s.TmpPath
meta, err := probe(r.Context(), src)
if err != nil {
slog.Error("probe before publish", "ctx", "submissions", "error", err, "submission", s.ID)
a.flash(w, "Äänitiedostoa ei löytynyt. Lähetä kappale uudelleen.")
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
return
}
tx, err := a.pool.Begin(r.Context())
if err != nil {
slog.Error("begin publish", "ctx", "submissions", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
var songID int64
err = tx.QueryRow(r.Context(), `
insert into songs (title, artist, genre, description, audio_file, duration_seconds,
source_url, submitted_by)
values ($1, $2, $3, $4, '', $5, $6, $7) returning id`,
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
if err != nil {
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
// The rename is inside the transaction: if the file move fails, the song row never existed.
// A crash between rename and commit leaves an orphan .ogg — the startup sweep gets it.
dst := a.audioPath(songID)
if err := os.Rename(src, dst); err != nil {
slog.Error("move audio", "ctx", "songs", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(),
`update songs set audio_file = $2 where id = $1`,
songID, filepath.Base(dst)); err != nil {
os.Rename(dst, src)
slog.Error("set audio file", "ctx", "songs", "error", err, "song", songID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil {
os.Rename(dst, src)
slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
os.Rename(dst, src)
slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("song published", "ctx", "songs", "song", songID, "user", s.UserID)
a.flash(w, "Kappale julkaistu.")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// A failed submission is denied in every sense that matters: invisible to everyone but its
// submitter and never in `songs`. Discard removes the row and its temp file.
func (a *app) discard(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
a.discardSubmission(r.Context(), s.ID, s.TmpPath)
os.Remove(a.tmpPath(s.ID, ".ogg"))
a.flash(w, "Lähetys poistettu.")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func nilIfEmpty(s string) *string {
if strings.TrimSpace(s) == "" {
return nil
}
return &s
}
// Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL.
func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) {
rows, err := a.pool.Query(ctx, `
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), created_at
from submissions where user_id = $1 order by created_at desc`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*submission
for rows.Next() {
var s submission
if err := rows.Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt); err != nil {
return nil, err
}
out = append(out, &s)
}
return out, rows.Err()
}
+222
View File
@@ -0,0 +1,222 @@
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestClean(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"Testikappale", "Testikappale"},
{" padded ", "padded"},
{"line\nbreak", "line break"},
{"tab\tsep", "tab sep"},
{"bell\x07null\x00", "bellnull"},
{"a b c", "a b c"},
} {
if got := clean(tc.in, 100); got != tc.want {
t.Errorf("clean(%q) = %q, want %q", tc.in, got, tc.want)
}
}
// Truncation counts runes, not bytes: a 100-ä title is 100 characters, not 50.
long := ""
for range 150 {
long += "ä"
}
if got := []rune(clean(long, 100)); len(got) != 100 {
t.Errorf("truncated to %d runes, want 100", len(got))
}
}
func makeAudio(t *testing.T, path string) {
t.Helper()
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not on PATH")
}
cmd := exec.Command("ffmpeg", "-nostdin", "-y", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-c:a", "libopus", path)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("ffmpeg: %v\n%s", err, out)
}
}
func (a *app) readySubmission(t *testing.T, userID int64) *submission {
t.Helper()
var id int64
err := a.pool.QueryRow(context.Background(), `
insert into submissions (user_id, status, title, artist, genre)
values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`,
userID).Scan(&id)
if err != nil {
t.Fatal(err)
}
path := a.tmpPath(id, ".ogg")
makeAudio(t, path)
if _, err := a.pool.Exec(context.Background(),
`update submissions set tmp_path = $2 where id = $1`, id, path); err != nil {
t.Fatal(err)
}
return &submission{ID: id, UserID: userID, Status: "ready", TmpPath: path}
}
// A song row and its .ogg appear together, or neither does.
func TestPublishIsAllOrNothing(t *testing.T) {
a := testApp(t)
ctx := context.Background()
a.cfg.storageDir = t.TempDir()
audioDir := filepath.Join(a.cfg.storageDir, "audio")
for _, d := range []string{"audio", "tmp"} {
if err := os.MkdirAll(filepath.Join(a.cfg.storageDir, d), 0o755); err != nil {
t.Fatal(err)
}
}
id := a.seedMember(t, "[email protected]")
sub := a.readySubmission(t, id)
// Make the move impossible, the same way a full or read-only disk would.
if err := os.Chmod(audioDir, 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(audioDir, 0o755) })
r := httptest.NewRequest("POST", fmt.Sprintf("/submit/%d/publish", sub.ID), nil)
r.SetPathValue("id", fmt.Sprint(sub.ID))
r = r.WithContext(context.WithValue(ctx, memberKey, &member{ID: id}))
w := httptest.NewRecorder()
a.publish(w, r)
if w.Code != http.StatusInternalServerError {
t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code)
}
var songs, submissions int
if err := a.pool.QueryRow(ctx, `select count(*) from songs`).Scan(&songs); err != nil {
t.Fatal(err)
}
if songs != 0 {
t.Fatalf("orphan song row: %d rows with no audio file", songs)
}
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
t.Fatal(err)
}
if submissions != 1 {
t.Fatalf("submission rows = %d, want 1 — a failed publish must leave it recoverable", submissions)
}
if _, err := os.Stat(sub.TmpPath); err != nil {
t.Fatalf("converted audio was lost: %v", err)
}
// With the directory writable again, the same submission publishes.
os.Chmod(audioDir, 0o755)
w = httptest.NewRecorder()
a.publish(w, r)
if w.Code != http.StatusSeeOther {
t.Fatalf("publish: status = %d, want 303", w.Code)
}
var songID int64
if err := a.pool.QueryRow(ctx, `select id from songs`).Scan(&songID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(a.audioPath(songID)); err != nil {
t.Fatalf("published song has no audio file: %v", err)
}
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
t.Fatal(err)
}
if submissions != 0 {
t.Fatalf("submission survived publish: %d rows", submissions)
}
}
// Five per rolling 24 hours, counting published songs, never counting failures.
func TestSubmissionQuota(t *testing.T) {
a := testApp(t)
ctx := context.Background()
id := a.seedMember(t, "[email protected]")
check := func(want bool, why string) {
t.Helper()
over, err := a.overQuota(ctx, id)
if err != nil {
t.Fatal(err)
}
if over != want {
t.Fatalf("%s: overQuota = %v, want %v", why, over, want)
}
}
check(false, "no submissions")
for range 4 {
if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil {
t.Fatal(err)
}
}
check(false, "four in flight")
// Failures never count — yt-dlp rot and bad files are not the submitter's fault.
for range 10 {
if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil {
t.Fatal(err)
}
}
check(false, "failures do not count")
// A published song still occupies a slot, even though its submission row is gone.
if _, err := a.pool.Exec(ctx, `
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil {
t.Fatal(err)
}
check(true, "four in flight plus one published")
// Yesterday's submissions are outside the window.
if _, err := a.pool.Exec(ctx,
`update submissions set created_at = now() - interval '25 hours' where user_id = $1`,
id); err != nil {
t.Fatal(err)
}
check(false, "older than 24 hours")
}
// A submission left mid-conversion by a restart must not say "converting" forever.
func TestRestartRecovery(t *testing.T) {
a := testApp(t)
ctx := context.Background()
id := a.seedMember(t, "[email protected]")
for _, status := range []string{"queued", "downloading", "converting"} {
if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil {
t.Fatal(err)
}
}
if err := sweep(ctx, a.pool); err != nil {
t.Fatal(err)
}
var stuck int
if err := a.pool.QueryRow(ctx,
`select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil {
t.Fatal(err)
}
if stuck != 0 {
t.Fatalf("%d submissions survived the sweep still in flight", stuck)
}
var msg string
if err := a.pool.QueryRow(ctx,
`select status_msg from submissions limit 1`).Scan(&msg); err != nil {
t.Fatal(err)
}
if msg == "" {
t.Fatal("swept submission carries no explanation")
}
}
+90
View File
@@ -0,0 +1,90 @@
{{define "content"}}
<h1>Ylläpito</h1>
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
<section class="adminsection">
<header>
<h2>Kutsut</h2>
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
</header>
<div class="body">
<table>
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
<tbody>
{{range .Data.Invites}}
<tr>
<td>
<a href="{{.Link}}">{{.Link}}</a>
</td>
<td class="nowrap"><span class="dot on"></span> käyttämätön</td>
<td>{{fidate .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">Ei käyttämättömiä kutsuja.</td></tr>
{{end}}
</tbody>
</table>
<p class="muted small">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi
täytettynä. Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
{{.Data.SpentCount}}{{end}}.</p>
</div>
</section>
<section class="adminsection">
<header><h2>Jäsenet</h2></header>
<div class="body">
<table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
<td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
</form>
<form method="post" action="/admin/users/{{.ID}}/password">
<input type="password" name="password" placeholder="uusi salasana" required>
<button type="submit" class="ghost">Vaihda salasana</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Kappaleet</h2></header>
<div class="body">
<table>
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
<tbody>
{{range .Data.Songs}}
<tr>
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
<td>{{.Submitter}}</td>
<td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions">
<a href="/admin/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "content"}}
<h1>Palautteet</h1>
<p><a href="/admin">← Ylläpito</a></p>
{{range .Data}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="who">{{.Reporter}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
<p class="meta break">{{.UserAgent}}</p>
{{if .Open}}
<form method="post" action="/admin/reports/{{.ID}}/resolve">
<button type="submit" class="ghost">Merkitse käsitellyksi</button>
</form>
{{end}}
</article>
{{else}}
<p class="empty">Ei palautteita.</p>
{{end}}
{{end}}
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="fi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Levyraati</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="preload" href="/static/fonts/oswald.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/player.js" defer></script>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
{{if .Admin}}
<nav class="navlinks"><a href="/admin" aria-current="page">Ylläpito</a></nav>
<span></span>
{{else if .Member}}
<nav class="navlinks">
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
</nav>
<div class="userblock">
<span class="lines">
<span class="name">{{.Member.Name}}</span>
<span class="email">{{.Member.Email}}</span>
</span>
<a href="/profile" title="{{.Member.Name}}">
{{if .Member.Avatar}}<img class="avatar" src="/avatars/{{.Member.ID}}" alt="Oma profiili">
{{else}}<span class="avatar">{{.Member.Initials}}</span>{{end}}
</a>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
<!-- <details> is the mobile panel: no JS, and Esc/click-away come free. -->
<details class="mobilenav">
<summary aria-label="Valikko"></summary>
<div class="panel">
<a href="/">Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
<a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a>
<a href="/stats">Tilastot</a>
<a href="/profile">Oma profiili</a>
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
</div>
</details>
</div>
{{else}}
<span></span>
<nav class="navlinks"><a href="/login">Kirjaudu</a></nav>
{{end}}
</div>
</header>
<main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
<footer class="sitefooter">
{{if .Member}}
<!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
{{end}}
<span class="slogan">We know good music, baby!</span>
<span class="copyright">© Kessinen</span>
<span class="version" title="Käytössä oleva versio">v{{.Version}}</span>
</footer>
{{with .Flash}}
<div class="toasts">
<div class="toast" role="status">
<p>{{.}}</p>
<button class="dismiss" aria-label="Sulje"
onclick="this.closest('.toast').remove()">&times;</button>
</div>
</div>
{{end}}
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{{define "content"}}
<h1>Kirjaudu</h1>
<!-- The app's own slogan, three decades old. A mark, not interface copy, so it stays English. -->
<p class="slogan hero">We know good music, baby!</p>
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/login" class="stack">
<label>Sähköposti
<input type="email" name="email" value="{{.Data.Email}}" required autofocus autocomplete="email">
</label>
<label>Salasana
<input type="password" name="password" required autocomplete="current-password">
</label>
<label class="row">
<input type="checkbox" name="remember" value="1"> Pysy kirjautuneena 30 päivää
</label>
<button type="submit">Kirjaudu</button>
</form>
<p class="muted">Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan
<a href="/register">tästä</a>.</p>
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "player"}}
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
the browser's own player, which is plain but complete. -->
<div class="playerwrap" data-duration="{{.Duration}}">
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
</div>
{{end}}
{{define "songcard"}}
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
{{if .Average}}<span class="scorebadge">{{score .Average}}</span>
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Pisteet paljastuvat kun arvostelet"></span>{{end}}
<span class="title">{{.Title}}</span>
<span class="artist">{{.Artist}}</span>
<span class="meta">
<span class="badge genre">{{.GenreLabel}}</span>
<span>{{.Length}}</span>
<span>{{.Submitter}}</span>
{{if .Own}}<span class="badge admin">oma</span>
{{else if .Reviewed}}<span class="badge reviewed">arvosteltu</span>
{{else}}<span class="badge pending">arvostelematta</span>{{end}}
{{if .ReviewCount}}<span>{{.ReviewCount}} arvostelua</span>{{end}}
</span>
</a>
{{end}}
{{define "fader"}}
<!-- A native range input turned vertical: keyboard, form submission and the value all stay free. -->
<div class="fader">
<span class="ticks" aria-hidden="true">
<span>100</span><span>75</span><span>50</span><span>25</span><span>1</span>
</span>
<input type="range" name="score" min="1" max="100" value="{{.}}"
aria-label="Pisteet 1100"
oninput="this.closest('.fader').querySelector('output').value = this.value">
<output class="readout">{{.}}</output>
</div>
{{end}}
+67
View File
@@ -0,0 +1,67 @@
{{define "content"}}
{{$p := .Data}}
<div class="profilehead">
{{if $p.Avatar}}
<img class="avatar big" src="/avatars/{{$p.ID}}" alt="">
{{else}}
<span class="avatar big">{{$p.Initials}}</span>
{{end}}
<div>
<h1>{{$p.Name}}</h1>
<p class="muted">Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}</p>
</div>
</div>
<div class="statgrid">
<div class="statcard"><span class="statvalue">{{$p.Stats.SongsSubmitted}}</span><span class="muted small">kappaletta</span></div>
<div class="statcard"><span class="statvalue">{{$p.Stats.ReviewsWritten}}</span><span class="muted small">arvostelua</span></div>
<!-- The one comparison that says something about a person, in the same language as the review
form: what they give versus what they get. -->
<div class="statcard wide">
<div class="channels compare">
{{if $p.Stats.AverageGiven}}
<div class="chan" style="--v: {{score $p.Stats.AverageGiven}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $p.Stats.AverageGiven}}</span>
<span class="chan-who">antanut</span>
</div>
{{end}}
{{if $p.Stats.AverageReceived}}
<div class="chan own" style="--v: {{score $p.Stats.AverageReceived}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $p.Stats.AverageReceived}}</span>
<span class="chan-who">saanut</span>
</div>
{{end}}
{{if and (not $p.Stats.AverageGiven) (not $p.Stats.AverageReceived)}}
<p class="muted small">Ei vielä pisteitä kumpaankaan suuntaan.</p>
{{end}}
</div>
</div>
</div>
{{if $p.Own}}
<details class="editbox">
<summary>Muokkaa tietoja</summary>
<form method="post" action="/profile" enctype="multipart/form-data" class="stack">
<label>Nimi <input name="name" value="{{$p.Name}}" maxlength="50" required></label>
<label>Sähköposti <input type="email" name="email" value="{{$p.Email}}" required></label>
<label>Kuva <input type="file" name="avatar" accept="image/*"></label>
<label>Nykyinen salasana <input type="password" name="current_password" autocomplete="current-password"></label>
<label>Uusi salasana <input type="password" name="new_password" autocomplete="new-password"></label>
<button type="submit">Tallenna</button>
</form>
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
</details>
{{end}}
<section>
<h2>Kappaleet</h2>
{{if $p.Songs}}
<div class="songgrid">{{range $p.Songs}}{{template "songcard" .}}{{end}}</div>
{{else}}
<p class="muted">Ei vielä yhtään kappaletta.</p>
{{end}}
</section>
{{end}}
+19
View File
@@ -0,0 +1,19 @@
{{define "content"}}
<h1>Jono</h1>
{{if .Data.Items}}
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
olet kirjoittanut oman arvostelusi.</p>
<div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}}
</div>
<p class="pager">
{{if .Data.Cursor}}<a href="/">← Alkuun</a>{{end}}
{{with .Data.NextCursor}}<a href="/?cursor={{.}}">Lisää →</a>{{end}}
</p>
{{else}}
<p class="empty">Kaikki kuunneltu.</p>
<p>Olet arvostellut kaiken, mitä muut ovat lähettäneet.
<a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
{{end}}
{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "content"}}
<h1>Liity</h1>
<form method="post" action="/register" class="stack">
<label>Kutsukoodi
<input name="code" value="{{.Data.Code}}" required>
{{with .Data.Errors.code}}<span class="error">{{.}}</span>{{end}}
</label>
<label>Nimi
<input name="name" value="{{.Data.Name}}" required maxlength="50">
{{with .Data.Errors.name}}<span class="error">{{.}}</span>{{end}}
</label>
<label>Sähköposti
<input type="email" name="email" value="{{.Data.Email}}" required autocomplete="email">
{{with .Data.Errors.email}}<span class="error">{{.}}</span>{{end}}
</label>
<label>Salasana
<input type="password" name="password" required autocomplete="new-password">
{{with .Data.Errors.password}}<span class="error">{{.}}</span>{{end}}
</label>
<button type="submit">Liity</button>
</form>
<p class="muted">Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.</p>
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{{define "content"}}
<h1>Palaute</h1>
<p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi
virke riittää.</p>
<form method="post" action="/report" class="stack">
<input type="hidden" name="from" value="{{.Data.From}}">
<label>Palaute
<textarea name="body" rows="6" maxlength="2000" required autofocus
placeholder="Esim. soitin ei toimi puhelimella."></textarea>
</label>
<button type="submit">Lähetä palaute</button>
</form>
<p class="muted small">Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.</p>
{{with .Data.Mine}}
<section>
<h2>Omat palautteet</h2>
{{range .}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
</article>
{{end}}
</section>
{{end}}
{{end}}
+151
View File
@@ -0,0 +1,151 @@
{{define "content"}}
{{$s := .Data}}
<h1>{{$s.Title}}</h1>
<p class="by">{{$s.Artist}}</p>
<!-- Four different kinds of fact, so four labelled cells rather than one run of text. "Oma
kappale" belongs here as the submitter, not as a badge: it is a fact about you. -->
<dl class="spec">
<div><dt>Genre</dt><dd>{{$s.GenreLabel}}</dd></div>
<div><dt>Kesto</dt><dd>{{$s.Length}}</dd></div>
<div><dt>Lähetti</dt>
<dd>{{if $s.Own}}sinä{{else}}<a href="/profile/{{$s.SubmitterID}}">{{$s.Submitter}}</a>{{end}}</dd>
</div>
<div><dt>Julkaistu</dt><dd>{{fiday $s.CreatedAt}}</dd></div>
</dl>
{{if $s.CanEdit}}
<details class="editbox">
<summary>Muokkaa tietoja</summary>
<form method="post" action="/songs/{{$s.ID}}" class="stack">
<label>Nimi <input name="title" value="{{$s.Title}}" maxlength="100" required></label>
<label>Esittäjä <input name="artist" value="{{$s.Artist}}" maxlength="100" required></label>
<label>Genre
<select name="genre" required>
{{$current := $s.Genre}}
{{range $s.Genres}}
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</label>
<label>Esittely <textarea name="description" rows="4" maxlength="2000">{{$s.Description}}</textarea></label>
<button type="submit">Tallenna</button>
</form>
<form method="post" action="/songs/{{$s.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
<button type="submit" class="ghost danger">Poista kappale</button>
</form>
<p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p>
</details>
{{else if $s.Own}}
<p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p>
{{end}}
{{if $s.CanReview}}
<!-- The channel strip: the fader is the score, the panel beside it is everything else you do
while the track plays. -->
<form method="post" action="/songs/{{$s.ID}}/review" class="strip">
{{template "fader" 50}}
<div class="deck">
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
<label class="grow">Arvostelu
<textarea name="text" rows="8" maxlength="5000" required
placeholder="Mitä kuulit?"></textarea>
</label>
<div class="deckfoot">
<button type="submit">Tallenna arvostelu</button>
<span class="muted small">Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata
tai poistaa arvostelusi 30 minuutin ajan.</span>
</div>
</div>
</form>
{{else}}
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
{{end}}
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{if $s.ViewerReview}}
<section>
<h2>Oma arvostelusi</h2>
{{with $s.ViewerReview}}
{{if .CanEdit}}
<form method="post" action="/reviews/{{.ID}}" class="strip">
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
{{template "fader" .Score}}
<div class="deck">
<label class="grow">Arvostelu
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
</label>
<div class="deckfoot">
<button type="submit">Päivitä</button>
<span class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</span>
</div>
</div>
</form>
<form method="post" action="/reviews/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko arvostelu?')">
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
<button type="submit" class="ghost danger">Poista arvostelu</button>
</form>
{{else}}
<p class="score">{{.Score}}</p>
<p class="reviewtext">{{.Text}}</p>
<p class="muted small">Muokkausaika on päättynyt.</p>
{{end}}
{{end}}
</section>
{{end}}
<section>
<h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2>
{{if $s.Revealed}}
{{if $s.Reviews}}
<!-- One channel per reviewer: the spread across the row is what "divisive" looks like. -->
<div class="channels">
{{range $i, $r := $s.Reviews}}
<div class="chan{{if $r.Own}} own{{end}}" style="--v: {{$r.Score}}; --i: {{$i}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{$r.Score}}</span>
<span class="chan-who" title="{{$r.Reviewer}}">{{$r.Initials}}</span>
</div>
{{end}}
{{if $s.Average}}
<div class="chan avg" style="--v: {{score $s.Average}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $s.Average}}</span>
<span class="chan-who">ka.</span>
</div>
{{end}}
</div>
{{range $s.Reviews}}
<article class="review{{if .Own}} own{{end}}">
<header>
<span class="avatar small">{{.Initials}}</span>
<span class="who">{{.Reviewer}}</span>
<span class="score">{{.Score}}</span>
<span class="meta">{{fidate .CreatedAt}}</span>
</header>
<p>{{.Text}}</p>
</article>
{{end}}
{{else}}
<p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p>
{{end}}
{{else}}
<p class="sealed-note">
<span class="sealed" aria-hidden="true"></span>
Muiden pisteet ja arvostelut paljastuvat kun kirjoitat omasi.
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}
</p>
{{end}}
</section>
{{if $s.NextInQueue}}
<p class="nextup"><a href="/songs/{{$s.NextInQueue}}">Seuraava jonossa →</a></p>
{{end}}
{{end}}
+16
View File
@@ -0,0 +1,16 @@
{{define "content"}}
<h1>Kappaleet</h1>
{{if .Data.Items}}
<div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}}
</div>
<p class="pager">
{{if .Data.Cursor}}<a href="/songs">← Uusimmat</a>{{end}}
{{with .Data.NextCursor}}<a href="/songs?cursor={{.}}">Vanhempia →</a>{{end}}
</p>
{{else}}
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
<p><a href="/submit">Lähetä ensimmäinen</a>.</p>
{{end}}
{{end}}
+65
View File
@@ -0,0 +1,65 @@
{{define "songboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/songs/{{.ID}}">{{.Title}}</a>
<span class="muted small">{{.Artist}}</span>
<span class="value">{{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.ReviewCount}} arv.</span>{{end}}
{{if $.Spread}}
<!-- Where the scores actually landed: a range says more than a deviation. -->
<span class="bar range" title="{{.Min}}{{.Max}}">
<span class="fill" style="left: {{.MinPct}}%; width: {{.SpanPct}}%"></span>
</span>
{{else if not $.Count}}
<span class="bar"><span class="fill" style="width: {{.Pct}}%"></span></span>
{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "userboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/profile/{{.ID}}">{{.Name}}</a>
<span class="value">{{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.Count}} kpl</span>{{end}}
{{if not $.Count}}<span class="bar"><span class="fill" style="width: {{value .Value}}%"></span></span>{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "content"}}
<h1>Tilastot</h1>
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.</p>
<div class="boards">
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
{{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}}
{{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive "Spread" true}}
{{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified "Spread" true}}
{{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" true}}
{{template "userboard" dict "Title" "Ankarin arvostelija" "Items" .Data.Harshest}}
{{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}}
{{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}}
{{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}}
</div>
{{end}}
+69
View File
@@ -0,0 +1,69 @@
{{define "submission-status"}}
<div class="status{{if .Failed}} failed{{end}}" {{if not .Done}}hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
<ol class="segments" aria-label="Lähetyksen tila">
<li class="{{if .Done}}done{{else}}now{{end}}">Lähetetty</li>
<li class="{{if .Done}}done{{else if eq .Status "converting"}}now{{else if eq .Status "downloading"}}now{{end}}">
{{if eq .Status "downloading"}}Ladataan{{else}}Muunnetaan{{end}}</li>
<li class="{{if .Ready}}done{{else if .Failed}}failed{{end}}">{{if .Failed}}Epäonnistui{{else}}Valmis{{end}}</li>
</ol>
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
{{if .Failed}}
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
<div class="actions">
{{if .CanRetry}}
<form method="post" action="/submit/{{.ID}}/retry">
<button type="submit">Yritä uudelleen</button>
</form>
{{end}}
<form method="post" action="/submit/{{.ID}}/discard">
<button type="submit" class="danger">Poista lähetys</button>
</form>
</div>
{{if not .CanRetry}}<p class="muted small">Lataa tiedosto uudelleen, jos haluat yrittää toisen kerran.</p>{{end}}
{{else}}
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
and publishes. Disabled until the audio has finished converting. -->
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
{{if not .Ready}}<p class="muted small">Julkaise aukeaa kun muunnos on valmis.</p>{{end}}
{{end}}
</div>
{{end}}
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
{{define "content"}}
<h1>Lähetys</h1>
{{if .Data.Failed}}{{template "submission-status" .Data}}{{end}}
{{if not .Data.Failed}}
<form id="meta" method="post" action="/submit/{{.Data.ID}}/publish" class="stack"
hx-post="/submit/{{.Data.ID}}" hx-trigger="input changed delay:1.2s, change"
hx-target="#saved" hx-swap="outerHTML">
<label>Kappaleen nimi
<input name="title" value="{{.Data.Title}}" maxlength="100" required>
</label>
<label>Esittäjä
<input name="artist" value="{{.Data.Artist}}" maxlength="100" required>
</label>
<label>Genre
<select name="genre" required>
<option value="">— valitse —</option>
{{$current := .Data.Genre}}
{{range .Data.Genres}}
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</label>
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
</label>
{{template "saved" ""}}
</form>
<p class="muted">Tiedot tallentuvat itsestään kirjoittaessasi. Nimi, esittäjä ja genre tarvitaan
ennen julkaisua.</p>
{{template "submission-status" .Data}}
{{end}}
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "content"}}
<h1>Lähetä kappale</h1>
{{with .Data.Error}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/submit" enctype="multipart/form-data" class="stack">
<label class="dropzone" id="dropzone" for="audio">
<input type="file" id="audio" name="audio" accept="audio/*">
<span class="dz-title">Raahaa äänitiedosto tähän</span>
<span class="muted small">tai valitse napsauttamalla</span>
<span class="filename" id="filename"></span>
</label>
<p class="or">tai</p>
<label>YouTube-linkki
<input type="url" name="url" placeholder="https://www.youtube.com/watch?v=…">
</label>
<button type="submit">Lähetä</button>
</form>
{{with .Data.Submissions}}
<section>
<h2>Omat lähetykset</h2>
<table>
<thead><tr><th>Kappale</th><th>Tila</th><th>Lähetetty</th></tr></thead>
<tbody>
{{range .}}
<tr>
<td><a href="/submit/{{.ID}}">{{if .Title}}{{.Title}}{{else}}(nimetön){{end}}</a></td>
<td>{{.Label}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted small">Valmis lähetys odottaa Julkaise-painallusta — vasta se tuo kappaleen
muiden nähtäville.</p>
</section>
{{end}}
<p class="muted">Enintään 50 MB ja 15 minuuttia. Tiedosto muunnetaan Opus-muotoon, ja pääset
kirjoittamaan esittelyn odotellessa. Kappale julkaistaan vasta kun painat Julkaise.</p>
<script>
// The native control can't be relabelled or styled, so it is hidden behind this one — which
// still is the input, so keyboard focus and form submission work untouched.
(function () {
const zone = document.getElementById('dropzone')
const input = document.getElementById('audio')
const name = document.getElementById('filename')
const show = () => {
name.textContent = input.files.length ? input.files[0].name : ''
zone.classList.toggle('has-file', input.files.length > 0)
}
input.addEventListener('change', show)
for (const type of ['dragenter', 'dragover']) {
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.add('over') })
}
for (const type of ['dragleave', 'dragend', 'drop']) {
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.remove('over') })
}
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files
show()
}
})
})()
</script>
{{end}}
+1
View File
File diff suppressed because one or more lines are too long