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:
Esa Kataja
2026-07-31 19:41:01 +03:00
parent 2474b42175
commit 80f82a82de
11 changed files with 542 additions and 3 deletions
+151
View File
@@ -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)
})
}