// Package web is the HTTP layer of compare.sr.ht. It renders the repository // landing, compare (base...head) and single-commit pages server-side, and // embeds a compact JSON payload plus the vendored esbuild bundle so the browser // renders the diff with @pierre/diffs and @pierre/trees. // // The package owns no state of its own: identity comes from the authz cookie // middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and // git data from gitx over bare repositories on disk. Every request that touches // a repository authorizes first (a not-found or forbidden repo is a 404, never // a 403, so private-repo existence never leaks) and only then reads the disk. // // # The chrome is not ours // // The nav/service-switcher, the brand, the login block and the environment // banner come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom // service on the instance shares. This package builds one chrome.Service at // startup, asks it for a chrome.Page per request, and embeds that Page in // viewData so the fields promote into the templates. Nothing here rebuilds the // switcher or re-derives a login URL: the copy that used to live in web/chrome.go // is exactly what ecore exists to have deleted. // // Two things about the chrome remain this service's own, because they are about // what compare renders and not about the instance: the vendored bundle's href // (BundleHref below), and the full-bleed ContainerClass the two diff views set — // a side-by-side diff in a centered "container" is a column of code half the // window wide. // // # What the cmd layer must wire // // Register only installs routes; it assumes the following middleware is already // applied to the router it is handed, in this order (outermost first): // // chi middleware.RealIP // chi middleware.Recoverer // chi middleware.Logger (optional, but recommended) // config.Middleware(conf, "compare.sr.ht") // required: authz + gitx read it // authz.Middleware() // required: never 401s; sets the viewer // // config.Middleware must run before authz.Middleware is irrelevant to authz // itself (it only reads the cookie), but the GraphQL authorizer invoked inside // handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so // config.Middleware is mandatory on every request that reaches a handler. package web import ( "fmt" "io/fs" "net/http" "path" "regexp" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" "sourcecraft.dev/bigbes/sr-ht-compare/authz" ) // configSection is this service's literal section in the shared config.ini. It // is what the switcher's "which entry is me" test compares against, so it must // be spelled the same here, in the config file and in the middleware the cmd // layer installs — a service that spelled it two ways would appear in the // instance's navigation and fail to recognise itself in it. const configSection = "compare.sr.ht" // hashedCSSRe matches the content-addressed stylesheet name so it can be served // with an immutable cache lifetime (the hash changes whenever the bytes do). var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`) // hashedBundleRe matches the content-addressed frontend bundle. Like the // stylesheet it carries a content hash in its name so a deploy busts the browser // cache (a stale bundle.js is why the file tree can render blank after an // upgrade); a matching name is served immutable. var hashedBundleRe = regexp.MustCompile(`^bundle\.[0-9a-f]{6,}\.js$`) // Server holds the immutable configuration a request handler needs. It is built // once at startup and is safe for concurrent use. type Server struct { authorizer authz.Authorizer reposRoot string // chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service // switcher, the login block and the environment banner, built once from // config.ini and asked for a per-request chrome.Page in view (chrome.go's // job until this service stopped carrying its own copy). chromeSvc *chrome.Service bundleHref string staticFileServer http.Handler } // New assembles a Server from the shared SourceHut config. It reads // [git.sr.ht] repos, [meta.sr.ht] origin and [compare.sr.ht] origin (all // required), hands the whole file to chrome.NewService — the switcher is a // question about every [*.sr.ht] section the instance defines, not about our own // keys — and resolves the hashed stylesheet and bundle names by globbing the // embedded static FS. A missing required key is a clear error, not a panic, so // the cmd layer can fail startup loudly. func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) { reposRoot, ok := conf.Get("git.sr.ht", "repos") if !ok || reposRoot == "" { return nil, fmt.Errorf("web: [git.sr.ht] repos is required") } // The two origins are checked through the chrome that will render them // rather than read a second time here, so the startup refusal and the links // on the page cannot disagree about which origins this service has. chromeSvc := chrome.NewService(conf, configSection) if chromeSvc.MetaOrigin() == "" { return nil, fmt.Errorf("web: [meta.sr.ht] origin is required") } if chromeSvc.SelfOrigin() == "" { return nil, fmt.Errorf("web: [%s] origin is required", configSection) } cssHref, err := resolveCSSHref() if err != nil { return nil, err } chromeSvc.StyleHref = cssHref bundleHref, err := resolveBundleHref() if err != nil { return nil, err } staticSub, err := fs.Sub(staticFS, "static") if err != nil { return nil, fmt.Errorf("web: sub static FS: %w", err) } return &Server{ authorizer: authorizer, reposRoot: reposRoot, chromeSvc: chromeSvc, bundleHref: bundleHref, staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))), }, nil } // viewData is the root value every template is executed against. // // chrome.Page is embedded rather than copied field by field, so the shared // partials — "srht-nav", "srht-env-banner", "srht-repo-list" — find the fields // they need on the dot they are handed, and a field ecore adds later arrives here // without an edit. The page's own payload lives under Data and is reached as // {{.Data.Something}}, which is what keeps a page from shadowing a chrome field. type viewData struct { chrome.Page // BundleHref is the hashed front-end bundle's URL. It is chrome — every page // may load it — but it is this service's alone: no other service on the // instance ships a diff renderer, so it stays here and not in ecore's Page. BundleHref string // Data is the page's own payload. Data any } // view builds the frame for one request: the shared chrome plus a title. // // The username is whatever the authz cookie middleware resolved, which is "" for // a viewer whose cookie is missing, expired or unreadable — so the nav offers // login to exactly the viewers the handlers treat as anonymous. func (s *Server) view(r *http.Request, title string) viewData { return viewData{ Page: s.chromeSvc.Page(r, title, authz.ForContext(r.Context())), BundleHref: s.bundleHref, } } // resolveCSSHref globs the embedded static FS for the content-addressed // stylesheet and returns its site-absolute URL. func resolveCSSHref() (string, error) { matches, err := fs.Glob(staticFS, "static/main.min.*.css") if err != nil { return "", fmt.Errorf("web: glob stylesheet: %w", err) } if len(matches) == 0 { return "", fmt.Errorf("web: no main.min.*.css in embedded static assets") } return "/static/" + path.Base(matches[0]), nil } // resolveBundleHref globs the embedded static FS for the content-addressed // frontend bundle and returns its site-absolute URL. func resolveBundleHref() (string, error) { matches, err := fs.Glob(staticFS, "static/bundle.*.js") if err != nil { return "", fmt.Errorf("web: glob bundle: %w", err) } if len(matches) == 0 { return "", fmt.Errorf("web: no bundle.*.js in embedded static assets") } return "/static/" + path.Base(matches[0]), nil }