Make the admin a member with a flag, and drop the second listener
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.
This commit is contained in:
@@ -56,34 +56,36 @@ build stays `CGO_ENABLED=0`. 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
|
||||
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. The first launch
|
||||
creates no users: log into the admin panel and mint an invite.
|
||||
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_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 |
|
||||
| `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 member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
|
||||
| `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_PASSWORD=dev SECURE_COOKIES=false
|
||||
export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||||
go run .
|
||||
```
|
||||
|
||||
Requires Go 1.25+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first:
|
||||
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:
|
||||
@@ -95,21 +97,19 @@ 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
|
||||
## Admin page
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
```sh
|
||||
ssh -L 8081:127.0.0.1:8081 you@server
|
||||
# then open http://localhost:8081
|
||||
```
|
||||
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports.
|
||||
|
||||
From there: 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?** Edit `.env` and `docker compose restart app`. There is no recovery
|
||||
endpoint and no recovery key — the credentials are the environment.
|
||||
**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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user