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:
+7
-3
@@ -1,10 +1,14 @@
|
|||||||
# Copy to .env and edit. The admin password has no default.
|
# Copy to .env and edit.
|
||||||
ADMIN_USER=admin
|
|
||||||
|
# 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_PASSWORD=
|
||||||
|
ADMIN_NAME=Ylläpito
|
||||||
|
|
||||||
# Set to false only for local development over plain HTTP.
|
# Set to false only for local development over plain HTTP.
|
||||||
SECURE_COOKIES=true
|
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.
|
# Unset falls back to a relative link, which is fine locally.
|
||||||
PUBLIC_URL=https://levyraati.example.com
|
PUBLIC_URL=https://levyraati.example.com
|
||||||
|
|||||||
+3
-3
@@ -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
|
_Avoid_: user, käyttäjä, account
|
||||||
|
|
||||||
**Admin** — _ylläpitäjä_:
|
**Admin** — _ylläpitäjä_:
|
||||||
The operator of the installation. Not a member and not an account — a set of credentials on a
|
A member who also operates the installation. The same account, the same session, one extra flag —
|
||||||
separate surface. Never submits, reviews, or appears in any list of people.
|
so an admin submits and reviews like anyone else and does appear in lists of people.
|
||||||
_Avoid_: admin user, superuser, role
|
_Avoid_: superuser, role, admin account (there is no separate account)
|
||||||
|
|
||||||
**Invite** — _kutsu_ / **invite code** — _kutsukoodi_:
|
**Invite** — _kutsu_ / **invite code** — _kutsukoodi_:
|
||||||
A one-time code that permits one registration. Spent only by a registration that succeeds.
|
A one-time code that permits one registration. Spent only by a registration that succeeds.
|
||||||
|
|||||||
@@ -56,34 +56,36 @@ build stays `CGO_ENABLED=0`. No Node, no npm, no bundler.
|
|||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```sh
|
```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
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Migrations apply themselves at startup, before the server accepts connections. The first launch
|
Migrations apply themselves at startup, before the server accepts connections. On an empty database
|
||||||
creates no users: log into the admin panel and mint an invite.
|
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
|
### Configuration
|
||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
|
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
|
||||||
| `ADMIN_USER` | `admin` | Admin panel username |
|
| `ADMIN_EMAIL` | — | Login address of the first account. Required on an empty database, ignored afterwards |
|
||||||
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
| `ADMIN_PASSWORD` | — | Password for that account. Required on an empty database, ignored afterwards |
|
||||||
| `ADDR` | `:8080` | Member-facing listener |
|
| `ADMIN_NAME` | `Ylläpito` | Display name for that account |
|
||||||
| `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 |
|
| `ADDR` | `:8080` | The only listener |
|
||||||
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
||||||
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
| `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
|
### Local development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||||||
go run .
|
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.
|
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:
|
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
|
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
|
||||||
template changes. `go build && ./levyraati` is the loop.
|
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
|
An admin is **an ordinary member with `is_admin` set** — the same account, the same login, the same
|
||||||
with HTTP Basic Auth, and is bound to loopback so it is not reachable from the internet. Reach it
|
session cookie. Admins submit and review like anyone else; the flag adds a Ylläpito link to the nav
|
||||||
through an SSH tunnel:
|
and unlocks `/admin` on the normal listener. A signed-in member without the flag gets a 404 there.
|
||||||
|
|
||||||
```sh
|
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports.
|
||||||
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.
|
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
|
**Lost the admin password?** There is no recovery endpoint and no recovery key. Reset the hash
|
||||||
endpoint and no recovery key — the credentials are the environment.
|
directly in the SQLite file, the same as for any locked-out member.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
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
|
var banned bool
|
||||||
err = a.db.QueryRowContext(r.Context(),
|
err = a.db.QueryRowContext(r.Context(),
|
||||||
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type member struct {
|
|||||||
Email string
|
Email string
|
||||||
Avatar *string
|
Avatar *string
|
||||||
Banned bool
|
Banned bool
|
||||||
|
IsAdmin bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +110,10 @@ func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
|
|||||||
)
|
)
|
||||||
err := a.db.QueryRowContext(r.Context(), `
|
err := a.db.QueryRowContext(r.Context(), `
|
||||||
select s.expires_at, s.idle_ttl,
|
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
|
from sessions s join users u on u.id = s.user_id
|
||||||
where s.token = $1 and s.expires_at > datetime('now')`, tok).
|
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 err != nil {
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
slog.Error("session lookup", "ctx", "auth", "error", err)
|
slog.Error("session lookup", "ctx", "auth", "error", err)
|
||||||
|
|||||||
+63
-4
@@ -25,13 +25,22 @@ func testApp(t *testing.T) *app {
|
|||||||
if err := migrate(ctx, db); err != nil {
|
if err := migrate(ctx, db); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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()
|
t.Helper()
|
||||||
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if token != "" {
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
|
||||||
|
}
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
return w
|
return w
|
||||||
@@ -132,6 +141,21 @@ func (a *app) seedMember(t *testing.T, email string) int64 {
|
|||||||
return id
|
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 {
|
func (a *app) sessionFor(t *testing.T, token string) *member {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
@@ -202,8 +226,8 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminMux := a.adminMux()
|
_, adminTok := a.seedAdminMember(t, "[email protected]")
|
||||||
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("ban: status = %d, want 303", w.Code)
|
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.
|
// 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)
|
t.Fatalf("unban: status = %d, want 303", w.Code)
|
||||||
}
|
}
|
||||||
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "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, "[email protected]")
|
||||||
|
|
||||||
|
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, "[email protected]")
|
||||||
|
|
||||||
|
plain := a.seedMember(t, "[email protected]")
|
||||||
|
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) {
|
func TestInitials(t *testing.T) {
|
||||||
for name, want := range map[string]string{
|
for name, want := range map[string]string{
|
||||||
"Esa Kataja": "EK",
|
"Esa Kataja": "EK",
|
||||||
|
|||||||
+4
-6
@@ -5,12 +5,11 @@ services:
|
|||||||
args:
|
args:
|
||||||
VERSION: ${VERSION:-dev}
|
VERSION: ${VERSION:-dev}
|
||||||
environment:
|
environment:
|
||||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
# Only used to create the first account on an empty database; inert after that.
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
|
||||||
ADDR: ":8080"
|
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}
|
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
||||||
@@ -18,5 +17,4 @@ services:
|
|||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "8081:8081"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+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
|
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
|
in-app visibility is ever wanted, build an *audit* view of domain events instead; those are
|
||||||
queries over tables that already exist.
|
queries over tables that already exist.
|
||||||
8. **The admin is not a user.** Env credentials, Basic Auth, its own loopback listener. This deletes
|
8. **The admin is a member with `is_admin` set.** ~~The admin is not a user.~~ *Reversed.* The
|
||||||
the `role` column, first-launch seeding, admin sessions, the "cannot ban the last admin" rules,
|
original call — env credentials, Basic Auth, its own loopback listener — bought network isolation
|
||||||
and every "exclude the admin" clause that would otherwise appear in user and stats queries.
|
at the price of a second port to tunnel and proxy, and a second credential in the password
|
||||||
9. **Same process, two listeners** — not a second binary. A management binary would need its own
|
manager. Basic Auth also sat outside the `SameSite` protection the member cookie already had, and
|
||||||
deploy and would race the startup migrations. Two listeners give the network isolation, which was
|
left admin actions with no actor to log. One boolean column reuses the session, the login rate
|
||||||
the only real benefit.
|
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
|
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
|
`qwerty123` default in `docker-compose.yml`. There is no route, no key and no default: an admin
|
||||||
editing it and restarting. No route, no key, no default.
|
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
|
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
|
confirms.** Costs a `submissions` table, buys a `songs` table where every row is a real song and
|
||||||
no query filters on readiness.
|
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.
|
# running version. Kept in .env so this file carries no host of yours.
|
||||||
image: ${IMAGE:?set IMAGE in .env}
|
image: ${IMAGE:?set IMAGE in .env}
|
||||||
environment:
|
environment:
|
||||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
# Only read while the users table is empty: they create the first account and are ignored
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
# 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"
|
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}
|
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
||||||
@@ -35,30 +35,25 @@ services:
|
|||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "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
|
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 |
|
| | Development | Server |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Source of the binary | `build:` from the checkout | `image:` pulled from the registry |
|
| 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 |
|
| 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
|
There is one port. `/admin` rides the member listener behind the same session cookie as everything
|
||||||
the admin panel has HTTP Basic Auth and nothing else — no session, no lockout, no second factor. On
|
else, so there is nothing extra to publish, tunnel or firewall.
|
||||||
a server that must be `127.0.0.1:8081:8081`.
|
|
||||||
|
|
||||||
Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image:
|
Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
|
IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
|
||||||
ADMIN_USER=admin
|
ADMIN_EMAIL=… # first start only
|
||||||
ADMIN_PASSWORD=…
|
ADMIN_PASSWORD=… # first start only
|
||||||
SECURE_COOKIES=true
|
SECURE_COOKIES=true
|
||||||
PUBLIC_URL=https://levyraati.example.com
|
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
|
- **Response buffering off**, or at least generous timeouts, for `/audio/{id}` — it serves Range
|
||||||
requests so the player can seek.
|
requests so the player can seek.
|
||||||
|
|
||||||
Do **not** proxy port 8081.
|
|
||||||
|
|
||||||
### Admin access
|
### 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
|
TLS at the proxy is what makes the invite *Kopioi* button work — the clipboard API needs a secure
|
||||||
ssh -L 8081:127.0.0.1:8081 you@server
|
context, and `https://` is one. Over plain HTTP on a real hostname the button will not fire.
|
||||||
# then open http://localhost:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
**Lost the admin password?** There is no recovery endpoint and no recovery key. Set a new bcrypt
|
||||||
no recovery key — the credentials *are* the environment.
|
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 |
|
| 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 |
|
| `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 |
|
| `/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 |
|
| 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 |
|
| Invite links are relative | `PUBLIC_URL` unset |
|
||||||
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
|
| 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 |
|
| 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
|
## 6. Admin
|
||||||
|
|
||||||
**The admin is not a user.** They never submit, never review, and never see the member-facing site.
|
**An admin is a member with `is_admin` set.** One boolean column on `users`, no role enum. They
|
||||||
No `role` column, no admin row, no admin session, no admin login page, no first-launch seeding — and
|
submit and review like anyone else and appear in every list and leaderboard, so no query has to
|
||||||
no query anywhere has to exclude the admin from a list, a leaderboard, or an aggregate. The admin UI
|
exclude them. The admin UI is Finnish, like everything else.
|
||||||
is Finnish, like everything else.
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
|
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
|
||||||
// (close the browser). Add a cookie session if a second admin ever needs one.
|
// resolve reports without also being able to reset passwords.
|
||||||
func requireAdmin(next http.Handler) http.Handler {
|
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
u, p, ok := r.BasicAuth()
|
m := memberFrom(r.Context())
|
||||||
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(adminUser)) != 1 ||
|
if m == nil {
|
||||||
subtle.ConstantTimeCompare([]byte(p), []byte(adminPass)) != 1 {
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
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
|
A signed-in member who is not an admin gets **404, not 403**: the admin pages are none of their
|
||||||
the env file already. The constant-time compare is the part that
|
business, and "forbidden" confirms there is something to be forbidden from. Everything else — the
|
||||||
matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is
|
session cookie, `SameSite` CSRF protection, the login rate limiter, ban-drops-sessions — is reused
|
||||||
worse than one that will not boot.
|
rather than reimplemented, which is the whole point of the flag.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
|
// One listener. /admin is a route on the member mux, gated per-route.
|
||||||
// or the reverse proxy. A separate binary would need its own deploy and would race the
|
log.Fatal(http.ListenAndServe(":8080", a.withMember(a.memberMux())))
|
||||||
// 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))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bootstrap:** the admin logs in with the env credentials and mints the first invite. That is the
|
**Bootstrap:** registration needs an invite and invites are minted from `/admin`, so an empty
|
||||||
entire first-launch story. Losing the password is an edit to `.env` and a restart.
|
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`,
|
**Routes:** `GET /admin` dashboard, `POST /admin/invites`, `POST /admin/users/{id}/password`,
|
||||||
`POST /admin/users/{id}/password`, `POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`,
|
`POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`, `GET /admin/reports`,
|
||||||
`GET /admin/reports`, `POST /admin/reports/{id}/resolve`, and `GET /admin/audio/{id}` — moderating a
|
`POST /admin/reports/{id}/resolve`. There is no `/admin/audio/{id}`: an admin is a member, so
|
||||||
complaint means listening to the song, and a separate audio route avoids branching auth inside the
|
`GET /audio/{id}` already works for them.
|
||||||
member handler.
|
|
||||||
|
|
||||||
**Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately.
|
**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
|
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
|
**The admin surface has no API.** No client but a browser, so JSON would be contract surface with no
|
||||||
be contract surface with no consumer.
|
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,
|
1. **Skeleton** — `main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose,
|
||||||
the two listeners.
|
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,
|
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
|
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
|
||||||
dependency.
|
dependency.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -11,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,33 +19,30 @@ var version = "dev"
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
dbPath string
|
dbPath string
|
||||||
adminUser 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
|
adminPass string
|
||||||
addr string
|
addr string
|
||||||
adminAddr string
|
|
||||||
storageDir string
|
storageDir string
|
||||||
secureCookies bool
|
secureCookies bool
|
||||||
// Public address of the member site, so admin-side invite links are pasteable. The admin
|
// Public address of the site, so invite links are pasteable out of the admin page.
|
||||||
// listener's own Host is a tunnel, not the site, so it cannot be derived.
|
|
||||||
publicURL string
|
publicURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
c := config{
|
c := config{
|
||||||
adminUser: env("ADMIN_USER", "admin"),
|
adminEmail: os.Getenv("ADMIN_EMAIL"),
|
||||||
|
adminName: env("ADMIN_NAME", "Ylläpito"),
|
||||||
adminPass: os.Getenv("ADMIN_PASSWORD"),
|
adminPass: os.Getenv("ADMIN_PASSWORD"),
|
||||||
addr: env("ADDR", ":8080"),
|
addr: env("ADDR", ":8080"),
|
||||||
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
|
|
||||||
storageDir: env("STORAGE_DIR", "./storage"),
|
storageDir: env("STORAGE_DIR", "./storage"),
|
||||||
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
||||||
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
||||||
}
|
}
|
||||||
// The database lives beside the audio, so one volume is the whole backup.
|
// The database lives beside the audio, so one volume is the whole backup.
|
||||||
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db"))
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,22 +119,48 @@ func main() {
|
|||||||
if err := sweep(ctx, db); err != nil {
|
if err := sweep(ctx, db); err != nil {
|
||||||
fatal("startup sweep", "error", err)
|
fatal("startup sweep", "error", err)
|
||||||
}
|
}
|
||||||
|
if err := seedAdmin(ctx, db, cfg); err != nil {
|
||||||
|
fatal("seed admin", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
a := &app{cfg: cfg, db: db}
|
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)
|
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
||||||
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
|
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 {
|
func (a *app) memberMux() *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
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}/lyrics", a.requireMember(a.suggestLyrics))
|
||||||
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
||||||
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
||||||
|
|
||||||
|
a.adminRoutes(mux)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) adminMux() *http.ServeMux {
|
// The admin pages sit on the same mux and the same session as everything else; only the guard
|
||||||
mux := http.NewServeMux()
|
// differs. There is no /admin/audio: requireAdmin members can reach GET /audio/{id} like anyone.
|
||||||
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
func (a *app) adminRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /admin", a.adminDashboard)
|
mux.HandleFunc("GET /admin", a.requireAdmin(a.adminDashboard))
|
||||||
mux.HandleFunc("POST /admin/invites", a.createInvite)
|
mux.HandleFunc("POST /admin/invites", a.requireAdmin(a.createInvite))
|
||||||
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
|
mux.HandleFunc("POST /admin/users/{id}/ban", a.requireAdmin(a.toggleBan))
|
||||||
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
|
mux.HandleFunc("POST /admin/users/{id}/password", a.requireAdmin(a.resetPassword))
|
||||||
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
|
mux.HandleFunc("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong))
|
||||||
mux.HandleFunc("GET /admin/reports", a.adminReports)
|
mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
|
||||||
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
|
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
|
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
|
||||||
// (close the browser). Add a cookie session if a second admin ever needs one.
|
// 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
|
// A signed-out visitor is sent to log in, the same as any member page. A signed-in member who is
|
||||||
// env file already. The constant-time compare is the part that matters.
|
// not an admin gets 404 rather than 403: the admin pages are none of their business, and saying
|
||||||
func (a *app) requireAdmin(next http.Handler) http.Handler {
|
// "forbidden" confirms there is something there to be forbidden from.
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
u, p, ok := r.BasicAuth()
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1
|
m := memberFrom(r.Context())
|
||||||
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1
|
if m == nil {
|
||||||
if !ok || !userOK || !passOK {
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
if !m.IsAdmin {
|
||||||
})
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-14
@@ -7,29 +7,29 @@ import (
|
|||||||
"testing"
|
"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) {
|
func TestRequireAdmin(t *testing.T) {
|
||||||
a := &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}}
|
a := &app{}
|
||||||
h := a.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
h := a.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusTeapot)
|
w.WriteHeader(http.StatusTeapot)
|
||||||
}))
|
})
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name, user, pass string
|
name string
|
||||||
auth bool
|
as *member
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{name: "no credentials", want: http.StatusUnauthorized},
|
{name: "signed out", as: nil, want: http.StatusSeeOther},
|
||||||
{name: "wrong password", user: "admin", pass: "hunter2", auth: true, want: http.StatusUnauthorized},
|
{name: "member", as: &member{ID: 1}, want: http.StatusNotFound},
|
||||||
{name: "wrong user", user: "root", pass: "s3cret", auth: true, want: http.StatusUnauthorized},
|
{name: "admin", as: &member{ID: 1, IsAdmin: true}, want: http.StatusTeapot},
|
||||||
{name: "correct", user: "admin", pass: "s3cret", auth: true, want: http.StatusTeapot},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
r := httptest.NewRequest("GET", "/admin", nil)
|
r := httptest.NewRequest("GET", "/admin", nil)
|
||||||
if tc.auth {
|
if tc.as != nil {
|
||||||
r.SetBasicAuth(tc.user, tc.pass)
|
r = r.WithContext(context.WithValue(r.Context(), memberKey, tc.as))
|
||||||
}
|
}
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
h(w, r)
|
||||||
if w.Code != tc.want {
|
if w.Code != tc.want {
|
||||||
t.Fatalf("status = %d, want %d", 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: "[email protected]", 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 != "[email protected]" {
|
||||||
|
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 = "[email protected]"
|
||||||
|
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) {
|
func TestMigrateIsIdempotent(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a := testApp(t) // already migrated once
|
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 {
|
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if n != 1 {
|
if n != 2 {
|
||||||
t.Fatalf("applied migrations = %d, want 1", n)
|
t.Fatalf("applied migrations = %d, want 2", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -190,9 +190,3 @@ func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<td>{{.Reviews}}</td>
|
<td>{{.Reviews}}</td>
|
||||||
<td class="nowrap">{{fidate .CreatedAt}}</td>
|
<td class="nowrap">{{fidate .CreatedAt}}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="/admin/audio/{{.ID}}">Kuuntele</a>
|
<a href="/audio/{{.ID}}">Kuuntele</a>
|
||||||
<form method="post" action="/admin/songs/{{.ID}}/delete"
|
<form method="post" action="/admin/songs/{{.ID}}/delete"
|
||||||
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
|
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
|
||||||
<button type="submit" class="ghost danger">Poista</button>
|
<button type="submit" class="ghost danger">Poista</button>
|
||||||
|
|||||||
@@ -16,15 +16,14 @@
|
|||||||
<div class="topbar-inner">
|
<div class="topbar-inner">
|
||||||
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
|
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
|
||||||
|
|
||||||
{{if .Admin}}
|
{{if .Member}}
|
||||||
<nav class="navlinks"><a href="/admin" aria-current="page">Ylläpito</a></nav>
|
|
||||||
<span></span>
|
|
||||||
{{else if .Member}}
|
|
||||||
<nav class="navlinks">
|
<nav class="navlinks">
|
||||||
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
|
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
|
||||||
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
|
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
|
||||||
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
|
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
|
||||||
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
|
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
|
||||||
|
<!-- An admin is a member first: the same nav, with one link the others do not get. -->
|
||||||
|
{{if .Member.IsAdmin}}<a href="/admin" {{if .Admin}}aria-current="page"{{end}}>Ylläpito</a>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="userblock">
|
<div class="userblock">
|
||||||
<span class="lines">
|
<span class="lines">
|
||||||
@@ -45,6 +44,7 @@
|
|||||||
<a href="/songs">Kappaleet</a>
|
<a href="/songs">Kappaleet</a>
|
||||||
<a href="/submit">Lähetä</a>
|
<a href="/submit">Lähetä</a>
|
||||||
<a href="/stats">Tilastot</a>
|
<a href="/stats">Tilastot</a>
|
||||||
|
{{if .Member.IsAdmin}}<a href="/admin">Ylläpito</a>{{end}}
|
||||||
<a href="/profile">Oma profiili</a>
|
<a href="/profile">Oma profiili</a>
|
||||||
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
|
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user