Go · 14331 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 metrics.ObserveActions(ctx, pool, 15*time.Second)
119 }
120 }
121
122 r := chi.NewRouter()
123
124 // Middleware stack — outermost first.
125 r.Use(middleware.RequestID)
126 r.Use(middleware.RealIP(middleware.RealIPConfig{}))
127 r.Use(middleware.AccessLog(logger))
128 r.Use(middleware.Metrics)
129 if cfg.Tracing.Enabled {
130 r.Use(tracing.Middleware)
131 }
132 r.Use(middleware.SecureHeaders(middleware.DefaultSecureHeaders()))
133 // Compress + Timeout are NOT global: the smart-HTTP git routes need
134 // to stream uncompressed pack data for many minutes. RegisterChi
135 // applies them inside the CSRF-exempt and CSRF-protected groups but
136 // skips the git group.
137 r.Use(middleware.SessionLoader(sessionStore, logger))
138 if pool != nil {
139 r.Use(middleware.OptionalUser(usernameLookup(pool)))
140 }
141 r.Use(middleware.PolicyCache())
142
143 deps := handlers.Deps{
144 Logger: logger,
145 TemplatesFS: TemplatesFS(),
146 StaticFS: StaticFS(),
147 LogoSVG: string(logoBytes),
148 SessionStore: sessionStore,
149 Pool: pool,
150 BaseURL: cfg.Auth.BaseURL,
151 CookieSecure: cfg.Session.Secure,
152 }
153 if pool != nil {
154 deps.ReadyCheck = func(ctx context.Context) error { return pool.Ping(ctx) }
155 }
156 if cfg.Metrics.Enabled {
157 deps.MetricsHandler = metrics.Handler(cfg.Metrics.BasicAuthUser, cfg.Metrics.BasicAuthPass)
158 }
159
160 if pool != nil {
161 objectStore, err := buildObjectStore(cfg.Storage.S3, logger)
162 if err != nil {
163 return fmt.Errorf("object store: %w", err)
164 }
165
166 auth, err := buildAuthHandlers(cfg, pool, sessionStore, objectStore, logger, deps.TemplatesFS)
167 if err != nil {
168 return fmt.Errorf("auth handlers: %w", err)
169 }
170 deps.AuthMounter = auth.Mount
171 deps.DeviceCodeAPIMounter = auth.MountDeviceCodeAPI
172
173 var (
174 runnerJWT *runnerjwt.Signer
175 actionsBox *secretbox.Box
176 )
177 if cfg.Auth.TOTPKeyB64 != "" {
178 runnerJWT, err = runnerjwt.NewFromTOTPKeyB64(cfg.Auth.TOTPKeyB64)
179 if err != nil {
180 return fmt.Errorf("runner jwt: %w", err)
181 }
182 actionsBox, err = secretbox.FromBase64(cfg.Auth.TOTPKeyB64)
183 if err != nil {
184 return fmt.Errorf("actions secretbox: %w", err)
185 }
186 } else {
187 logger.Warn("actions runner API disabled: auth.totp_key_b64 is not configured",
188 "hint", "set SHITHUB_TOTP_KEY=$(openssl rand -base64 32) to enable runner job JWTs")
189 }
190 api, err := buildAPIHandlers(cfg, pool, objectStore, runnerJWT, actionsBox, ratelimit.New(pool), logger)
191 if err != nil {
192 return fmt.Errorf("api handlers: %w", err)
193 }
194 deps.APIMounter = api.Mount
195
196 profile, err := buildProfileHandlers(cfg, pool, objectStore, deps.TemplatesFS, logger)
197 if err != nil {
198 return fmt.Errorf("profile handlers: %w", err)
199 }
200 deps.AvatarMounter = profile.MountAvatars
201 deps.ProfileMounter = profile.MountProfile
202 deps.OrgRepositoriesMounter = profile.MountOrgRepositories
203
204 repoH, err := buildRepoHandlers(cfg, pool, objectStore, deps.TemplatesFS, logger)
205 if err != nil {
206 return fmt.Errorf("repo handlers: %w", err)
207 }
208 // /new is wrapped in RequireUser — it requires a logged-in caller.
209 deps.RepoNewMounter = func(r chi.Router) {
210 r.Group(func(r chi.Router) {
211 r.Use(middleware.RequireUser)
212 repoH.MountNew(r)
213 })
214 }
215 deps.RepoHomeMounter = repoH.MountRepoHome
216 deps.RepoActionsStreamMounter = repoH.MountRepoActionsStreams
217 deps.RepoCodeMounter = repoH.MountCode
218 deps.RepoHistoryMounter = repoH.MountHistory
219 deps.RepoRefsMounter = repoH.MountRefs
220 deps.RepoSettingsBranchesMounter = func(r chi.Router) {
221 r.Group(func(r chi.Router) {
222 r.Use(middleware.RequireUser)
223 repoH.MountSettingsBranches(r)
224 })
225 }
226 deps.RepoActionsAPIMounter = func(r chi.Router) {
227 r.Group(func(r chi.Router) {
228 r.Use(middleware.RequireUser)
229 repoH.MountRepoActionsAPI(r)
230 })
231 }
232 deps.RepoSettingsGeneralMounter = func(r chi.Router) {
233 r.Group(func(r chi.Router) {
234 r.Use(middleware.RequireUser)
235 repoH.MountSettingsGeneral(r)
236 })
237 }
238 deps.RepoSettingsActionsMounter = func(r chi.Router) {
239 r.Group(func(r chi.Router) {
240 r.Use(middleware.RequireUser)
241 repoH.MountSettingsActions(r)
242 })
243 }
244 deps.RepoWebhooksMounter = func(r chi.Router) {
245 r.Group(func(r chi.Router) {
246 r.Use(middleware.RequireUser)
247 repoH.MountWebhooks(r)
248 })
249 }
250 // Issues GETs are public (subject to policy.Can), POSTs require
251 // auth. The handler enforces auth + policy per request, so we
252 // register the whole surface in the public group; an unauth
253 // POST hits the policy gate and 404s out of the existence-leak
254 // path. Browser flows still need RequireUser to redirect-to-login,
255 // so the POST routes get wrapped through the same group with
256 // RequireUser inserted only for state-mutating verbs.
257 deps.RepoIssuesMounter = repoH.MountIssues
258 deps.RepoPullsMounter = repoH.MountPulls
259 deps.RepoSocialMounter = repoH.MountSocial
260 deps.RepoForkMounter = repoH.MountFork
261
262 // Search gets its own Limiter wired around /search +
263 // /search/quick (audit 2026-05-10 H4). Independent instance
264 // from auth's RateLimiter; both share DB-backed counter
265 // state, segregated by Policy.Scope.
266 searchH, err := buildSearchHandlers(pool, deps.TemplatesFS, logger, ratelimit.New(pool))
267 if err != nil {
268 return fmt.Errorf("search handlers: %w", err)
269 }
270 deps.SearchMounter = searchH.Mount
271
272 notifH, err := buildNotifHandlers(cfg, pool, deps.TemplatesFS, logger)
273 if err != nil {
274 return fmt.Errorf("notif handlers: %w", err)
275 }
276 deps.NotifInboxMounter = func(r chi.Router) {
277 r.Group(func(r chi.Router) {
278 r.Use(middleware.RequireUser)
279 notifH.MountAuthed(r)
280 })
281 }
282 deps.NotifPublicMounter = notifH.MountPublic
283
284 // S30 — orgs.
285 orgH, err := buildOrgHandlers(cfg, pool, objectStore, deps.TemplatesFS, logger)
286 if err != nil {
287 return fmt.Errorf("org handlers: %w", err)
288 }
289 if cfg.Billing.Enabled {
290 deps.BillingWebhookMounter = orgH.MountBillingWebhook
291 }
292 deps.OrgCreateMounter = func(r chi.Router) {
293 r.Group(func(r chi.Router) {
294 r.Use(middleware.RequireUser)
295 orgH.MountCreate(r)
296 })
297 }
298 // /{org}/people: GETs are public (org existence is non-secret;
299 // member lists for private orgs are deferred). Mutations are
300 // owner-checked inside the handler, but RequireUser wraps the
301 // POST routes so unauth submits redirect to /login.
302 deps.OrgRoutesMounter = func(r chi.Router) {
303 r.Group(func(r chi.Router) {
304 orgH.MountOrgRoutes(r)
305 })
306 }
307 deps.OrgInvitationsMounter = func(r chi.Router) {
308 r.Group(func(r chi.Router) {
309 r.Use(middleware.RequireUser)
310 orgH.MountInvitations(r)
311 })
312 }
313
314 // Lifecycle danger-zone routes — also auth-required.
315 deps.RepoLifecycleMounter = func(r chi.Router) {
316 r.Group(func(r chi.Router) {
317 r.Use(middleware.RequireUser)
318 repoH.MountLifecycle(r)
319 })
320 }
321
322 // S34 — site admin. Gated by RequireUser + RequireSiteAdmin
323 // (404 not 403 for non-admins). Uses its own renderer so the
324 // admin templates are loaded once at boot.
325 //
326 // Email sender is the same one auth uses; the admin "Reset
327 // password" action sends through it (SR2 C3). Version is the
328 // build-time-stamped value so /admin/system reports reality
329 // instead of the literal "dev" (SR2 L6).
330 adminSender, err := pickEmailSender(cfg)
331 if err != nil {
332 return fmt.Errorf("admin handlers: pick email sender: %w", err)
333 }
334 adminH, err := buildAdminHandlers(cfg, pool, deps.TemplatesFS, logger, version.Version, adminSender)
335 if err != nil {
336 return fmt.Errorf("admin handlers: %w", err)
337 }
338 deps.AdminMounter = func(r chi.Router) {
339 r.Group(func(r chi.Router) {
340 r.Use(middleware.RequireUser)
341 r.Use(middleware.RequireSiteAdmin(nil)) // nil ⇒ http.NotFound
342 adminH.Mount(r)
343 })
344 }
345
346 gitHTTPH, err := buildGitHTTPHandlers(cfg, pool, runnerJWT, logger)
347 if err != nil {
348 return fmt.Errorf("git-http handlers: %w", err)
349 }
350 deps.GitHTTPMounter = gitHTTPH.MountSmartHTTP
351 } else {
352 logger.Warn("auth: no DB pool — signup/login routes not mounted")
353 }
354
355 _, panicHandler, notFoundHandler, err := handlers.RegisterChi(r, deps)
356 if err != nil {
357 return fmt.Errorf("register handlers: %w", err)
358 }
359 r.NotFound(notFoundHandler)
360
361 rootHandler := middleware.Recover(logger, panicHandler)(r)
362
363 srv := &http.Server{
364 Addr: cfg.Web.Addr,
365 Handler: rootHandler,
366 ReadHeaderTimeout: 10 * time.Second,
367 ReadTimeout: cfg.Web.ReadTimeout,
368 WriteTimeout: cfg.Web.WriteTimeout,
369 IdleTimeout: 120 * time.Second,
370 }
371
372 errCh := make(chan error, 1)
373 go func() {
374 logger.Info(
375 "shithub web server starting",
376 "addr", srv.Addr,
377 "env", cfg.Env,
378 "db", pool != nil,
379 "metrics", cfg.Metrics.Enabled,
380 "tracing", cfg.Tracing.Enabled,
381 "errrep", cfg.ErrorReporting.DSN != "",
382 )
383 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
384 errCh <- err
385 }
386 close(errCh)
387 }()
388
389 sigCh := make(chan os.Signal, 1)
390 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
391 defer signal.Stop(sigCh)
392
393 select {
394 case err, ok := <-errCh:
395 if !ok {
396 return nil
397 }
398 return err
399 case sig := <-sigCh:
400 logger.Info("shutdown signal received", "signal", sig.String())
401 case <-ctx.Done():
402 logger.Info("context canceled, shutting down")
403 }
404
405 shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Web.ShutdownTimeout)
406 defer cancel()
407 if err := srv.Shutdown(shutdownCtx); err != nil {
408 return fmt.Errorf("shutdown: %w", err)
409 }
410 return nil
411 }
412
413 // buildSessionStore constructs the cookie session store from the config's
414 // session block. SHITHUB_SESSION_KEY (env) overrides cfg.KeyB64 when set.
415 func buildSessionStore(cfg config.SessionConfig, logger *slog.Logger) (session.Store, error) {
416 keyB64 := os.Getenv("SHITHUB_SESSION_KEY")
417 if keyB64 == "" {
418 keyB64 = cfg.KeyB64
419 }
420 var key []byte
421 if keyB64 != "" {
422 decoded, err := base64.StdEncoding.DecodeString(keyB64)
423 if err != nil {
424 return nil, fmt.Errorf("session key: invalid base64: %w", err)
425 }
426 if len(decoded) != chacha20poly1305.KeySize {
427 return nil, fmt.Errorf("session key: must be %d bytes, got %d",
428 chacha20poly1305.KeySize, len(decoded))
429 }
430 key = decoded
431 } else {
432 generated, err := session.GenerateKey()
433 if err != nil {
434 return nil, fmt.Errorf("session key: generate: %w", err)
435 }
436 key = generated
437 logger.Warn(
438 "session: no key configured; generated an ephemeral key (sessions will not survive restart)",
439 "hint", "set SHITHUB_SESSION_KEY=<base64 32-byte> or session.key_b64 in production",
440 )
441 }
442 store, err := session.NewCookieStore(session.CookieStoreConfig{
443 Key: key,
444 MaxAge: cfg.MaxAge,
445 Secure: cfg.Secure,
446 })
447 if err != nil {
448 return nil, fmt.Errorf("session: build store: %w", err)
449 }
450 return store, nil
451 }
452