~bigbes/sr-ht-ecore

sr-ht-ecore/chrome/chrome.go -rw-r--r-- 18.9 KiB
b36a9272 — Eugene Blikh beads: ignore the JSONL exports a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
// Package chrome is the shared page chrome for the custom services of a
// self-hosted SourceHut instance (diff, spec, dolt, cov, bench, ...).
//
// Every one of those services renders the same top strip: the brand (circle
// icon + site name + red service label), the service switcher derived from the
// shared config.ini, and the login box against meta.sr.ht's unified login.
// Before this package each service carried its own copy of the nav-building
// code and markup, and the copies drifted (hardcoded brand labels, string vs
// const active checks, divergent hub handling). This package is the one copy.
//
// Usage:
//
//	svc := chrome.NewService(conf, "diff.sr.ht")
//	svc.StyleHref = cssHref            // after discovering the hashed stylesheet
//	page := svc.Page(r, "My title", username)
//
// and in the layout, after chrome.Attach(t) has parsed the shared partials:
//
//	{{template "srht-env-banner" .}}
//	<nav class="container navbar navbar-light navbar-expand-sm">
//	  {{template "srht-nav" .}}
//	</nav>
//
// The template dot must expose the Page fields — either a Page itself or a
// service view struct that embeds it (promoted fields resolve in templates).
//
// Unified policy decisions, deliberately baked in rather than parameterized:
// the switcher renders only for authenticated viewers; paste, pages and hub
// never appear in it (hub is the brand's business); the profile link prefers
// hub's ~username page when hub is configured; the brand is always circle +
// site name + red service label, with the name linking to hub and the label to
// the service's own root.
package chrome

import (
	"html/template"
	"net/http"
	"net/url"
	"sort"
	"strings"
	"time"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
)

// navCanonical is the SourceHut service-switcher order, mirroring upstream
// core.sr.ht's _network_order. Services not listed here (including the custom
// ones) sort alphabetically after these.
var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}

// navExcluded are service sections that never appear in the switcher: paste
// and pages have no top-level UI worth linking, and hub is not a sibling
// service but the network's front page.
var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}

// NavItem is one entry in the service switcher (or a service-specific extra).
type NavItem struct {
	Name   string // link text, e.g. "git"
	Origin string // href
	Active bool   // highlights the current service
}

// Section is one entry in a service's own navigation row — the second row, the
// one the family draws below the switcher and upstream spells .header-tabbed
// wrapping .nav.nav-tabs (meta's profile/security/keys, git's summary/tree/log).
//
// It is deliberately not a NavItem. The switcher's entries are services, each
// on an origin of its own, and its active element is spent naming the service
// the reader is in — which is true of every page that service serves and
// therefore says nothing about which one. These are pages of one service, so
// they carry a path rather than an origin, and their active element is the only
// thing in the chrome that can say where inside the service the reader stands.
// artifacts kept its three sections in ExtraNav before this type existed, and
// that row was the sum of both mistakes: a switcher whose membership changed
// from service to service, and no active element anywhere below it.
type Section struct {
	// Name is the tab's text; Href is where it leads, as a path on this
	// service.
	Name string
	Href string
	// Paths are matched as path segments: a page stands in the section when
	// its path equals one of them, or continues one after a slash. "/mirrors"
	// therefore covers "/mirrors" and "/mirrors/alpine/rules" but not
	// "/mirrorsomething", and the service root "/" matches only itself.
	Paths []string
	// Prefixes are matched as literal string prefixes, for the shapes a
	// segment boundary cannot express. "/~" is one: a channel, a repository
	// and a database are all spelled "/~owner/name", and every such page
	// belongs to the section whose listing carries it.
	Prefixes []string
}

// SectionTab is a Section as one rendered page sees it: the entry, plus whether
// this page is the one standing in it.
type SectionTab struct {
	Name   string
	Href   string
	Active bool
}

// sectionTabs is the row as a request's own path sees it.
//
// Deriving the active entry from the path is what makes a page that forgot to
// declare its section impossible: a service has a dozen render paths and one of
// them is the error page, reached from every other.
//
// A path in no section — the 404 that "/nowhere" renders — lights nothing
// rather than falling back to the first tab, because a row whose active element
// is always lit would be claiming the reader is somewhere they are not.
func sectionTabs(sections []Section, path string) []SectionTab {
	if len(sections) == 0 {
		return nil
	}
	tabs := make([]SectionTab, 0, len(sections))
	for _, section := range sections {
		tabs = append(tabs, SectionTab{
			Name:   section.Name,
			Href:   section.Href,
			Active: section.matches(path),
		})
	}
	return tabs
}

// matches answers whether a path stands in this section; see Section.Paths and
// Section.Prefixes for the two rules and why both exist.
func (s Section) matches(path string) bool {
	for _, segment := range s.Paths {
		if path == segment {
			return true
		}
		// The service root is the one segment with nothing under it: trimming
		// its slash leaves "", and "" + "/" is the prefix of every path on the
		// service, so a root declared this way would light its tab on every
		// page and darken it nowhere. Pages below the root belong to whichever
		// section claims them - through Prefixes, as "/~" does - or to none.
		if segment == "/" {
			continue
		}
		if strings.HasPrefix(path, strings.TrimSuffix(segment, "/")+"/") {
			return true
		}
	}
	for _, prefix := range s.Prefixes {
		if strings.HasPrefix(path, prefix) {
			return true
		}
	}
	return false
}

// BuildNav derives the service switcher from the shared config: every section
// whose name ends in ".sr.ht" (with a configured origin) except the excluded
// ones, ordered canonically then alphabetically, with the section named by
// active marked as the current service.
//
// The ".sr.ht" suffix is the whole membership rule — it is what core.sr.ht's
// own _network does, and it is why a custom service's section must be named
// literally "<name>.sr.ht" no matter what host it is served from.
func BuildNav(conf ini.File, active string) []NavItem {
	var items []NavItem
	for section := range conf {
		if !strings.HasSuffix(section, ".sr.ht") {
			continue
		}
		short := strings.TrimSuffix(section, ".sr.ht")
		if navExcluded[short] {
			continue
		}
		origin := config.GetOrigin(conf, section, true)
		if origin == "" {
			continue
		}
		items = append(items, NavItem{
			Name:   short,
			Origin: origin,
			Active: section == active,
		})
	}
	sort.SliceStable(items, func(i, j int) bool {
		ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
		if ci != cj {
			return ci < cj
		}
		return items[i].Name < items[j].Name
	})
	return items
}

// canonIndex returns a service's position in navCanonical, or a sentinel past
// the end for services that are not canonically ordered.
func canonIndex(name string) int {
	for i, n := range navCanonical {
		if n == name {
			return i
		}
	}
	return len(navCanonical)
}

// Page is the chrome every rendered page shares. Services embed it in their
// own view struct and add page payload (and service-specific chrome fields)
// next to it.
//
// Embedding names the field Page, so a view struct that wants "Page" for its
// own payload — a pagination counter, most often — has to rename that field
// (PageNum, say). The collision is a compile error, not a silent shadow.
type Page struct {
	Title     string
	SiteName  string
	SiteLabel string // red brand suffix: the service's short name

	Nav      []NavItem
	ExtraNav []NavItem // service-specific entries appended after the switcher

	// Tabs is this service's own navigation row, below the switcher, with the
	// entry the request's path stands in marked Active. Empty for a service
	// that declared no Sections, and empty for an anonymous viewer — the rule
	// the switcher already follows, since a row of destinations is chrome for
	// someone with a session rather than a second front door.
	Tabs []SectionTab

	Username    string // "" for an anonymous viewer
	LoginURL    string // meta login with return_to back to the current URL
	LogoutURL   string // meta logout with return_to to this service's root
	RegisterURL string
	ProfileURL  string // hub's ~username page when hub is configured, else meta profile

	MetaOrigin string
	SelfOrigin string
	HubOrigin  string

	StyleHref string // "" when the binary was built without a stylesheet

	// FaviconHref is the icon for this page's <head>; "" renders no <link>.
	// Guarded rather than emitted empty for the same reason StyleHref is:
	// <link href=""> re-requests the page it is on.
	FaviconHref template.URL

	// Assets are the hashed hrefs of the extra build artefacts a layout links
	// beyond the stylesheet — a vendored chart library, a front-end bundle —
	// keyed by names the service picks. Read as {{index .Assets "uplot.js"}},
	// guarded on emptiness exactly like StyleHref.
	//
	// They belong to the chrome for the reason StyleHref does: the hash in the
	// name is a property of this binary, not of any page. A page that had to
	// be handed its own asset URLs is a page that can be written without them
	// and silently render nothing where the chart was.
	//
	// The map is the Service's, shared by every Page it builds: written once
	// at startup, read-only afterwards. A handler must not write to it.
	Assets map[string]string

	Environment string // uppercased; banner text
	ShowBanner  bool   // true outside production

	// ContainerClass selects the width of the page's content wrapper: the
	// centered Bootstrap "container" by default; services override it to
	// "container-fluid" for full-bleed pages (diff views, annotated source).
	ContainerClass string
}

// ListItem is one project in a listing — a repository, a database, a space.
// Title is the display name ("~owner/name"); Visibility is the service's
// literal enum value ("PUBLIC"/"UNLISTED"/"PRIVATE", "" to render nothing —
// the partials show it lowercase, non-public only).
//
// Updated and Meta are optional, and deliberately so. Four services wanted a
// listing here and disagreed about its shape: bench and spec needed a
// modification time, dolt has no timestamp in its schema at all, and cov's
// index is a table of percentages and sparklines that no shared partial will
// ever render. A required column would have pushed dolt back onto a local
// copy; a zero Updated and a nil Meta render nothing, which is what keeps all
// three of them consumers.
//
// Updated is a time.Time rather than a preformatted string so the partial can
// render "3 hours ago" with the exact stamp in the title attribute, once,
// instead of every service picking its own spelling — the drift RelTime and
// AbsTime were hoisted to end.
type ListItem struct {
	Href        string
	Title       string
	Visibility  string
	Description string
	Updated     time.Time
	Meta        []string
}

// RepoList is the dot for the srht-repo-list and srht-repo-table partials: the
// items, and the muted text shown when there are none.
//
// Two partials over one type because the two shapes are not variants of each
// other: srht-repo-list is the family's event-list cards, srht-repo-table the
// same data as aligned columns for a service whose listing is long enough to
// scan. Making the cards partial grow columns would have made it a worse cards
// partial for the services that wanted cards.
type RepoList struct {
	Items []ListItem
	Empty string
}

// Service is the static half of the chrome, built once at startup. The
// exported fields may be adjusted between NewService and the first Page call
// (they are read, never written, by Page).
type Service struct {
	// Section is the literal config section, e.g. "diff.sr.ht".
	Section string
	// StyleHref is the href of the built stylesheet (the hashed
	// main.min.<sha>.css); the zero value renders a bare page rather than
	// failing, matching how the services degrade without CSS.
	StyleHref string
	// ExtraNav holds service-specific switcher entries (e.g. a /tokens link),
	// rendered after the shared network entries, for authenticated viewers.
	//
	// Deprecated: it has no correct use left. Its two historical ones both
	// turned out to be mistakes with the same shape — putting a page of one
	// service into the row that lists the instance's services. bench and cov
	// rode it for a local /tokens until the instance deployed a tokens.sr.ht
	// and the word appeared in the navbar twice; artifacts rode it for three
	// sections that are Sections now. A service's own pages belong in Sections;
	// the switcher is the instance's, not the service's.
	ExtraNav []NavItem
	// Sections is this service's own navigation row, rendered below the
	// switcher. Declare it at startup, in the order the tabs should print;
	// Page marks the one the request stands in. A service that declares none
	// renders no row at all, which is every service that had none before this
	// field existed.
	Sections []Section
	// Assets holds the extra hashed asset hrefs every Page carries; see
	// Page.Assets. Populate it at startup, next to StyleHref.
	Assets map[string]string
	// FaviconHref is the icon linked from every page's <head>. NewService sets
	// it to DefaultFaviconHref; a service with a logo of its own overwrites it
	// (through assets.Resolve, so a hashed icon earns the immutable lifetime),
	// and "" renders no <link> at all.
	FaviconHref template.URL

	siteName    string
	environment string
	selfOrigin  string
	metaOrigin  string
	hubOrigin   string
	nav         []NavItem
}

// DefaultFaviconHref is the icon a service gets without shipping one: the
// brand's circle, inlined as a data: URI.
//
// A data: URI rather than a path into a static tree, because the alternative
// fails in a way that is easy to miss. bench deliberately embeds no favicon
// and its layout says why: a <link rel="icon"> pointing at an asset the binary
// does not have is a 404 — a rendered error page, on every page load, for a
// file no human asked for. A default that is a path would hand that to every
// service that has not made a logo yet; a default that carries its own bytes
// cannot 404. It also costs no request at all, which a 500-byte icon is not
// worth making.
//
// The stroke follows the viewer's colour scheme, since a favicon sits on the
// browser's chrome rather than on ours, and a near-black ring disappears into
// a dark tab strip.
//
// The type is template.URL because html/template rewrites any href whose
// scheme is not http, https or mailto to "#ZgotmplZ" — a data: URI reaches the
// page only if the caller says it meant it. That is also the guard on a
// service overriding this field: the value has to come from somewhere the
// service vouches for, not from a request.
const DefaultFaviconHref template.URL = "data:image/svg+xml," +
	"%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E" +
	"%3Cstyle%3Ecircle%7Bstroke:%23222%7D" +
	"@media(prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23eee%7D%7D%3C/style%3E" +
	"%3Ccircle%20cx='16'%20cy='16'%20r='11'%20fill='none'%20stroke-width='6'/%3E%3C/svg%3E"

// NewService reads the shared config once and caches everything Page needs.
// section must be this service's literal config section name.
func NewService(conf ini.File, section string) *Service {
	env := config.GetString(conf, "sr.ht", "environment", "development")
	return &Service{
		Section:     section,
		FaviconHref: DefaultFaviconHref,
		siteName:    config.GetString(conf, "sr.ht", "site-name", "sr.ht"),
		environment: env,
		selfOrigin:  strings.TrimRight(config.GetOrigin(conf, section, true), "/"),
		metaOrigin:  strings.TrimRight(config.GetOrigin(conf, "meta.sr.ht", true), "/"),
		hubOrigin:   strings.TrimRight(config.GetOrigin(conf, "hub.sr.ht", true), "/"),
		nav:         BuildNav(conf, section),
	}
}

// SelfOrigin returns the service's own external origin, as resolved from the
// config section given to NewService.
func (s *Service) SelfOrigin() string { return s.selfOrigin }

// MetaOrigin returns meta.sr.ht's external origin.
func (s *Service) MetaOrigin() string { return s.metaOrigin }

// HubOrigin returns hub.sr.ht's external origin, or "" when the instance has
// no hub.
func (s *Service) HubOrigin() string { return s.hubOrigin }

// SiteName returns the instance's brand text.
func (s *Service) SiteName() string { return s.siteName }

// Environment returns the configured environment as written in the config
// (lowercase); Page uppercases it for the banner.
func (s *Service) Environment() string { return s.environment }

// LoginURLFor is meta.sr.ht's login with return_to pointing back at the URL
// being served — the same link the nav's "Log in" carries. Exported for the
// handlers that gate a page behind login and only need somewhere to redirect,
// so they do not have to build a whole Page to read one field off it.
func (s *Service) LoginURLFor(r *http.Request) string {
	return s.metaOrigin + "/login?return_to=" + url.QueryEscape(s.selfOrigin+r.URL.RequestURI())
}

// Page builds the chrome for one request. Login return_to is the current full
// URL (so the viewer lands back where they were); logout return_to is this
// service's origin. username is the caller's *authoritative* identity — pass
// "" for viewers whose cookie grants nothing, and the nav offers login.
func (s *Service) Page(r *http.Request, title, username string) Page {
	profileURL := s.metaOrigin + "/profile"
	if s.hubOrigin != "" && username != "" {
		profileURL = s.hubOrigin + "/~" + username
	}

	// The section row follows the switcher's own rule, which the partial states
	// in markup: chrome for a reader with a session. Not a permission check —
	// a service's public sections stay reachable by their addresses either way
	// — but a row printed for a logged-out visitor would be offering tabs it
	// cannot know are answerable, and the switcher beside it would be empty.
	var tabs []SectionTab
	if username != "" {
		tabs = sectionTabs(s.Sections, r.URL.Path)
	}

	return Page{
		Title:          title,
		SiteName:       s.siteName,
		SiteLabel:      strings.TrimSuffix(s.Section, ".sr.ht"),
		Nav:            s.nav,
		ExtraNav:       s.ExtraNav,
		Tabs:           tabs,
		Username:       username,
		LoginURL:       s.LoginURLFor(r),
		LogoutURL:      s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.selfOrigin),
		RegisterURL:    s.metaOrigin,
		ProfileURL:     profileURL,
		MetaOrigin:     s.metaOrigin,
		SelfOrigin:     s.selfOrigin,
		HubOrigin:      s.hubOrigin,
		StyleHref:      s.StyleHref,
		FaviconHref:    s.FaviconHref,
		Assets:         s.Assets,
		Environment:    strings.ToUpper(s.environment),
		ShowBanner:     s.environment != "" && s.environment != "production",
		ContainerClass: "container",
	}
}