136 lines
4.1 KiB
Go
136 lines
4.1 KiB
Go
// Command api is the Krow HTTP API.
|
|
//
|
|
// Configuration, a verified PostgreSQL pool, /health, the sign-in endpoints,
|
|
// and the entity and current-user endpoints described in docs/api-contract.md.
|
|
//
|
|
// Every request outside the public allowlist carries a session cookie that this
|
|
// process resolves to a real user. The development organization that used to be
|
|
// injected into every request is gone: identity now comes from the sessions
|
|
// table, and a database with no users is a database nobody can sign in to,
|
|
// which is the correct behaviour rather than a gap.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/auth"
|
|
"github.com/krow/krow-backend/go-api/internal/config"
|
|
"github.com/krow/krow-backend/go-api/internal/db"
|
|
"github.com/krow/krow-backend/go-api/internal/httpserver"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
slog.Error("fatal", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log := newLogger(cfg.Log.Level)
|
|
log.Info("starting krow-api", "env", cfg.AppEnv, "database", cfg.DB.Redacted())
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
database, err := db.Open(ctx, cfg.DB)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer database.Close()
|
|
log.Info("database connected", "schema", cfg.DB.Schema)
|
|
|
|
server, err := httpserver.New(cfg, database, log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The sweeper's context is cancelled by the same signal that stops the
|
|
// server, so the ticker goes away with the process rather than outliving
|
|
// the pool it queries.
|
|
go sweepSessions(ctx, server.Sessions(), log)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- server.Start() }()
|
|
log.Info("listening", "addr", server.Addr(), "endpoints", server.Endpoints(),
|
|
"health", "http://"+server.Addr()+"/health",
|
|
"cors_origins", cfg.HTTP.CORSOrigins)
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
return err
|
|
case <-ctx.Done():
|
|
log.Info("shutdown signal received, draining")
|
|
// context.Background: ctx is already cancelled, and Shutdown needs a
|
|
// live deadline of its own to drain in-flight requests.
|
|
return server.Shutdown(context.Background())
|
|
}
|
|
}
|
|
|
|
// sweepInterval is how often dead sessions are collected.
|
|
//
|
|
// Sweeping is housekeeping, not correctness: Manager.Authenticate already
|
|
// refuses an expired session and deletes the row as it finds it, so a session
|
|
// is never usable between its expiry and the next sweep. This only collects the
|
|
// rows nobody comes back for. Fifteen minutes keeps the table from growing
|
|
// without putting a DELETE on any hot path.
|
|
const sweepInterval = 15 * time.Minute
|
|
|
|
// sweepSessions deletes expired sessions until the context is cancelled.
|
|
//
|
|
// It runs once immediately so a process that has been down for a while does not
|
|
// carry a backlog for a further fifteen minutes, then on the ticker. A failed
|
|
// sweep is logged and retried at the next tick: the table being briefly larger
|
|
// than it should be is not worth stopping the API for.
|
|
func sweepSessions(ctx context.Context, sessions *auth.Manager, log *slog.Logger) {
|
|
ticker := time.NewTicker(sweepInterval)
|
|
defer ticker.Stop()
|
|
|
|
sweep := func() {
|
|
// A deadline of its own, so a slow or wedged DELETE cannot leave this
|
|
// goroutine blocked past shutdown.
|
|
sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
n, err := sessions.Sweep(sweepCtx)
|
|
switch {
|
|
case err != nil && ctx.Err() != nil:
|
|
// Shutting down; the cancellation is expected, not a failure.
|
|
case err != nil:
|
|
log.Warn("session sweep failed", "error", err)
|
|
case n > 0:
|
|
log.Info("swept expired sessions", "deleted", n)
|
|
default:
|
|
log.Debug("session sweep found nothing to delete")
|
|
}
|
|
}
|
|
|
|
sweep()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Debug("session sweeper stopped")
|
|
return
|
|
case <-ticker.C:
|
|
sweep()
|
|
}
|
|
}
|
|
}
|
|
|
|
func newLogger(level string) *slog.Logger {
|
|
var lvl slog.Level
|
|
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
|
lvl = slog.LevelInfo
|
|
}
|
|
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
|
|
}
|