Files
BountyBoard/internal/http/server.go
T
etalon c69c028028 feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)
- scripts/seed.go: idempotent demo data per §11.11 (make seed)
- forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses
  against enumeration, sessions revoked on reset; login page link + pages
- profile hover cards on [data-user-card] elements (§11.13)
- keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10)
- bulk archive endpoint (§11.9)
- hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download
- make backup / make restore (mongodump archive via the mongo container)
- README: quick start, demo data, runbook, breaker/job operations, working
  Caddy + nginx reverse-proxy samples (WS block, client_max_body_size),
  documented later-stubs (§11.24)
- smoke.sh now exercises register → logout → login → me → board → pages

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

137 lines
3.9 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"
"bountyboard/internal/workperform"
)
// 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
mailer *auth.Mailer
loginLimiter *auth.RateLimiter
checks []ReadinessCheck
startedAt time.Time
httpSrv *http.Server
// late-bound subsystem hooks (see providers.go)
atomizer BreakerInfo
performer BreakerInfo
syncProvider StatusProvider
jobsProvider StatusProvider
syncTrigger func(customerID string)
publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
sendTo func(userIDs []string, channel, event string, payload any)
performerClient *workperform.Client
}
// SetSendTo wires targeted hub delivery (chat messages, typing).
func (s *Server) SetSendTo(f func(userIDs []string, channel, event string, payload any)) {
s.sendTo = f
}
// SetPerformerClient wires the §5.2 client (also feeds the status panel).
func (s *Server) SetPerformerClient(c *workperform.Client) {
s.performerClient = c
s.performer = c
}
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),
mailer: auth.NewMailer(cfg.SMTP),
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.routesAdmin(mux)
s.routesTasks(mux)
s.routesBoard(mux)
s.routesConsultant(mux)
s.routesWorkResults(mux)
s.routesMessages(mux)
s.routesMetrics(mux)
s.routesDocs(mux)
mux.HandleFunc("GET /ws", s.handleWS)
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
}