Go · 14136 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package web boots the shithub HTTP server. The full middleware stack
4 // (recover, request_id, logging, real-IP, timeout, compress, secure
5 // headers, CSRF, session, CORS, metrics, tracing), the chi router, the
6 // session store, the styled error pages, and the observability sinks
7 // (logging, metrics, tracing, error reporting) are composed here.
8 package web
9
10 import (
11 "context"
12 "encoding/base64"
13 "errors"
14 "fmt"
15 "log/slog"
16 "net/http"
17 "os"
18 "os/signal"
19 "syscall"
20 "time"
21
22 "github.com/go-chi/chi/v5"
23 "github.com/jackc/pgx/v5/pgxpool"
24 "golang.org/x/crypto/chacha20poly1305"
25
26 "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
27 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
28 "github.com/tenseleyFlow/shithub/internal/auth/session"
29 "github.com/tenseleyFlow/shithub/internal/infra/config"
30 "github.com/tenseleyFlow/shithub/internal/infra/db"
31 "github.com/tenseleyFlow/shithub/internal/infra/errrep"
32 infralog "github.com/tenseleyFlow/shithub/internal/infra/log"
33 "github.com/tenseleyFlow/shithub/internal/infra/metrics"
34 "github.com/tenseleyFlow/shithub/internal/infra/tracing"
35 "github.com/tenseleyFlow/shithub/internal/ratelimit"
36 "github.com/tenseleyFlow/shithub/internal/version"
37 "github.com/tenseleyFlow/shithub/internal/web/handlers"
38 "github.com/tenseleyFlow/shithub/internal/web/middleware"
39 )
40
41 // Options configures the web server. Addr overrides config when non-empty
42 // (preserves the existing --addr CLI flag behavior).
43 type Options struct {
44 Addr string
45 }
46
47 // Run boots the web server and blocks until shutdown.
48 func Run(ctx context.Context, opts Options) error {
49 cfg, err := config.Load(nil)
50 if err != nil {
51 return err
52 }
53 if opts.Addr != "" {
54 cfg.Web.Addr = opts.Addr
55 }
56
57 logger := infralog.New(infralog.Options{
58 Level: cfg.Log.Level,
59 Format: cfg.Log.Format,
60 Writer: os.Stderr,
61 })
62
63 // Error reporting (no-op when DSN empty).
64 flushErrRep, err := errrep.Init(errrep.Config{
65 DSN: cfg.ErrorReporting.DSN,
66 Environment: cfg.ErrorReporting.Environment,
67 Release: cfg.ErrorReporting.Release,
68 })
69 if err != nil {
70 return fmt.Errorf("errrep: %w", err)
71 }
72 defer func() { _ = flushErrRep(context.Background()) }()
73 if cfg.ErrorReporting.DSN != "" {
74 // Wrap the slog handler so error-level records are reported.
75 // We rebuild the logger so every component that pulls it from
76 // here gets the wrapped chain.
77 logger = slog.New(&errrep.SlogHandler{Inner: logger.Handler()})
78 }
79
80 // Tracing (no-op when disabled).
81 flushTracing, err := tracing.Init(ctx, tracing.Config{
82 Enabled: cfg.Tracing.Enabled,
83 Endpoint: cfg.Tracing.Endpoint,
84 SampleRate: cfg.Tracing.SampleRate,
85 ServiceName: cfg.Tracing.ServiceName,
86 })
87 if err != nil {
88 return fmt.Errorf("tracing: %w", err)
89 }
90 defer func() { _ = flushTracing(context.Background()) }()
91
92 logoBytes, err := LogoSVG()
93 if err != nil {
94 return fmt.Errorf("load logo: %w", err)
95 }
96
97 sessionStore, err := buildSessionStore(cfg.Session, logger)
98 if err != nil {
99 return err
100 }
101
102 // Optional DB pool (carried over from S01); now driven by config.
103 var pool *pgxpool.Pool
104 if cfg.DB.URL != "" {
105 //nolint:gosec // G115: max_conns is operator-configured with small numeric values (typ. 10–100).
106 p, err := db.Open(ctx, db.Config{
107 URL: cfg.DB.URL,
108 MaxConns: int32(cfg.DB.MaxConns),
109 MinConns: int32(cfg.DB.MinConns),
110 ConnectTimeout: cfg.DB.ConnectTimeout,
111 })
112 if err != nil {
113 logger.Warn("db: open failed; /readyz will report unhealthy", "error", err)
114 } else {
115 pool = p
116 defer p.Close()
117 metrics.ObserveDBPool(ctx, pool, 10*time.Second)
118 }
119 }
120
121 r := chi.NewRouter()
122
123 // Middleware stack — outermost first.
124 r.Use(middleware.RequestID)
125 r.Use(middleware.RealIP(middleware.RealIPConfig{}))
126 r.Use(middleware.AccessLog(logger))
127 r.Use(middleware.Metrics)
128 if cfg.Tracing.Enabled {
129 r.Use(tracing.Middleware)
130 }
131 r.Use(middleware.SecureHeaders(middleware.DefaultSecureHeaders()))
132 // Compress + Timeout are NOT global: the smart-HTTP git routes need
133 // to stream uncompressed pack data for many minutes. RegisterChi
134 // applies them inside the CSRF-exempt and CSRF-protected groups but
135 // skips the git group.
136 r.Use(middleware.SessionLoader(sessionStore, logger))
137 if pool != nil {
138 r.Use(middleware.OptionalUser(usernameLookup(pool)))
139 }
140 r.Use(middleware.PolicyCache())
141
142 deps := handlers.Deps{
143 Logger: logger,
144 TemplatesFS: TemplatesFS(),
145 StaticFS: StaticFS(),
146 LogoSVG: string(logoBytes),
147 SessionStore: sessionStore,
148 Pool: pool,
149 BaseURL: cfg.Auth.BaseURL,
150 CookieSecure: cfg.Session.Secure,
151 }
152 if pool != nil {
153 deps.ReadyCheck = func(ctx context.Context) error { return pool.Ping(ctx) }
154 }
155 if cfg.Metrics.Enabled {
156 deps.MetricsHandler = metrics.Handler(cfg.Metrics.BasicAuthUser, cfg.Metrics.BasicAuthPass)
157 }
158
159 if pool != nil {
160 objectStore, err := buildObjectStore(cfg.Storage.S3, logger)
161 if err != nil {
162 return fmt.Errorf("object store: %w", err)
163 }
164
165 auth, err := buildAuthHandlers(cfg, pool, sessionStore, objectStore, logger, deps.TemplatesFS)
166 if err != nil {
167 return fmt.Errorf("auth handlers: %w", err)
168 }
169 deps.AuthMounter = auth.Mount
170
171 var (
172 runnerJWT *runnerjwt.Signer
173 actionsBox *secretbox.Box
174 )
175 if cfg.Auth.TOTPKeyB64 != "" {
176 runnerJWT, err = runnerjwt.NewFromTOTPKeyB64(cfg.Auth.TOTPKeyB64)
177 if err != nil {
178 return fmt.Errorf("runner jwt: %w", err)
179 }
180 actionsBox, err = secretbox.FromBase64(cfg.Auth.TOTPKeyB64)
181 if err != nil {
182 return fmt.Errorf("actions secretbox: %w", err)
183 }
184 } else {
185 logger.Warn("actions runner API disabled: auth.totp_key_b64 is not configured",
186 "hint", "set SHITHUB_TOTP_KEY=$(openssl rand -base64 32) to enable runner job JWTs")
187 }
188 api, err := buildAPIHandlers(cfg, pool, objectStore, runnerJWT, actionsBox, ratelimit.New(pool), logger)
189 if err != nil {
190 return fmt.Errorf("api handlers: %w", err)
191 }
192 deps.APIMounter = api.Mount
193
194 profile, err := buildProfileHandlers(cfg, pool, objectStore, deps.TemplatesFS, logger)
195 if err != nil {
196 return fmt.Errorf("profile handlers: %w", err)
197 }
198 deps.AvatarMounter = profile.MountAvatars
199 deps.ProfileMounter = profile.MountProfile
200 deps.OrgRepositoriesMounter = profile.MountOrgRepositories
201
202 repoH, err := buildRepoHandlers(cfg, pool, objectStore, deps.TemplatesFS, logger)
203 if err != nil {
204 return fmt.Errorf("repo handlers: %w", err)
205 }
206 // /new is wrapped in RequireUser — it requires a logged-in caller.
207 deps.RepoNewMounter = func(r chi.Router) {
208 r.Group(func(r chi.Router) {
209 r.Use(middleware.RequireUser)
210 repoH.MountNew(r)
211 })
212 }
213 deps.RepoHomeMounter = repoH.MountRepoHome
214 deps.RepoActionsStreamMounter = repoH.MountRepoActionsStreams
215 deps.RepoCodeMounter = repoH.MountCode
216 deps.RepoHistoryMounter = repoH.MountHistory
217 deps.RepoRefsMounter = repoH.MountRefs
218 deps.RepoSettingsBranchesMounter = func(r chi.Router) {
219 r.Group(func(r chi.Router) {
220 r.Use(middleware.RequireUser)
221 repoH.MountSettingsBranches(r)
222 })
223 }
224 deps.RepoActionsAPIMounter = func(r chi.Router) {
225 r.Group(func(r chi.Router) {
226 r.Use(middleware.RequireUser)
227 repoH.MountRepoActionsAPI(r)
228 })
229 }
230 deps.RepoSettingsGeneralMounter = func(r chi.Router) {
231 r.Group(func(r chi.Router) {
232 r.Use(middleware.RequireUser)
233 repoH.MountSettingsGeneral(r)
234 })
235 }
236 deps.RepoSettingsActionsMounter = func(r chi.Router) {
237 r.Group(func(r chi.Router) {
238 r.Use(middleware.RequireUser)
239 repoH.MountSettingsActions(r)
240 })
241 }
242 deps.RepoWebhooksMounter = func(r chi.Router) {
243 r.Group(func(r chi.Router) {
244 r.Use(middleware.RequireUser)
245 repoH.MountWebhooks(r)
246 })
247 }
248 // Issues GETs are public (subject to policy.Can), POSTs require
249 // auth. The handler enforces auth + policy per request, so we
250 // register the whole surface in the public group; an unauth
251 // POST hits the policy gate and 404s out of the existence-leak
252 // path. Browser flows still need RequireUser to redirect-to-login,
253 // so the POST routes get wrapped through the same group with
254 // RequireUser inserted only for state-mutating verbs.
255 deps.RepoIssuesMounter = repoH.MountIssues
256 deps.RepoPullsMounter = repoH.MountPulls
257 deps.RepoSocialMounter = repoH.MountSocial
258 deps.RepoForkMounter = repoH.MountFork
259
260 // Search gets its own Limiter wired around /search +
261 // /search/quick (audit 2026-05-10 H4). Independent instance
262 // from auth's RateLimiter; both share DB-backed counter
263 // state, segregated by Policy.Scope.
264 searchH, err := buildSearchHandlers(pool, deps.TemplatesFS, logger, ratelimit.New(pool))
265 if err != nil {
266 return fmt.Errorf("search handlers: %w", err)
267 }
268 deps.SearchMounter = searchH.Mount
269
270 notifH, err := buildNotifHandlers(cfg, pool, deps.TemplatesFS, logger)
271 if err != nil {
272 return fmt.Errorf("notif handlers: %w", err)
273 }
274 deps.NotifInboxMounter = func(r chi.Router) {
275 r.Group(func(r chi.Router) {
276 r.Use(middleware.RequireUser)
277 notifH.MountAuthed(r)
278 })
279 }
280 deps.NotifPublicMounter = notifH.MountPublic
281
282 // S30 — orgs.
283 orgH, err := buildOrgHandlers(cfg, pool, objectStore, deps.TemplatesFS, logger)
284 if err != nil {
285 return fmt.Errorf("org handlers: %w", err)
286 }
287 deps.OrgCreateMounter = func(r chi.Router) {
288 r.Group(func(r chi.Router) {
289 r.Use(middleware.RequireUser)
290 orgH.MountCreate(r)
291 })
292 }
293 // /{org}/people: GETs are public (org existence is non-secret;
294 // member lists for private orgs are deferred). Mutations are
295 // owner-checked inside the handler, but RequireUser wraps the
296 // POST routes so unauth submits redirect to /login.
297 deps.OrgRoutesMounter = func(r chi.Router) {
298 r.Group(func(r chi.Router) {
299 orgH.MountOrgRoutes(r)
300 })
301 }
302 deps.OrgInvitationsMounter = func(r chi.Router) {
303 r.Group(func(r chi.Router) {
304 r.Use(middleware.RequireUser)
305 orgH.MountInvitations(r)
306 })
307 }
308
309 // Lifecycle danger-zone routes — also auth-required.
310 deps.RepoLifecycleMounter = func(r chi.Router) {
311 r.Group(func(r chi.Router) {
312 r.Use(middleware.RequireUser)
313 repoH.MountLifecycle(r)
314 })
315 }
316
317 // S34 — site admin. Gated by RequireUser + RequireSiteAdmin
318 // (404 not 403 for non-admins). Uses its own renderer so the
319 // admin templates are loaded once at boot.
320 //
321 // Email sender is the same one auth uses; the admin "Reset
322 // password" action sends through it (SR2 C3). Version is the
323 // build-time-stamped value so /admin/system reports reality
324 // instead of the literal "dev" (SR2 L6).
325 adminSender, err := pickEmailSender(cfg)
326 if err != nil {
327 return fmt.Errorf("admin handlers: pick email sender: %w", err)
328 }
329 adminH, err := buildAdminHandlers(cfg, pool, deps.TemplatesFS, logger, version.Version, adminSender)
330 if err != nil {
331 return fmt.Errorf("admin handlers: %w", err)
332 }
333 deps.AdminMounter = func(r chi.Router) {
334 r.Group(func(r chi.Router) {
335 r.Use(middleware.RequireUser)
336 r.Use(middleware.RequireSiteAdmin(nil)) // nil ⇒ http.NotFound
337 adminH.Mount(r)
338 })
339 }
340
341 gitHTTPH, err := buildGitHTTPHandlers(cfg, pool, runnerJWT, logger)
342 if err != nil {
343 return fmt.Errorf("git-http handlers: %w", err)
344 }
345 deps.GitHTTPMounter = gitHTTPH.MountSmartHTTP
346 } else {
347 logger.Warn("auth: no DB pool — signup/login routes not mounted")
348 }
349
350 _, panicHandler, notFoundHandler, err := handlers.RegisterChi(r, deps)
351 if err != nil {
352 return fmt.Errorf("register handlers: %w", err)
353 }
354 r.NotFound(notFoundHandler)
355
356 rootHandler := middleware.Recover(logger, panicHandler)(r)
357
358 srv := &http.Server{
359 Addr: cfg.Web.Addr,
360 Handler: rootHandler,
361 ReadHeaderTimeout: 10 * time.Second,
362 ReadTimeout: cfg.Web.ReadTimeout,
363 WriteTimeout: cfg.Web.WriteTimeout,
364 IdleTimeout: 120 * time.Second,
365 }
366
367 errCh := make(chan error, 1)
368 go func() {
369 logger.Info(
370 "shithub web server starting",
371 "addr", srv.Addr,
372 "env", cfg.Env,
373 "db", pool != nil,
374 "metrics", cfg.Metrics.Enabled,
375 "tracing", cfg.Tracing.Enabled,
376 "errrep", cfg.ErrorReporting.DSN != "",
377 )
378 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
379 errCh <- err
380 }
381 close(errCh)
382 }()
383
384 sigCh := make(chan os.Signal, 1)
385 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
386 defer signal.Stop(sigCh)
387
388 select {
389 case err, ok := <-errCh:
390 if !ok {
391 return nil
392 }
393 return err
394 case sig := <-sigCh:
395 logger.Info("shutdown signal received", "signal", sig.String())
396 case <-ctx.Done():
397 logger.Info("context canceled, shutting down")
398 }
399
400 shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Web.ShutdownTimeout)
401 defer cancel()
402 if err := srv.Shutdown(shutdownCtx); err != nil {
403 return fmt.Errorf("shutdown: %w", err)
404 }
405 return nil
406 }
407
408 // buildSessionStore constructs the cookie session store from the config's
409 // session block. SHITHUB_SESSION_KEY (env) overrides cfg.KeyB64 when set.
410 func buildSessionStore(cfg config.SessionConfig, logger *slog.Logger) (session.Store, error) {
411 keyB64 := os.Getenv("SHITHUB_SESSION_KEY")
412 if keyB64 == "" {
413 keyB64 = cfg.KeyB64
414 }
415 var key []byte
416 if keyB64 != "" {
417 decoded, err := base64.StdEncoding.DecodeString(keyB64)
418 if err != nil {
419 return nil, fmt.Errorf("session key: invalid base64: %w", err)
420 }
421 if len(decoded) != chacha20poly1305.KeySize {
422 return nil, fmt.Errorf("session key: must be %d bytes, got %d",
423 chacha20poly1305.KeySize, len(decoded))
424 }
425 key = decoded
426 } else {
427 generated, err := session.GenerateKey()
428 if err != nil {
429 return nil, fmt.Errorf("session key: generate: %w", err)
430 }
431 key = generated
432 logger.Warn(
433 "session: no key configured; generated an ephemeral key (sessions will not survive restart)",
434 "hint", "set SHITHUB_SESSION_KEY=<base64 32-byte> or session.key_b64 in production",
435 )
436 }
437 store, err := session.NewCookieStore(session.CookieStoreConfig{
438 Key: key,
439 MaxAge: cfg.MaxAge,
440 Secure: cfg.Secure,
441 })
442 if err != nil {
443 return nil, fmt.Errorf("session: build store: %w", err)
444 }
445 return store, nil
446 }
447