Files
BountyBoard/internal/http/server.go
T
etalon 34bf5b5ac2 feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)
- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
  system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
  contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:39:38 +02:00

102 lines
2.7 KiB
Go

package httpx
import (
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"time"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/files"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
)
// shutdownBudget is the §12 graceful-shutdown drain window.
const shutdownBudget = 15 * time.Second
type Server struct {
cfg *config.Config
log *slog.Logger
metrics *metrics.Registry
store *store.Store
files *files.Store
templates map[string]*template.Template
oidc *auth.OIDCClient
loginLimiter *auth.RateLimiter
checks []ReadinessCheck
startedAt time.Time
httpSrv *http.Server
}
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server {
templates, err := parseTemplates()
if err != nil {
// Templates are embedded; failure is a build defect caught by any
// test or first boot, never a runtime condition.
panic(fmt.Sprintf("parse templates: %v", err))
}
s := &Server{
cfg: cfg,
log: log,
metrics: reg,
store: st,
files: fs,
templates: templates,
oidc: auth.NewOIDCClient(cfg, log),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(),
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
mux.HandleFunc("GET /metricsz", s.handleMetricsz)
s.routesAuth(mux)
s.routesProfile(mux)
s.routesWeb(mux)
s.httpSrv = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.AppPort),
Handler: s.withMiddleware(mux),
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
}
return s
}
// Handler exposes the full middleware-wrapped handler for tests.
func (s *Server) Handler() http.Handler { return s.httpSrv.Handler }
// Run serves until ctx is canceled (SIGTERM/SIGINT), then drains in-flight
// requests within the shutdown budget.
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.log.Info("http server listening", "addr", s.httpSrv.Addr, "baseUrl", s.cfg.AppBaseURL)
if err := s.httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
}
s.log.Info("shutting down", "budget", shutdownBudget.String())
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownBudget)
defer cancel()
if err := s.httpSrv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return <-errCh
}