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.
173 lines
7.0 KiB
Markdown
173 lines
7.0 KiB
Markdown
# Levyraati
|
||
|
||
A private music review club. Members submit songs — by file upload or YouTube link — and review each
|
||
other's picks on a 1–100 scale. Other people's reviews of a song stay hidden until you've written
|
||
your own.
|
||
|
||
Invite-only, no public registration. Built for about ten friends.
|
||
|
||
> **Status:** not built yet. This README describes the app defined in [docs/spec.md](docs/spec.md);
|
||
> commands here are the intended interface, not a record of anything that runs today.
|
||
|
||
## Docs
|
||
|
||
| File | What's in it |
|
||
|---|---|
|
||
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish |
|
||
| [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema |
|
||
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only |
|
||
| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff |
|
||
| [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
|
||
| [docs/deployment.md](docs/deployment.md) | Running it on a server: the compose file, releases, upgrades, backups |
|
||
|
||
## Branches and releases
|
||
|
||
- **`main`** — released code only. Every commit on it is something that ran in production, or is
|
||
meant to. Tagged at each release.
|
||
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and
|
||
reaches `main` by merge at release time.
|
||
|
||
Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1.
|
||
The version is injected at build time, so no file in the repo carries it:
|
||
|
||
```sh
|
||
git switch main && git merge --no-ff dev
|
||
git tag 2026.07.31-1
|
||
VERSION=$(git describe --tags --exact-match) docker compose build app
|
||
docker compose up -d app
|
||
```
|
||
|
||
A plain `go build` reports `dev`, which is the honest answer for a local binary. The running
|
||
version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is
|
||
actually deployed" is answerable without an SSH session.
|
||
|
||
The app never sends email — there is no verification, no password reset link, and no notifications.
|
||
Members have an address because it is their login and because mail is a planned feature.
|
||
|
||
## Stack
|
||
|
||
Go, SQLite, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
|
||
yt-dlp. One binary, one origin, one container — there is no separate frontend and no database server
|
||
to deploy.
|
||
|
||
Go dependencies: `modernc.org/sqlite` and `golang.org/x/crypto`. The SQLite driver is pure Go, so the
|
||
build stays `CGO_ENABLED=0`. No Node, no npm, no bundler.
|
||
|
||
## Running it
|
||
|
||
```sh
|
||
cp .env.example .env # then edit — set ADMIN_EMAIL and ADMIN_PASSWORD before the first start
|
||
docker compose up -d
|
||
```
|
||
|
||
Migrations apply themselves at startup, before the server accepts connections. On an empty database
|
||
the first launch creates one account from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and marks it admin; log
|
||
in as that account and mint invites for everyone else. The two variables are read only while the
|
||
`users` table is empty, so once that account exists they do nothing and can leave the environment.
|
||
|
||
### Configuration
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
|
||
| `ADMIN_EMAIL` | — | Login address of the first account. Required on an empty database, ignored afterwards |
|
||
| `ADMIN_PASSWORD` | — | Password for that account. Required on an empty database, ignored afterwards |
|
||
| `ADMIN_NAME` | `Ylläpito` | Display name for that account |
|
||
| `ADDR` | `:8080` | The only listener |
|
||
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
||
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
||
| `PUBLIC_URL` | — | Public address of the site, e.g. `https://levyraati.example.com`. Used to build invite links on the admin page; unset gives relative links |
|
||
|
||
### Local development
|
||
|
||
```sh
|
||
export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||
go run .
|
||
```
|
||
|
||
Requires Go 1.27+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first:
|
||
the database is a file under `./storage`, created on the first run.
|
||
|
||
Tests get a fresh database file in a temp directory each, so they need no setup and touch nothing:
|
||
|
||
```sh
|
||
go test ./...
|
||
```
|
||
|
||
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
|
||
template changes. `go build && ./levyraati` is the loop.
|
||
|
||
## Admin page
|
||
|
||
An admin is **an ordinary member with `is_admin` set** — the same account, the same login, the same
|
||
session cookie. Admins submit and review like anyone else; the flag adds a Ylläpito link to the nav
|
||
and unlocks `/admin` on the normal listener. A signed-in member without the flag gets a 404 there.
|
||
|
||
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports.
|
||
|
||
An admin cannot ban themselves, since banning drops every session for the target and nothing would
|
||
be left to undo it.
|
||
|
||
**Lost the admin password?** There is no recovery endpoint and no recovery key. Reset the hash
|
||
directly in the SQLite file, the same as for any locked-out member.
|
||
|
||
## Operations
|
||
|
||
### yt-dlp goes stale
|
||
|
||
yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community
|
||
repository, whose active branch tracks upstream closely, so rebuilding is how you update it:
|
||
|
||
```sh
|
||
docker compose build --no-cache app && docker compose up -d app
|
||
```
|
||
|
||
**Rebuild monthly.** When submissions start failing with download errors, this is the first thing to
|
||
try. Failures are shown to the submitter with the yt-dlp error attached, so they're visible without
|
||
reading logs.
|
||
|
||
### Logs
|
||
|
||
```sh
|
||
docker compose logs -f app
|
||
```
|
||
|
||
JSON to stdout, nothing else. There is no log table and no log viewer in the app.
|
||
|
||
### Backups
|
||
|
||
`./storage` holds everything: audio files, avatars, and `levyraati.db`. It is a bind mount, so a copy
|
||
of that one directory is the whole backup. `storage/tmp/` is in-flight conversions and is safe to
|
||
skip; it's cleared on startup anyway.
|
||
|
||
Copying the file while the app is running is not a backup — WAL means the latest writes live in a
|
||
sidecar file. Ask SQLite for a consistent snapshot instead:
|
||
|
||
```sh
|
||
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
|
||
gzip -c storage/tmp/backup.db > backup-$(date +%F).db.gz && rm storage/tmp/backup.db
|
||
```
|
||
|
||
### Database shell
|
||
|
||
```sh
|
||
docker compose exec app sqlite3 /storage/levyraati.db
|
||
```
|
||
|
||
## Layout
|
||
|
||
Go source is flat at the repository root, one package. Beyond that: `templates/` and `static/` are
|
||
embedded assets, `migrations/` holds numbered `.sql` files applied in order at startup, and
|
||
`testdata/` holds the golden JSON files that guard the API contract, plus `ytdlp-noose.json` — a real
|
||
`yt-dlp -J` dump of an ordinary upload, used to test metadata prefill against a video that has no
|
||
`track`, `artist` or `album` at all.
|
||
|
||
## Notes
|
||
|
||
Downloading audio from YouTube is against YouTube's terms of service. This is a private app among a
|
||
handful of friends; the decision is deliberate rather than accidental.
|
||
|
||
## Licence
|
||
|
||
MIT — see [LICENSE](LICENSE).
|