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