// Command web is the Phase 1 walking-skeleton HTTP server. It loads env, // builds a slog handler, opens a pgxpool, runs goose migrations, mounts the // chi router, and serves /, /healthz, /readyz, /demo/time, and /static/* with // graceful shutdown on SIGINT/SIGTERM (CONTEXT D-19, D-10, DEPLOY-03/04). // // No .env parser lives here — `.env` is exported into the process // environment by `just dev`; production injects real env vars (D-15). package main import ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "strconv" "syscall" "time" assets "backend" "backend/internal/auth" "backend/internal/db" "backend/internal/db/sqlc" "backend/internal/files" "backend/internal/web" ) func main() { env := os.Getenv("ENV") if env == "" { env = "development" } port := os.Getenv("PORT") if port == "" { port = "8080" } dsn := os.Getenv("DATABASE_URL") // Logger first so even fatal-on-missing-DSN paths produce structured // output. Per Pattern 3: JSON in production, text everywhere else. slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout))) if dsn == "" { slog.Error("DATABASE_URL is required but unset") os.Exit(1) } // Load the CSRF authentication key from SESSION_SECRET env var (D-15). // Fails fast with a clear message if missing or wrong length — the server // cannot operate without a valid CSRF key (AUTH-06). csrfKey, err := auth.LoadKeyFromEnv() if err != nil { slog.Error("invalid SESSION_SECRET", "err", err, "hint", "generate with: openssl rand -hex 32") os.Exit(1) } // signal.NotifyContext (Go 1.21+) is the canonical idiom — equivalent // to signal.Notify + a channel but the resulting ctx propagates the // cancellation through to handlers, pgxpool dialing, etc. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() pool, err := db.NewPool(ctx, dsn) if err != nil { // T-01-12: never log the DSN — only the error type/message. slog.Error("db connect failed", "err", err) os.Exit(1) } // D-10: run goose migrations from the embedded FS before constructing the // router. goose.Up is idempotent — already-applied migrations are skipped. if err := db.RunMigrations(ctx, pool, assets.Migrations); err != nil { slog.Error("migrations failed", "err", err) os.Exit(1) } q := sqlc.New(pool) store := auth.NewStore(q) secure := env != "development" && env != "dev" // Rate limiter for POST /login (D-16, AUTH-07). rl := auth.NewLimiterStore() stopJanitor := make(chan struct{}) rl.StartJanitor(time.Minute, stopJanitor) oauthCfg := auth.OAuthConfig{ Google: auth.GoogleProviderConfig{ ClientID: os.Getenv("GOOGLE_CLIENT_ID"), ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), RedirectURL: os.Getenv("GOOGLE_REDIRECT_URL"), }, } var googleExchanger auth.CodeExchanger var googleVerifier auth.IDTokenVerifier if oauthCfg.Google.Configured() { googleExchanger = auth.OAuth2CodeExchanger{Config: oauthCfg.Google.OAuth2Config()} googleVerifier = auth.OIDCVerifier{ Provider: "google", Issuer: "https://accounts.google.com", ClientID: oauthCfg.Google.ClientID, } } deps := web.AuthDeps{ Queries: q, Store: store, Secure: secure, Limiter: rl, DB: pool, OAuth: oauthCfg, GoogleTokenExchanger: googleExchanger, GoogleVerifier: googleVerifier, } tabloDeps := web.TablosDeps{Queries: q} taskDeps := web.TasksDeps{Queries: q} // S3 / files store (D-02: read env vars and pass to files.NewStore). s3Endpoint := os.Getenv("S3_ENDPOINT") s3Bucket := os.Getenv("S3_BUCKET") s3AccessKey := os.Getenv("S3_ACCESS_KEY") s3SecretKey := os.Getenv("S3_SECRET_KEY") s3Region := os.Getenv("S3_REGION") if s3Region == "" { s3Region = "us-east-1" } s3UsePathStyle := os.Getenv("S3_USE_PATH_STYLE") == "true" maxUploadMB := 25 if v := os.Getenv("MAX_UPLOAD_SIZE_MB"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { maxUploadMB = n } } var filesStore web.FileStorer if s3Endpoint != "" && s3Bucket != "" { var fsErr error filesStore, fsErr = files.NewStore(ctx, s3Endpoint, s3Bucket, s3Region, s3AccessKey, s3SecretKey, s3UsePathStyle) if fsErr != nil { slog.Error("s3 client init failed", "err", fsErr) os.Exit(1) } } else { slog.Warn("S3_ENDPOINT or S3_BUCKET unset — file upload routes will return 503") } fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} etapeDeps := web.EtapesDeps{Queries: q} eventDeps := web.EventsDeps{Queries: q} discussionDeps := web.DiscussionDeps{Queries: q, Realtime: web.NewDiscussionBroker()} planningDeps := web.PlanningDeps{Queries: q} // D-09: pass the embedded static FS — binary has zero runtime file dependencies. router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, etapeDeps, eventDeps, discussionDeps, planningDeps, fileDeps, csrfKey, env) if err != nil { slog.Error("router init failed", "err", err) os.Exit(1) } srv := &http.Server{ Addr: ":" + port, Handler: router, // T-01-10 slow-client mitigation per RESEARCH Security Domain. // ReadTimeout covers request header + body read; 15 s is sufficient for API // calls but upload routes read up to MAX_UPLOAD_SIZE_MB (default 25 MB). The // MaxBytesReader in FileUploadHandler bounds the body size, not time; a slow // upload at ~256 KB/s takes ~100 s. WriteTimeout covers the full request // lifecycle from accept to response flush, so it must be generous enough for // large uploads. 120 s accommodates 25 MB at ~250 KB/s with headroom. ReadTimeout: 120 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 60 * time.Second, } go func() { slog.Info("listening", "addr", srv.Addr, "env", env) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server error", "err", err) os.Exit(1) } }() <-ctx.Done() slog.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("shutdown error", "err", err) } // Stop the rate-limiter janitor goroutine after HTTP server is fully shut down. close(stopJanitor) // Pitfall 4: close the pool AFTER Shutdown returns, NOT via defer in // main — defer ordering is unreliable on fatal-exit paths. pool.Close() slog.Info("shutdown complete") }