# Levyraati — specification What the app does. This file and the code must never disagree; when behaviour changes, this changes with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in [decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md). Stack and configuration are in the [README](../README.md); running it on a server is in [deployment.md](./deployment.md). --- ## 1. Access - **Invite-only.** No public registration, no self-service password reset. The admin resets passwords. - Registration requires a valid, unused invite code. Codes are one-time and are spent **only after a registration actually succeeds** — a failed signup must leave the code usable. - **No roles.** Every account is a member. The admin is not an account (§5). - **Email is the login identifier.** The app never sends mail in v1 and cannot verify addresses. - **Names are not unique** — they are display only, and changing yours must never affect logging in. - Banned members are rejected at login. ### 1.1 Sessions - Random token (32 bytes, hex) in `sessions`, presented in an `HttpOnly; Secure; SameSite=Lax` cookie **or** an `Authorization: Bearer` header. - **Idle timeout.** A session dies after a period with no requests: **24 hours**, or **30 days** if "remember me" was ticked at login. The per-session `idle_ttl` is stored on the row, so extending is `expires_at = now() + idle_ttl` with no branch. Skip the write unless at least a minute has passed, so this is not a write per request. - Logging out deletes the row. Changing your password deletes your other sessions. Banning deletes all of a member's sessions. - Expired rows are swept at startup (§4.6). They are harmless until then — `expires_at` is checked on every read. ```go 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("session"); err == nil { return c.Value } return "" } ``` Reading the token from a header does not weaken CSRF: `SameSite=Lax` still guards the cookie path, and a cross-origin page cannot set `Authorization` without CORS, which is not enabled. ### 1.2 Security behaviours - Changing your own password requires the current password. - 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. - Audio is auth-gated. Avatars are public — they are not secret, and gating them buys nothing. - Never log secrets: no passwords, no session tokens, no invite codes, no admin password. --- ## 2. Songs A song is a published piece of music with converted audio. A row in `songs` is a real song with no qualifier — in-flight work lives in `submissions` (§4). - Any member may submit: title (≤100), artist (≤100), genre (fixed list), optional description ("short introduction"), and either an audio file or a YouTube URL. - **Max 5 submissions per rolling 24 hours** per member. Failed and discarded submissions never count — no audio, no submission — so a yt-dlp outage costs nobody their quota. Checked before anything is written to disk. - Audio is Opus, 96 kbps, stereo, `.ogg`: `ffmpeg -i IN -c:a libopus -b:a 96k -ac 2 -vn OUT.ogg`. The original is discarded. - Stored at `storage/audio/.ogg`; the DB stores the filename. - `duration_seconds` is captured from `ffprobe` during conversion and stored on the row. - **Tracks longer than 15 minutes are rejected** at submit, from metadata, before any download. - `source_url` is kept on the song row for YouTube submissions — "listen on YouTube", and re-downloading if a conversion turns out bad. ### 2.1 Locked A song with at least one review is **locked**. While it is unlocked, its submitter may change **title, artist, genre, and description**, and may delete it. Audio is never editable. Locked is a live state, not a one-way door: if a review is deleted (§3) and the song drops back to zero reviews, it is editable again. - Submitter deletes: `POST /songs/{id}/delete`, unlocked only. - Admin deletes: `POST /admin/songs/{id}/delete`, unconditional. - Two routes rather than one route with a branch. - **Deleting a song deletes its `.ogg`.** The row and the file always go together. ### 2.2 Genres 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: | 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 | --- ## 3. Reviews - Score: integer 1–100. Text: required, ≤5000 chars. - One review per member per song. Rely on the DB unique constraint on `(song_id, reviewer_id)` and map error `23505` to a 409. - You cannot review your own song. - Reviews are never anonymous. - **30-minute window**, measured from `updated_at`, during which the reviewer may **edit or delete** their own review. An edit extends the window. After it closes the review is permanent. - Nobody else deletes a review. The admin's remedy for a bad one is deleting the song or talking to the person. ### 3.1 Reveal Other members' opinions of a song are hidden until you have reviewed it, or you are its submitter. - Covers **the reviews and the song's average score**, everywhere a song is named: the queue, the browse list, the song page, the API. - Enforced **server-side in the query**. Hidden reviews are never sent and hidden with CSS. - Does **not** apply to `/stats` — leaderboards are always public. - Profiles show counts and history-wide averages, never how someone reviewed a particular song. The principle, which settles future "can this page show X" questions: *per-song opinion is gated; whole-history aggregate is public.* --- ## 4. Submission pipeline Both paths — file upload and YouTube URL — converge on one pipeline. A YouTube song is not a different kind of song; it has an extra download step and a `source_url`. Two principles, and the rest follows: 1. **Nothing enters `songs` until the audio converts and the submitter confirms it.** So `songs` has no `status` column and no query anywhere filters on readiness. 2. **Metadata is read synchronously; conversion runs in the background.** Metadata arriving later would land in a form the submitter is already typing into, and every prefill would race their keystrokes. ### 4.1 Submit — `POST /submit` - **File upload:** cap the request body at 50 MB, write to `storage/tmp/`, then read tags with `ffprobe` (local, effectively instant). - **URL:** validate the host against an allowlist (`youtube.com`, `youtu.be`, `music.youtube.com`) *before* touching yt-dlp. Never pass an unvalidated string into a subprocess argument list, and never through a shell. Then `yt-dlp -J` for metadata only — no download yet. - Reject over-long tracks here, from `duration`. - Insert the `submissions` row with any prefilled title/artist, then redirect to `GET /submit/{id}`. `yt-dlp -J` is a 1–3 s network call, acceptable in the handler with a 15 s timeout. On timeout, render blank fields rather than failing the submission. **Prefill is a suggestion, not an answer:** | Source | Title | Artist | |---|---|---| | Uploaded file (`ffprobe`) | `title` tag | `artist` → `album_artist` | | YouTube (`yt-dlp -J`) | `track` → `title` | `artist` → `creator` → `uploader` | - `ffprobe` tag keys vary in case by container (`title`, `TITLE`, `Title`) — lowercase the map first. - YouTube's `track`/`artist` exist only for Topic channels, YouTube Music entries, and videos with a "Music in this video" panel. `testdata/ytdlp-noose.json` is a real dump of an ordinary upload: `track`, `artist`, `creator` and `album` are all null, leaving `uploader` and a title with double spaces in it. - If a field resolves to empty, **leave it blank.** A blank field prompts the submitter; a plausible `"Unknown"` does not. - **Never prefill the description from YouTube's `description`** — it is tracklists, socials, and affiliate links. The introduction wants the submitter's own words. - Tag text is attacker-controlled. `html/template` escapes on render, but truncate to the column limit and strip control characters and newlines before the insert: a title with an embedded newline wrecks every list layout it appears in. ### 4.2 Convert — background worker ```go // 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. var slots = make(chan struct{}, 2) ``` Unbounded goroutines shelling out to ffmpeg is how one enthusiastic evening fork-bombs a small VPS. Everything past the cap waits in `queued`. States: `queued` → (`downloading`, URL only) → `converting` → `ready` | `failed`. - Download: `yt-dlp -f bestaudio --no-playlist --max-filesize 100M -o storage/tmp/.%(ext)s ` - Convert: the ffmpeg command in §2. Its success **is** the validation — no container sniffing, no magic-byte library. If ffmpeg produced an Opus stream, it was audio. - On failure, store the stderr tail in `status_msg`. "Video unavailable in this region" is worth showing; "submission failed" is not. - Output lands at `storage/tmp/.ogg` — still not in `storage/audio/`. ### 4.3 The waiting page — `GET /submit/{id}` Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the introduction rather than watching a spinner. **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: ```html

{{.Label}}

``` The page includes the partial on first paint and `GET /submit/{id}/status` returns the same partial alone, so the markup exists once and arrives populated — no flash, no empty state, no client-side templating. HTMX stops polling when the fragment drops `hx-trigger`, which it does on a terminal status. Finnish strings never leave Go. With JS disabled the page degrades to "saving the form shows the current status", which is what a `` would have given at the cost of wiping whatever is being typed. ### 4.4 Publish — `POST /submit/{id}/publish` **Title, artist and genre are required to publish**; description is optional. Validated here, not at submit time — the form is meant to be filled during the wait. A blank field comes back as a field error on the same page with the audio still converted and waiting. ```go // Rename 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. tx.QueryRow(ctx, `INSERT INTO songs (...) VALUES (...) RETURNING id`).Scan(&songID) os.Rename(sub.TmpPath, filepath.Join("storage/audio", fmt.Sprint(songID)+".ogg")) tx.Exec(ctx, `DELETE FROM submissions WHERE id = $1`, sub.ID) tx.Commit(ctx) ``` ### 4.5 Failure means denied A failed submission is invisible to everyone but its submitter and never reaches `songs`. The row survives only to carry the error message and offer: - **Discard** — delete the row and unlink the temp file. - **Retry** — URL submissions only, re-queued with the title and introduction already typed. An upload cannot retry, its temp file is gone; offer re-upload. Neither costs quota (§2). ### 4.6 Startup sweeps Next to the migrations, before serving: ```sql UPDATE submissions SET status='failed', status_msg='interrupted by restart' WHERE status IN ('queued','downloading','converting'); DELETE FROM submissions WHERE created_at < now() - interval '7 days'; DELETE FROM sessions WHERE expires_at < now(); ``` An in-process goroutine dies with the process; without the first statement those rows say `converting` forever. The second covers failures *and* conversions nobody came back to publish — unlink `tmp_path` as you delete. Also unlink any `.ogg` in `storage/audio/` with no matching song row. No cron, no partitioning. Log a count of `failed` submissions at startup: an unpublished submission is invisible to everyone but its submitter, so a broken pipeline has no other way of announcing itself. ### 4.7 Operational notes - **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. --- ## 5. Pages Nav: **Jono** (`/`), **Kappaleet** (`/songs`), **Tilastot** (`/stats`), **Oma profiili** (`/profile`). ### 5.1 Jono — the queue, `GET /` The work list, not a catalogue: - Songs you have **not** reviewed, **oldest first**. - **Excludes your own songs** — you can never review them, so they would sit at the front forever. - No average score, no review text (§3.1). Review count is fine. - Drains to empty, and the empty state is a real state worth rendering well. - Cursor pagination, 20 per page. ### 5.2 Kappaleet — browse, `GET /songs` Everything, newest first, cursor-paginated, with badges (`arvosteltu`, `oma kappale`). `average_score` shows only for songs you have revealed. This is where a song lives after it leaves the queue, and the only way back to something you have already reviewed. ### 5.3 Song — `GET /songs/{id}` Player, metadata, your own review or the review form, and — once revealed — everyone else's reviews and the average. ### 5.4 Tilastot — `GET /stats` Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking; `min_reviews` is published, not hardcoded in a client. - Songs: Top 10 all-time, Bottom 10, Most Divisive (highest score spread), Most Unified (lowest), Most Reviewed. - Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific Submitter. - SQL does all of it: `avg()`, `count()`, `HAVING count(*) >= 3`. Order and limit in SQL, never in Go. SQLite has no `stddev_pop`, so the divisive/unified boards spell the population formula out — see `stddevPop` in `stats.go`. - **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC, id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads for no reason. - The count-based lists (Most Reviewed, Most Active) go stale once everyone has worked the queue. Prune them when they stop being interesting. ### 5.5 Profiles — `GET /profile`, `GET /profile/{id}` The member's songs, and counts and averages over their whole history: songs submitted, reviews written, average score given, average score received. **Never a list of their reviews** (§3.1). `POST /profile` edits name, email, password, avatar. No avatar → `avatar_url` is `null` and the template renders a circle with the member's initials, in CSS. No default image on disk. ### 5.6 Palaute — `GET /report?from=…`, `POST /report` - Any member, free text (≤2000 chars), nothing else. No category, no priority, no severity — with ten users a sentence and a page URL beat a taxonomy nobody fills in honestly. - A footer link on every page carries the current path into a hidden field. The server already knows where they were, so no JS. - `user_agent` is captured from the header. "Only on my phone" is the most common bug report and this answers it without asking. - Submitting redirects back to `from` with a flash toast. The reporter sees their own past reports listed, which is what stops the same bug arriving four times. --- ## 6. Admin **An admin is a member with `is_admin` set.** One boolean column on `users`, no role enum. They submit and review like anyone else and appear in every list and leaderboard, so no query has to exclude them. The admin UI is Finnish, like everything else. ```go // ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to // resolve reports without also being able to reset passwords. func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { m := memberFrom(r.Context()) if m == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if !m.IsAdmin { http.NotFound(w, r) return } next(w, r) } } ``` A signed-in member who is not an admin gets **404, not 403**: the admin pages are none of their business, and "forbidden" confirms there is something to be forbidden from. Everything else — the session cookie, `SameSite` CSRF protection, the login rate limiter, ban-drops-sessions — is reused rather than reimplemented, which is the whole point of the flag. ```go // One listener. /admin is a route on the member mux, gated per-route. log.Fatal(http.ListenAndServe(":8080", a.withMember(a.memberMux()))) ``` **Bootstrap:** registration needs an invite and invites are minted from `/admin`, so an empty database cannot grow a first user on its own. `seedAdmin` breaks the circle exactly once — on an empty `users` table it creates account number one from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and sets `is_admin`. Against a populated database it does nothing, which is what makes it safe to leave in the boot sequence. **Fatal at startup if those are unset on an empty database** — a site nobody can log into is worse than one that will not boot. **Routes:** `GET /admin` dashboard, `POST /admin/invites`, `POST /admin/users/{id}/password`, `POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`, `GET /admin/reports`, `POST /admin/reports/{id}/resolve`. There is no `/admin/audio/{id}`: an admin is a member, so `GET /audio/{id}` already works for them. **Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately. Their songs and reviews stay, keep counting in the stats, and keep their name on them: a ban ends participation, it does not rewrite history. An admin cannot ban *themselves* — the sessions would go with it and nothing would be left to undo it. **The admin surface has no API.** No client but a browser, so JSON would be contract surface with no consumer. --- ## 7. Two surfaces **One binary, one origin.** HTML at `/`, JSON at `/api/…`. No separate frontend, no static bundle, no second domain, and therefore no CORS. A reverse proxy in front is TLS termination, not a second application. **The page surface returns HTML, including fragments for HTMX. The API surface returns JSON and never HTML.** Which you get is decided by the route, not by an `Accept` header — one URL returning two shapes is a contract a client has to hope is honored, and it drags `Vary: Accept` along to stay cache-correct. ```go mux.HandleFunc("GET /songs/{id}", page(a.songData, "song.html")) // HTML, browser mux.HandleFunc("GET /api/songs/{id}", jsonOf(a.songData)) // JSON, always ``` **Every route is two thin adapters over one data function.** `songData` owns the reveal rule, the edit window, the locked rule — everything. The surfaces are physically incapable of diverging, which is the only real cost an API has. Templates receive the same struct the API marshals. The discipline that keeps it honest: - `json` tags on everything exposed; the struct **is** the contract. - **No display formatting in the data.** Dates stay timestamps, statuses stay codes with a `label` alongside. Formatting is a template func. - **Additive changes only** once a client is installed somewhere you cannot update. - **CORS** is a one-line middleware added the day a client fetches from another origin. Not before. - **Design the contract now; implement each endpoint when something calls it.** Nothing consumes `/api` while the browser talks HTML, and each route is one line over a data function that already exists. `GET /audio/{id}` stays a plain, Range-capable, auth-gated URL — not under `/api`, because it serves bytes rather than JSON. `http.ServeContent` handles 206/416/If-Range correctly for free. Parse `{id}` as an integer before it touches a path; that **is** the traversal check. ### 7.1 Page routes | Route | Purpose | |---|---| | `POST /login`, `POST /register`, `POST /logout` | Form posts, redirect on success | | `GET /` | Jono — the queue (§5.1) | | `GET /songs` | Kappaleet — browse (§5.2) | | `GET /songs/{id}` | Song detail, reveal rule applied | | `POST /songs/{id}` | Submitter edits title/artist/genre/description, unlocked only | | `POST /songs/{id}/delete` | Submitter deletes, unlocked only | | `POST /submit` | Upload or URL (§4.1) | | `GET /submit/{id}` | Waiting page, submitter-only | | `GET /submit/{id}/status` | HTML partial for the HTMX poll | | `POST /submit/{id}/publish`, `/retry`, `/discard` | (§4.4, §4.5) | | `POST /songs/{id}/review` | Create a review | | `POST /reviews/{id}`, `POST /reviews/{id}/delete` | Edit or delete, inside the 30-minute window | | `GET /stats` | Leaderboards | | `GET /profile`, `GET /profile/{id}`, `POST /profile` | Profiles | | `GET /audio/{id}` | Auth-gated, Range-capable | | `GET /avatars/{id}` | Public; 404 when there is no upload | | `GET /report`, `POST /report` | Palaute (§5.6) | HTML forms cannot send `PATCH` or `DELETE`, which is exactly why the browser uses `POST` here and the API uses the proper methods. The two-surface split earns its keep rather than contorting one surface to serve both. ### 7.2 Frontend Hand-written `style.css` (~200 lines, CSS custom properties at the top for the dark rock/metal theme, Oswald headings, orange/red accents) plus **vendored** HTMX and Alpine — downloaded files, not a CDN, ~30 KB together. Two script tags, no build step, no npm. - **HTMX** when the answer must come from the server: it swaps a rendered partial in, so there is no JSON tier and no duplicated markup. - **Alpine** when the page reacts only to itself. - **Neither** when a native element does the job: | Need | Use | |---|---| | Audio player | `