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:
+14
-8
@@ -33,15 +33,21 @@ and `storage` was test data, so **the schema has no legacy to respect.**
|
||||
retention `DELETE`, a table and a filtered page — ~150 lines to avoid `docker compose logs`. If
|
||||
in-app visibility is ever wanted, build an *audit* view of domain events instead; those are
|
||||
queries over tables that already exist.
|
||||
8. **The admin is not a user.** Env credentials, Basic Auth, its own loopback listener. This deletes
|
||||
the `role` column, first-launch seeding, admin sessions, the "cannot ban the last admin" rules,
|
||||
and every "exclude the admin" clause that would otherwise appear in user and stats queries.
|
||||
9. **Same process, two listeners** — not a second binary. A management binary would need its own
|
||||
deploy and would race the startup migrations. Two listeners give the network isolation, which was
|
||||
the only real benefit.
|
||||
8. **The admin is a member with `is_admin` set.** ~~The admin is not a user.~~ *Reversed.* The
|
||||
original call — env credentials, Basic Auth, its own loopback listener — bought network isolation
|
||||
at the price of a second port to tunnel and proxy, and a second credential in the password
|
||||
manager. Basic Auth also sat outside the `SameSite` protection the member cookie already had, and
|
||||
left admin actions with no actor to log. One boolean column reuses the session, the login rate
|
||||
limiter, the ban-drops-sessions path and CSRF protection that all existed anyway. The costs the
|
||||
original entry named are real but small here: seeding is `seedAdmin` on an empty database, and
|
||||
the only lockout rule is that an admin cannot ban themselves. Banning a *second* admin is allowed
|
||||
— with one admin per installation there is no last-admin case to protect.
|
||||
9. **No moderator tier.** A four-level role enum was considered and dropped: nothing in the admin
|
||||
surface distinguishes a superadmin from an admin, and moderator is a second column on the day
|
||||
somebody needs to resolve reports without also being able to reset passwords.
|
||||
10. **The admin recovery endpoint is dropped.** The old app had a key-gated credential reset with a
|
||||
`qwerty123` default in `docker-compose.yml`. The password is an env var now, so recovery is
|
||||
editing it and restarting. No route, no key, no default.
|
||||
`qwerty123` default in `docker-compose.yml`. There is no route, no key and no default: an admin
|
||||
who loses their password is reset from the database, the same as any locked-out member.
|
||||
11. **Conversion runs in the background; nothing enters `songs` until it succeeds and the submitter
|
||||
confirms.** Costs a `submissions` table, buys a `songs` table where every row is a real song and
|
||||
no query filters on readiness.
|
||||
|
||||
+25
-28
@@ -22,12 +22,12 @@ services:
|
||||
# running version. Kept in .env so this file carries no host of yours.
|
||||
image: ${IMAGE:?set IMAGE in .env}
|
||||
environment:
|
||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||
# Only read while the users table is empty: they create the first account and are ignored
|
||||
# from then on. Safe to remove once that account exists.
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
|
||||
ADDR: ":8080"
|
||||
# The admin listener binds the container's own interface. What keeps it private is the
|
||||
# published port below, bound to the host's loopback.
|
||||
ADMIN_ADDR: ":8081"
|
||||
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
||||
@@ -35,30 +35,25 @@ services:
|
||||
- ./storage:/storage
|
||||
ports:
|
||||
- "8080:8080"
|
||||
# Loopback only. The admin panel is Basic Auth and nothing else, so it must never be
|
||||
# reachable from the network — reach it over an SSH tunnel, below.
|
||||
- "127.0.0.1:8081:8081"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Three differences from the development file, and the reason for each:
|
||||
Two differences from the development file, and the reason for each:
|
||||
|
||||
| | Development | Server |
|
||||
|---|---|---|
|
||||
| Source of the binary | `build:` from the checkout | `image:` pulled from the registry |
|
||||
| Version | `VERSION` build arg, `dev` by default | baked into the tagged image |
|
||||
| Admin port | `8081:8081`, reachable, convenient locally | `127.0.0.1:8081:8081`, loopback only |
|
||||
|
||||
**The admin port is the one that matters.** Published as `8081:8081` it binds every interface, and
|
||||
the admin panel has HTTP Basic Auth and nothing else — no session, no lockout, no second factor. On
|
||||
a server that must be `127.0.0.1:8081:8081`.
|
||||
There is one port. `/admin` rides the member listener behind the same session cookie as everything
|
||||
else, so there is nothing extra to publish, tunnel or firewall.
|
||||
|
||||
Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image:
|
||||
|
||||
```sh
|
||||
IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=…
|
||||
ADMIN_EMAIL=… # first start only
|
||||
ADMIN_PASSWORD=… # first start only
|
||||
SECURE_COOKIES=true
|
||||
PUBLIC_URL=https://levyraati.example.com
|
||||
```
|
||||
@@ -132,23 +127,24 @@ Proxy your public hostname to `127.0.0.1:8080`. Two things matter beyond the def
|
||||
- **Response buffering off**, or at least generous timeouts, for `/audio/{id}` — it serves Range
|
||||
requests so the player can seek.
|
||||
|
||||
Do **not** proxy port 8081.
|
||||
|
||||
### Admin access
|
||||
|
||||
Bound to the host's loopback, so reach it through an SSH tunnel:
|
||||
Log in as your own account and open `/admin`. Nothing to tunnel, nothing extra to proxy: the page is
|
||||
part of the site and is gated on the `is_admin` flag on your user row. A signed-in member without the
|
||||
flag gets a 404 there, so the page does not advertise itself.
|
||||
|
||||
```sh
|
||||
ssh -L 8081:127.0.0.1:8081 you@server
|
||||
# then open http://localhost:8081
|
||||
```
|
||||
TLS at the proxy is what makes the invite *Kopioi* button work — the clipboard API needs a secure
|
||||
context, and `https://` is one. Over plain HTTP on a real hostname the button will not fire.
|
||||
|
||||
Localhost also happens to be a secure context, which is what makes the invite *Kopioi* button work.
|
||||
From the page: mint invites, reset passwords, ban members, delete songs, read feedback.
|
||||
|
||||
From the panel: mint invites, reset passwords, ban members, delete songs, read feedback.
|
||||
**First start.** On an empty database the app creates one account from `ADMIN_EMAIL` /
|
||||
`ADMIN_PASSWORD` and marks it admin. Once it exists those variables do nothing; drop them from
|
||||
`.env` if you would rather not keep a password there.
|
||||
|
||||
**Lost the admin password?** Edit `.env`, `docker compose up -d`. 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. Set a new bcrypt
|
||||
hash directly in the SQLite file — re-running the app with `ADMIN_PASSWORD` will not help, because
|
||||
seeding only fires on an empty `users` table.
|
||||
|
||||
---
|
||||
|
||||
@@ -266,7 +262,7 @@ bounded by the two conversion slots.
|
||||
|
||||
| Symptom | Cause |
|
||||
|---|---|
|
||||
| Container exits immediately | `ADMIN_PASSWORD` unset. The log says so, and it is deliberate — an admin panel that silently opens is worse than one that will not boot |
|
||||
| Container exits immediately on a first start | `ADMIN_EMAIL` or `ADMIN_PASSWORD` unset on an empty database. The log says so; a site nobody can log into is worse than one that will not boot |
|
||||
| `set IMAGE in .env` | Compose has no image to run; `IMAGE` is required and unset |
|
||||
| `/healthz` says `ok dev` | The image was built without `--build-arg VERSION`, so what is deployed cannot be identified |
|
||||
| Login never sticks | Plain HTTP with `SECURE_COOKIES=true`. Terminate TLS, or set it `false` for a local test |
|
||||
@@ -274,4 +270,5 @@ bounded by the two conversion slots.
|
||||
| Invite links are relative | `PUBLIC_URL` unset |
|
||||
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
|
||||
| Submissions all fail at download | yt-dlp is stale; rebuild and publish the image |
|
||||
| Admin panel answers from another machine | The admin port is published on all interfaces — it must be `127.0.0.1:8081:8081` |
|
||||
| `/admin` returns 404 while logged in | That account has no `is_admin`. Set it in the database; nothing in the UI grants it |
|
||||
| Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |
|
||||
|
||||
+37
-35
@@ -364,56 +364,58 @@ template renders a circle with the member's initials, in CSS. No default image o
|
||||
|
||||
## 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.
|
||||
**An admin is a member with `is_admin` set.** One boolean column on `users`, no role enum. They
|
||||
submit and review like anyone else and appear in every list and leaderboard, so no query has to
|
||||
exclude them. 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)
|
||||
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
|
||||
// resolve reports without also being able to reset passwords.
|
||||
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m := memberFrom(r.Context())
|
||||
if m == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
if !m.IsAdmin {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in
|
||||
the env file 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.
|
||||
A signed-in member who is not an admin gets **404, not 403**: the admin pages are none of their
|
||||
business, and "forbidden" confirms there is something to be forbidden from. Everything else — the
|
||||
session cookie, `SameSite` CSRF protection, the login rate limiter, ban-drops-sessions — is reused
|
||||
rather than reimplemented, which is the whole point of the flag.
|
||||
|
||||
```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))
|
||||
// One listener. /admin is a route on the member mux, gated per-route.
|
||||
log.Fatal(http.ListenAndServe(":8080", a.withMember(a.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.
|
||||
**Bootstrap:** registration needs an invite and invites are minted from `/admin`, so an empty
|
||||
database cannot grow a first user on its own. `seedAdmin` breaks the circle exactly once — on an
|
||||
empty `users` table it creates account number one from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and sets
|
||||
`is_admin`. Against a populated database it does nothing, which is what makes it safe to leave in
|
||||
the boot sequence. **Fatal at startup if those are unset on an empty database** — a site nobody can
|
||||
log into is worse than one that will not boot.
|
||||
|
||||
**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.
|
||||
**Routes:** `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`. There is no `/admin/audio/{id}`: an admin is a member, so
|
||||
`GET /audio/{id}` already works for them.
|
||||
|
||||
**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.
|
||||
participation, it does not rewrite history. An admin cannot ban *themselves* — the sessions would go
|
||||
with it and nothing would be left to undo it.
|
||||
|
||||
**The admin surface has no API.** Basic Auth on loopback with no client but a browser — JSON would
|
||||
be contract surface with no consumer.
|
||||
**The admin surface has no API.** No client but a browser, so JSON would be contract surface with no
|
||||
consumer.
|
||||
|
||||
---
|
||||
|
||||
@@ -731,7 +733,7 @@ panel, so the admin surface comes first — before a single member can exist.
|
||||
|
||||
1. **Skeleton** — `main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose,
|
||||
the two listeners.
|
||||
2. **Admin, invites, auth** — Basic Auth listener, mint an invite, register, log in, sessions, ban.
|
||||
2. **Admin, invites, auth** — seed the first admin, 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.
|
||||
|
||||
Reference in New Issue
Block a user