package main import ( "context" "crypto/subtle" "database/sql" "fmt" "log/slog" "net/http" "os" "path/filepath" "strings" _ "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 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{ 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"), "/"), } // The database lives beside the audio, so one volume is the whole backup. c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db")) // 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 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) } a := &app{cfg: cfg, db: db} // 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.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)) 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 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) }) }