xtablo-source/backend/cmd/web/main.go
Arthur Belleville a12c5abea6
feat(05-02): 3-tab layout + files templates + router + main.go S3 wiring
- tablos.templ: TabloDetailPage gains files+activeTab params, 3-tab nav with hx-push-url
- tablos.templ: TabloOverviewTabFragment + TasksTabFragment (wraps KanbanBoard) added
- files.templ: FilesTabFragment, FileUploadForm (hx-encoding=multipart/form-data),
  FileListRow, FileListEmpty, FileRowGone, UploadErrorFragment
- files_helpers.go: formatBytes() converts int64 bytes to human-readable string
- router.go: fileDeps FilesDeps param added; TabloTasksTabHandler + file routes wired
- handlers_tablos.go: both TabloDetailPage call sites updated (nil, 'overview')
- main.go: S3_ENDPOINT/S3_BUCKET/S3_REGION env vars read; files.NewStore constructed;
  fileDeps wired; nil filesStore allowed when S3 env unset (503 from handlers)
- All test routers updated to pass FilesDeps{} in new param position
2026-05-15 12:28:33 +02:00

151 lines
4.4 KiB
Go

// Command web is the Phase 1 walking-skeleton HTTP server. It loads env,
// builds a slog handler, opens a pgxpool, mounts the chi router, and serves
// /, /healthz, /demo/time, and /static/* with graceful shutdown on
// SIGINT/SIGTERM (CONTEXT D-19).
//
// 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"
"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)
}
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)
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
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}
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
srv := &http.Server{
Addr: ":" + port,
Handler: router,
// T-01-10 slow-client mitigation per RESEARCH Security Domain.
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * 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")
}