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}