~bigbes/sr-ht-ecore

ref: 43ad9287cc063fc6a74e1398a3a8b642631cbcae sr-ht-ecore/chrome/chrome.go -rw-r--r-- 13.8 KiB
43ad9287 — Eugene Blikh bearer: say what IsRefusal does not answer for 9 days 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
// Package chrome is the shared page chrome for the custom services of a
// self-hosted SourceHut instance (compare, spec, dolt, cover, 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, "compare.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
}

// 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

	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 cover'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. "compare.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.
	ExtraNav []NavItem
	// 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
	}

	return Page{
		Title:          title,
		SiteName:       s.siteName,
		SiteLabel:      strings.TrimSuffix(s.Section, ".sr.ht"),
		Nav:            s.nav,
		ExtraNav:       s.ExtraNav,
		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",
	}
}