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}