Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.
The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:
- timestamptz becomes timestamp holding 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.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
formula out, guarded with max(0.0, ...) because cancellation returns a
tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
pragma is set on every connection.
Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
742 lines
36 KiB
Markdown
742 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, 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/<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
|
||
|
||
**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 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 | `<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** — Basic Auth listener, 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).
|