Go · 17303 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 "github.com/jackc/pgx/v5/pgxpool"
19
20 "github.com/tenseleyFlow/shithub/internal/auth/session"
21 "github.com/tenseleyFlow/shithub/internal/web/middleware"
22 "github.com/tenseleyFlow/shithub/internal/web/render"
23 )
24
25 // Deps holds the dependencies the handlers need. The web package owns the
26 // embedded filesystems and constructs Deps; this package stays decoupled
27 // from the embed.FS instances so it remains testable.
28 type Deps struct {
29 Logger *slog.Logger
30 TemplatesFS fs.FS
31 StaticFS fs.FS
32 LogoSVG string
33 SessionStore session.Store
34 Pool *pgxpool.Pool
35 // BaseURL is the public scheme+host for canonical crawler URLs
36 // (for example https://shithub.sh). Empty falls back to the request
37 // host, which keeps tests and local dev working.
38 BaseURL string
39 // CookieSecure is the Secure flag for session-related cookies
40 // (currently the CSRF cookie). Mirrors session.Config.Secure
41 // from the loaded config so the CSRF cookie matches the
42 // session cookie in TLS deployments. Defaults to false when
43 // unset, which is correct for tests and dev (SR2 H6).
44 CookieSecure bool
45 // ReadyCheck is optionally invoked by /readyz. Returning a non-nil
46 // error makes /readyz report 503. If nil, /readyz always reports ready.
47 ReadyCheck func(context.Context) error
48 // MetricsHandler, when non-nil, is mounted at /metrics. Caller is
49 // responsible for any access control (e.g. HTTP Basic auth wrapping).
50 MetricsHandler http.Handler
51 // AuthMounter, when non-nil, is invoked inside the CSRF-protected
52 // route group with the chi.Router so the auth handlers can register
53 // signup/login/logout/reset/verify routes.
54 AuthMounter func(chi.Router)
55 // APIMounter, when non-nil, is invoked inside the CSRF-EXEMPT route
56 // group so the API surface (PAT-authenticated, no browser-form
57 // posts) can register its routes.
58 APIMounter func(chi.Router)
59 // DeviceCodeAPIMounter, when non-nil, registers the RFC 8628
60 // device-code JSON endpoints (/login/device/code +
61 // /login/oauth/access_token) on the CSRF-exempt group. The
62 // matching browser-facing /login/device verification page is
63 // mounted by AuthMounter (CSRF-protected).
64 DeviceCodeAPIMounter func(chi.Router)
65 // AvatarMounter, when non-nil, registers /avatars/{username} on the
66 // CSRF-exempt group (avatar GETs are safe and benefit from caching).
67 AvatarMounter func(chi.Router)
68 // RepoNewMounter, when non-nil, registers /new on the CSRF-protected
69 // group. The handler enforces auth itself.
70 RepoNewMounter func(chi.Router)
71 // RepoHomeMounter, when non-nil, registers /{owner}/{repo} on the
72 // CSRF-protected group. Two-segment match doesn't collide with the
73 // /{username} catch-all.
74 RepoHomeMounter func(chi.Router)
75 // RepoLifecycleMounter, when non-nil, registers the danger-zone
76 // routes (rename, transfer, archive, visibility, delete, restore,
77 // transfer accept/decline/cancel, inbox). All routes are auth-
78 // required; the handler enforces policy.Can per route.
79 RepoLifecycleMounter func(chi.Router)
80 // RepoCodeMounter, when non-nil, registers /tree/* /blob/* /raw/*
81 // /find/* under the repo two-segment prefix. Public for read; the
82 // handler runs the policy gate per request.
83 RepoCodeMounter func(chi.Router)
84 // RepoHistoryMounter registers /commits/{ref}, /commit/{sha},
85 // /blame/{ref}/{path...}, /commits/{ref}.atom (S18).
86 RepoHistoryMounter func(chi.Router)
87 // RepoRefsMounter registers /branches, /tags, /compare/* (S20).
88 RepoRefsMounter func(chi.Router)
89 // RepoSettingsBranchesMounter registers /settings/branches +
90 // /settings/default-branch (S20). Auth-required.
91 RepoSettingsBranchesMounter func(chi.Router)
92 // RepoActionsAPIMounter registers POST/state-changing routes
93 // under /{owner}/{repo}/actions/ — currently the
94 // workflow_dispatch endpoint (S41b). Auth-required + per-handler
95 // repo-write check.
96 RepoActionsAPIMounter func(chi.Router)
97 // RepoActionsStreamMounter registers long-lived Actions log-stream
98 // routes. It MUST bypass response compression and request timeout;
99 // the handler still runs the normal repo-read policy gate.
100 RepoActionsStreamMounter func(chi.Router)
101 // RepoSettingsGeneralMounter registers the General/Access tabs and
102 // the deferred-tab placeholders (webhooks, keys, notifications,
103 // tags) under /{owner}/{repo}/settings/* (S32). Auth-required.
104 RepoSettingsGeneralMounter func(chi.Router)
105 // RepoSettingsActionsMounter registers Actions secrets + variables
106 // settings under /{owner}/{repo}/settings/* (S41c). Auth-required.
107 RepoSettingsActionsMounter func(chi.Router)
108 // RepoWebhooksMounter registers the per-repo webhook CRUD +
109 // delivery views under /{owner}/{repo}/settings/webhooks/* (S33).
110 // Auth-required.
111 RepoWebhooksMounter func(chi.Router)
112 // RepoIssuesMounter registers /{owner}/{repo}/issues, /labels, and
113 // /milestones routes (S21). Reads are public (per-repo policy gate);
114 // writes are auth-required.
115 RepoIssuesMounter func(chi.Router)
116 // RepoPullsMounter registers /{owner}/{repo}/pulls* routes (S22).
117 // Same auth shape as issues — reads public, writes auth-required.
118 RepoPullsMounter func(chi.Router)
119 // RepoSocialMounter registers /{owner}/{repo}/{star,unstar,watch,
120 // stargazers,watchers} (S26). Stargazer/watcher GETs are public
121 // (subject to repo visibility); the action POSTs require auth.
122 RepoSocialMounter func(chi.Router)
123 // RepoForkMounter registers /{owner}/{repo}/{fork,sync,forks}
124 // (S27). The forks list GET is public; fork + sync POSTs are
125 // auth-required.
126 RepoForkMounter func(chi.Router)
127 // SearchMounter registers /search and /search/quick (S28).
128 // Both are public — visibility scoping is done inside the
129 // search package via policy.VisibilityPredicate.
130 SearchMounter func(chi.Router)
131 // NotifInboxMounter registers the per-viewer notification inbox
132 // + thread-subscribe + mark-read routes (S29). RequireUser is
133 // applied inside the wiring layer because every route in the
134 // set is per-recipient.
135 NotifInboxMounter func(chi.Router)
136 // NotifPublicMounter registers the unauthenticated one-click
137 // unsubscribe endpoint (S29). HMAC-signed URL = no session
138 // needed, so the route lives in the public group alongside
139 // /healthz / /static.
140 NotifPublicMounter func(chi.Router)
141 // BillingWebhookMounter registers the Stripe webhook receiver at
142 // /stripe/webhook. It lives in the public, CSRF-exempt group and is
143 // mounted only when billing is enabled and fully configured.
144 BillingWebhookMounter func(chi.Router)
145 // OrgCreateMounter registers /organizations/new + POST
146 // /organizations (S30). Wrapped in RequireUser at the wiring
147 // layer.
148 OrgCreateMounter func(chi.Router)
149 // OrgRoutesMounter registers /{org}/people + invite + member
150 // management. Reads (people page) are public; mutations are
151 // owner-gated inside the handler. Must register BEFORE the
152 // /{username} catch-all so the `people` segment matches.
153 OrgRoutesMounter func(chi.Router)
154 // OrgRepositoriesMounter registers /orgs/{org}/repositories. The
155 // GitHub-style /orgs prefix avoids stealing /{user}/repositories
156 // from a real user-owned repo named "repositories".
157 OrgRepositoriesMounter func(chi.Router)
158 // OrgInvitationsMounter registers /invitations/{token} +
159 // accept/decline. RequireUser at the wiring layer.
160 OrgInvitationsMounter func(chi.Router)
161 // AdminMounter, when non-nil, registers /admin/* routes (S34).
162 // The mounter wraps the handler chain in RequireUser +
163 // RequireSiteAdmin so non-admins receive 404, not 403.
164 AdminMounter func(chi.Router)
165 // GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
166 // (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
167 // land in a route group that bypasses CSRF, response compression,
168 // and the global request timeout — git generates its own pack
169 // format, uses HTTP Basic, and clones can run for many minutes.
170 GitHTTPMounter func(chi.Router)
171 // ProfileMounter, when non-nil, registers the /{username} catch-all
172 // route. MUST run last in its group — chi matches in registration
173 // order, and {username} swallows everything else.
174 ProfileMounter func(chi.Router)
175 }
176
177 // panicHandler implements middleware.PanicHandler. The recover middleware
178 // invokes it when a downstream handler panics; we render the styled 500
179 // page through the registered renderer.
180 type panicHandler struct {
181 render *render.Renderer
182 }
183
184 func (h *panicHandler) HandlePanic(w http.ResponseWriter, r *http.Request, _ string, _ any) {
185 h.render.HTTPError(w, r, http.StatusInternalServerError, "")
186 }
187
188 // RegisterChi wires every S02 route into r. Returns the chi.Router (for
189 // further wiring), a panic handler that the caller installs in the
190 // recover middleware, and a NotFound handler for the catch-all.
191 func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http.HandlerFunc, error) {
192 if deps.Logger == nil {
193 return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil Logger")
194 }
195 if deps.TemplatesFS == nil {
196 return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil TemplatesFS")
197 }
198 if deps.StaticFS == nil {
199 return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil StaticFS")
200 }
201
202 rr, err := render.New(deps.TemplatesFS, render.Options{
203 Octicons: render.BuiltinOcticons(),
204 })
205 if err != nil {
206 return nil, nil, nil, fmt.Errorf("renderer: %w", err)
207 }
208
209 csrf := middleware.CSRF(middleware.CSRFConfig{
210 // SR2 H6: session-cookie Secure flag mirrors here so TLS
211 // deployments don't accept the CSRF cookie over plaintext.
212 Secure: deps.CookieSecure,
213 FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
214 rr.HTTPError(w, r, http.StatusForbidden, "csrf")
215 }),
216 })
217
218 // /metrics MUST NOT pass through Compress: Prometheus scrapers
219 // (Alloy 1.16, vmagent, …) advertise Accept-Encoding: gzip but
220 // mis-handle Content-Encoding: gzip on the response, parsing the
221 // raw 0x1f magic byte as text and failing the scrape (up=0).
222 // Mount it on the bare router so only the global middleware
223 // (request_id, access_log, metrics, secure_headers) applies.
224 if deps.MetricsHandler != nil {
225 r.Handle("/metrics", deps.MetricsHandler)
226 }
227
228 // Static and health endpoints are CSRF-exempt; everything else passes
229 // through the CSRF wrapper for state-changing methods.
230 r.Group(func(r chi.Router) {
231 r.Use(middleware.Compress)
232 r.Use(middleware.Timeout(30 * time.Second))
233 r.Handle("/static/*", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
234 crawlers := crawlerHandler{baseURL: deps.BaseURL}
235 r.Get("/robots.txt", crawlers.serveRobots)
236 r.Get("/sitemap.xml", crawlers.serveSitemap)
237 // S17: Chroma highlight CSS is generated at runtime from the
238 // theme; serve under /static/css/chroma.css so the layout can
239 // link it without a build step.
240 r.Get("/static/css/chroma.css", chromaCSSHandler())
241 // HEAD honored alongside GET so strict probes (HEAD-only health
242 // checks, some Kubernetes-style livenessProbes) get 200 not 405
243 // (SR2 L8).
244 r.Get("/healthz", healthz)
245 r.Head("/healthz", healthz)
246 r.Handle("/readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
247 if deps.APIMounter != nil {
248 deps.APIMounter(r)
249 }
250 if deps.DeviceCodeAPIMounter != nil {
251 deps.DeviceCodeAPIMounter(r)
252 }
253 if deps.AvatarMounter != nil {
254 deps.AvatarMounter(r)
255 }
256 // One-click unsubscribe lands in the public group (no CSRF,
257 // no session) — RFC 8058 mailers click it from arbitrary
258 // agents.
259 if deps.NotifPublicMounter != nil {
260 deps.NotifPublicMounter(r)
261 }
262 if deps.BillingWebhookMounter != nil {
263 deps.BillingWebhookMounter(r)
264 }
265 })
266
267 // Smart-HTTP git routes get their own group: NO CSRF (HTTP Basic
268 // flow, no browser form posts), NO response compression (git emits
269 // its own pack format), and NO global request timeout (long clones
270 // run for minutes). The global SecureHeaders / RealIP / RequestID
271 // stack still applies; everything else is per-group.
272 if deps.GitHTTPMounter != nil {
273 r.Group(func(r chi.Router) {
274 deps.GitHTTPMounter(r)
275 })
276 }
277
278 // Actions step-log SSE also streams for minutes. Keep it out of the
279 // app group's timeout/compression stack so EventSource receives each
280 // event as the handler flushes it. Browser CSRF protection is not
281 // needed for this GET-only route; repo visibility is enforced inside
282 // the handler through policy.ActionRepoRead.
283 if deps.RepoActionsStreamMounter != nil {
284 r.Group(func(r chi.Router) {
285 deps.RepoActionsStreamMounter(r)
286 })
287 }
288
289 // Application routes — CSRF protected. Compress + Timeout live in
290 // this group (and the static one above) rather than globally so the
291 // streaming groups can opt out.
292 r.Group(func(r chi.Router) {
293 r.Use(middleware.Compress)
294 r.Use(middleware.Timeout(30 * time.Second))
295 r.Use(csrf)
296 marketing := marketingHandler{render: rr, baseURL: deps.BaseURL, logger: deps.Logger}
297 r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, baseURL: deps.BaseURL, logger: deps.Logger}.ServeHTTP)
298 r.Get("/about", marketing.serveAbout)
299 r.Get("/explore", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeExplore)
300 r.Get("/trending", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeTrending)
301 globalNavH := globalNavHandler{render: rr, logger: deps.Logger, pool: deps.Pool}
302 r.Group(func(r chi.Router) {
303 r.Use(middleware.RequireUser)
304 r.Get("/issues", globalNavH.RedirectIssues)
305 r.Get("/issues/new", globalNavH.ServeNewIssue)
306 r.Get("/issues/{view}", globalNavH.ServeIssues)
307 r.Get("/pulls", globalNavH.ServePulls)
308 r.Get("/repos", globalNavH.ServeRepos)
309 })
310 // /internal/panic is a dev affordance: GET it to trigger the
311 // panic-recovery path so an operator can confirm the styled 500
312 // page renders. S35 will gate this behind a dev flag.
313 r.Get("/internal/panic", panicTrigger)
314 if deps.AuthMounter != nil {
315 deps.AuthMounter(r)
316 }
317 if deps.RepoNewMounter != nil {
318 deps.RepoNewMounter(r)
319 }
320 // Code-tab + history routes register BEFORE RepoHome's two-segment
321 // route so /{owner}/{repo}/tree/* and /commit/* don't get
322 // swallowed.
323 if deps.RepoCodeMounter != nil {
324 deps.RepoCodeMounter(r)
325 }
326 if deps.RepoHistoryMounter != nil {
327 deps.RepoHistoryMounter(r)
328 }
329 if deps.RepoRefsMounter != nil {
330 deps.RepoRefsMounter(r)
331 }
332 if deps.RepoSettingsBranchesMounter != nil {
333 deps.RepoSettingsBranchesMounter(r)
334 }
335 if deps.RepoActionsAPIMounter != nil {
336 deps.RepoActionsAPIMounter(r)
337 }
338 if deps.RepoSettingsGeneralMounter != nil {
339 deps.RepoSettingsGeneralMounter(r)
340 }
341 if deps.RepoSettingsActionsMounter != nil {
342 deps.RepoSettingsActionsMounter(r)
343 }
344 // Webhooks (S33) — register BEFORE the general mounter so the
345 // /settings/webhooks GET resolves to the new CRUD list, not
346 // any stale placeholder.
347 if deps.RepoWebhooksMounter != nil {
348 deps.RepoWebhooksMounter(r)
349 }
350 if deps.RepoIssuesMounter != nil {
351 deps.RepoIssuesMounter(r)
352 }
353 if deps.RepoPullsMounter != nil {
354 deps.RepoPullsMounter(r)
355 }
356 if deps.RepoSocialMounter != nil {
357 deps.RepoSocialMounter(r)
358 }
359 if deps.RepoForkMounter != nil {
360 deps.RepoForkMounter(r)
361 }
362 if deps.SearchMounter != nil {
363 deps.SearchMounter(r)
364 }
365 if deps.NotifInboxMounter != nil {
366 deps.NotifInboxMounter(r)
367 }
368 if deps.OrgCreateMounter != nil {
369 deps.OrgCreateMounter(r)
370 }
371 if deps.OrgInvitationsMounter != nil {
372 deps.OrgInvitationsMounter(r)
373 }
374 // /{org}/people MUST register before /{username} catch-all
375 // so the explicit `people` segment matches first.
376 if deps.OrgRoutesMounter != nil {
377 deps.OrgRoutesMounter(r)
378 }
379 if deps.OrgRepositoriesMounter != nil {
380 deps.OrgRepositoriesMounter(r)
381 }
382 if deps.RepoHomeMounter != nil {
383 deps.RepoHomeMounter(r)
384 }
385 // Lifecycle danger-zone + transfers + restore. Order: after
386 // RepoHome so explicit settings paths are matched first, before
387 // Profile's /{username} catch-all.
388 if deps.AdminMounter != nil {
389 deps.AdminMounter(r)
390 }
391 if deps.RepoLifecycleMounter != nil {
392 deps.RepoLifecycleMounter(r)
393 }
394 // Profile is registered LAST so /{username} doesn't shadow any
395 // static top-level route.
396 if deps.ProfileMounter != nil {
397 deps.ProfileMounter(r)
398 }
399 })
400
401 notFound := func(w http.ResponseWriter, r *http.Request) {
402 rr.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
403 }
404
405 return r, &panicHandler{render: rr}, notFound, nil
406 }
407
408 // Register is preserved for the existing test suite that exercises the
409 // surface without bringing up the full server. Internally it wraps
410 // RegisterChi and mounts the chi router on mux.
411 func Register(mux *http.ServeMux, deps Deps) error {
412 r := chi.NewRouter()
413 _, _, notFound, err := RegisterChi(r, deps)
414 if err != nil {
415 return err
416 }
417 r.NotFound(notFound)
418 mux.Handle("/", r)
419 return nil
420 }
421
422 // panicTrigger panics on demand to exercise the recover middleware.
423 func panicTrigger(_ http.ResponseWriter, _ *http.Request) {
424 panic("S02 panic trigger: this is intentional")
425 }
426