// 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" .}} // // // 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 ( "net/http" "net/url" "sort" "strings" "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 ".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 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 srht-repo-list partial shows it lowercase, non-public only). type ListItem struct { Href string Title string Visibility string Description string } // RepoList is the dot for the srht-repo-list partial: the items, and the // muted text shown when there are none. 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..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 siteName string environment string selfOrigin string metaOrigin string hubOrigin string nav []NavItem } // 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, 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, Environment: strings.ToUpper(s.environment), ShowBanner: s.environment != "" && s.environment != "production", ContainerClass: "container", } }