Add project docs, glossary, and licence
The Nuxt version is being rewritten in Go. This is the design that came out of it, split by how each part ages: - CONTEXT.md glossary, English identifiers and Finnish UI wording - docs/spec.md behaviour: rules, submission pipeline, routes, API contract, schema - docs/decisions.md why, append-only - docs/later.md deliberately not in v1 testdata/ytdlp-noose.json is a real yt-dlp dump used to test metadata prefill against a video with no track, artist or album tags.
This commit is contained in:
+705
@@ -0,0 +1,705 @@
|
||||
# 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.
|
||||
- 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:
|
||||
|
||||
Rock, Metal, Punk, Blues, Jazz, Electronic, Hip Hop, Pop, Folk / Country, Classical, Soundtrack,
|
||||
Experimental, Finnish, Just Plain Weird, Other
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
The button reads *Muunnetaan…* and is disabled until `status = 'ready'`, when it becomes
|
||||
**Julkaise**. Publishing is always an explicit click — firing it automatically would race the
|
||||
submitter mid-sentence.
|
||||
|
||||
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 {{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.** `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 | `<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
|
||||
|
||||
Fixed before implementation, because the shape is the expensive thing to change once a client is
|
||||
installed somewhere you cannot update.
|
||||
|
||||
**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 (`bigserial`, 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 `bigserial`. 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.
|
||||
|
||||
```sql
|
||||
users (id bigserial pk, name, email unique, password_hash, avatar, banned, created_at)
|
||||
sessions (token pk, user_id fk not null, idle_ttl interval not null,
|
||||
expires_at, created_at) -- token: 32 random bytes, hex
|
||||
songs (id bigserial pk, title, artist, genre, description, audio_file,
|
||||
duration_seconds int,
|
||||
source_url, -- nullable, for YouTube submissions
|
||||
submitted_by fk users, created_at)
|
||||
submissions (id bigserial 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,
|
||||
created_at)
|
||||
reviews (id bigserial pk, song_id fk on delete cascade, reviewer_id fk users,
|
||||
score int, text, created_at, updated_at,
|
||||
unique (song_id, reviewer_id))
|
||||
invites (id bigserial pk, code unique, is_valid bool, created_at)
|
||||
reports (id bigserial pk, user_id fk not null, body text not null,
|
||||
page text, user_agent text,
|
||||
resolved_at timestamptz, -- null = open
|
||||
created_at)
|
||||
```
|
||||
|
||||
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, pgxpool, 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**, once something wants them.
|
||||
Reference in New Issue
Block a user