1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package web is the HTML front of ermite: the pages of the read-only
4// showcase, rendered with templ from the data of the use cases.
5//
6// Like every other inbound adapter, the package reads and writes through the
7// use case interfaces of package ports (ADR 0024), never through the API. The
8// caller of every use case call is passed explicitly: the principal of the
9// request, which is anonymous unless the request carries a valid owner session
10// (ServeHTTP sets it in the context, principal reads it). The use cases then
11// show public repositories only to an anonymous caller; a private repository
12// is indistinguishable from a missing one (domain.ErrNotFound), here as
13// everywhere.
14//
15// Adding a page: a handler on Server, a route in routes, a templ page in
16// pages.templ and its view model in view.go.
17package web
18
19import (
20 "bytes"
21 "context"
22 "crypto/rand"
23 "encoding/hex"
24 "errors"
25 "fmt"
26 "net/http"
27 "net/netip"
28 "net/url"
29 "strings"
30 "time"
31
32 "github.com/a-h/templ"
33 "github.com/bnema/zerowrap"
34
35 "bnema.dev/ermite/internal/domain"
36 "bnema.dev/ermite/internal/ports"
37)
38
39// Patterns are the ServeMux patterns the handler serves: everything. The
40// router of the package answers the paths it does not know with its 404
41// page, and the more specific patterns of the server (health, API, Git) win
42// over this one.
43var Patterns = []string{"/"}
44
45// requestTimeout bounds the use case calls that build one page.
46const requestTimeout = 30 * time.Second
47
48// Config configures a Server.
49type Config struct {
50 // Repos and Browse are the use cases the pages are built from.
51 Repos ports.RepoService
52 Browse ports.BrowseService
53 // MergeRequests serves the merge request pages (ADR 0017). Without it
54 // there are no such pages, nor section for them. Leave it unset rather
55 // than assigning a typed nil pointer.
56 MergeRequests ports.MergeRequestService
57 // Issues serves the issue pages (ADR 0031). Without it there are no such
58 // pages, nor tab for them. Leave it unset rather than assigning a typed
59 // nil pointer.
60 Issues ports.IssueService
61 // IssueNumbers tells whether a number names an issue or a merge request,
62 // so that the page of the wrong kind redirects to the right one. Without
63 // it there is no such redirect. Leave it unset rather than assigning a
64 // typed nil pointer.
65 IssueNumbers ports.IssueNumberResolver
66 // IssueForm serves the public issue form of repositories whose issues
67 // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68 // it there is no such form. Leave it unset rather than assigning a typed
69 // nil pointer.
70 IssueForm ports.IssueSubmissionService
71 // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72 // pending issues in the inbox. It needs Inbox. Without it there are no
73 // such routes. Leave it unset rather than assigning a typed nil pointer.
74 Moderation ports.IssueModerationService
75 // Inbox serves the owner's inbox page (/-/inbox) and the button that
76 // leads to it. Without it there is neither. Leave it unset rather than
77 // assigning a typed nil pointer.
78 Inbox ports.InboxService
79 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
80 // commits and merge requests. Without it there are no such pages. Leave it
81 // unset rather than assigning a typed nil pointer.
82 Runs ports.RunService
83 // Artifacts serves the artifacts of the runs: a section of the run page,
84 // their download and their deletion. It needs Runs. Without it there is
85 // neither. Leave it unset rather than assigning a typed nil pointer.
86 Artifacts ports.ArtifactService
87 // Events is the source of the event stream behind /-/events. Without
88 // it the pages are not live and the route answers 404. Leave it unset
89 // rather than assigning a typed nil pointer.
90 Events ports.EventService
91 // Heartbeat is how often an idle event stream gets a comment line;
92 // DefaultHeartbeat when zero.
93 Heartbeat time.Duration
94 // Owner is the owner login the GitHub-shaped URLs of CI show
95 // (<server>/<owner>/<repo>/...). A GET or HEAD of /<owner>/<rest> is
96 // redirected to /<rest> unless a repository named like the owner is
97 // visible to the caller. Empty: no redirect.
98 Owner string
99 // PublicURL is the base URL of the instance, without a trailing slash:
100 // the clone URLs are built on it, and its host is the instance name.
101 PublicURL string
102 // SourceURL is the link to the source of the running version (AGPL §13).
103 SourceURL string
104 // Version is the version of the running server.
105 Version string
106 // Now is the clock of relative dates; time.Now when nil.
107 Now func() time.Time
108 // Sessions signs the owner in (ADR 0018). Without it there are no login
109 // routes and every visitor is anonymous.
110 Sessions ports.WebSessionService
111 // Passkeys signs the owner in with a passkey and manages the passkeys
112 // (ADR 0026). Without it, or without Sessions, there are no passkey
113 // routes and no link to them. Leave it unset rather than assigning a typed
114 // nil pointer.
115 Passkeys ports.WebPasskeyService
116 // Limiter counts the failed logins; required with Sessions. Share it with
117 // the API and Git, so that a client has one budget of failures.
118 Limiter ports.FailureLimiter
119 // TrustedProxies are the networks allowed to set X-Forwarded-For, to find
120 // the address of the client that fails a login.
121 TrustedProxies []netip.Prefix
122}
123
124// Server serves the pages. It is an http.Handler.
125type Server struct {
126 cfg Config
127 instance string
128 origin string // scheme://host of the public URL, what a POST must come from
129 assets *assetSet
130 md *markdown
131 // pathCache holds the path lists the file finder fetches.
132 pathCache *pathCache
133 // hl holds the colours of highlighted diffs.
134 hl *hlCache
135 mux *http.ServeMux
136 // ownerRepo is the owner login as a repository name, zero when it is not
137 // a valid one (then no repository can be named like the owner).
138 ownerRepo domain.RepoName
139 // refsFan shares the fragments of the refs page between the watchers of
140 // a push; entries are few and short-lived.
141 refsFan fanout
142 // pulses holds the activity curves of the home rows.
143 pulses pulseCache
144}
145
146// New builds the web handler.
147func New(cfg Config) (*Server, error) {
148 if cfg.Repos == nil || cfg.Browse == nil {
149 return nil, errors.New("web: the repository and browse use cases are required")
150 }
151 u, err := url.Parse(cfg.PublicURL)
152 if err != nil || u.Host == "" {
153 return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
154 }
155 if cfg.Now == nil {
156 cfg.Now = time.Now
157 }
158 assets, err := loadAssets()
159 if err != nil {
160 return nil, fmt.Errorf("web: load assets: %w", err)
161 }
162 if cfg.Sessions != nil && cfg.Limiter == nil {
163 return nil, errors.New("web: sessions need a failure limiter")
164 }
165 s := &Server{
166 cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
167 pathCache: newPathCache(pathCacheBytes, pathBuilds),
168 hl: newHLCache(hlCacheBytes),
169 origin: originOf(u),
170 }
171 s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner)
172 s.mux = s.routes()
173 return s, nil
174}
175
176// routes is the router of the web. Any path that is not a page is the 404
177// page.
178func (s *Server) routes() *http.ServeMux {
179 mux := http.NewServeMux()
180 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
181 mux.HandleFunc("GET /-/events", s.events)
182 mux.HandleFunc("GET "+ServiceWorkerPath, s.assets.sw.serve)
183 if s.cfg.Sessions != nil {
184 mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
185 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
186 mux.HandleFunc("POST /-/logout", s.logout)
187 }
188 if s.passkeysOn() {
189 s.passkeyRoutes(mux)
190 }
191 if s.cfg.Inbox != nil {
192 mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193 if s.cfg.Moderation != nil {
194 mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195 mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196 mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197 }
198 }
199 mux.HandleFunc("GET /{$}", s.page(s.home))
200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
202 mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
203 mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
204 mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
205 mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
206 mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
207 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
208 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
209 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
210 mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
211 mux.HandleFunc("GET /{repo}/context/{sha}", s.page(s.diffContext))
212 if s.cfg.MergeRequests != nil {
213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
215 mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
219 // The writes: POST only, behind the gate (ServeHTTP).
220 mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
221 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
222 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
223 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
224 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
225 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
226 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
229 }
230 if s.cfg.Issues != nil {
231 mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232 mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233 mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234 mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235 // The writes: POST only, behind the gate (ServeHTTP).
236 mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237 mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238 mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239 mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240 }
241 if s.cfg.IssueForm != nil {
242 mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243 mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244 }
245 if s.cfg.Runs != nil {
246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
247 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
248 mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run))
249 mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job))
250 mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog)
251 mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream)
252 mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel))
253 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
254 mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
255 if s.cfg.Artifacts != nil {
256 mux.HandleFunc("GET /{repo}/actions/runs/{id}/artifacts/{artifact}", s.artifactDownload)
257 mux.HandleFunc("POST /{repo}/actions/runs/{id}/artifacts/{artifact}/delete", s.ciAction(s.artifactDelete))
258 }
259 }
260 mux.HandleFunc(catchAll, s.page(s.notFound))
261 return mux
262}
263
264// ServeHTTP implements http.Handler.
265func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
266 // Whatever the context carried, the caller is anonymous until a valid
267 // session says otherwise (withSession).
268 r = r.WithContext(domain.ContextWithPrincipal(r.Context(), domain.Anonymous()))
269 r = s.withSession(w, r)
270 if !s.gate(w, r) {
271 return
272 }
273 if to, ok := s.ownerRedirect(r); ok {
274 http.Redirect(w, r, to, http.StatusPermanentRedirect)
275 return
276 }
277 s.mux.ServeHTTP(w, r)
278}
279
280// catchAll is the pattern of the paths that are no page.
281const catchAll = "GET /{repo}/{rest...}"
282
283// ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the
284// URLs that the github context of CI shows (html_url, commit and compare
285// URLs, clone_url) have the owner of the instance in front, like GitHub's.
286//
287// The rule is by repository, not by route: when no repository named like the
288// owner is visible to the caller, every GET or HEAD of /<owner>/<rest> is
289// redirected, so /<owner>/actions reaches the repository named "actions". A
290// repository named like the owner keeps its own pages. A private one is seen
291// as missing by an anonymous caller (the use case answers ErrNotFound), so the
292// redirect is the same as for a name nobody has and tells nothing.
293//
294// The target is built from a segment that parses as a repository name, which
295// has no slash, backslash, percent sign or dot-dot: it can only be a path on
296// this origin, never a "//host" one.
297func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
298 if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
299 return "", false
300 }
301 first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
302 if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" {
303 return "", false
304 }
305 repo, _, _ := strings.Cut(rest, "/")
306 if _, err := domain.ParseRepoPath(repo); err != nil {
307 return "", false
308 }
309 if s.ownerNamesARepo(r) {
310 return "", false
311 }
312 to := "/" + rest
313 if r.URL.RawQuery != "" {
314 to += "?" + r.URL.RawQuery
315 }
316 return to, true
317}
318
319// ownerNamesARepo tells whether the caller can see a repository named like
320// the owner. A lookup that fails otherwise counts as yes: the request is then
321// served as it was before, without a redirect.
322func (s *Server) ownerNamesARepo(r *http.Request) bool {
323 if s.ownerRepo.IsZero() {
324 return false // the owner login is not a repository name
325 }
326 ctx := r.Context()
327 _, err := s.cfg.Repos.Get(ctx, principal(ctx), s.ownerRepo)
328 switch {
329 case err == nil:
330 return true
331 case errors.Is(err, domain.ErrNotFound):
332 return false
333 }
334 log := zerowrap.FromCtx(ctx)
335 log.Debug().Err(err).Msg("owner redirect: the lookup of the repository named like the owner failed")
336 return true
337}
338
339// handler builds a page. It returns the component and its status, or an
340// error, which becomes the 404 or 500 page.
341type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
342
343// page adapts a handler: it bounds the use case calls, renders the component into
344// a buffer (so that a failure never leaves half a page), and maps errors to
345// the state pages.
346func (s *Server) page(h handler) http.HandlerFunc {
347 return func(w http.ResponseWriter, r *http.Request) {
348 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
349 defer cancel()
350 c, status, err := h(ctx, r)
351 if err != nil {
352 s.fail(ctx, w, r, err)
353 return
354 }
355 s.write(ctx, w, r, status, c)
356 }
357}
358
359// write renders c with the given status.
360func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
361 var buf bytes.Buffer
362 if err := c.Render(ctx, &buf); err != nil {
363 // The error page itself failed: a bare answer is all that is left.
364 log := zerowrap.FromCtx(ctx)
365 log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page")
366 http.Error(w, "internal error", http.StatusInternalServerError)
367 return
368 }
369 h := w.Header()
370 h.Set("Content-Type", "text/html; charset=utf-8")
371 // Links on this origin are prefetched on hover, so a click shows the page
372 // at once. Every GET page is safe to load (nothing is marked read on GET).
373 h.Set("Speculation-Rules", `"`+s.assets.urls.Rules+`"`)
374 // Public pages: the browser and any proxy revalidate each time. A page
375 // rendered for the owner is private and never stored; a handler that set
376 // its own Cache-Control keeps it.
377 if h.Get("Cache-Control") == "" {
378 cacheHeaders(ctx, h, "no-cache", sessionCache)
379 }
380 body := buf.Bytes()
381 // Whether the body is sent gzipped follows from its size and the request
382 // alone, so that the tag is known before the compression is spent.
383 gz := len(body) >= minCompress && acceptsGzip(r.Header) &&
384 !noTransform(h) && h.Get("Content-Encoding") == ""
385 addVary(h, "Accept-Encoding")
386 readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead
387 // A page that may be stored gets a validator: the browser then
388 // revalidates with If-None-Match and a page that did not change costs a
389 // 304. A no-store page (the owner's) is never revalidated.
390 if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) {
391 tag := pageETag(body, SignedIn(ctx), gz)
392 h.Set("ETag", tag)
393 if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) {
394 h.Del("Content-Type")
395 w.WriteHeader(http.StatusNotModified)
396 return
397 }
398 }
399 if gz {
400 if z, err := gzipPage(body); err == nil && len(z) < len(body) {
401 body = z
402 h.Set("Content-Encoding", "gzip")
403 }
404 }
405 h.Set("Content-Length", itoa(len(body)))
406 w.WriteHeader(status)
407 if r.Method != http.MethodHead {
408 _, _ = w.Write(body)
409 }
410}
411
412// fail answers an error of a use case: not found (and a name or revision the
413// use case refuses, which no page can show either) gives the 404 page, a
414// visitor that left gives nothing, anything else gives the 500 page with an id
415// to find the cause in the log.
416func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
417 switch {
418 case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
419 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
420 case errors.Is(err, context.Canceled):
421 // The client is gone; there is nobody to answer.
422 default:
423 id := requestID()
424 log := zerowrap.FromCtx(ctx)
425 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed")
426 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
427 }
428}
429
430// errNotFound is returned by handlers for a resource that does not exist
431// although the use case did not say so (a ref or a path that is not there).
432var errNotFound = domain.ErrNotFound
433
434// requestID is a short random id, shown on the 500 page and logged.
435func requestID() string {
436 var b [4]byte
437 _, _ = rand.Read(b[:])
438 return hex.EncodeToString(b[:])
439}
440
441// notFound is the handler of the paths without a page.
442func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
443 return nil, 0, errNotFound
444}
445
446// base is the Layout every page starts from.
447func (s *Server) base() Layout {
448 return Layout{
449 Owner: s.cfg.Owner,
450 Version: s.cfg.Version,
451 SourceURL: s.cfg.SourceURL,
452 Assets: s.assets.urls,
453 Passkeys: s.passkeysOn(),
454 }
455}
456
457// stateLayout is the layout of a state page: only the way home.
458func (s *Server) stateLayout(title string, crumbs []Crumb) Layout {
459 l := s.base()
460 l.Title = title + " · " + s.instance
461 l.Crumbs = crumbs
462 l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
463 return l
464}
465
466func (s *Server) notFoundPage() templ.Component {
467 l := s.stateLayout("404", []Crumb{{Label: "not found", Current: true}})
468 return State(l, StatePage{
469 Code: "404", Title: "not found",
470 Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
471 Back: []Link{{Label: "home", Href: "/"}},
472 })
473}
474
475func (s *Server) errorPage(id string) templ.Component {
476 l := s.stateLayout("error", []Crumb{{Label: "error", Current: true}})
477 return State(l, StatePage{
478 Code: "500", Title: "internal error",
479 Lines: []string{
480 "the server failed to answer this request.",
481 "request id: " + id,
482 "retry in a moment; if it persists, the owner has the log.",
483 },
484 Back: []Link{{Label: "home", Href: "/"}},
485 })
486}
487
488// originOf is the origin of a URL as a browser writes it in the Origin
489// header: scheme and host in lower case, the port only when it is not the
490// default of the scheme.
491func originOf(u *url.URL) string {
492 scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port()
493 if strings.Contains(host, ":") {
494 host = "[" + host + "]"
495 }
496 defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
497 if port != "" && !defaultPort {
498 host += ":" + port
499 }
500 return scheme + "://" + host
501}