A => .gitignore +2 -0
@@ 1,2 @@
+# macOS
+.DS_Store
A => README.md +50 -0
@@ 1,50 @@
+# sr-ht-ecore
+
+Extended core for the custom services of a self-hosted SourceHut instance
+(compare, spec, dolt, cover, bench, ...). Everything these services share that
+is *ours* — not upstream's — lives here, so the sr-ht-core fork can stay a
+clean mirror of upstream core-go, and so the services stop carrying drifting
+copies of the same code.
+
+## Packages
+
+- `chrome` — the shared page chrome: service-switcher nav built from the
+ shared config.ini (`chrome.BuildNav`), per-request `chrome.Page` with
+ login/logout/profile URLs against meta.sr.ht's unified login, embedded
+ `srht-nav` / `srht-env-banner` template partials (circle brand + red service
+ label + switcher + login box), and the generic template helpers (`dict`,
+ `shortsha`).
+
+## Usage
+
+```go
+svc := chrome.NewService(conf, "compare.sr.ht")
+svc.StyleHref = cssHref // after discovering the hashed stylesheet
+
+t := chrome.MustAttach(template.New("layout").Funcs(chrome.Funcs()))
+// ... parse the service's own templates into t ...
+
+page := svc.Page(r, "Page title", username) // username "" = anonymous
+```
+
+In the layout:
+
+```html
+{{template "srht-env-banner" .}}
+<nav class="container navbar navbar-light navbar-expand-sm">
+ {{template "srht-nav" .}}
+</nav>
+```
+
+The template dot must expose the `chrome.Page` fields — either a `Page`
+itself, or a service view struct that embeds one (promoted fields resolve in
+templates).
+
+## Policy
+
+The chrome bakes in the instance-wide decisions instead of parameterizing
+them: the switcher renders only for authenticated viewers; paste, pages and
+hub never appear in it; the brand is always circle + site name + red service
+label and links to the service's own root; the profile link prefers hub's
+`~username` page when hub is configured. Service-specific nav entries go
+through `Service.ExtraNav`; per-page width through `Page.ContainerClass`.
A => chrome/chrome.go +217 -0
@@ 1,217 @@
+// 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, and this brand links to the
+// service's own root instead); the profile link prefers hub's ~username page
+// when hub is configured; the brand is always circle + site name + red label.
+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 "<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.
+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
+}
+
+// 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
+
+ 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 }
+
+// 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 {
+ current := s.selfOrigin + r.URL.RequestURI()
+
+ 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.metaOrigin + "/login?return_to=" + url.QueryEscape(current),
+ 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",
+ }
+}
A => chrome/chrome_test.go +155 -0
@@ 1,155 @@
+package chrome
+
+import (
+ "html/template"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/vaughan0/go-ini"
+)
+
+// testConf synthesizes the shared instance config the way the services read
+// it: one section per service plus the [sr.ht] block.
+func testConf() ini.File {
+ return ini.File{
+ "sr.ht": {
+ "site-name": "srht.example",
+ "environment": "production",
+ },
+ "meta.sr.ht": {"origin": "https://meta.example"},
+ "git.sr.ht": {"origin": "https://git.example"},
+ "todo.sr.ht": {"origin": "https://todo.example"},
+ "builds.sr.ht": {"origin": "https://builds.example"},
+ "hub.sr.ht": {"origin": "https://hub.example"},
+ "paste.sr.ht": {"origin": "https://paste.example"},
+ "pages.sr.ht": {"origin": "https://pages.example"},
+ "compare.sr.ht": {"origin": "https://compare.example"},
+ "dolt.sr.ht": {"origin": "https://dolt.example"},
+ "ghost.sr.ht": {}, // no origin -> must not appear
+ "webhooks": {"private-key": "x"},
+ }
+}
+
+func TestBuildNavOrderExclusionsActive(t *testing.T) {
+ nav := BuildNav(testConf(), "compare.sr.ht")
+
+ var names []string
+ for _, it := range nav {
+ names = append(names, it.Name)
+ }
+ // Canonical services first in canonical order, customs alphabetical after;
+ // hub/paste/pages and the origin-less section excluded.
+ assert.Equal(t, []string{"git", "todo", "builds", "meta", "compare", "dolt"}, names)
+
+ for _, it := range nav {
+ assert.Equal(t, it.Name == "compare", it.Active, "active flag for %s", it.Name)
+ }
+}
+
+func TestPageURLsAndIdentity(t *testing.T) {
+ svc := NewService(testConf(), "compare.sr.ht")
+ r := httptest.NewRequest("GET", "/~alice/demo?a=1", nil)
+
+ p := svc.Page(r, "t", "alice")
+ assert.Equal(t, "srht.example", p.SiteName)
+ assert.Equal(t, "compare", p.SiteLabel)
+ assert.Equal(t, "https://meta.example/login?return_to="+
+ "https%3A%2F%2Fcompare.example%2F~alice%2Fdemo%3Fa%3D1", p.LoginURL)
+ assert.Equal(t, "https://meta.example/logout?return_to="+
+ "https%3A%2F%2Fcompare.example", p.LogoutURL)
+ // Hub is configured, so the profile link prefers hub's ~username page.
+ assert.Equal(t, "https://hub.example/~alice", p.ProfileURL)
+ assert.Equal(t, "container", p.ContainerClass)
+ assert.False(t, p.ShowBanner, "production must not show the env banner")
+}
+
+func TestPageProfileFallsBackToMeta(t *testing.T) {
+ conf := testConf()
+ delete(conf, "hub.sr.ht")
+ svc := NewService(conf, "compare.sr.ht")
+ r := httptest.NewRequest("GET", "/", nil)
+
+ assert.Equal(t, "https://meta.example/profile", svc.Page(r, "t", "alice").ProfileURL)
+ // Anonymous viewers get the meta profile link regardless of hub.
+ assert.Equal(t, "https://meta.example/profile",
+ NewService(testConf(), "compare.sr.ht").Page(r, "t", "").ProfileURL)
+}
+
+func TestPageEnvBanner(t *testing.T) {
+ conf := testConf()
+ conf["sr.ht"]["environment"] = "staging"
+ svc := NewService(conf, "compare.sr.ht")
+ p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "")
+
+ assert.True(t, p.ShowBanner)
+ assert.Equal(t, "STAGING", p.Environment)
+}
+
+// render executes a minimal layout that invokes both shared partials against v.
+func render(t *testing.T, v any) string {
+ t.Helper()
+ tpl := template.New("layout")
+ tpl = MustAttach(tpl)
+ tpl, err := tpl.Parse(`{{template "srht-env-banner" .}}<nav>{{template "srht-nav" .}}</nav>`)
+ require.NoError(t, err)
+ var b strings.Builder
+ require.NoError(t, tpl.Execute(&b, v))
+ return b.String()
+}
+
+func TestNavTemplateLoggedIn(t *testing.T) {
+ svc := NewService(testConf(), "compare.sr.ht")
+ svc.ExtraNav = []NavItem{{Name: "tokens", Origin: "/tokens"}}
+ p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")
+
+ out := render(t, p)
+ assert.Contains(t, out, "icon icon-circle")
+ assert.Contains(t, out, `<span class="text-danger">compare</span>`)
+ assert.Contains(t, out, `href="https://git.example"`)
+ assert.Contains(t, out, `href="/tokens"`, "extra nav entries must render")
+ assert.Contains(t, out, "Logged in as")
+ assert.NotContains(t, out, "ENVIRONMENT")
+}
+
+func TestNavTemplateAnonymous(t *testing.T) {
+ svc := NewService(testConf(), "compare.sr.ht")
+ p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "")
+
+ out := render(t, p)
+ // The switcher renders only for authenticated viewers.
+ assert.NotContains(t, out, `href="https://git.example"`)
+ assert.Contains(t, out, "Log in")
+ assert.Contains(t, out, "Register")
+}
+
+// TestNavTemplateEmbeddedPage guards the documented consumption pattern: a
+// service view struct embedding Page resolves the promoted fields.
+func TestNavTemplateEmbeddedPage(t *testing.T) {
+ type viewData struct {
+ Page
+ Data any
+ }
+ svc := NewService(testConf(), "dolt.sr.ht")
+ v := viewData{Page: svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")}
+
+ out := render(t, v)
+ assert.Contains(t, out, `<span class="text-danger">dolt</span>`)
+ assert.Contains(t, out, "Logged in as")
+}
+
+func TestFuncs(t *testing.T) {
+ assert.Equal(t, "12345678", ShortSHA("1234567890abcdef"))
+ assert.Equal(t, "abc", ShortSHA("abc"))
+
+ m, err := Dict("a", 1, "b", "x")
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{"a": 1, "b": "x"}, m)
+
+ _, err = Dict("a")
+ require.Error(t, err)
+ _, err = Dict(1, "v")
+ require.Error(t, err)
+}
A => chrome/funcs.go +44 -0
@@ 1,44 @@
+package chrome
+
+import (
+ "fmt"
+ "html/template"
+)
+
+// Funcs returns the template helpers every service was carrying its own copy
+// of. Merge into a service's FuncMap before its own helpers, so a service can
+// still shadow a name deliberately.
+func Funcs() template.FuncMap {
+ return template.FuncMap{
+ "dict": Dict,
+ "shortsha": ShortSHA,
+ }
+}
+
+// Dict builds a map from alternating key/value arguments, so a partial that
+// needs several fields can be invoked with an inline context:
+// {{template "x" (dict "A" .A "B" .B)}}. An odd argument count or a non-string
+// key is a template authoring error and surfaces as a render error.
+func Dict(kv ...any) (map[string]any, error) {
+ if len(kv)%2 != 0 {
+ return nil, fmt.Errorf("dict: expected an even number of arguments, got %d", len(kv))
+ }
+ m := make(map[string]any, len(kv)/2)
+ for i := 0; i < len(kv); i += 2 {
+ k, ok := kv[i].(string)
+ if !ok {
+ return nil, fmt.Errorf("dict: key %d is not a string", i)
+ }
+ m[k] = kv[i+1]
+ }
+ return m, nil
+}
+
+// ShortSHA abbreviates an object id to its first 8 characters (or returns it
+// unchanged if shorter), the convention used everywhere commits are listed.
+func ShortSHA(s string) string {
+ if len(s) > 8 {
+ return s[:8]
+ }
+ return s
+}
A => chrome/templates.go +26 -0
@@ 1,26 @@
+package chrome
+
+import (
+ "embed"
+ "html/template"
+)
+
+//go:embed templates/chrome.tmpl
+var templateFS embed.FS
+
+// Attach parses the shared chrome partials ("srht-nav", "srht-env-banner")
+// into t, so a service layout can invoke them. Call it once per template set,
+// before the layout that references the partials is executed.
+func Attach(t *template.Template) (*template.Template, error) {
+ return t.ParseFS(templateFS, "templates/chrome.tmpl")
+}
+
+// MustAttach is Attach for the common wire-up-at-startup path, where a parse
+// failure is a programmer error.
+func MustAttach(t *template.Template) *template.Template {
+ t, err := Attach(t)
+ if err != nil {
+ panic(err)
+ }
+ return t
+}
A => chrome/templates/chrome.tmpl +57 -0
@@ 1,57 @@
+{{/*
+ Shared chrome partials. The dot must expose the chrome.Page fields — either
+ a Page itself or a view struct embedding one.
+
+ srht-env-banner renders the non-production warning strip; place it first in
+ <body>. srht-nav renders the navbar INNER content (brand + switcher + login
+ box); the service's layout owns the <nav> element itself so it can keep its
+ own classes.
+*/}}
+
+{{define "srht-env-banner" -}}
+{{if .ShowBanner}}
+<div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
+ {{.Environment}} ENVIRONMENT
+</div>
+{{end}}
+{{- end}}
+
+{{define "srht-nav" -}}
+<span class="navbar-brand">
+ <span class="icon icon-circle" aria-hidden="true"><svg width="22" height="22" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/></svg></span>
+ <a class="navbar-brand" href="/">
+ {{.SiteName}}
+ <span class="text-danger">{{.SiteLabel}}</span>
+ </a>
+</span>
+<ul class="navbar-nav">
+ {{if .Username}}
+ {{range .Nav}}
+ <li class="nav-item {{if .Active}}active{{end}}">
+ <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
+ </li>
+ {{end}}
+ {{range .ExtraNav}}
+ <li class="nav-item {{if .Active}}active{{end}}">
+ <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
+ </li>
+ {{end}}
+ {{end}}
+</ul>
+<div class="login">
+ {{if .Username}}
+ <span class="navbar-text">
+ Logged in as
+ <a href="{{.ProfileURL}}">{{.Username}}</a>
+ —
+ <a href="{{.LogoutURL}}">Log out</a>
+ </span>
+ {{else}}
+ <span class="navbar-text">
+ <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
+ —
+ <a href="{{.RegisterURL}}">Register</a>
+ </span>
+ {{end}}
+</div>
+{{- end}}
A => go.mod +15 -0
@@ 1,15 @@
+module sourcecraft.dev/bigbes/sr-ht-ecore
+
+go 1.24
+
+require (
+ github.com/stretchr/testify v1.10.0
+ github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
+ sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
A => go.sum +14 -0
@@ 1,14 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=
+github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
+sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=