v0.7.0 45bfc5b

web.go RawHistoryFolder

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	// Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58	// commits and merge requests. Without it there are no such pages. Leave it
59	// unset rather than assigning a typed nil pointer.
60	Runs ports.RunService
61	// Events is the source of the event stream behind /-/events. Without
62	// it the pages are not live and the route answers 404. Leave it unset
63	// rather than assigning a typed nil pointer.
64	Events ports.EventService
65	// Heartbeat is how often an idle event stream gets a comment line;
66	// DefaultHeartbeat when zero.
67	Heartbeat time.Duration
68	// PublicURL is the base URL of the instance, without a trailing slash:
69	// the clone URLs are built on it, and its host is the instance name.
70	PublicURL string
71	// SourceURL is the link to the source of the running version (AGPL §13).
72	SourceURL string
73	// Version is the version of the running server.
74	Version string
75	// Now is the clock of relative dates; time.Now when nil.
76	Now func() time.Time
77	// Sessions signs the owner in (ADR 0018). Without it there are no login
78	// routes and every visitor is anonymous.
79	Sessions ports.WebSessionService
80	// Passkeys signs the owner in with a passkey and manages the passkeys
81	// (ADR 0026). Without it, or without Sessions, there are no passkey
82	// routes and no link to them. Leave it unset rather than assigning a typed
83	// nil pointer.
84	Passkeys ports.WebPasskeyService
85	// Limiter counts the failed logins; required with Sessions. Share it with
86	// the API and Git, so that a client has one budget of failures.
87	Limiter ports.FailureLimiter
88	// TrustedProxies are the networks allowed to set X-Forwarded-For, to find
89	// the address of the client that fails a login.
90	TrustedProxies []netip.Prefix
91}
92
93// Server serves the pages. It is an http.Handler.
94type Server struct {
95	cfg      Config
96	instance string
97	origin   string // scheme://host of the public URL, what a POST must come from
98	assets   *assetSet
99	md       *markdown
100	// pathCache holds the path lists the file finder fetches.
101	pathCache *pathCache
102	mux       *http.ServeMux
103	// refsFan shares the fragments of the refs page between the watchers of
104	// a push; entries are few and short-lived.
105	refsFan fanout
106}
107
108// New builds the web handler.
109func New(cfg Config) (*Server, error) {
110	if cfg.Repos == nil || cfg.Browse == nil {
111		return nil, errors.New("web: the repository and browse use cases are required")
112	}
113	u, err := url.Parse(cfg.PublicURL)
114	if err != nil || u.Host == "" {
115		return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
116	}
117	if cfg.Now == nil {
118		cfg.Now = time.Now
119	}
120	assets, err := loadAssets()
121	if err != nil {
122		return nil, fmt.Errorf("web: load assets: %w", err)
123	}
124	if cfg.Sessions != nil && cfg.Limiter == nil {
125		return nil, errors.New("web: sessions need a failure limiter")
126	}
127	s := &Server{
128		cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
129		pathCache: newPathCache(pathCacheBytes, pathBuilds),
130		origin:    originOf(u),
131	}
132	s.mux = s.routes()
133	return s, nil
134}
135
136// routes is the router of the web. Any path that is not a page is the 404
137// page.
138func (s *Server) routes() *http.ServeMux {
139	mux := http.NewServeMux()
140	mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
141	mux.HandleFunc("GET /-/events", s.events)
142	if s.cfg.Sessions != nil {
143		mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
144		mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
145		mux.HandleFunc("POST /-/logout", s.logout)
146	}
147	if s.passkeysOn() {
148		s.passkeyRoutes(mux)
149	}
150	mux.HandleFunc("GET /{$}", s.page(s.home))
151	mux.HandleFunc("GET /{repo}", s.page(s.summary))
152	mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
153	mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
154	mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
155	mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
156	mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
157	mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
158	mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
159	mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
160	mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
161	mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
162	if s.cfg.MergeRequests != nil {
163		mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
164		mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
165		mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
166		mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
167		mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
168		// The writes: POST only, behind the gate (ServeHTTP).
169		mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
170		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
171		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
172		mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
173		mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
174		mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
175	}
176	if s.cfg.Runs != nil {
177		mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
178		mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
179		mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run))
180		mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job))
181		mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog)
182		mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream)
183		mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel))
184		mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
185		mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
186	}
187	mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
188	return mux
189}
190
191// ServeHTTP implements http.Handler.
192func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
193	// Whatever the context carried, the caller is anonymous until a valid
194	// session says otherwise (withSession).
195	r = r.WithContext(domain.ContextWithPrincipal(r.Context(), domain.Anonymous()))
196	r = s.withSession(w, r)
197	if !s.gate(w, r) {
198		return
199	}
200	s.mux.ServeHTTP(w, r)
201}
202
203// handler builds a page. It returns the component and its status, or an
204// error, which becomes the 404 or 500 page.
205type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
206
207// page adapts a handler: it bounds the use case calls, renders the component into
208// a buffer (so that a failure never leaves half a page), and maps errors to
209// the state pages.
210func (s *Server) page(h handler) http.HandlerFunc {
211	return func(w http.ResponseWriter, r *http.Request) {
212		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
213		defer cancel()
214		c, status, err := h(ctx, r)
215		if err != nil {
216			s.fail(ctx, w, r, err)
217			return
218		}
219		s.write(ctx, w, r, status, c)
220	}
221}
222
223// write renders c with the given status.
224func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
225	var buf bytes.Buffer
226	if err := c.Render(ctx, &buf); err != nil {
227		// The error page itself failed: a bare answer is all that is left.
228		log := zerowrap.FromCtx(ctx)
229		log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page")
230		http.Error(w, "internal error", http.StatusInternalServerError)
231		return
232	}
233	h := w.Header()
234	h.Set("Content-Type", "text/html; charset=utf-8")
235	// Links on this origin are prefetched on hover, so a click shows the page
236	// at once. Every GET page is safe to load (nothing is marked read on GET).
237	h.Set("Speculation-Rules", `"`+s.assets.urls.Rules+`"`)
238	// Public pages: the browser and any proxy revalidate each time. A page
239	// rendered for the owner is private and never stored; a handler that set
240	// its own Cache-Control keeps it.
241	if h.Get("Cache-Control") == "" {
242		cacheHeaders(ctx, h, "no-cache", sessionCache)
243	}
244	body := buf.Bytes()
245	// Whether the body is sent gzipped follows from its size and the request
246	// alone, so that the tag is known before the compression is spent.
247	gz := len(body) >= minCompress && acceptsGzip(r.Header) &&
248		!noTransform(h) && h.Get("Content-Encoding") == ""
249	addVary(h, "Accept-Encoding")
250	readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead
251	// A page that may be stored gets a validator: the browser then
252	// revalidates with If-None-Match and a page that did not change costs a
253	// 304. A no-store page (the owner's) is never revalidated.
254	if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) {
255		tag := pageETag(body, SignedIn(ctx), gz)
256		h.Set("ETag", tag)
257		if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) {
258			h.Del("Content-Type")
259			w.WriteHeader(http.StatusNotModified)
260			return
261		}
262	}
263	if gz {
264		if z, err := gzipPage(body); err == nil && len(z) < len(body) {
265			body = z
266			h.Set("Content-Encoding", "gzip")
267		}
268	}
269	h.Set("Content-Length", itoa(len(body)))
270	w.WriteHeader(status)
271	if r.Method != http.MethodHead {
272		_, _ = w.Write(body)
273	}
274}
275
276// fail answers an error of a use case: not found (and a name or revision the
277// use case refuses, which no page can show either) gives the 404 page, a
278// visitor that left gives nothing, anything else gives the 500 page with an id
279// to find the cause in the log.
280func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
281	switch {
282	case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
283		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
284	case errors.Is(err, context.Canceled):
285		// The client is gone; there is nobody to answer.
286	default:
287		id := requestID()
288		log := zerowrap.FromCtx(ctx)
289		log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed")
290		s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
291	}
292}
293
294// errNotFound is returned by handlers for a resource that does not exist
295// although the use case did not say so (a ref or a path that is not there).
296var errNotFound = domain.ErrNotFound
297
298// requestID is a short random id, shown on the 500 page and logged.
299func requestID() string {
300	var b [4]byte
301	_, _ = rand.Read(b[:])
302	return hex.EncodeToString(b[:])
303}
304
305// notFound is the handler of the paths without a page.
306func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
307	return nil, 0, errNotFound
308}
309
310// base is the Layout every page starts from.
311func (s *Server) base() Layout {
312	return Layout{
313		Instance:  s.instance,
314		Version:   s.cfg.Version,
315		SourceURL: s.cfg.SourceURL,
316		Assets:    s.assets.urls,
317		Passkeys:  s.passkeysOn(),
318	}
319}
320
321// stateLayout is the layout of a state page: only the way home.
322func (s *Server) stateLayout(title string, crumbs []Crumb) Layout {
323	l := s.base()
324	l.Title = title + " · " + s.instance
325	l.Crumbs = crumbs
326	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
327	return l
328}
329
330func (s *Server) notFoundPage() templ.Component {
331	l := s.stateLayout("404", []Crumb{{Label: "not found", Current: true}})
332	return State(l, StatePage{
333		Code: "404", Title: "not found",
334		Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
335		Back:  []Link{{Label: "home", Href: "/"}},
336	})
337}
338
339func (s *Server) errorPage(id string) templ.Component {
340	l := s.stateLayout("error", []Crumb{{Label: "error", Current: true}})
341	return State(l, StatePage{
342		Code: "500", Title: "internal error",
343		Lines: []string{
344			"the server failed to answer this request.",
345			"request id: " + id,
346			"retry in a moment; if it persists, the owner has the log.",
347		},
348		Back: []Link{{Label: "home", Href: "/"}},
349	})
350}
351
352// originOf is the origin of a URL as a browser writes it in the Origin
353// header: scheme and host in lower case, the port only when it is not the
354// default of the scheme.
355func originOf(u *url.URL) string {
356	scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port()
357	if strings.Contains(host, ":") {
358		host = "[" + host + "]"
359	}
360	defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
361	if port != "" && !defaultPort {
362		host += ":" + port
363	}
364	return scheme + "://" + host
365}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close