Files
Levyraati26_go/README.md
T
Esa Kataja deaadd2f5c Add announcements, and record when members last logged in
A single place to say "downloads work again" without messaging everyone.
The body is markdown, stored as typed and rendered on the way out, so a post
survives editing without a lossy round trip through HTML. goldmark drops raw
HTML rather than rendering it, which matters because the body reaches the
page through template.HTML with Go's own escaping switched off.

The front page carries the three newest under the queue, newest expanded,
and links to /news only when there is a fourth. News is decoration there: if
the query fails the queue still renders. Drafts exist so a post can be
written before it is sent, and hiding is the same toggle as publishing.

Ages read as "5 minuuttia sitten" for a week and then become a date, since
past that the exact age stops being the interesting part.

last_login_at is unrelated to the feed — it answers "does anyone actually
use this", and stays null until a real login, which is how an unused invite
shows up in the members table.

Ago and HTML take value receivers on purpose: templates reach them through
dict, which boxes the item in an interface, and a pointer method on a
non-addressable value is invisible there. That failure renders as a 500 and
is invisible to go vet, so TestFrontPageRendersNews renders the real page.
2026-09-05 16:00:01 +03:00

207 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Levyraati
A private music review club. Members submit songs — by file upload or YouTube link — and review each
other's picks on a 1100 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 |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. An unparseable value falls back to `info` |
| `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 ./src
```
The package lives in `src/`, together with the `templates/`, `static/` and `migrations/` it embeds —
`//go:embed` cannot reach outside its own directory, so the assets live beside the code that reads
them. `storage/` stays at the root, since it is runtime data rather than source.
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.
`make` is everything that has to pass before a commit — formatting, `go vet`, and the tests:
```sh
make # gofmt -l, go vet, go test
make test # just the tests
```
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
template changes. `make run` 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,
post announcements, and see when each member last logged in.
## Announcements
`/admin` has a plain title-and-textarea form. The body is **markdown**, stored exactly as typed and
rendered on the way out, so a post can be edited without a lossy round trip through HTML. Raw HTML
in a post is dropped rather than rendered — the parser is [goldmark](https://github.com/yuin/goldmark)
with the unsafe option deliberately off.
A post is published unless *Tallenna luonnoksena* is ticked. Draft and published is one toggle
afterwards, so something that went out too early can be pulled back without losing the text.
Members see the three newest on the front page under the queue, newest expanded, with the rest on
`/news`. Reading requires login, like everything else. Timestamps are relative for the first week
(*5 minuuttia sitten*, *eilen*, *3 päivää sitten*) and a plain date after that.
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
`src/` is the whole program: one flat `package main`, with the assets it embeds beside it, because
`//go:embed` cannot reach outside its own directory. `templates/` and `static/` are those 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.
The root keeps what is not source: `docs/`, the container and compose files, the `Makefile`, and
`storage/` once the app has run.
| Command | Does |
|---|---|
| `make` | gofmt, `go vet`, `go test` — everything that must pass before a commit |
| `make run` | Build and start on `127.0.0.1:8080` with development defaults |
| `make fix` | Show `go fix` modernizer suggestions as a diff, without applying them |
| `make image IMAGE=…` | Build a release image tagged from `git describe`; refuses an untagged HEAD |
| `make db` / `make backup` | SQLite shell, and a WAL-safe snapshot, against the running container |
## 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).