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