SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
194 lines
5.5 KiB
Go
194 lines
5.5 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
|
|
}
|
|
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)
|
|
}
|