// Package web is the HTTP layer of diff.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 ecore's login // 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. // // One thing about the chrome remains this service's own, because it is about // what compare renders and not about the instance: 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. The vendored bundle's // href is no longer among them: it is a hashed build artefact like the // stylesheet, so it lives in chrome.Service.Assets, which is the slot ecore // grew once three services had each added their own field for it. // // # What the cmd layer must wire // // Register installs the middleware that needs this Server — the private cache // policy, panic recovery through this package's error page, and the same-origin // guard — and assumes the following is already applied to the router it is // handed, in this order (outermost first): // // chi middleware.RequestID // chi middleware.RealIP // chimw.RequestLogger(...) // the request line, as a slog record // chi middleware.Recoverer // config.Middleware(conf, "diff.sr.ht") // required: authz + gitx read it // login.Optional() // required: never 401s; sets the viewer // // login.Optional and not login.Required: every page here is either public or a // 404, and git.sr.ht decides which — a viewer this service refused would be a // viewer git.sr.ht was never asked about. // // config.Middleware must run before login.Optional is irrelevant to login // 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 ( "io/fs" "log/slog" "net/http" "github.com/vaughan0/go-ini" "go.bigb.es/auxilia/culpa" "sourcecraft.dev/bigbes/sr-ht-ecore/assets" "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" "sourcecraft.dev/bigbes/sr-ht-ecore/login" "sourcecraft.dev/bigbes/sr-ht-ecore/pages" "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 = "diff.sr.ht" // bundleAsset is the key compare's layout reads its front-end bundle's href // under, in chrome.Service.Assets. It is spelled once here and once in the // "scripts" block of the two diff pages; a third spelling would render no // script tag at all rather than fail, which is why the two that exist are a // constant and a template guarded on emptiness. const bundleAsset = "bundle.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 // pages is one parsed template set per page, discovered from the embedded // tree by ecore rather than listed here. A page that defines no "content" // never gets this far: pages.Load refuses it at startup, where the // alternative was the chrome around a hole served with a 200. pages pages.Set // static serves the embedded asset tree with the cache policy the hashed // name implies, and answers everything that is not a file — a directory // above all — through this service's own 404 page. static http.Handler } // New assembles a Server from the shared SourceHut config. It reads // [git.sr.ht] repos, [meta.sr.ht] origin and [diff.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 — resolves the hashed stylesheet and bundle through ecore's assets, and // parses the page templates through ecore's pages. A missing required key is a // clear error, not a panic, so the cmd layer can fail startup loudly. // // A missing build artefact is not one of those errors. assets.Resolve answers // "" for a stylesheet or a bundle this binary was built without, because a // checkout that has not run `make css` must still be runnable; the layout // guards both hrefs on emptiness so a bare page is what such a build serves, // rather than a that re-requests the page it is on. func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) { // Every refusal below carries a hint naming the config key or the build step // that fixes it. These are the only errors this package returns, they all // arrive at one slog.Error in the cmd layer, and the reader of that record // is an operator who wants the remedy rather than the call path. reposRoot, ok := conf.Get("git.sr.ht", "repos") if !ok || reposRoot == "" { return nil, missingKey("git.sr.ht", "repos", "the root directory holding the bare repositories") } // 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, missingKey("meta.sr.ht", "origin", "the login and logout links in the nav are built from it") } if chromeSvc.SelfOrigin() == "" { return nil, missingKey(configSection, "origin", "the same-origin guard and every return_to are built from it") } cssHref, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix) if err != nil { return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the stylesheet"), "the glob is a literal in this package, so this is a bug and not a deployment fault") } bundleHref, err := assets.Resolve(staticFS, bundleGlob, assets.DefaultPrefix) if err != nil { return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the bundle"), "the glob is a literal in this package, so this is a bug and not a deployment fault") } if cssHref == "" || bundleHref == "" { slog.Warn("web: built without a front-end artefact; run `make` before `go build`", "css", cssHref, "bundle", bundleHref) } chromeSvc.StyleHref = cssHref chromeSvc.Assets = map[string]string{bundleAsset: bundleHref} set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap}) if err != nil { return nil, culpa.WithHint(culpa.Wrap(err, "web: load the page templates"), "a page in web/templates defines no {{define \"content\"}}, or the layout is missing") } staticSub, err := fs.Sub(staticFS, "static") if err != nil { return nil, culpa.Wrap(err, "web: sub static FS") } s := &Server{ authorizer: authorizer, reposRoot: reposRoot, chromeSvc: chromeSvc, pages: set, } // Built after the Server exists because the not-found arm is this service's // own error page: an asset URL typed by hand lands on a page with a nav to // get out of, and a directory — /static/, which the file server alone would // answer with a listing of every artefact in the binary — lands there too. s.static = assets.Handler(staticSub, assets.DefaultPrefix, http.HandlerFunc(s.handleNotFound)) return s, nil } // missingKey is the refusal for a config key this service cannot start without: // the key in the message, and what it is for in the hint. why completes the // sentence "it is ...", so it reads as an answer to the question an operator // staring at a failed unit actually has. func missingKey(section, key, why string) error { return culpa.WithHint( culpa.Errorf("web: [%s] %s is required", section, key), "it is "+why, ) } // 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 // 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 login.Optional resolved, which is "" for a viewer // whose cookie is missing, expired, unreadable or carries a name that could not // be one — 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, login.FromContext(r.Context()))} }