diff --git a/.env.example b/.env.example index 9f07709..86da3af 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,14 @@ -# Copy to .env and edit. The admin password has no default. -ADMIN_USER=admin +# Copy to .env and edit. + +# The first account. Only read when the database has no users: the app creates that account, +# marks it admin, and ignores these afterwards. Everyone else joins by invite. +ADMIN_EMAIL= ADMIN_PASSWORD= +ADMIN_NAME=Ylläpito # Set to false only for local development over plain HTTP. SECURE_COOKIES=true -# Public address of the member site. Used to build pasteable invite links in the admin panel. +# Public address of the site. Used to build pasteable invite links on the admin page. # Unset falls back to a relative link, which is fine locally. PUBLIC_URL=https://levyraati.example.com diff --git a/CONTEXT.md b/CONTEXT.md index 09f5ea5..2e5dbc1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,9 +13,9 @@ A person with an account. Every account is a member; there is no other kind. _Avoid_: user, käyttäjä, account **Admin** — _ylläpitäjä_: -The operator of the installation. Not a member and not an account — a set of credentials on a -separate surface. Never submits, reviews, or appears in any list of people. -_Avoid_: admin user, superuser, role +A member who also operates the installation. The same account, the same session, one extra flag — +so an admin submits and reviews like anyone else and does appear in lists of people. +_Avoid_: superuser, role, admin account (there is no separate account) **Invite** — _kutsu_ / **invite code** — _kutsukoodi_: A one-time code that permits one registration. Spent only by a registration that succeeds. diff --git a/README.md b/README.md index 6db24d7..d9a8df6 100644 --- a/README.md +++ b/README.md @@ -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=dev@example.com 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 diff --git a/admin.go b/admin.go index 3bd8c54..cbe1417 100644 --- a/admin.go +++ b/admin.go @@ -135,6 +135,13 @@ func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) return } + // Banning drops every session for the target, so an admin doing it to themselves would be + // locked out with nothing left that could unban them. The only route back is the database. + if me := memberFrom(r.Context()); me != nil && me.ID == id { + a.flash(w, "Et voi estää itseäsi.") + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } var banned bool err = a.db.QueryRowContext(r.Context(), `update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned) diff --git a/auth.go b/auth.go index 1a8a417..d486a7a 100644 --- a/auth.go +++ b/auth.go @@ -30,6 +30,7 @@ type member struct { Email string Avatar *string Banned bool + IsAdmin bool CreatedAt time.Time } @@ -109,10 +110,10 @@ func (a *app) session(w http.ResponseWriter, r *http.Request) *member { ) err := a.db.QueryRowContext(r.Context(), ` select s.expires_at, s.idle_ttl, - u.id, u.name, u.email, u.avatar, u.banned, u.created_at + u.id, u.name, u.email, u.avatar, u.banned, u.is_admin, u.created_at from sessions s join users u on u.id = s.user_id where s.token = $1 and s.expires_at > datetime('now')`, tok). - Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt) + Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.IsAdmin, &m.CreatedAt) if err != nil { if !errors.Is(err, sql.ErrNoRows) { slog.Error("session lookup", "ctx", "auth", "error", err) diff --git a/auth_test.go b/auth_test.go index eb3a13d..0b2d6b2 100644 --- a/auth_test.go +++ b/auth_test.go @@ -25,13 +25,22 @@ func testApp(t *testing.T) *app { if err := migrate(ctx, db); err != nil { t.Fatal(err) } - return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, db: db} + return &app{db: db} } func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + return postAs(t, h, path, form, "") +} + +// postAs is post with a session cookie, which is now the only way to reach an admin route. +func postAs(t *testing.T, h http.Handler, path string, form url.Values, token string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if token != "" { + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token}) + } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w @@ -132,6 +141,21 @@ func (a *app) seedMember(t *testing.T, email string) int64 { return id } +// seedAdminMember returns an admin account and a live session token for it. +func (a *app) seedAdminMember(t *testing.T, email string) (int64, string) { + t.Helper() + id := a.seedMember(t, email) + if _, err := a.db.ExecContext(context.Background(), + `update users set is_admin = 1 where id = $1`, id); err != nil { + t.Fatal(err) + } + tok, _, err := a.startSession(context.Background(), id, false) + if err != nil { + t.Fatal(err) + } + return id, tok +} + func (a *app) sessionFor(t *testing.T, token string) *member { t.Helper() r := httptest.NewRequest("GET", "/", nil) @@ -202,8 +226,8 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { t.Fatal(err) } - adminMux := a.adminMux() - if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther { + _, adminTok := a.seedAdminMember(t, "yllapito@example.com") + if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther { t.Fatalf("ban: status = %d, want 303", w.Code) } @@ -222,7 +246,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { } // Reversible: unban, and the same credentials work again. - if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther { + if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther { t.Fatalf("unban: status = %d, want 303", w.Code) } w = post(t, mux, "/login", url.Values{"email": {"esa@example.com"}, "password": {"salasana1"}}) @@ -231,6 +255,41 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { } } +// Self-banning drops your own sessions, and only an admin could undo it. Refuse. +func TestAdminCannotBanSelf(t *testing.T) { + a := testApp(t) + mux := a.withMember(a.memberMux()) + adminID, adminTok := a.seedAdminMember(t, "yllapito@example.com") + + if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", adminID), nil, adminTok); w.Code != http.StatusSeeOther { + t.Fatalf("self-ban: status = %d, want 303", w.Code) + } + var banned bool + if err := a.db.QueryRowContext(context.Background(), + `select banned from users where id = $1`, adminID).Scan(&banned); err != nil { + t.Fatal(err) + } + if banned { + t.Fatal("admin banned themselves") + } +} + +// A signed-in member who is not an admin must not be able to ban anyone. +func TestMemberCannotReachAdminRoutes(t *testing.T) { + a := testApp(t) + mux := a.withMember(a.memberMux()) + victim := a.seedMember(t, "uhri@example.com") + + plain := a.seedMember(t, "jasen@example.com") + tok, _, err := a.startSession(context.Background(), plain, false) + if err != nil { + t.Fatal(err) + } + if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", victim), nil, tok); w.Code != http.StatusNotFound { + t.Fatalf("member ban: status = %d, want 404", w.Code) + } +} + func TestInitials(t *testing.T) { for name, want := range map[string]string{ "Esa Kataja": "EK", diff --git a/docker-compose.yml b/docker-compose.yml index 1234149..9549e64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,11 @@ services: args: VERSION: ${VERSION:-dev} environment: - ADMIN_USER: ${ADMIN_USER:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} + # Only used to create the first account on an empty database; inert after that. + ADMIN_EMAIL: ${ADMIN_EMAIL:-} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ADMIN_NAME: ${ADMIN_NAME:-Ylläpito} ADDR: ":8080" - # Inside the container the admin listener must bind the container's own interface; it is not - # published below, so it stays unreachable from outside without a tunnel or the proxy. - 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. @@ -18,5 +17,4 @@ services: - ./storage:/storage ports: - "8080:8080" - - "8081:8081" restart: unless-stopped diff --git a/docs/decisions.md b/docs/decisions.md index caa3eff..a579dad 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/deployment.md b/docs/deployment.md index 6ccb756..7f79fa1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 | diff --git a/docs/spec.md b/docs/spec.md index 8f2605b..0fedd6a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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. diff --git a/main.go b/main.go index 00b7087..73c6bbe 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/subtle" "database/sql" "fmt" "log/slog" @@ -11,6 +10,7 @@ import ( "path/filepath" "strings" + "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" ) @@ -18,34 +18,31 @@ import ( var version = "dev" type config struct { - dbPath string - adminUser string + dbPath string + // Only read when the database has no users at all: seedAdmin turns these into account number + // one. Once that account exists they are dead weight and can leave the environment. + adminEmail string + adminName string adminPass string addr string - adminAddr string storageDir string secureCookies bool - // Public address of the member site, so admin-side invite links are pasteable. The admin - // listener's own Host is a tunnel, not the site, so it cannot be derived. + // Public address of the site, so invite links are pasteable out of the admin page. publicURL string } func loadConfig() config { c := config{ - adminUser: env("ADMIN_USER", "admin"), + adminEmail: os.Getenv("ADMIN_EMAIL"), + adminName: env("ADMIN_NAME", "Ylläpito"), adminPass: os.Getenv("ADMIN_PASSWORD"), addr: env("ADDR", ":8080"), - adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"), storageDir: env("STORAGE_DIR", "./storage"), secureCookies: env("SECURE_COOKIES", "true") != "false", publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"), } // The database lives beside the audio, so one volume is the whole backup. c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db")) - // An admin panel that silently opens is worse than one that won't boot. - if c.adminPass == "" { - fatal("ADMIN_PASSWORD is not set") - } return c } @@ -122,22 +119,48 @@ func main() { if err := sweep(ctx, db); err != nil { fatal("startup sweep", "error", err) } + if err := seedAdmin(ctx, db, cfg); err != nil { + fatal("seed admin", "error", err) + } a := &app{cfg: cfg, db: db} - // 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() { - slog.Info("admin listening", "ctx", "startup", "addr", cfg.adminAddr) - err := http.ListenAndServe(cfg.adminAddr, a.requireAdmin(a.adminMux())) - fatal("admin listener", "error", err) - }() - slog.Info("listening", "ctx", "startup", "addr", cfg.addr) fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux()))) } +// Registration needs an invite and invites are minted from the admin page, so a database with no +// users has no way to grow one. seedAdmin breaks that circle exactly once: on an empty users table +// it creates account number one from the environment and marks it admin. Every account after it +// arrives through an invite like anyone else. +// +// ponytail: no promote-existing-user path and no password reset here. Re-running against a +// populated database does nothing, which is what makes it safe to leave in the boot sequence. +func seedAdmin(ctx context.Context, db *sql.DB, cfg config) error { + var users int + if err := db.QueryRowContext(ctx, `select count(*) from users`).Scan(&users); err != nil { + return err + } + if users > 0 { + return nil + } + if cfg.adminEmail == "" || cfg.adminPass == "" { + // A site nobody can log into is worse than one that won't boot. + fatal("empty database: set ADMIN_EMAIL and ADMIN_PASSWORD to create the first account") + } + hash, err := bcrypt.GenerateFromPassword([]byte(cfg.adminPass), bcrypt.DefaultCost) + if err != nil { + return err + } + if _, err := db.ExecContext(ctx, + `insert into users (name, email, password_hash, is_admin) values ($1, $2, $3, 1)`, + cfg.adminName, strings.ToLower(cfg.adminEmail), string(hash)); err != nil { + return err + } + slog.Info("first admin created", "ctx", "startup", "email", cfg.adminEmail) + return nil +} + func (a *app) memberMux() *http.ServeMux { mux := http.NewServeMux() mux.Handle("GET /static/", http.FileServerFS(assetFS)) @@ -187,41 +210,40 @@ func (a *app) memberMux() *http.ServeMux { mux.HandleFunc("POST /submit/{id}/lyrics", a.requireMember(a.suggestLyrics)) mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry)) mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard)) + + a.adminRoutes(mux) return mux } -func (a *app) adminMux() *http.ServeMux { - mux := http.NewServeMux() - mux.Handle("GET /static/", http.FileServerFS(assetFS)) - mux.HandleFunc("GET /admin", a.adminDashboard) - mux.HandleFunc("POST /admin/invites", a.createInvite) - mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan) - mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword) - mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong) - mux.HandleFunc("GET /admin/reports", a.adminReports) - mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport) - mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio) - mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/admin", http.StatusSeeOther) - }) - return mux +// The admin pages sit on the same mux and the same session as everything else; only the guard +// differs. There is no /admin/audio: requireAdmin members can reach GET /audio/{id} like anyone. +func (a *app) adminRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /admin", a.requireAdmin(a.adminDashboard)) + mux.HandleFunc("POST /admin/invites", a.requireAdmin(a.createInvite)) + mux.HandleFunc("POST /admin/users/{id}/ban", a.requireAdmin(a.toggleBan)) + mux.HandleFunc("POST /admin/users/{id}/password", a.requireAdmin(a.resetPassword)) + mux.HandleFunc("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong)) + mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports)) + mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(a.resolveReport)) } -// 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. +// 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. // -// No bcrypt: 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. -func (a *app) requireAdmin(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - u, p, ok := r.BasicAuth() - userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1 - passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1 - if !ok || !userOK || !passOK { - w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`) - http.Error(w, "unauthorized", http.StatusUnauthorized) +// A signed-out visitor is sent to log in, the same as any member page. A signed-in member who is +// not an admin gets 404 rather than 403: the admin pages are none of their business, and saying +// "forbidden" confirms there is something there to be forbidden from. +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) + } } diff --git a/main_test.go b/main_test.go index b1afe9e..191209c 100644 --- a/main_test.go +++ b/main_test.go @@ -7,29 +7,29 @@ import ( "testing" ) +// A plain member must not be able to tell that /admin exists, and a stranger must be sent to log in. func TestRequireAdmin(t *testing.T) { - a := &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}} - h := a.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a := &app{} + h := a.requireAdmin(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) - })) + }) for _, tc := range []struct { - name, user, pass string - auth bool - want int + name string + as *member + want int }{ - {name: "no credentials", want: http.StatusUnauthorized}, - {name: "wrong password", user: "admin", pass: "hunter2", auth: true, want: http.StatusUnauthorized}, - {name: "wrong user", user: "root", pass: "s3cret", auth: true, want: http.StatusUnauthorized}, - {name: "correct", user: "admin", pass: "s3cret", auth: true, want: http.StatusTeapot}, + {name: "signed out", as: nil, want: http.StatusSeeOther}, + {name: "member", as: &member{ID: 1}, want: http.StatusNotFound}, + {name: "admin", as: &member{ID: 1, IsAdmin: true}, want: http.StatusTeapot}, } { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest("GET", "/admin", nil) - if tc.auth { - r.SetBasicAuth(tc.user, tc.pass) + if tc.as != nil { + r = r.WithContext(context.WithValue(r.Context(), memberKey, tc.as)) } w := httptest.NewRecorder() - h.ServeHTTP(w, r) + h(w, r) if w.Code != tc.want { t.Fatalf("status = %d, want %d", w.Code, tc.want) } @@ -37,6 +37,43 @@ func TestRequireAdmin(t *testing.T) { } } +// The first account cannot arrive by invite, because minting an invite needs an admin. +func TestSeedAdminOnlyOnEmptyDatabase(t *testing.T) { + a := testApp(t) + ctx := context.Background() + cfg := config{adminEmail: "Esa@Example.com", adminName: "Ylläpito", adminPass: "salasana1"} + + if err := seedAdmin(ctx, a.db, cfg); err != nil { + t.Fatal(err) + } + var name, email string + var isAdmin bool + if err := a.db.QueryRowContext(ctx, + `select name, email, is_admin from users`).Scan(&name, &email, &isAdmin); err != nil { + t.Fatal(err) + } + if !isAdmin || name != "Ylläpito" { + t.Fatalf("seeded %q is_admin=%v, want Ylläpito admin", name, isAdmin) + } + // Login is by lowercased email, so the seed must not smuggle in a capital. + if email != "esa@example.com" { + t.Fatalf("email = %q, want lowercased", email) + } + + // Re-running on a populated database must not add a second account or reset the first. + cfg.adminEmail = "toinen@example.com" + if err := seedAdmin(ctx, a.db, cfg); err != nil { + t.Fatal(err) + } + var n int + if err := a.db.QueryRowContext(ctx, `select count(*) from users`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("users = %d, want 1", n) + } +} + func TestMigrateIsIdempotent(t *testing.T) { ctx := context.Background() a := testApp(t) // already migrated once @@ -52,7 +89,7 @@ func TestMigrateIsIdempotent(t *testing.T) { if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil { t.Fatal(err) } - if n != 1 { - t.Fatalf("applied migrations = %d, want 1", n) + if n != 2 { + t.Fatalf("applied migrations = %d, want 2", n) } } diff --git a/migrations/002_users_is_admin.sql b/migrations/002_users_is_admin.sql new file mode 100644 index 0000000..3414822 --- /dev/null +++ b/migrations/002_users_is_admin.sql @@ -0,0 +1,8 @@ +-- The admin became an ordinary account with a flag, so the separate Basic Auth listener could go. +-- Same integer-as-boolean convention as `banned` above it. +alter table users add column is_admin integer not null default 0; + +-- An existing database already has its admin sitting in row one: the person who was handed the +-- first invite from the old panel. A fresh database has no rows, so this is a no-op there and +-- seedAdmin creates the account from the environment instead. +update users set is_admin = 1 where id = (select min(id) from users); diff --git a/reports.go b/reports.go index d7454c8..9331cec 100644 --- a/reports.go +++ b/reports.go @@ -190,9 +190,3 @@ func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) { } return out, rows.Err() } - -// Moderating a complaint means listening to the song, so the admin surface has its own audio route -// rather than branching auth inside the member handler. -func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) { - a.audio(w, r) -} diff --git a/templates/admin.html b/templates/admin.html index bc6f026..6303423 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -75,7 +75,7 @@ {{.Reviews}} {{fidate .CreatedAt}} - Kuuntele + Kuuntele
diff --git a/templates/layout.html b/templates/layout.html index dabe689..c8b29aa 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -16,15 +16,14 @@
Levyraati{{if .Admin}} ylläpito{{end}} - {{if .Admin}} - - - {{else if .Member}} + {{if .Member}}
@@ -45,6 +44,7 @@ Kappaleet Lähetä Tilastot + {{if .Member.IsAdmin}}Ylläpito{{end}} Oma profiili