# 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, configuration, and operations are in the [README](../README.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.** `pip install -U yt-dlp` at image build; rebuild monthly. - 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 `stddev_pop`), Most Unified (lowest), Most Reviewed. - Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific Submitter. - Postgres does all of it: `avg()`, `count()`, `stddev_pop()`, `HAVING count(*) >= 3`. Order and limit in SQL, never in 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 **The admin is not a user.** They never submit, never review, and never see the member-facing site. No `role` column, no admin row, no admin session, no admin login page, no first-launch seeding — and no query anywhere has to exclude the admin from a list, a leaderboard, or an aggregate. The admin UI is Finnish, like everything else. ```go // 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. func requireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(adminUser)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(adminPass)) != 1 { w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`) http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } ``` No bcrypt here: 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. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is worse than one that will not boot. ```go // 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() { log.Fatal(http.ListenAndServe("127.0.0.1:8081", requireAdmin(adminMux))) }() log.Fatal(http.ListenAndServe(":8080", memberMux)) ``` **Bootstrap:** the admin logs in with the env credentials and mints the first invite. That is the entire first-launch story. Losing the password is an edit to `.env` and a restart. **Routes** (all on the loopback listener): `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`, and `GET /admin/audio/{id}` — moderating a complaint means listening to the song, and a separate audio route avoids branching auth inside the member handler. **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. **The admin surface has no API.** Basic Auth on loopback with no client but a browser — 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 | `