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"} { 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(func(w http.ResponseWriter, r *http.Request) { a.render(w, r, http.StatusOK, "home.html", page{Title: "Jono"}) })) 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("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) }) }