Files
Levyraati26_go/main.go
T
Esa Kataja f1e907bac3 Add stats, profiles, avatars and palaute
Step 6. The surfaces around the review loop.

- Nine leaderboards, ordered and limited in SQL, each with a deterministic
  tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews
  to qualify, for reviewer boards too
- Profiles show counts and history-wide averages and the member's songs,
  never a list of their reviews — per-song opinion stays gated
- Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation.
  No upload still means initials, and avatars are public
- Changing your own password requires the current one and drops your other
  sessions
- Palaute: free text plus the page you were on, carried in a footer link, and
  the user agent from the header. Reporters see their own; the admin resolves
  them with a timestamp rather than a status enum
- Admin gained the song list with delete, the reports page, and an open-report
  count on the dashboard

Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter
because display:grid suppresses list markers, and count-based boards were
printing 3.0 where they mean 3.
2026-07-31 22:34:04 +03:00

204 lines
6.8 KiB
Go

package main
import (
"context"
"crypto/subtle"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type config struct {
databaseURL string
adminUser 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.
publicURL string
}
func loadConfig() config {
c := config{
databaseURL: os.Getenv("DATABASE_URL"),
adminUser: env("ADMIN_USER", "admin"),
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"), "/"),
}
if c.databaseURL == "" {
fatal("DATABASE_URL is not set")
}
// 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
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func fatal(msg string, args ...any) {
slog.Error(msg, args...)
os.Exit(1)
}
type app struct {
cfg config
pool *pgxpool.Pool
logins limiter // zero value is ready to use
}
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
cfg := loadConfig()
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.databaseURL)
if err != nil {
fatal("database connect", "error", err)
}
defer pool.Close()
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
for i := 0; ; i++ {
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
err = pool.Ping(pingCtx)
cancel()
if err == nil {
break
}
if i == 10 {
fatal("database unreachable", "error", err)
}
time.Sleep(time.Second)
}
if err := migrate(ctx, pool); err != nil {
fatal("migrations", "error", err)
}
if err := sweep(ctx, pool); err != nil {
fatal("startup sweep", "error", err)
}
for _, dir := range []string{"audio", "tmp", "avatars"} {
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
fatal("storage dir", "error", err, "dir", dir)
}
}
a := &app{cfg: cfg, pool: pool}
// 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())))
}
func (a *app) memberMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := a.pool.Ping(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /login", a.loginPage)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("GET /register", a.registerPage)
mux.HandleFunc("POST /register", a.register)
mux.HandleFunc("POST /logout", a.logout)
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
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
}
// 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.
//
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the
// env file next to the Postgres password 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)
return
}
next.ServeHTTP(w, r)
})
}