Add skeleton: config, migrations, startup sweep, two listeners
Step 1 of the build order in docs/decisions.md. Boots, applies migrations before serving, and serves a health check and an empty admin page. - 001_init.sql is the full schema from docs/spec.md, including the check constraints and indexes the old app lacked - The startup sweep fails submissions left mid-conversion by a restart; an in-process goroutine dies with the process and those rows would otherwise say converting forever - Admin is Basic Auth from env on its own listener, fatal at startup when ADMIN_PASSWORD is unset
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
databaseURL string
|
||||
adminUser string
|
||||
adminPass string
|
||||
addr string
|
||||
adminAddr string
|
||||
storageDir string
|
||||
secureCookies bool
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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.memberMux()))
|
||||
}
|
||||
|
||||
func (a *app) memberMux() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
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"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func (a *app) adminMux() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("levyraati admin"))
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user