v0.2.0 41cf2ec

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	mux      *http.ServeMux
89}
90
91// New builds the web handler.
92func New(cfg Config) (*Server, error) {
93	if cfg.Repos == nil || cfg.Git == nil {
94		return nil, errors.New("web: the repository and Git API clients are required")
95	}
96	u, err := url.Parse(cfg.PublicURL)
97	if err != nil || u.Host == "" {
98		return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
99	}
100	if cfg.Now == nil {
101		cfg.Now = time.Now
102	}
103	assets, err := loadAssets()
104	if err != nil {
105		return nil, fmt.Errorf("web: load assets: %w", err)
106	}
107	if cfg.Sessions != nil && cfg.Limiter == nil {
108		return nil, errors.New("web: sessions need a failure limiter")
109	}
110	s := &Server{
111		cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
112		origin: originOf(u),
113	}
114	s.mux = s.routes()
115	return s, nil
116}
117
118// routes is the router of the web. Any path that is not a page is the 404
119// page.
120func (s *Server) routes() *http.ServeMux {
121	mux := http.NewServeMux()
122	mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
123	mux.HandleFunc("GET /-/events", s.events)
124	if s.cfg.Sessions != nil {
125		mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
126		mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
127		mux.HandleFunc("POST /-/logout", s.logout)
128	}
129	mux.HandleFunc("GET /{$}", s.page(s.home))
130	mux.HandleFunc("GET /{repo}", s.page(s.summary))
131	mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
132	mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
133	mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
134	mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
135	mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
136	mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
137	mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
138	mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
139	mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
140	if s.cfg.MergeRequests != nil {
141		mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
142		mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
143		mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
144		mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
145		mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
146		// The writes: POST only, behind the gate (ServeHTTP).
147		mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
148		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
149		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
150		mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
151		mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
152		mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
153	}
154	mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
155	return mux
156}
157
158// ServeHTTP implements http.Handler.
159func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
160	r = s.withSession(w, r)
161	if !s.gate(w, r) {
162		return
163	}
164	s.mux.ServeHTTP(w, r)
165}
166
167// handler builds a page. It returns the component and its status, or an
168// error, which becomes the 404 or 500 page.
169type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
170
171// page adapts a handler: it bounds the API calls, renders the component into
172// a buffer (so that a failure never leaves half a page), and maps errors to
173// the state pages.
174func (s *Server) page(h handler) http.HandlerFunc {
175	return func(w http.ResponseWriter, r *http.Request) {
176		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
177		defer cancel()
178		c, status, err := h(ctx, r)
179		if err != nil {
180			s.fail(ctx, w, r, err)
181			return
182		}
183		s.write(ctx, w, r, status, c)
184	}
185}
186
187// write renders c with the given status.
188func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
189	var buf bytes.Buffer
190	if err := c.Render(ctx, &buf); err != nil {
191		// The error page itself failed: a bare answer is all that is left.
192		log := zerowrap.FromCtx(ctx)
193		log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page")
194		http.Error(w, "internal error", http.StatusInternalServerError)
195		return
196	}
197	h := w.Header()
198	h.Set("Content-Type", "text/html; charset=utf-8")
199	// Public pages: the browser and any proxy revalidate each time. A page
200	// rendered for the owner is private and never stored; a handler that set
201	// its own Cache-Control keeps it.
202	if h.Get("Cache-Control") == "" {
203		cacheHeaders(ctx, h, "no-cache", sessionCache)
204	}
205	w.WriteHeader(status)
206	if r.Method != http.MethodHead {
207		_, _ = w.Write(buf.Bytes())
208	}
209}
210
211// fail answers an error of the API: not found (and a name or revision the
212// API refuses, which no page can show either) gives the 404 page, a visitor
213// that left gives nothing, anything else gives the 500 page with an id to
214// find the cause in the log.
215func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
216	switch code := connect.CodeOf(err); {
217	case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
218		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
219	case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
220		// The client is gone; there is nobody to answer.
221	default:
222		id := requestID()
223		log := zerowrap.FromCtx(ctx)
224		log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed")
225		s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
226	}
227}
228
229// errNotFound is returned by handlers for a resource that does not exist
230// although the API did not say so (a ref or a path that is not there).
231var errNotFound = errors.New("not found")
232
233// requestID is a short random id, shown on the 500 page and logged.
234func requestID() string {
235	var b [4]byte
236	_, _ = rand.Read(b[:])
237	return hex.EncodeToString(b[:])
238}
239
240// notFound is the handler of the paths without a page.
241func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
242	return nil, 0, errNotFound
243}
244
245// base is the Layout every page starts from.
246func (s *Server) base() Layout {
247	return Layout{
248		Instance:  s.instance,
249		Version:   s.cfg.Version,
250		SourceURL: s.cfg.SourceURL,
251		Assets:    s.assets.urls,
252	}
253}
254
255// stateLayout is the layout of a state page: only the way home.
256func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout {
257	l := s.base()
258	l.Title = title + " · " + s.instance
259	l.Crumbs = crumbs
260	l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}}
261	l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"}
262	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
263	return l
264}
265
266func (s *Server) notFoundPage() templ.Component {
267	l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}})
268	return State(l, StatePage{
269		Code: "404", Title: "not found",
270		Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
271		Back:  []Link{{Label: "home", Href: "/"}},
272	})
273}
274
275func (s *Server) errorPage(id string) templ.Component {
276	l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}})
277	return State(l, StatePage{
278		Code: "500", Title: "internal error",
279		Lines: []string{
280			"the server failed to answer this request.",
281			"request id: " + id,
282			"retry in a moment; if it persists, the owner has the log.",
283		},
284		Back: []Link{{Label: "home", Href: "/"}},
285	})
286}
287
288// originOf is the origin of a URL as a browser writes it in the Origin
289// header: scheme and host in lower case, the port only when it is not the
290// default of the scheme.
291func originOf(u *url.URL) string {
292	scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port()
293	if strings.Contains(host, ":") {
294		host = "[" + host + "]"
295	}
296	defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
297	if port != "" && !defaultPort {
298		host += ":" + port
299	}
300	return scheme + "://" + host
301}

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