The admin was a set of env credentials on its own loopback listener. That bought network isolation, and charged a second port to tunnel and proxy and a second credential in the password manager. It also sat outside the SameSite protection the member cookie already had, and left every ban and password reset with no actor to log. is_admin on users reuses what was already there: the session, the login rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member mux. A member without the flag gets 404 rather than 403 — the pages are none of their business, and "forbidden" confirms there is something to be forbidden from. Registration needs an invite and invites come from /admin, so an empty database cannot grow its first user. seedAdmin breaks that circle exactly once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a database that already has users. An admin cannot ban themselves: banning drops the target's sessions, and nothing would be left that could undo it. This reverses decision 8, which is rewritten rather than deleted, along with the admin entry in the CONTEXT.md vocabulary.
745 lines
36 KiB
Markdown
745 lines
36 KiB
Markdown
# 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/<song_id>.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/<submission_id>`, 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/<id>.%(ext)s <url>`
|
||
- 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/<submission_id>.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
|
||
<!-- {{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 type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
|
||
</div>
|
||
```
|
||
|
||
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
|
||
`<meta http-equiv="refresh">` 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 | `<audio controls>` |
|
||
| Mobile nav toggle | `<details>` |
|
||
| Toasts | flash cookie, rendered server-side, cleared on read |
|
||
| Score slider | `<input type=range>` + `<output>` |
|
||
|
||
Today HTMX drives exactly one thing (the status poll) and Alpine has no job at all. Both are present
|
||
so the next dynamic feature needs no decision, not because v1 leans on them. **A page with no server
|
||
round trip and no local state gets neither `hx-` nor `x-` attributes.**
|
||
|
||
The ceiling: this pairing is for server-owned state. An interface where a lot of state lives on the
|
||
client and changes constantly is what a real client framework is for; nothing here is close.
|
||
|
||
**The audio player lives in one template partial** (`{{template "player" .}}`) — not an abstraction,
|
||
just not copy-pasting five lines into four pages. Replacing the native controls later then edits one
|
||
file.
|
||
|
||
---
|
||
|
||
## 8. API contract
|
||
|
||
**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**
|
||
|
||
- `snake_case` field names, matching the SQL columns.
|
||
- Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale
|
||
string, never a unix int.
|
||
- Ids are JSON numbers (SQLite rowids, safely under 2⁵³).
|
||
- Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a
|
||
few bytes, and "missing" versus "null" is a distinction clients get wrong.
|
||
- Scores are integers, averages are floats.
|
||
- **Every enum ships with a `label`**: `{"status":"converting","label":"Muunnetaan…"}`. Clients
|
||
render the label and never map codes to strings, so adding a status later breaks nothing and the
|
||
Finnish stays in Go.
|
||
- **Derived booleans accompany state**: `done`, `ready`, `failed`, `can_edit`, `can_delete`. A client
|
||
asking "can I edit this?" must never reimplement the 30-minute rule.
|
||
- Collections are `{"items": [...], "next_cursor": "…"|null}`. The cursor is **opaque** — today it is
|
||
the last id, but naming it a cursor means changing that is not a breaking change.
|
||
|
||
**Client contract:** ignore unknown fields, tolerate unknown enum values by falling back to `label`,
|
||
never derive permissions locally.
|
||
|
||
**Object shapes**
|
||
|
||
```jsonc
|
||
// song_summary — queue, browse, stats entries
|
||
{ "id": 42, "title": "…", "artist": "…", "genre": "Metal", "duration_seconds": 213,
|
||
"audio_url": "/audio/42", "submitted_by": <user_summary>, "created_at": "…",
|
||
"review_count": 5,
|
||
"reviews_revealed": false, // whether the viewer has unlocked this song
|
||
"average_score": null, // null when not revealed — see below
|
||
"own": false, "viewer_reviewed": false }
|
||
|
||
// song — detail, adds:
|
||
{ …song_summary,
|
||
"description": "…"|null, "source_url": null,
|
||
"reviews": [<review>]|null, // null when not revealed — never a lie-by-empty-array
|
||
"viewer_review": <review>|null,
|
||
"can_review": true, "can_edit": false, "can_delete": false }
|
||
|
||
// user_summary
|
||
{ "id": 3, "name": "Esa", "avatar_url": null } // null → client renders initials
|
||
|
||
// user — profile, adds created_at and:
|
||
{ "stats": { "songs_submitted": 4, "reviews_written": 12,
|
||
"average_score_given": 63.2, "average_score_received": 70.1 } }
|
||
|
||
// review
|
||
{ "id": 9, "song_id": 42, "reviewer": <user_summary>, "score": 88, "text": "…",
|
||
"created_at": "…", "updated_at": "…",
|
||
"editable_until": "…"|null, "can_edit": true, "can_delete": true }
|
||
|
||
// submission
|
||
{ "id": 7, "status": "converting", "label": "Muunnetaan…",
|
||
"done": false, "ready": false, "failed": false, "status_msg": null,
|
||
"title": "…"|null, "artist": "…"|null, "genre": null, "description": null,
|
||
"source_url": null, "can_retry": false, "created_at": "…" }
|
||
|
||
// report
|
||
{ "id": 2, "body": "…", "page": "/songs/42", "created_at": "…", "resolved_at": null }
|
||
```
|
||
|
||
`reviews: null` versus `[]` is deliberate: null means the reveal rule is withholding them, `[]` means
|
||
the song genuinely has none. Collapsing them would show "no reviews yet" on a song with twenty.
|
||
`average_score` is `null` for both reasons — `reviews_revealed` and `review_count` disambiguate, and
|
||
a client must not infer a score from either.
|
||
|
||
**Endpoints**
|
||
|
||
| Method | Path | Body / Query | Returns |
|
||
|---|---|---|---|
|
||
| `POST` | `/api/login` | `{email, password, remember?}` | `{token, expires_at, user}` |
|
||
| `POST` | `/api/logout` | — | `204` |
|
||
| `GET` | `/api/me` | — | `user` + `email` |
|
||
| `PATCH` | `/api/me` | `{name?, email?, current_password?, new_password?}` | `user` |
|
||
| `POST` | `/api/me/avatar` | multipart | `user` |
|
||
| `GET` | `/api/songs` | `?cursor=&limit=20&unreviewed=&order=` | `{items: [song_summary], next_cursor}` |
|
||
| `GET` | `/api/songs/{id}` | — | `song` |
|
||
| `PATCH` | `/api/songs/{id}` | `{title?, artist?, genre?, description?}` | `song` |
|
||
| `DELETE` | `/api/songs/{id}` | — | `204` |
|
||
| `POST` | `/api/songs/{id}/reviews` | `{score, text}` | `review` (`409` if one exists) |
|
||
| `PATCH` | `/api/reviews/{id}` | `{score?, text?}` | `review` (`403` past the window) |
|
||
| `DELETE` | `/api/reviews/{id}` | — | `204` (`403` past the window) |
|
||
| `POST` | `/api/submissions` | multipart, or `{source_url}` | `submission` (`429` over quota) |
|
||
| `GET` | `/api/submissions/{id}` | — | `submission` |
|
||
| `PATCH` | `/api/submissions/{id}` | `{title?, artist?, genre?, description?}` | `submission` |
|
||
| `POST` | `/api/submissions/{id}/publish` | — | `song` |
|
||
| `POST` | `/api/submissions/{id}/retry` | — | `submission` |
|
||
| `DELETE` | `/api/submissions/{id}` | — | `204` |
|
||
| `GET` | `/api/users/{id}` | — | `user` |
|
||
| `GET` | `/api/stats` | — | see below |
|
||
| `POST` | `/api/reports` | `{body, page?}` | `report` |
|
||
| `GET` | `/api/reports` | — | `{items: [report]}` (own only) |
|
||
|
||
`GET /api/songs` is the browse list by default: all songs, newest first. The queue is the same
|
||
endpoint with `?unreviewed=1&order=oldest`. Parameters rather than a second route, because the queue
|
||
is a *view* of songs and a future filter is another param, not another shape.
|
||
|
||
`GET /api/stats` returns one object of named leaderboards, every entry the same shape so a client
|
||
renders them all with one component:
|
||
|
||
```jsonc
|
||
{ "min_reviews": 3,
|
||
"top_songs": [{ "song": <song_summary>, "value": 88.2, "review_count": 6 }],
|
||
"bottom_songs": [ … ], "most_divisive": [ … ], "most_unified": [ … ],
|
||
"most_reviewed": [ … ],
|
||
"harshest_critics": [{ "user": <user_summary>, "value": 41.3, "review_count": 9 }],
|
||
"most_generous": [ … ], "most_active": [ … ], "most_prolific": [ … ] }
|
||
```
|
||
|
||
Adding a leaderboard later is a new key, which is additive. Removing one is not, which is one more
|
||
reason the count-based lists stay until they are proven useless.
|
||
|
||
**Errors** — one shape, always:
|
||
|
||
```jsonc
|
||
{ "error": { "code": "already_reviewed",
|
||
"message": "Olet jo arvostellut tämän kappaleen.",
|
||
"fields": { "score": "Pisteiden tulee olla 1–100." } } }
|
||
```
|
||
|
||
`code` is machine-stable and never changes; `message` is Finnish and may. `fields` appears only for
|
||
`validation_failed`. Codes: `unauthorized` (401), `forbidden` (403), `not_found` (404),
|
||
`validation_failed` (422), `already_reviewed` (409), `edit_window_expired` (403), `invite_invalid`
|
||
(422), `banned` (403), `payload_too_large` (413), `quota_exceeded` (429).
|
||
|
||
---
|
||
|
||
## 9. Data model
|
||
|
||
Ids are `integer primary key autoincrement` — never reused, because `storage/audio/<song_id>.ogg` is
|
||
named after one. Session tokens stay random — those are secrets, ids are not, and enumerable ids are
|
||
not a threat model for a login-walled app for ten friends.
|
||
|
||
Timestamps are declared `timestamp` and hold UTC `YYYY-MM-DD HH:MM:SS`: the declared type is what
|
||
makes the driver return `time.Time`, and the fixed-width UTC string is what makes ordering and
|
||
comparison against `datetime('now')` mean what they say. Booleans are `integer`, 0 or 1.
|
||
|
||
```sql
|
||
users (id pk, name, email unique, password_hash, avatar, banned, created_at)
|
||
sessions (token pk, user_id fk not null, idle_ttl integer not null, -- seconds
|
||
expires_at, created_at) -- token: 32 random bytes, hex
|
||
songs (id pk, title, artist, genre, description, lyrics, audio_file,
|
||
duration_seconds integer,
|
||
source_url, -- nullable, for YouTube submissions
|
||
submitted_by fk users, created_at)
|
||
submissions (id pk, user_id fk not null,
|
||
status text not null default 'queued', -- queued|downloading|converting|ready|failed
|
||
status_msg text,
|
||
source_url, tmp_path,
|
||
title, artist, genre, description, lyrics,
|
||
created_at)
|
||
reviews (id pk, song_id fk on delete cascade, reviewer_id fk users,
|
||
score integer, text, created_at, updated_at,
|
||
unique (song_id, reviewer_id))
|
||
invites (id pk, code unique, is_valid integer, created_at)
|
||
reports (id pk, user_id fk not null, body text not null,
|
||
page text, user_agent text,
|
||
resolved_at timestamp, -- null = open
|
||
created_at)
|
||
```
|
||
|
||
`foreign_keys` is off by default in SQLite, so the cascades above only exist because the pragma is
|
||
set on every connection — see `openDB` in `main.go`.
|
||
|
||
No `role` column (§6). No `status` on `songs` (§4).
|
||
|
||
Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on
|
||
`reviews(song_id)`, and an index supporting the queue's "songs this member has not reviewed".
|
||
|
||
Files on disk: `storage/tmp/<submission_id>[.ogg]` in flight, `storage/audio/<song_id>.ogg` once
|
||
published. A row and its file are always created and deleted together.
|
||
|
||
Migrations are numbered `.sql` files in an `embed.FS`, applied at startup before serving.
|
||
|
||
---
|
||
|
||
## 10. Logging
|
||
|
||
`slog.NewJSONHandler(os.Stdout, …)` and nothing else. `docker compose logs` is the log viewer — there
|
||
is no log table and no in-app viewer.
|
||
|
||
Group with `slog.With("ctx", …)`: `songs`, `reviews`, `auth`, `invites`, `submissions`, `startup`.
|
||
|
||
---
|
||
|
||
## 11. Tests
|
||
|
||
Each of these is a place where a plausible-looking implementation is silently wrong:
|
||
|
||
- **Invite burning** — a failed registration leaves the code usable; a successful one does not.
|
||
- **The 30-minute window** — measured from `updated_at`, so an edit extends it. Check the boundary,
|
||
the expired case, and that it gates delete as well as edit.
|
||
- **The reveal query** — a member who has not reviewed a song must not receive other reviews *in the
|
||
result set*, and must not receive the average. Assert on the JSON: `reviews` is `null`, not `[]`,
|
||
and `average_score` is `null`.
|
||
- **The queue** — excludes your own songs and songs you have reviewed, orders oldest first, and
|
||
re-includes nothing when a review is deleted by someone else.
|
||
- **Unlock** — deleting the only review makes the song editable by its submitter again.
|
||
- **The submission quota** — five in 24 hours, and a failed submission does not consume one.
|
||
- **Publish** — the song row and its `.ogg` appear together, or neither does. Force the rename to
|
||
fail and assert no orphan row.
|
||
- **Restart recovery** — a submission left `converting` becomes `failed` at the next startup.
|
||
- **The API contract** — one golden file per object shape. It fails the moment a field is renamed or
|
||
dropped, which is the breakage that is expensive later and invisible in review.
|
||
|
||
Everything else is forms and `INSERT`s. No framework, no fixtures beyond a test database.
|
||
|
||
---
|
||
|
||
## 12. Build order
|
||
|
||
Each step leaves something runnable. Registration needs an invite and invites come from the admin
|
||
panel, so the admin surface comes first — before a single member can exist.
|
||
|
||
1. **Skeleton** — `main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose,
|
||
the two listeners.
|
||
2. **Admin, invites, auth** — seed the first admin, mint an invite, register, log in, sessions, ban.
|
||
3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet,
|
||
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
|
||
dependency.
|
||
4. **Queue, song page, reviews, reveal** — the product loop. **Usable by ten people at the end of
|
||
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** — deferred until something wants them (decision 43).
|