86215f1eb1
Three connected features for the deployment-strategy demo: 1. Visitor identity cookie (v3). A new visitors table backs a first-party gb_visitor cookie: middleware on /api checks the cookie's UUID against the database on every request and regenerates + persists it when the id is missing, malformed, or unknown (fresh browser, pruned identity, or a reset database). The identity remembers the name a visitor signed with (pre-filled next time, used for unsigned posts), replaces the standalone reaction cookie for reaction de-dupe, and feeds a live "N visiting" counter (identities seen in the last 5 minutes) shown on the wall, in /api/info, the admin overview, and the presence/tick SSE events. The admin console gains Visitors/Visiting tiles and a visitors table, plus a GET /api/me endpoint and GET /api/admin/visitors. 2. guestbook reset command. Wipes entries (including soft-deleted rows), reactions and visitor identities under the schema advisory lock, with --dry-run, --hard (also reset wall settings) and --reseed flags; meant to run as a one-off Miabi Job. After a reset every outstanding cookie is invalidated, so browsers get a fresh identity on their next request. 3. cleanup learns --prune-visitors (default 30 days): the cron job now also forgets idle identities together with their reactions. Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity talking point is in act 3, and the API tables list the new endpoints. Akili-Task: tsk_ptdi5ufndrzwlljk4v5g Akili-Agent: goldfish
328 lines
10 KiB
Go
328 lines
10 KiB
Go
// Command guestbook is a tiny, self-contained web application used to
|
|
// demonstrate deploying to Miabi: an Okapi (Go) API + embedded SPA, backed by
|
|
// a Postgres (or SQLite) database via GORM.
|
|
//
|
|
// It is a classic "guestbook" — visitors leave a name and a message, and the
|
|
// wall shows every signature. Everything a real Miabi deployment exercises is
|
|
// here in miniature: env-based config (DATABASE_URL), an attached managed
|
|
// database, an optional Redis for multi-replica live updates, a secret-guarded
|
|
// admin console, failure-injection endpoints, a scheduled cleanup job, a
|
|
// /healthz readiness probe, and a single static binary that ships the whole
|
|
// frontend embedded — no Node build step.
|
|
//
|
|
// Subcommands (okapicli): server (default), migrate, cleanup, reset.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/jkaninda/logger"
|
|
"github.com/jkaninda/okapi"
|
|
"github.com/jkaninda/okapi/okapicli"
|
|
)
|
|
|
|
//go:embed all:web
|
|
var webFS embed.FS
|
|
|
|
func main() {
|
|
cfg, err := LoadConfig()
|
|
if err != nil {
|
|
logger.Fatal("invalid configuration", "error", err)
|
|
}
|
|
|
|
opts := []okapi.OptionFunc{okapi.WithPort(cfg.Port)}
|
|
if len(cfg.TrustedProxies) > 0 {
|
|
opts = append(opts, okapi.WithTrustedProxies(cfg.TrustedProxies...))
|
|
}
|
|
app := okapi.New(opts...)
|
|
cli := okapicli.New(app, "guestbook")
|
|
|
|
cli.Command("server", "Start the Guestbook HTTP server", func(cmd *okapicli.Command) error {
|
|
return runServer(cmd, cfg)
|
|
}).Int("port", "p", cfg.Port, "HTTP server port")
|
|
|
|
cli.Command("migrate", "Apply database migrations and exit (e.g. as a pre-deploy step)", func(cmd *okapicli.Command) error {
|
|
store, err := openStore(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
logger.Info("migrations applied", "db", dbDisplayName(cfg.DBDriver))
|
|
return nil
|
|
})
|
|
|
|
cli.Command("cleanup", "Purge deleted entries and optionally expire old ones (run as a scheduled job)", func(cmd *okapicli.Command) error {
|
|
return runCleanup(cmd, cfg)
|
|
}).
|
|
Duration("purge-after", "", 7*24*time.Hour, "Permanently remove entries deleted longer ago than this").
|
|
Duration("max-age", "", 0, "Also delete unpinned entries older than this (0 = keep forever)").
|
|
Duration("prune-visitors", "", 30*24*time.Hour, "Forget visitor identities idle longer than this (0 = keep forever)").
|
|
Bool("dry-run", "", false, "Report what would be removed without changing anything")
|
|
|
|
cli.Command("reset", "Wipe the database: entries, reactions and visitor identities (run as a one-off job)", func(cmd *okapicli.Command) error {
|
|
return runReset(cmd, cfg)
|
|
}).
|
|
Bool("hard", "", false, "Also reset the wall settings (pause, banner, theme)").
|
|
Bool("reseed", "", false, "Insert the sample signatures after the wipe").
|
|
Bool("dry-run", "", false, "Report what would be removed without changing anything")
|
|
|
|
cli.DefaultCommand("server")
|
|
|
|
if err := cli.Execute(); err != nil {
|
|
logger.Fatal("command failed", "error", err)
|
|
}
|
|
}
|
|
|
|
func runServer(cmd *okapicli.Command, cfg Config) error {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
store, err := openStore(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.Seed {
|
|
if err := Seed(ctx, store, cfg.DBDriver); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
host, _ := os.Hostname()
|
|
broker := NewBroker(host, cfg.Version, nil)
|
|
if cfg.RedisURL != "" {
|
|
if err := broker.UseRedis(cfg.RedisURL); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
go broker.Run(ctx)
|
|
go publishTicks(ctx, broker, cfg.Version, host)
|
|
logger.Info("live updates ready", "broker", broker.Mode())
|
|
|
|
h := &Handler{
|
|
store: store,
|
|
broker: broker,
|
|
auth: NewAdminAuth(cfg.AdminToken),
|
|
appName: cfg.AppName,
|
|
version: cfg.Version,
|
|
host: host,
|
|
dbDriver: cfg.DBDriver,
|
|
debug: cfg.DebugEndpoints,
|
|
started: time.Now(),
|
|
}
|
|
broker.requests = h.requests.Load
|
|
broker.visitors = func() int64 { return h.visitorsActive(context.Background()) }
|
|
if !h.auth.Enabled() {
|
|
logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin")
|
|
}
|
|
if h.debug {
|
|
logger.Warn("debug endpoints enabled under /api/debug (admin auth required)")
|
|
}
|
|
|
|
app := cmd.Okapi()
|
|
app.WithPort(cmd.GetInt("port"))
|
|
app.WithDebug()
|
|
app.Use(okapi.LoggerMiddleware, okapi.RequestID(), h.countRequests)
|
|
registerRoutes(app, h)
|
|
|
|
return cmd.CLI().RunServer(&okapicli.RunOptions{
|
|
ShutdownTimeout: 10 * time.Second,
|
|
OnStarted: func() {
|
|
logger.Info("server started", "app", cfg.AppName, "version", cfg.Version, "port", cmd.GetInt("port"))
|
|
},
|
|
OnShutdown: func() {
|
|
logger.Info("shutting down")
|
|
closeCtx, done := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer done()
|
|
cancel()
|
|
broker.Close(closeCtx)
|
|
_ = store.Close()
|
|
},
|
|
})
|
|
}
|
|
|
|
func registerRoutes(app *okapi.Okapi, h *Handler) {
|
|
app.Get("/healthz", h.Health)
|
|
|
|
// Every API request resolves the visitor identity: the cookie is checked
|
|
// against the database and regenerated when its id is unknown (fresh
|
|
// browser, pruned identity, or a reset database).
|
|
api := app.Group("/api", h.identifyVisitor)
|
|
api.Get("/info", h.Info)
|
|
api.Get("/time", h.Time)
|
|
api.Get("/settings", h.PublicSettings)
|
|
api.Get("/me", h.VisitorMe)
|
|
api.Get("/entries", h.ListEntries)
|
|
api.Post("/entries", h.CreateEntry)
|
|
api.Post("/entries/{id:int}/reactions", h.React)
|
|
api.Delete("/entries/{id:int}/reactions", h.Unreact)
|
|
api.Get("/stream", h.Stream)
|
|
|
|
api.Get("/admin/session", h.AdminSession)
|
|
api.Post("/admin/login", h.AdminLogin)
|
|
api.Post("/admin/logout", h.AdminLogout)
|
|
|
|
admin := app.Group("/api/admin", h.identifyVisitor, h.auth.Require)
|
|
admin.Get("/overview", h.AdminOverview)
|
|
admin.Get("/entries", h.AdminListEntries)
|
|
admin.Patch("/entries/{id:int}", h.AdminUpdateEntry)
|
|
admin.Delete("/entries/{id:int}", h.DeleteEntry)
|
|
admin.Get("/settings", h.AdminSettings)
|
|
admin.Put("/settings", h.AdminSaveSettings)
|
|
admin.Get("/visitors", h.AdminVisitors)
|
|
admin.Get("/export.csv", h.AdminExport)
|
|
|
|
debug := app.Group("/api/debug", h.auth.Require, h.RequireDebug)
|
|
debug.Post("/crash", h.DebugCrash)
|
|
debug.Get("/slow", h.DebugSlow)
|
|
debug.Get("/error", h.DebugError)
|
|
debug.Post("/health", h.DebugHealth)
|
|
debug.Post("/cpu", h.DebugCPU)
|
|
debug.Post("/memory", h.DebugMemory)
|
|
debug.Post("/logs", h.DebugLogs)
|
|
|
|
adminPage, err := fs.ReadFile(webFS, "web/admin.html")
|
|
if err != nil {
|
|
logger.Fatal("admin page missing from embedded assets", "error", err)
|
|
}
|
|
app.Get("/admin", func(c *okapi.Context) error {
|
|
return c.Data(http.StatusOK, "text/html; charset=utf-8", adminPage)
|
|
})
|
|
|
|
// Browsers ask for /favicon.ico on their own; the logo serves as the icon.
|
|
app.Get("/favicon.ico", func(c *okapi.Context) error {
|
|
c.Redirect(http.StatusFound, "/badges/icon.svg")
|
|
return nil
|
|
})
|
|
|
|
// Okapi serves real files directly and falls back to index.html for any
|
|
// other path so the client-side router can take over.
|
|
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
|
|
}
|
|
|
|
func runCleanup(cmd *okapicli.Command, cfg Config) error {
|
|
ctx := context.Background()
|
|
store, err := openStore(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
|
|
dryRun := cmd.GetBool("dry-run")
|
|
now := time.Now()
|
|
|
|
purged, err := store.Purge(ctx, now.Add(-cmd.GetDuration("purge-after")), dryRun)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var expired int64
|
|
if maxAge := cmd.GetDuration("max-age"); maxAge > 0 {
|
|
if expired, err = store.Expire(ctx, now.Add(-maxAge), dryRun); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var pruned int64
|
|
if prune := cmd.GetDuration("prune-visitors"); prune > 0 {
|
|
if pruned, err = store.PurgeVisitors(ctx, now.Add(-prune), dryRun); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
remaining, _ := store.Count(ctx)
|
|
logger.Info("cleanup finished",
|
|
"purged", purged, "expired", expired, "visitors_pruned", pruned,
|
|
"remaining", remaining, "dry_run", dryRun)
|
|
return nil
|
|
}
|
|
|
|
// runReset wipes the database (the "clean slate" job): entries, reactions and
|
|
// visitor identities go, settings stay unless --hard. With --reseed the sample
|
|
// signatures are inserted right away; otherwise restart the app so its first
|
|
// boot seeds the empty wall. The wall's cookies stop matching any visitor row
|
|
// after a reset, so browsers get a fresh identity on their next request.
|
|
func runReset(cmd *okapicli.Command, cfg Config) error {
|
|
ctx := context.Background()
|
|
store, err := openStore(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = store.Close() }()
|
|
|
|
dryRun := cmd.GetBool("dry-run")
|
|
hard := cmd.GetBool("hard")
|
|
|
|
stats, err := store.Reset(ctx, hard, dryRun)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dryRun {
|
|
logger.Info("reset dry run, nothing removed",
|
|
"entries", stats.Entries, "reactions", stats.Reactions,
|
|
"visitors", stats.Visitors, "settings", stats.Settings)
|
|
return nil
|
|
}
|
|
logger.Warn("database reset",
|
|
"entries", stats.Entries, "reactions", stats.Reactions,
|
|
"visitors", stats.Visitors, "settings", stats.Settings, "hard", hard)
|
|
|
|
if cmd.GetBool("reseed") {
|
|
if err := Seed(ctx, store, cfg.DBDriver); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
logger.Info("wall is empty; restart the app to re-seed it")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openStore connects to the database and applies migrations, retrying while
|
|
// the database comes up.
|
|
func openStore(cfg Config) (*Store, error) {
|
|
store, err := NewStore(cfg.DBDriver, cfg.DatabaseDSN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := migrateWithRetry(store, 30, 2*time.Second); err != nil {
|
|
_ = store.Close()
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
// publishTicks sends each replica's own clock to its local clients once a
|
|
// second, so under a canary rollout the clock card reveals which build served
|
|
// you. Ticks stay local on purpose, even with Redis.
|
|
func publishTicks(ctx context.Context, b *Broker, version, host string) {
|
|
t := time.NewTicker(time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-t.C:
|
|
b.PublishLocal(Event{
|
|
Type: "tick",
|
|
Time: now.Format(time.RFC3339),
|
|
Version: version,
|
|
Host: host,
|
|
Visitors: b.visitorsActive(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func migrateWithRetry(store *Store, attempts int, wait time.Duration) error {
|
|
var err error
|
|
for i := 0; i < attempts; i++ {
|
|
if err = store.Migrate(); err == nil {
|
|
return nil
|
|
}
|
|
logger.Warn("waiting for database", "attempt", i+1, "of", attempts, "error", err)
|
|
time.Sleep(wait)
|
|
}
|
|
return err
|
|
}
|