v0.1.0 3f267b5

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/url"
27	"time"
28
29	"connectrpc.com/connect"
30	"github.com/a-h/templ"
31	"github.com/bnema/zerowrap"
32
33	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
34)
35
36// Patterns are the ServeMux patterns the handler serves: everything. The
37// router of the package answers the paths it does not know with its 404
38// page, and the more specific patterns of the server (health, API, Git) win
39// over this one.
40var Patterns = []string{"/"}
41
42// requestTimeout bounds the API calls that build one page.
43const requestTimeout = 30 * time.Second
44
45// Config configures a Server.
46type Config struct {
47	// Repos and Git are the API clients the pages are built from.
48	Repos ermiteconnect.RepoServiceClient
49	Git   ermiteconnect.GitServiceClient
50	// Events is the client of the event stream behind /-/events. Without
51	// it the pages are not live and the route answers 404.
52	Events ermiteconnect.EventServiceClient
53	// Heartbeat is how often an idle event stream gets a comment line;
54	// DefaultHeartbeat when zero.
55	Heartbeat time.Duration
56	// PublicURL is the base URL of the instance, without a trailing slash:
57	// the clone URLs are built on it, and its host is the instance name.
58	PublicURL string
59	// SourceURL is the link to the source of the running version (AGPL §13).
60	SourceURL string
61	// Version is the version of the running server.
62	Version string
63	// Now is the clock of relative dates; time.Now when nil.
64	Now func() time.Time
65}
66
67// Server serves the pages. It is an http.Handler.
68type Server struct {
69	cfg      Config
70	instance string
71	assets   *assetSet
72	md       *markdown
73	mux      *http.ServeMux
74}
75
76// New builds the web handler.
77func New(cfg Config) (*Server, error) {
78	if cfg.Repos == nil || cfg.Git == nil {
79		return nil, errors.New("web: the repository and Git API clients are required")
80	}
81	u, err := url.Parse(cfg.PublicURL)
82	if err != nil || u.Host == "" {
83		return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
84	}
85	if cfg.Now == nil {
86		cfg.Now = time.Now
87	}
88	assets, err := loadAssets()
89	if err != nil {
90		return nil, fmt.Errorf("web: load assets: %w", err)
91	}
92	s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()}
93	s.mux = s.routes()
94	return s, nil
95}
96
97// routes is the router of the web. Any path that is not a page is the 404
98// page.
99func (s *Server) routes() *http.ServeMux {
100	mux := http.NewServeMux()
101	mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
102	mux.HandleFunc("GET /-/events", s.events)
103	mux.HandleFunc("GET /{$}", s.page(s.home))
104	mux.HandleFunc("GET /{repo}", s.page(s.summary))
105	mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
106	mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
107	mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
108	mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
109	mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
110	mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
111	mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
112	mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
113	mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
114	mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
115	return mux
116}
117
118// ServeHTTP implements http.Handler.
119func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
120	s.mux.ServeHTTP(w, r)
121}
122
123// handler builds a page. It returns the component and its status, or an
124// error, which becomes the 404 or 500 page.
125type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
126
127// page adapts a handler: it bounds the API calls, renders the component into
128// a buffer (so that a failure never leaves half a page), and maps errors to
129// the state pages.
130func (s *Server) page(h handler) http.HandlerFunc {
131	return func(w http.ResponseWriter, r *http.Request) {
132		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
133		defer cancel()
134		c, status, err := h(ctx, r)
135		if err != nil {
136			s.fail(ctx, w, r, err)
137			return
138		}
139		s.write(ctx, w, r, status, c)
140	}
141}
142
143// write renders c with the given status.
144func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
145	var buf bytes.Buffer
146	if err := c.Render(ctx, &buf); err != nil {
147		// The error page itself failed: a bare answer is all that is left.
148		log := zerowrap.FromCtx(ctx)
149		log.Error().Err(err).Str("path", r.URL.Path).Msg("render page")
150		http.Error(w, "internal error", http.StatusInternalServerError)
151		return
152	}
153	h := w.Header()
154	h.Set("Content-Type", "text/html; charset=utf-8")
155	// Public pages: the browser and any proxy revalidate each time.
156	h.Set("Cache-Control", "no-cache")
157	w.WriteHeader(status)
158	if r.Method != http.MethodHead {
159		_, _ = w.Write(buf.Bytes())
160	}
161}
162
163// fail answers an error of the API: not found (and a name or revision the
164// API refuses, which no page can show either) gives the 404 page, a visitor
165// that left gives nothing, anything else gives the 500 page with an id to
166// find the cause in the log.
167func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
168	switch code := connect.CodeOf(err); {
169	case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
170		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
171	case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
172		// The client is gone; there is nobody to answer.
173	default:
174		id := requestID()
175		log := zerowrap.FromCtx(ctx)
176		log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed")
177		s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
178	}
179}
180
181// errNotFound is returned by handlers for a resource that does not exist
182// although the API did not say so (a ref or a path that is not there).
183var errNotFound = errors.New("not found")
184
185// requestID is a short random id, shown on the 500 page and logged.
186func requestID() string {
187	var b [4]byte
188	_, _ = rand.Read(b[:])
189	return hex.EncodeToString(b[:])
190}
191
192// notFound is the handler of the paths without a page.
193func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
194	return nil, 0, errNotFound
195}
196
197// base is the Layout every page starts from.
198func (s *Server) base() Layout {
199	return Layout{
200		Instance:  s.instance,
201		Version:   s.cfg.Version,
202		SourceURL: s.cfg.SourceURL,
203		Assets:    s.assets.urls,
204	}
205}
206
207// stateLayout is the layout of a state page: only the way home.
208func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout {
209	l := s.base()
210	l.Title = title + " · " + s.instance
211	l.Crumbs = crumbs
212	l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}}
213	l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"}
214	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
215	return l
216}
217
218func (s *Server) notFoundPage() templ.Component {
219	l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}})
220	return State(l, StatePage{
221		Code: "404", Title: "not found",
222		Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
223		Back:  []Link{{Label: "home", Href: "/"}},
224	})
225}
226
227func (s *Server) errorPage(id string) templ.Component {
228	l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}})
229	return State(l, StatePage{
230		Code: "500", Title: "internal error",
231		Lines: []string{
232			"the server failed to answer this request.",
233			"request id: " + id,
234			"retry in a moment; if it persists, the owner has the log.",
235		},
236		Back: []Link{{Label: "home", Href: "/"}},
237	})
238}

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 2

Tags 45

↑↓ moveenter openesc close