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.pool.QueryRow(r.Context(), `select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil { adminError(w, "invites", err) return } rows, err := a.pool.Query(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.pool.Query(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.pool.QueryRow(r.Context(), `select count(*)::int 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.pool.Exec(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 } var banned bool err = a.pool.QueryRow(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.pool.Exec(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.pool.Exec(r.Context(), `update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil { adminError(w, "auth", err) return } if _, err := a.pool.Exec(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) }