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) } }