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}