Go · 8371 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package handlers registers HTTP handlers on the web server's mux.
4 //
5 // S02 ships the full chi-routed surface plus error pages. Each future
6 // sprint adds its own routes via this package.
7 package handlers
8
9 import (
10 "context"
11 "fmt"
12 "io/fs"
13 "log/slog"
14 "net/http"
15 "time"
16
17 "github.com/go-chi/chi/v5"
18
19 "github.com/tenseleyFlow/shithub/internal/auth/session"
20 "github.com/tenseleyFlow/shithub/internal/web/middleware"
21 "github.com/tenseleyFlow/shithub/internal/web/render"
22 )
23
24 // Deps holds the dependencies the handlers need. The web package owns the
25 // embedded filesystems and constructs Deps; this package stays decoupled
26 // from the embed.FS instances so it remains testable.
27 type Deps struct {
28 Logger *slog.Logger
29 TemplatesFS fs.FS
30 StaticFS fs.FS
31 LogoSVG string
32 SessionStore session.Store
33 // ReadyCheck is optionally invoked by /readyz. Returning a non-nil
34 // error makes /readyz report 503. If nil, /readyz always reports ready.
35 ReadyCheck func(context.Context) error
36 // MetricsHandler, when non-nil, is mounted at /metrics. Caller is
37 // responsible for any access control (e.g. HTTP Basic auth wrapping).
38 MetricsHandler http.Handler
39 // AuthMounter, when non-nil, is invoked inside the CSRF-protected
40 // route group with the chi.Router so the auth handlers can register
41 // signup/login/logout/reset/verify routes.
42 AuthMounter func(chi.Router)
43 // APIMounter, when non-nil, is invoked inside the CSRF-EXEMPT route
44 // group so the API surface (PAT-authenticated, no browser-form
45 // posts) can register its routes.
46 APIMounter func(chi.Router)
47 // AvatarMounter, when non-nil, registers /avatars/{username} on the
48 // CSRF-exempt group (avatar GETs are safe and benefit from caching).
49 AvatarMounter func(chi.Router)
50 // RepoNewMounter, when non-nil, registers /new on the CSRF-protected
51 // group. The handler enforces auth itself.
52 RepoNewMounter func(chi.Router)
53 // RepoHomeMounter, when non-nil, registers /{owner}/{repo} on the
54 // CSRF-protected group. Two-segment match doesn't collide with the
55 // /{username} catch-all.
56 RepoHomeMounter func(chi.Router)
57 // RepoLifecycleMounter, when non-nil, registers the danger-zone
58 // routes (rename, transfer, archive, visibility, delete, restore,
59 // transfer accept/decline/cancel, inbox). All routes are auth-
60 // required; the handler enforces policy.Can per route.
61 RepoLifecycleMounter func(chi.Router)
62 // RepoCodeMounter, when non-nil, registers /tree/* /blob/* /raw/*
63 // /find/* under the repo two-segment prefix. Public for read; the
64 // handler runs the policy gate per request.
65 RepoCodeMounter func(chi.Router)
66 // RepoHistoryMounter registers /commits/{ref}, /commit/{sha},
67 // /blame/{ref}/{path...}, /commits/{ref}.atom (S18).
68 RepoHistoryMounter func(chi.Router)
69 // GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
70 // (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
71 // land in a route group that bypasses CSRF, response compression,
72 // and the global request timeout — git generates its own pack
73 // format, uses HTTP Basic, and clones can run for many minutes.
74 GitHTTPMounter func(chi.Router)
75 // ProfileMounter, when non-nil, registers the /{username} catch-all
76 // route. MUST run last in its group — chi matches in registration
77 // order, and {username} swallows everything else.
78 ProfileMounter func(chi.Router)
79 }
80
81 // panicHandler implements middleware.PanicHandler. The recover middleware
82 // invokes it when a downstream handler panics; we render the styled 500
83 // page through the registered renderer.
84 type panicHandler struct {
85 render *render.Renderer
86 }
87
88 func (h *panicHandler) HandlePanic(w http.ResponseWriter, r *http.Request, _ string, _ any) {
89 h.render.HTTPError(w, r, http.StatusInternalServerError, "")
90 }
91
92 // RegisterChi wires every S02 route into r. Returns the chi.Router (for
93 // further wiring), a panic handler that the caller installs in the
94 // recover middleware, and a NotFound handler for the catch-all.
95 func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http.HandlerFunc, error) {
96 if deps.Logger == nil {
97 return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil Logger")
98 }
99 if deps.TemplatesFS == nil {
100 return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil TemplatesFS")
101 }
102 if deps.StaticFS == nil {
103 return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil StaticFS")
104 }
105
106 rr, err := render.New(deps.TemplatesFS, render.Options{
107 Octicons: render.BuiltinOcticons(),
108 })
109 if err != nil {
110 return nil, nil, nil, fmt.Errorf("renderer: %w", err)
111 }
112
113 csrf := middleware.CSRF(middleware.CSRFConfig{
114 Secure: false, // S37 enables under TLS
115 FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116 rr.HTTPError(w, r, http.StatusForbidden, "csrf")
117 }),
118 })
119
120 // Static and health endpoints are CSRF-exempt; everything else passes
121 // through the CSRF wrapper for state-changing methods.
122 r.Group(func(r chi.Router) {
123 r.Use(middleware.Compress)
124 r.Use(middleware.Timeout(30 * time.Second))
125 r.Handle("/static/*", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
126 // S17: Chroma highlight CSS is generated at runtime from the
127 // theme; serve under /static/css/chroma.css so the layout can
128 // link it without a build step.
129 r.Get("/static/css/chroma.css", chromaCSSHandler())
130 r.Get("/healthz", healthz)
131 r.Handle("/readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
132 if deps.MetricsHandler != nil {
133 r.Handle("/metrics", deps.MetricsHandler)
134 }
135 if deps.APIMounter != nil {
136 deps.APIMounter(r)
137 }
138 if deps.AvatarMounter != nil {
139 deps.AvatarMounter(r)
140 }
141 })
142
143 // Smart-HTTP git routes get their own group: NO CSRF (HTTP Basic
144 // flow, no browser form posts), NO response compression (git emits
145 // its own pack format), and NO global request timeout (long clones
146 // run for minutes). The global SecureHeaders / RealIP / RequestID
147 // stack still applies; everything else is per-group.
148 if deps.GitHTTPMounter != nil {
149 r.Group(func(r chi.Router) {
150 deps.GitHTTPMounter(r)
151 })
152 }
153
154 // Application routes — CSRF protected. Compress + Timeout live in
155 // this group (and the static one above) rather than globally so the
156 // git-HTTP group can opt out.
157 r.Group(func(r chi.Router) {
158 r.Use(middleware.Compress)
159 r.Use(middleware.Timeout(30 * time.Second))
160 r.Use(csrf)
161 r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, logger: deps.Logger}.ServeHTTP)
162 // /internal/panic is a dev affordance: GET it to trigger the
163 // panic-recovery path so an operator can confirm the styled 500
164 // page renders. S35 will gate this behind a dev flag.
165 r.Get("/internal/panic", panicTrigger)
166 if deps.AuthMounter != nil {
167 deps.AuthMounter(r)
168 }
169 if deps.RepoNewMounter != nil {
170 deps.RepoNewMounter(r)
171 }
172 // Code-tab + history routes register BEFORE RepoHome's two-segment
173 // route so /{owner}/{repo}/tree/* and /commit/* don't get
174 // swallowed.
175 if deps.RepoCodeMounter != nil {
176 deps.RepoCodeMounter(r)
177 }
178 if deps.RepoHistoryMounter != nil {
179 deps.RepoHistoryMounter(r)
180 }
181 if deps.RepoHomeMounter != nil {
182 deps.RepoHomeMounter(r)
183 }
184 // Lifecycle danger-zone + transfers + restore. Order: after
185 // RepoHome so explicit settings paths are matched first, before
186 // Profile's /{username} catch-all.
187 if deps.RepoLifecycleMounter != nil {
188 deps.RepoLifecycleMounter(r)
189 }
190 // Profile is registered LAST so /{username} doesn't shadow any
191 // static top-level route.
192 if deps.ProfileMounter != nil {
193 deps.ProfileMounter(r)
194 }
195 })
196
197 notFound := func(w http.ResponseWriter, r *http.Request) {
198 rr.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
199 }
200
201 return r, &panicHandler{render: rr}, notFound, nil
202 }
203
204 // Register is preserved for the existing test suite that exercises the
205 // surface without bringing up the full server. Internally it wraps
206 // RegisterChi and mounts the chi router on mux.
207 func Register(mux *http.ServeMux, deps Deps) error {
208 r := chi.NewRouter()
209 _, _, notFound, err := RegisterChi(r, deps)
210 if err != nil {
211 return err
212 }
213 r.NotFound(notFound)
214 mux.Handle("/", r)
215 return nil
216 }
217
218 // panicTrigger panics on demand to exercise the recover middleware.
219 func panicTrigger(_ http.ResponseWriter, _ *http.Request) {
220 panic("S02 panic trigger: this is intentional")
221 }
222