Files
Levyraati26_go/admin.go
T
Esa Kataja 2af29fe999 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.
2026-09-05 13:40:26 +03:00

201 lines
5.8 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
"golang.org/x/crypto/bcrypt"
)
type adminInvite struct {
ID int64
Code string
IsValid bool
CreatedAt time.Time
Link string
}
type adminMember struct {
ID int64
Name string
Email string
Banned bool
CreatedAt time.Time
}
type dashboard struct {
Invites []adminInvite
SpentCount int
Members []adminMember
Songs []adminSong
OpenCount int
}
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
var d dashboard
// Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating
// a list silently reads as "that's all of them".
if err := a.db.QueryRowContext(r.Context(),
`select count(*) from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
adminError(w, "invites", err)
return
}
rows, err := a.db.QueryContext(r.Context(),
`select id, code, is_valid, created_at from invites where is_valid order by created_at desc`)
if err != nil {
adminError(w, "invites", err)
return
}
for rows.Next() {
var i adminInvite
if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil {
adminError(w, "invites", err)
return
}
i.Link = a.inviteLink(i.Code)
d.Invites = append(d.Invites, i)
}
rows.Close()
if err := rows.Err(); err != nil {
adminError(w, "invites", err)
return
}
rows, err = a.db.QueryContext(r.Context(),
`select id, name, email, banned, created_at from users order by created_at`)
if err != nil {
adminError(w, "users", err)
return
}
defer rows.Close()
for rows.Next() {
var m adminMember
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
adminError(w, "users", err)
return
}
d.Members = append(d.Members, m)
}
if err := rows.Err(); err != nil {
adminError(w, "users", err)
return
}
if d.Songs, err = a.adminSongs(r.Context()); err != nil {
adminError(w, "songs", err)
return
}
if err := a.db.QueryRowContext(r.Context(),
`select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
adminError(w, "reports", err)
return
}
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
}
// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining.
func inviteCode() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// The link is what actually gets sent to someone: the register form reads ?code= and prefills it,
// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path,
// which is enough locally.
func (a *app) inviteLink(code string) string {
return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code)
}
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
code := inviteCode()
if _, err := a.db.ExecContext(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
adminError(w, "invites", err)
return
}
slog.Info("invite minted", "ctx", "invites")
// The dashboard lists it as a clickable link immediately below, newest first, so the flash
// doesn't repeat the URL as unclickable text.
a.flash(w, "Uusi kutsulinkki luotu.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login
// would leave a banned member browsing until their session expired.
func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
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)
if err != nil {
adminError(w, "users", err)
return
}
if banned {
if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
adminError(w, "users", err)
return
}
a.flash(w, "Jäsen estetty.")
} else {
a.flash(w, "Esto poistettu.")
}
slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
// The admin reset is the only password recovery there is, so it also drops the member's sessions.
func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
password := r.FormValue("password")
if password == "" {
a.flash(w, "Salasana on pakollinen.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
adminError(w, "auth", err)
return
}
if _, err := a.db.ExecContext(r.Context(),
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
adminError(w, "auth", err)
return
}
if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
adminError(w, "auth", err)
return
}
slog.Info("password reset by admin", "ctx", "auth", "user", id)
a.flash(w, "Salasana vaihdettu.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func adminError(w http.ResponseWriter, ctx string, err error) {
slog.Error("admin", "ctx", ctx, "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
}