Step 2 of the build order. The admin mints an invite link, the recipient registers with it, and from then on has a session. - The invite is spent in the same transaction that creates the account, so a failed signup leaves the code usable - Sessions are idle timeouts, 24h or 30 days with remember me, read from a cookie or a bearer header, extended at most once a minute - Ban is a reversible toggle that drops the member's live sessions - No password minimum; login is rate limited instead, 10 failures per email in 15 minutes, cleared by a correct password - Invite codes render as links carrying ?code=, which the register form prefills; PUBLIC_URL makes them pasteable from the loopback admin panel Tests cover invite spending, the idle timeout, ban, and the rate limiter.
159 lines
5.8 KiB
Markdown
159 lines
5.8 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/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
|
||
|
||
## Branches
|
||
|
||
- **`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.
|
||
|
||
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, Postgres, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
|
||
yt-dlp. One binary, one origin — there is no separate frontend to deploy.
|
||
|
||
Go dependencies: `pgx/v5` and `golang.org/x/crypto`. No Node, no npm, no bundler.
|
||
|
||
## Running it
|
||
|
||
```sh
|
||
cp .env.example .env # then edit — ADMIN_PASSWORD has no default and the app won't start without it
|
||
docker compose up -d
|
||
```
|
||
|
||
Migrations apply themselves at startup, before the server accepts connections. The first launch
|
||
creates no users: log into the admin panel and mint an invite.
|
||
|
||
### Configuration
|
||
|
||
| Variable | Default | Notes |
|
||
|---|---|---|
|
||
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
|
||
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
|
||
| `ADMIN_USER` | `admin` | Admin panel username |
|
||
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
||
| `ADDR` | `:8080` | Member-facing listener |
|
||
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
|
||
| `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 member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
|
||
|
||
### Local development
|
||
|
||
```sh
|
||
docker compose up -d postgres
|
||
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
|
||
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||
go run .
|
||
```
|
||
|
||
Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
|
||
|
||
Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the
|
||
migration test drops and recreates the `public` schema, so never point it at anything you care about.
|
||
|
||
```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 panel
|
||
|
||
The admin is **not a user account**. It exists only as `ADMIN_USER` / `ADMIN_PASSWORD`, authenticates
|
||
with HTTP Basic Auth, and is bound to loopback so it is not reachable from the internet. Reach it
|
||
through an SSH tunnel:
|
||
|
||
```sh
|
||
ssh -L 8081:127.0.0.1:8081 you@server
|
||
# then open http://localhost:8081
|
||
```
|
||
|
||
From there: mint invites, reset member passwords, ban members, delete songs, read issue reports.
|
||
|
||
**Lost the admin password?** Edit `.env` and `docker compose restart app`. There is no recovery
|
||
endpoint and no recovery key — the credentials are the environment.
|
||
|
||
## Operations
|
||
|
||
### yt-dlp goes stale
|
||
|
||
yt-dlp needs regular updates to keep working against YouTube. It is installed with
|
||
`pip install -U yt-dlp` at image build time, 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
|
||
|
||
Two paths hold everything:
|
||
|
||
- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
|
||
the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data`
|
||
- `./storage` — audio files and avatars
|
||
|
||
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
|
||
startup anyway.
|
||
|
||
```sh
|
||
docker compose exec postgres pg_dump -U levyraati levyraati | gzip > backup-$(date +%F).sql.gz
|
||
```
|
||
|
||
### Database shell
|
||
|
||
```sh
|
||
docker compose exec postgres psql -U levyraati levyraati
|
||
```
|
||
|
||
## 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).
|