coverage~bigbes/sr-ht-ecore3bd158fbchrome/chrome.go

Coverage
100.0% 53/53 statements
Δ
Blob
c47987d
Uncovered nothing — every instrumented line ran
1 // Package chrome is the shared page chrome for the custom services of a
2 // self-hosted SourceHut instance (diff, spec, dolt, cov, bench, ...).
3 //
4 // Every one of those services renders the same top strip: the brand (circle
5 // icon + site name + red service label), the service switcher derived from the
6 // shared config.ini, and the login box against meta.sr.ht's unified login.
7 // Before this package each service carried its own copy of the nav-building
8 // code and markup, and the copies drifted (hardcoded brand labels, string vs
9 // const active checks, divergent hub handling). This package is the one copy.
10 //
11 // Usage:
12 //
13 // svc := chrome.NewService(conf, "diff.sr.ht")
14 // svc.StyleHref = cssHref // after discovering the hashed stylesheet
15 // page := svc.Page(r, "My title", username)
16 //
17 // and in the layout, after chrome.Attach(t) has parsed the shared partials:
18 //
19 // {{template "srht-env-banner" .}}
20 // <nav class="container navbar navbar-light navbar-expand-sm">
21 // {{template "srht-nav" .}}
22 // </nav>
23 //
24 // The template dot must expose the Page fields — either a Page itself or a
25 // service view struct that embeds it (promoted fields resolve in templates).
26 //
27 // Unified policy decisions, deliberately baked in rather than parameterized:
28 // the switcher renders only for authenticated viewers; paste, pages and hub
29 // never appear in it (hub is the brand's business); the profile link prefers
30 // hub's ~username page when hub is configured; the brand is always circle +
31 // site name + red service label, with the name linking to hub and the label to
32 // the service's own root.
33 package chrome
34
35 import (
36 "html/template"
37 "net/http"
38 "net/url"
39 "sort"
40 "strings"
41 "time"
42
43 "github.com/vaughan0/go-ini"
44 "sourcecraft.dev/bigbes/sr-ht-core/config"
45 )
46
47 // navCanonical is the SourceHut service-switcher order, mirroring upstream
48 // core.sr.ht's _network_order. Services not listed here (including the custom
49 // ones) sort alphabetically after these.
50 var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}
51
52 // navExcluded are service sections that never appear in the switcher: paste
53 // and pages have no top-level UI worth linking, and hub is not a sibling
54 // service but the network's front page.
55 var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}
56
57 // NavItem is one entry in the service switcher (or a service-specific extra).
58 type NavItem struct {
59 Name string // link text, e.g. "git"
60 Origin string // href
61 Active bool // highlights the current service
62 }
63
64 // Section is one entry in a service's own navigation row — the second row, the
65 // one the family draws below the switcher and upstream spells .header-tabbed
66 // wrapping .nav.nav-tabs (meta's profile/security/keys, git's summary/tree/log).
67 //
68 // It is deliberately not a NavItem. The switcher's entries are services, each
69 // on an origin of its own, and its active element is spent naming the service
70 // the reader is in — which is true of every page that service serves and
71 // therefore says nothing about which one. These are pages of one service, so
72 // they carry a path rather than an origin, and their active element is the only
73 // thing in the chrome that can say where inside the service the reader stands.
74 // artifacts kept its three sections in ExtraNav before this type existed, and
75 // that row was the sum of both mistakes: a switcher whose membership changed
76 // from service to service, and no active element anywhere below it.
77 type Section struct {
78 // Name is the tab's text; Href is where it leads, as a path on this
79 // service.
80 Name string
81 Href string
82 // Paths are matched as path segments: a page stands in the section when
83 // its path equals one of them, or continues one after a slash. "/mirrors"
84 // therefore covers "/mirrors" and "/mirrors/alpine/rules" but not
85 // "/mirrorsomething", and the service root "/" matches only itself.
86 Paths []string
87 // Prefixes are matched as literal string prefixes, for the shapes a
88 // segment boundary cannot express. "/~" is one: a channel, a repository
89 // and a database are all spelled "/~owner/name", and every such page
90 // belongs to the section whose listing carries it.
91 Prefixes []string
92 }
93
94 // SectionTab is a Section as one rendered page sees it: the entry, plus whether
95 // this page is the one standing in it.
96 type SectionTab struct {
97 Name string
98 Href string
99 Active bool
100 }
101
102 // sectionTabs is the row as a request's own path sees it.
103 //
104 // Deriving the active entry from the path is what makes a page that forgot to
105 // declare its section impossible: a service has a dozen render paths and one of
106 // them is the error page, reached from every other.
107 //
108 // A path in no section — the 404 that "/nowhere" renders — lights nothing
109 // rather than falling back to the first tab, because a row whose active element
110 // is always lit would be claiming the reader is somewhere they are not.
111 16 func sectionTabs(sections []Section, path string) []SectionTab {
112 16 if len(sections) == 0 {
113 8 return nil
114 8 }
115 8 tabs := make([]SectionTab, 0, len(sections))
116 32 for _, section := range sections {
117 32 tabs = append(tabs, SectionTab{
118 32 Name: section.Name,
119 32 Href: section.Href,
120 32 Active: section.matches(path),
121 32 })
122 32 }
123 8 return tabs
124 }
125
126 // matches answers whether a path stands in this section; see Section.Paths and
127 // Section.Prefixes for the two rules and why both exist.
128 35 func (s Section) matches(path string) bool {
129 35 for _, segment := range s.Paths {
130 35 if path == segment {
131 4 return true
132 4 }
133 // The service root is the one segment with nothing under it: trimming
134 // its slash leaves "", and "" + "/" is the prefix of every path on the
135 // service, so a root declared this way would light its tab on every
136 // page and darken it nowhere. Pages below the root belong to whichever
137 // section claims them - through Prefixes, as "/~" does - or to none.
138 31 if segment == "/" {
139 9 continue
140 }
141 22 if strings.HasPrefix(path, strings.TrimSuffix(segment, "/")+"/") {
142 2 return true
143 2 }
144 }
145 29 for _, prefix := range s.Prefixes {
146 7 if strings.HasPrefix(path, prefix) {
147 1 return true
148 1 }
149 }
150 28 return false
151 }
152
153 // BuildNav derives the service switcher from the shared config: every section
154 // whose name ends in ".sr.ht" (with a configured origin) except the excluded
155 // ones, ordered canonically then alphabetically, with the section named by
156 // active marked as the current service.
157 //
158 // The ".sr.ht" suffix is the whole membership rule — it is what core.sr.ht's
159 // own _network does, and it is why a custom service's section must be named
160 // literally "<name>.sr.ht" no matter what host it is served from.
161 20 func BuildNav(conf ini.File, active string) []NavItem {
162 20 var items []NavItem
163 237 for section := range conf {
164 237 if !strings.HasSuffix(section, ".sr.ht") {
165 40 continue
166 }
167 197 short := strings.TrimSuffix(section, ".sr.ht")
168 197 if navExcluded[short] {
169 57 continue
170 }
171 140 origin := config.GetOrigin(conf, section, true)
172 140 if origin == "" {
173 20 continue
174 }
175 120 items = append(items, NavItem{
176 120 Name: short,
177 120 Origin: origin,
178 120 Active: section == active,
179 120 })
180 }
181 205 sort.SliceStable(items, func(i, j int) bool {
182 205 ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
183 205 if ci != cj {
184 185 return ci < cj
185 185 }
186 20 return items[i].Name < items[j].Name
187 })
188 20 return items
189 }
190
191 // canonIndex returns a service's position in navCanonical, or a sentinel past
192 // the end for services that are not canonically ordered.
193 410 func canonIndex(name string) int {
194 2549 for i, n := range navCanonical {
195 2549 if n == name {
196 284 return i
197 284 }
198 }
199 126 return len(navCanonical)
200 }
201
202 // Page is the chrome every rendered page shares. Services embed it in their
203 // own view struct and add page payload (and service-specific chrome fields)
204 // next to it.
205 //
206 // Embedding names the field Page, so a view struct that wants "Page" for its
207 // own payload — a pagination counter, most often — has to rename that field
208 // (PageNum, say). The collision is a compile error, not a silent shadow.
209 type Page struct {
210 Title string
211 SiteName string
212 SiteLabel string // red brand suffix: the service's short name
213
214 Nav []NavItem
215 ExtraNav []NavItem // service-specific entries appended after the switcher
216
217 // Tabs is this service's own navigation row, below the switcher, with the
218 // entry the request's path stands in marked Active. Empty for a service
219 // that declared no Sections, and empty for an anonymous viewer — the rule
220 // the switcher already follows, since a row of destinations is chrome for
221 // someone with a session rather than a second front door.
222 Tabs []SectionTab
223
224 Username string // "" for an anonymous viewer
225 LoginURL string // meta login with return_to back to the current URL
226 LogoutURL string // meta logout with return_to to this service's root
227 RegisterURL string
228 ProfileURL string // hub's ~username page when hub is configured, else meta profile
229
230 MetaOrigin string
231 SelfOrigin string
232 HubOrigin string
233
234 StyleHref string // "" when the binary was built without a stylesheet
235
236 // FaviconHref is the icon for this page's <head>; "" renders no <link>.
237 // Guarded rather than emitted empty for the same reason StyleHref is:
238 // <link href=""> re-requests the page it is on.
239 FaviconHref template.URL
240
241 // Assets are the hashed hrefs of the extra build artefacts a layout links
242 // beyond the stylesheet — a vendored chart library, a front-end bundle —
243 // keyed by names the service picks. Read as {{index .Assets "uplot.js"}},
244 // guarded on emptiness exactly like StyleHref.
245 //
246 // They belong to the chrome for the reason StyleHref does: the hash in the
247 // name is a property of this binary, not of any page. A page that had to
248 // be handed its own asset URLs is a page that can be written without them
249 // and silently render nothing where the chart was.
250 //
251 // The map is the Service's, shared by every Page it builds: written once
252 // at startup, read-only afterwards. A handler must not write to it.
253 Assets map[string]string
254
255 Environment string // uppercased; banner text
256 ShowBanner bool // true outside production
257
258 // ContainerClass selects the width of the page's content wrapper: the
259 // centered Bootstrap "container" by default; services override it to
260 // "container-fluid" for full-bleed pages (diff views, annotated source).
261 ContainerClass string
262 }
263
264 // ListItem is one project in a listing — a repository, a database, a space.
265 // Title is the display name ("~owner/name"); Visibility is the service's
266 // literal enum value ("PUBLIC"/"UNLISTED"/"PRIVATE", "" to render nothing —
267 // the partials show it lowercase, non-public only).
268 //
269 // Updated and Meta are optional, and deliberately so. Four services wanted a
270 // listing here and disagreed about its shape: bench and spec needed a
271 // modification time, dolt has no timestamp in its schema at all, and cov's
272 // index is a table of percentages and sparklines that no shared partial will
273 // ever render. A required column would have pushed dolt back onto a local
274 // copy; a zero Updated and a nil Meta render nothing, which is what keeps all
275 // three of them consumers.
276 //
277 // Updated is a time.Time rather than a preformatted string so the partial can
278 // render "3 hours ago" with the exact stamp in the title attribute, once,
279 // instead of every service picking its own spelling — the drift RelTime and
280 // AbsTime were hoisted to end.
281 type ListItem struct {
282 Href string
283 Title string
284 Visibility string
285 Description string
286 Updated time.Time
287 Meta []string
288 }
289
290 // RepoList is the dot for the srht-repo-list and srht-repo-table partials: the
291 // items, and the muted text shown when there are none.
292 //
293 // Two partials over one type because the two shapes are not variants of each
294 // other: srht-repo-list is the family's event-list cards, srht-repo-table the
295 // same data as aligned columns for a service whose listing is long enough to
296 // scan. Making the cards partial grow columns would have made it a worse cards
297 // partial for the services that wanted cards.
298 type RepoList struct {
299 Items []ListItem
300 Empty string
301 }
302
303 // Service is the static half of the chrome, built once at startup. The
304 // exported fields may be adjusted between NewService and the first Page call
305 // (they are read, never written, by Page).
306 type Service struct {
307 // Section is the literal config section, e.g. "diff.sr.ht".
308 Section string
309 // StyleHref is the href of the built stylesheet (the hashed
310 // main.min.<sha>.css); the zero value renders a bare page rather than
311 // failing, matching how the services degrade without CSS.
312 StyleHref string
313 // ExtraNav holds service-specific switcher entries (e.g. a /tokens link),
314 // rendered after the shared network entries, for authenticated viewers.
315 //
316 // Deprecated: it has no correct use left. Its two historical ones both
317 // turned out to be mistakes with the same shape — putting a page of one
318 // service into the row that lists the instance's services. bench and cov
319 // rode it for a local /tokens until the instance deployed a tokens.sr.ht
320 // and the word appeared in the navbar twice; artifacts rode it for three
321 // sections that are Sections now. A service's own pages belong in Sections;
322 // the switcher is the instance's, not the service's.
323 ExtraNav []NavItem
324 // Sections is this service's own navigation row, rendered below the
325 // switcher. Declare it at startup, in the order the tabs should print;
326 // Page marks the one the request stands in. A service that declares none
327 // renders no row at all, which is every service that had none before this
328 // field existed.
329 Sections []Section
330 // Assets holds the extra hashed asset hrefs every Page carries; see
331 // Page.Assets. Populate it at startup, next to StyleHref.
332 Assets map[string]string
333 // FaviconHref is the icon linked from every page's <head>. NewService sets
334 // it to DefaultFaviconHref; a service with a logo of its own overwrites it
335 // (through assets.Resolve, so a hashed icon earns the immutable lifetime),
336 // and "" renders no <link> at all.
337 FaviconHref template.URL
338
339 siteName string
340 environment string
341 selfOrigin string
342 metaOrigin string
343 hubOrigin string
344 nav []NavItem
345 }
346
347 // DefaultFaviconHref is the icon a service gets without shipping one: the
348 // brand's circle, inlined as a data: URI.
349 //
350 // A data: URI rather than a path into a static tree, because the alternative
351 // fails in a way that is easy to miss. bench deliberately embeds no favicon
352 // and its layout says why: a <link rel="icon"> pointing at an asset the binary
353 // does not have is a 404 — a rendered error page, on every page load, for a
354 // file no human asked for. A default that is a path would hand that to every
355 // service that has not made a logo yet; a default that carries its own bytes
356 // cannot 404. It also costs no request at all, which a 500-byte icon is not
357 // worth making.
358 //
359 // The stroke follows the viewer's colour scheme, since a favicon sits on the
360 // browser's chrome rather than on ours, and a near-black ring disappears into
361 // a dark tab strip.
362 //
363 // The type is template.URL because html/template rewrites any href whose
364 // scheme is not http, https or mailto to "#ZgotmplZ" — a data: URI reaches the
365 // page only if the caller says it meant it. That is also the guard on a
366 // service overriding this field: the value has to come from somewhere the
367 // service vouches for, not from a request.
368 const DefaultFaviconHref template.URL = "data:image/svg+xml," +
369 "%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E" +
370 "%3Cstyle%3Ecircle%7Bstroke:%23222%7D" +
371 "@media(prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23eee%7D%7D%3C/style%3E" +
372 "%3Ccircle%20cx='16'%20cy='16'%20r='11'%20fill='none'%20stroke-width='6'/%3E%3C/svg%3E"
373
374 // NewService reads the shared config once and caches everything Page needs.
375 // section must be this service's literal config section name.
376 19 func NewService(conf ini.File, section string) *Service {
377 19 env := config.GetString(conf, "sr.ht", "environment", "development")
378 19 return &Service{
379 19 Section: section,
380 19 FaviconHref: DefaultFaviconHref,
381 19 siteName: config.GetString(conf, "sr.ht", "site-name", "sr.ht"),
382 19 environment: env,
383 19 selfOrigin: strings.TrimRight(config.GetOrigin(conf, section, true), "/"),
384 19 metaOrigin: strings.TrimRight(config.GetOrigin(conf, "meta.sr.ht", true), "/"),
385 19 hubOrigin: strings.TrimRight(config.GetOrigin(conf, "hub.sr.ht", true), "/"),
386 19 nav: BuildNav(conf, section),
387 19 }
388 19 }
389
390 // SelfOrigin returns the service's own external origin, as resolved from the
391 // config section given to NewService.
392 1 func (s *Service) SelfOrigin() string { return s.selfOrigin }
393
394 // MetaOrigin returns meta.sr.ht's external origin.
395 1 func (s *Service) MetaOrigin() string { return s.metaOrigin }
396
397 // HubOrigin returns hub.sr.ht's external origin, or "" when the instance has
398 // no hub.
399 2 func (s *Service) HubOrigin() string { return s.hubOrigin }
400
401 // SiteName returns the instance's brand text.
402 1 func (s *Service) SiteName() string { return s.siteName }
403
404 // Environment returns the configured environment as written in the config
405 // (lowercase); Page uppercases it for the banner.
406 1 func (s *Service) Environment() string { return s.environment }
407
408 // LoginURLFor is meta.sr.ht's login with return_to pointing back at the URL
409 // being served — the same link the nav's "Log in" carries. Exported for the
410 // handlers that gate a page behind login and only need somewhere to redirect,
411 // so they do not have to build a whole Page to read one field off it.
412 25 func (s *Service) LoginURLFor(r *http.Request) string {
413 25 return s.metaOrigin + "/login?return_to=" + url.QueryEscape(s.selfOrigin+r.URL.RequestURI())
414 25 }
415
416 // Page builds the chrome for one request. Login return_to is the current full
417 // URL (so the viewer lands back where they were); logout return_to is this
418 // service's origin. username is the caller's *authoritative* identity — pass
419 // "" for viewers whose cookie grants nothing, and the nav offers login.
420 23 func (s *Service) Page(r *http.Request, title, username string) Page {
421 23 profileURL := s.metaOrigin + "/profile"
422 23 if s.hubOrigin != "" && username != "" {
423 14 profileURL = s.hubOrigin + "/~" + username
424 14 }
425
426 // The section row follows the switcher's own rule, which the partial states
427 // in markup: chrome for a reader with a session. Not a permission check —
428 // a service's public sections stay reachable by their addresses either way
429 // — but a row printed for a logged-out visitor would be offering tabs it
430 // cannot know are answerable, and the switcher beside it would be empty.
431 23 var tabs []SectionTab
432 23 if username != "" {
433 16 tabs = sectionTabs(s.Sections, r.URL.Path)
434 16 }
435
436 23 return Page{
437 23 Title: title,
438 23 SiteName: s.siteName,
439 23 SiteLabel: strings.TrimSuffix(s.Section, ".sr.ht"),
440 23 Nav: s.nav,
441 23 ExtraNav: s.ExtraNav,
442 23 Tabs: tabs,
443 23 Username: username,
444 23 LoginURL: s.LoginURLFor(r),
445 23 LogoutURL: s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.selfOrigin),
446 23 RegisterURL: s.metaOrigin,
447 23 ProfileURL: profileURL,
448 23 MetaOrigin: s.metaOrigin,
449 23 SelfOrigin: s.selfOrigin,
450 23 HubOrigin: s.hubOrigin,
451 23 StyleHref: s.StyleHref,
452 23 FaviconHref: s.FaviconHref,
453 23 Assets: s.Assets,
454 23 Environment: strings.ToUpper(s.environment),
455 23 ShowBanner: s.environment != "" && s.environment != "production",
456 23 ContainerClass: "container",
457 23 }
458 }