Files
Levyraati26_go/main.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

250 lines
9.1 KiB
Go

package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
var version = "dev"
type config struct {
dbPath 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
addr string
storageDir string
secureCookies bool
// Public address of the site, so invite links are pasteable out of the admin page.
publicURL string
}
func loadConfig() config {
c := config{
adminEmail: os.Getenv("ADMIN_EMAIL"),
adminName: env("ADMIN_NAME", "Ylläpito"),
adminPass: os.Getenv("ADMIN_PASSWORD"),
addr: env("ADDR", ":8080"),
storageDir: env("STORAGE_DIR", "./storage"),
secureCookies: env("SECURE_COOKIES", "true") != "false",
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
}
// The database lives beside the audio, so one volume is the whole backup.
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db"))
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
db *sql.DB
logins limiter // zero value is ready to use
}
// openDB opens the file with the pragmas the schema assumes. foreign_keys is off by default in
// SQLite, so without it every `on delete cascade` is decoration; WAL plus busy_timeout is what lets
// a conversion goroutine write while a request reads; _txlock=immediate takes the write lock at
// BEGIN rather than failing partway through a transaction that started out reading.
//
// _time_format and _timezone make Go write timestamps in exactly the shape datetime('now')
// produces, so the two sources of a timestamp sort and compare against each other.
func openDB(path string) (*sql.DB, error) {
return sql.Open("sqlite", "file:"+path+"?"+strings.Join([]string{
"_pragma=busy_timeout(5000)",
"_pragma=journal_mode(WAL)",
"_pragma=foreign_keys(1)",
"_pragma=synchronous(NORMAL)",
"_time_format=datetime",
"_timezone=UTC",
"_txlock=immediate",
}, "&"))
}
// database/sql splits the row count off into a second return value. Every caller here only asks
// whether the statement matched anything, and a driver that could not report a count would already
// have failed at Exec.
func affected(res sql.Result) int64 {
n, _ := res.RowsAffected()
return n
}
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig()
ctx := context.Background()
// The storage directories come first: the database file lives in one of them.
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)
}
}
db, err := openDB(cfg.dbPath)
if err != nil {
fatal("database open", "error", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
fatal("database unreachable", "error", err, "path", cfg.dbPath)
}
if err := migrate(ctx, db); err != nil {
fatal("migrations", "error", err)
}
if err := sweep(ctx, db); err != nil {
fatal("startup sweep", "error", err)
}
if err := seedAdmin(ctx, db, cfg); err != nil {
fatal("seed admin", "error", err)
}
a := &app{cfg: cfg, db: db}
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
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 {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := a.db.PingContext(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
// The version answers "what is actually running out there" without an SSH session.
fmt.Fprintf(w, "ok %s\n", version)
})
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("POST /songs/{id}/lyrics", a.requireMember(a.editLyrics))
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}/lyrics", a.requireMember(a.suggestLyrics))
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
a.adminRoutes(mux)
return mux
}
// The admin pages sit on the same mux and the same session as everything else; only the guard
// differs. There is no /admin/audio: requireAdmin members can reach GET /audio/{id} like anyone.
func (a *app) adminRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin", a.requireAdmin(a.adminDashboard))
mux.HandleFunc("POST /admin/invites", a.requireAdmin(a.createInvite))
mux.HandleFunc("POST /admin/users/{id}/ban", a.requireAdmin(a.toggleBan))
mux.HandleFunc("POST /admin/users/{id}/password", a.requireAdmin(a.resetPassword))
mux.HandleFunc("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong))
mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(a.resolveReport))
}
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
// resolve reports without also being able to reset passwords.
//
// A signed-out visitor is sent to log in, the same as any member page. A signed-in member who is
// not an admin gets 404 rather than 403: the admin pages are none of their business, and saying
// "forbidden" confirms there is something there to be forbidden from.
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := memberFrom(r.Context())
if m == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !m.IsAdmin {
http.NotFound(w, r)
return
}
next(w, r)
}
}