v0.6.0 d5cc8fe

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 ermite API.
5//
6// The package is a client of the API like any other. It talks to the
7// generated ermite-api Connect clients, which cmd binds in process to the
8// handlers of the rpc adapter (ADR 0020), and it knows nothing of the use
9// cases or the domain. Who the visitor is comes with the request context, set
10// by the middleware cmd puts in front of the handler: anonymous in P5. The
11// API then shows public repositories only; a private repository is
12// indistinguishable from a missing one, here as everywhere.
13//
14// Adding a page: a handler on Server, a route in routes, a templ page in
15// pages.templ and its view model in view.go.
16package web
17
18import (
19	"bytes"
20	"context"
21	"crypto/rand"
22	"encoding/hex"
23	"errors"
24	"fmt"
25	"net/http"
26	"net/netip"
27	"net/url"
28	"strings"
29	"time"
30
31	"connectrpc.com/connect"
32	"github.com/a-h/templ"
33	"github.com/bnema/zerowrap"
34
35	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
36)
37
38// Patterns are the ServeMux patterns the handler serves: everything. The
39// router of the package answers the paths it does not know with its 404
40// page, and the more specific patterns of the server (health, API, Git) win
41// over this one.
42var Patterns = []string{"/"}
43
44// requestTimeout bounds the API calls that build one page.
45const requestTimeout = 30 * time.Second
46
47// Config configures a Server.
48type Config struct {
49	// Repos and Git are the API clients the pages are built from.
50	Repos ermiteconnect.RepoServiceClient
51	Git   ermiteconnect.GitServiceClient
52	// MergeRequests is the client of the merge request pages (ADR 0017).
53	// Without it there are no such pages, nor section for them.
54	MergeRequests ermiteconnect.MergeRequestServiceClient
55	// Events is the client of the event stream behind /-/events. Without
56	// it the pages are not live and the route answers 404.
57	Events ermiteconnect.EventServiceClient
58	// Heartbeat is how often an idle event stream gets a comment line;
59	// DefaultHeartbeat when zero.
60	Heartbeat time.Duration
61	// PublicURL is the base URL of the instance, without a trailing slash:
62	// the clone URLs are built on it, and its host is the instance name.
63	PublicURL string
64	// SourceURL is the link to the source of the running version (AGPL §13).
65	SourceURL string
66	// Version is the version of the running server.
67	Version string
68	// Now is the clock of relative dates; time.Now when nil.
69	Now func() time.Time
70	// Sessions signs the owner in (ADR 0018). Without it there are no login
71	// routes and every visitor is anonymous.
72	Sessions SessionAuth
73	// Limiter counts the failed logins; required with Sessions. Share it with
74	// the API and Git, so that a client has one budget of failures.
75	Limiter FailureLimiter
76	// TrustedProxies are the networks allowed to set X-Forwarded-For, to find
77	// the address of the client that fails a login.
78	TrustedProxies []netip.Prefix
79}
80
81// Server serves the pages. It is an http.Handler.
82type Server struct {
83	cfg      Config
84	instance string
85	origin   string // scheme://host of the public URL, what a POST must come from
86	assets   *assetSet
87	md       *markdown
88	// pathCache holds the path lists the file finder fetches.
89	pathCache *pathCache
90	mux       *http.ServeMux
91}
92
93// New builds the web handler.
94func New(cfg Config) (*Server, error) {
95	if cfg.Repos == nil || cfg.Git == nil {
96		return nil, errors.New("web: the repository and Git API clients are required")
97	}
98	u, err := url.Parse(cfg.PublicURL)
99	if err != nil || u.Host == "" {
100		return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
101	}
102	if cfg.Now == nil {
103		cfg.Now = time.Now
104	}
105	assets, err := loadAssets()
106	if err != nil {
107		return nil, fmt.Errorf("web: load assets: %w", err)
108	}
109	if cfg.Sessions != nil && cfg.Limiter == nil {
110		return nil, errors.New("web: sessions need a failure limiter")
111	}
112	s := &Server{
113		cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
114		pathCache: newPathCache(pathCacheBytes, pathBuilds),
115		origin:    originOf(u),
116	}
117	s.mux = s.routes()
118	return s, nil
119}
120
121// routes is the router of the web. Any path that is not a page is the 404
122// page.
123func (s *Server) routes() *http.ServeMux {
124	mux := http.NewServeMux()
125	mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
126	mux.HandleFunc("GET /-/events", s.events)
127	if s.cfg.Sessions != nil {
128		mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
129		mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
130		mux.HandleFunc("POST /-/logout", s.logout)
131	}
132	mux.HandleFunc("GET /{$}", s.page(s.home))
133	mux.HandleFunc("GET /{repo}", s.page(s.summary))
134	mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
135	mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
136	mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
137	mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
138	mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
139	mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
140	mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
141	mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
142	mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
143	mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
144	if s.cfg.MergeRequests != nil {
145		mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
146		mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
147		mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
148		mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
149		mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
150		// The writes: POST only, behind the gate (ServeHTTP).
151		mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
152		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
153		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
154		mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
155		mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
156		mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
157	}
158	mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
159	return mux
160}
161
162// ServeHTTP implements http.Handler.
163func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
164	r = s.withSession(w, r)
165	if !s.gate(w, r) {
166		return
167	}
168	s.mux.ServeHTTP(w, r)
169}
170
171// handler builds a page. It returns the component and its status, or an
172// error, which becomes the 404 or 500 page.
173type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
174
175// page adapts a handler: it bounds the API calls, renders the component into
176// a buffer (so that a failure never leaves half a page), and maps errors to
177// the state pages.
178func (s *Server) page(h handler) http.HandlerFunc {
179	return func(w http.ResponseWriter, r *http.Request) {
180		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
181		defer cancel()
182		c, status, err := h(ctx, r)
183		if err != nil {
184			s.fail(ctx, w, r, err)
185			return
186		}
187		s.write(ctx, w, r, status, c)
188	}
189}
190
191// write renders c with the given status.
192func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
193	var buf bytes.Buffer
194	if err := c.Render(ctx, &buf); err != nil {
195		// The error page itself failed: a bare answer is all that is left.
196		log := zerowrap.FromCtx(ctx)
197		log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page")
198		http.Error(w, "internal error", http.StatusInternalServerError)
199		return
200	}
201	h := w.Header()
202	h.Set("Content-Type", "text/html; charset=utf-8")
203	// Links on this origin are prefetched on hover, so a click shows the page
204	// at once. Every GET page is safe to load (nothing is marked read on GET).
205	h.Set("Speculation-Rules", `"`+s.assets.urls.Rules+`"`)
206	// Public pages: the browser and any proxy revalidate each time. A page
207	// rendered for the owner is private and never stored; a handler that set
208	// its own Cache-Control keeps it.
209	if h.Get("Cache-Control") == "" {
210		cacheHeaders(ctx, h, "no-cache", sessionCache)
211	}
212	w.WriteHeader(status)
213	if r.Method != http.MethodHead {
214		_, _ = w.Write(buf.Bytes())
215	}
216}
217
218// fail answers an error of the API: not found (and a name or revision the
219// API refuses, which no page can show either) gives the 404 page, a visitor
220// that left gives nothing, anything else gives the 500 page with an id to
221// find the cause in the log.
222func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
223	switch code := connect.CodeOf(err); {
224	case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
225		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
226	case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
227		// The client is gone; there is nobody to answer.
228	default:
229		id := requestID()
230		log := zerowrap.FromCtx(ctx)
231		log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed")
232		s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
233	}
234}
235
236// errNotFound is returned by handlers for a resource that does not exist
237// although the API did not say so (a ref or a path that is not there).
238var errNotFound = errors.New("not found")
239
240// requestID is a short random id, shown on the 500 page and logged.
241func requestID() string {
242	var b [4]byte
243	_, _ = rand.Read(b[:])
244	return hex.EncodeToString(b[:])
245}
246
247// notFound is the handler of the paths without a page.
248func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
249	return nil, 0, errNotFound
250}
251
252// base is the Layout every page starts from.
253func (s *Server) base() Layout {
254	return Layout{
255		Instance:  s.instance,
256		Version:   s.cfg.Version,
257		SourceURL: s.cfg.SourceURL,
258		Assets:    s.assets.urls,
259	}
260}
261
262// stateLayout is the layout of a state page: only the way home.
263func (s *Server) stateLayout(title string, crumbs []Crumb) Layout {
264	l := s.base()
265	l.Title = title + " · " + s.instance
266	l.Crumbs = crumbs
267	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
268	return l
269}
270
271func (s *Server) notFoundPage() templ.Component {
272	l := s.stateLayout("404", []Crumb{{Label: "not found", Current: true}})
273	return State(l, StatePage{
274		Code: "404", Title: "not found",
275		Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
276		Back:  []Link{{Label: "home", Href: "/"}},
277	})
278}
279
280func (s *Server) errorPage(id string) templ.Component {
281	l := s.stateLayout("error", []Crumb{{Label: "error", Current: true}})
282	return State(l, StatePage{
283		Code: "500", Title: "internal error",
284		Lines: []string{
285			"the server failed to answer this request.",
286			"request id: " + id,
287			"retry in a moment; if it persists, the owner has the log.",
288		},
289		Back: []Link{{Label: "home", Href: "/"}},
290	})
291}
292
293// originOf is the origin of a URL as a browser writes it in the Origin
294// header: scheme and host in lower case, the port only when it is not the
295// default of the scheme.
296func originOf(u *url.URL) string {
297	scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port()
298	if strings.Contains(host, ":") {
299		host = "[" + host + "]"
300	}
301	defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
302	if port != "" && !defaultPort {
303		host += ":" + port
304	}
305	return scheme + "://" + host
306}

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