// 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",
}
}