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