| 1 |
|
// Package web is the HTTP layer of diff.sr.ht. It renders the repository |
| 2 |
|
// landing, compare (base...head) and single-commit pages server-side, and |
| 3 |
|
// embeds a compact JSON payload plus the vendored esbuild bundle so the browser |
| 4 |
|
// renders the diff with @pierre/diffs and @pierre/trees. |
| 5 |
|
// |
| 6 |
|
// The package owns no state of its own: identity comes from ecore's login |
| 7 |
|
// middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and |
| 8 |
|
// git data from gitx over bare repositories on disk. Every request that touches |
| 9 |
|
// a repository authorizes first (a not-found or forbidden repo is a 404, never |
| 10 |
|
// a 403, so private-repo existence never leaks) and only then reads the disk. |
| 11 |
|
// |
| 12 |
|
// # The chrome is not ours |
| 13 |
|
// |
| 14 |
|
// The nav/service-switcher, the brand, the login block and the environment |
| 15 |
|
// banner come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom |
| 16 |
|
// service on the instance shares. This package builds one chrome.Service at |
| 17 |
|
// startup, asks it for a chrome.Page per request, and embeds that Page in |
| 18 |
|
// viewData so the fields promote into the templates. Nothing here rebuilds the |
| 19 |
|
// switcher or re-derives a login URL: the copy that used to live in web/chrome.go |
| 20 |
|
// is exactly what ecore exists to have deleted. |
| 21 |
|
// |
| 22 |
|
// One thing about the chrome remains this service's own, because it is about |
| 23 |
|
// what compare renders and not about the instance: the full-bleed |
| 24 |
|
// ContainerClass the two diff views set — a side-by-side diff in a centered |
| 25 |
|
// "container" is a column of code half the window wide. The vendored bundle's |
| 26 |
|
// href is no longer among them: it is a hashed build artefact like the |
| 27 |
|
// stylesheet, so it lives in chrome.Service.Assets, which is the slot ecore |
| 28 |
|
// grew once three services had each added their own field for it. |
| 29 |
|
// |
| 30 |
|
// # What the cmd layer must wire |
| 31 |
|
// |
| 32 |
|
// Register installs the middleware that needs this Server — the private cache |
| 33 |
|
// policy, panic recovery through this package's error page, and the same-origin |
| 34 |
|
// guard — and assumes the following is already applied to the router it is |
| 35 |
|
// handed, in this order (outermost first): |
| 36 |
|
// |
| 37 |
|
// chi middleware.RequestID |
| 38 |
|
// chi middleware.RealIP |
| 39 |
|
// chimw.RequestLogger(...) // the request line, as a slog record |
| 40 |
|
// chi middleware.Recoverer |
| 41 |
|
// config.Middleware(conf, "diff.sr.ht") // required: authz + gitx read it |
| 42 |
|
// login.Optional() // required: never 401s; sets the viewer |
| 43 |
|
// |
| 44 |
|
// login.Optional and not login.Required: every page here is either public or a |
| 45 |
|
// 404, and git.sr.ht decides which — a viewer this service refused would be a |
| 46 |
|
// viewer git.sr.ht was never asked about. |
| 47 |
|
// |
| 48 |
|
// config.Middleware must run before login.Optional is irrelevant to login |
| 49 |
|
// itself (it only reads the cookie), but the GraphQL authorizer invoked inside |
| 50 |
|
// handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so |
| 51 |
|
// config.Middleware is mandatory on every request that reaches a handler. |
| 52 |
|
package web |
| 53 |
|
|
| 54 |
|
import ( |
| 55 |
|
"io/fs" |
| 56 |
|
"log/slog" |
| 57 |
|
"net/http" |
| 58 |
|
|
| 59 |
|
"github.com/vaughan0/go-ini" |
| 60 |
|
"go.bigb.es/auxilia/culpa" |
| 61 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/assets" |
| 62 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome" |
| 63 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/login" |
| 64 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/pages" |
| 65 |
|
|
| 66 |
|
"sourcecraft.dev/bigbes/sr-ht-compare/authz" |
| 67 |
|
) |
| 68 |
|
|
| 69 |
|
// configSection is this service's literal section in the shared config.ini. It |
| 70 |
|
// is what the switcher's "which entry is me" test compares against, so it must |
| 71 |
|
// be spelled the same here, in the config file and in the middleware the cmd |
| 72 |
|
// layer installs — a service that spelled it two ways would appear in the |
| 73 |
|
// instance's navigation and fail to recognise itself in it. |
| 74 |
|
const configSection = "diff.sr.ht" |
| 75 |
|
|
| 76 |
|
// bundleAsset is the key compare's layout reads its front-end bundle's href |
| 77 |
|
// under, in chrome.Service.Assets. It is spelled once here and once in the |
| 78 |
|
// "scripts" block of the two diff pages; a third spelling would render no |
| 79 |
|
// script tag at all rather than fail, which is why the two that exist are a |
| 80 |
|
// constant and a template guarded on emptiness. |
| 81 |
|
const bundleAsset = "bundle.js" |
| 82 |
|
|
| 83 |
|
// Server holds the immutable configuration a request handler needs. It is built |
| 84 |
|
// once at startup and is safe for concurrent use. |
| 85 |
|
type Server struct { |
| 86 |
|
authorizer authz.Authorizer |
| 87 |
|
reposRoot string |
| 88 |
|
|
| 89 |
|
// chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service |
| 90 |
|
// switcher, the login block and the environment banner, built once from |
| 91 |
|
// config.ini and asked for a per-request chrome.Page in view (chrome.go's |
| 92 |
|
// job until this service stopped carrying its own copy). |
| 93 |
|
chromeSvc *chrome.Service |
| 94 |
|
|
| 95 |
|
// pages is one parsed template set per page, discovered from the embedded |
| 96 |
|
// tree by ecore rather than listed here. A page that defines no "content" |
| 97 |
|
// never gets this far: pages.Load refuses it at startup, where the |
| 98 |
|
// alternative was the chrome around a hole served with a 200. |
| 99 |
|
pages pages.Set |
| 100 |
|
|
| 101 |
|
// static serves the embedded asset tree with the cache policy the hashed |
| 102 |
|
// name implies, and answers everything that is not a file — a directory |
| 103 |
|
// above all — through this service's own 404 page. |
| 104 |
|
static http.Handler |
| 105 |
|
} |
| 106 |
|
|
| 107 |
|
// New assembles a Server from the shared SourceHut config. It reads |
| 108 |
|
// [git.sr.ht] repos, [meta.sr.ht] origin and [diff.sr.ht] origin (all |
| 109 |
|
// required), hands the whole file to chrome.NewService — the switcher is a |
| 110 |
|
// question about every [*.sr.ht] section the instance defines, not about our own |
| 111 |
|
// keys — resolves the hashed stylesheet and bundle through ecore's assets, and |
| 112 |
|
// parses the page templates through ecore's pages. A missing required key is a |
| 113 |
|
// clear error, not a panic, so the cmd layer can fail startup loudly. |
| 114 |
|
// |
| 115 |
|
// A missing build artefact is not one of those errors. assets.Resolve answers |
| 116 |
|
// "" for a stylesheet or a bundle this binary was built without, because a |
| 117 |
|
// checkout that has not run `make css` must still be runnable; the layout |
| 118 |
|
// guards both hrefs on emptiness so a bare page is what such a build serves, |
| 119 |
|
// rather than a <link href=""> that re-requests the page it is on. |
| 120 |
24 |
func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) { |
| 121 |
24 |
// Every refusal below carries a hint naming the config key or the build step |
| 122 |
24 |
// that fixes it. These are the only errors this package returns, they all |
| 123 |
24 |
// arrive at one slog.Error in the cmd layer, and the reader of that record |
| 124 |
24 |
// is an operator who wants the remedy rather than the call path. |
| 125 |
24 |
reposRoot, ok := conf.Get("git.sr.ht", "repos") |
| 126 |
24 |
if !ok || reposRoot == "" { |
| 127 |
0 |
return nil, missingKey("git.sr.ht", "repos", "the root directory holding the bare repositories") |
| 128 |
0 |
} |
| 129 |
|
|
| 130 |
|
// The two origins are checked through the chrome that will render them |
| 131 |
|
// rather than read a second time here, so the startup refusal and the links |
| 132 |
|
// on the page cannot disagree about which origins this service has. |
| 133 |
24 |
chromeSvc := chrome.NewService(conf, configSection) |
| 134 |
24 |
if chromeSvc.MetaOrigin() == "" { |
| 135 |
0 |
return nil, missingKey("meta.sr.ht", "origin", "the login and logout links in the nav are built from it") |
| 136 |
0 |
} |
| 137 |
24 |
if chromeSvc.SelfOrigin() == "" { |
| 138 |
0 |
return nil, missingKey(configSection, "origin", "the same-origin guard and every return_to are built from it") |
| 139 |
0 |
} |
| 140 |
|
|
| 141 |
24 |
cssHref, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix) |
| 142 |
24 |
if err != nil { |
| 143 |
0 |
return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the stylesheet"), |
| 144 |
0 |
"the glob is a literal in this package, so this is a bug and not a deployment fault") |
| 145 |
0 |
} |
| 146 |
24 |
bundleHref, err := assets.Resolve(staticFS, bundleGlob, assets.DefaultPrefix) |
| 147 |
24 |
if err != nil { |
| 148 |
0 |
return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the bundle"), |
| 149 |
0 |
"the glob is a literal in this package, so this is a bug and not a deployment fault") |
| 150 |
0 |
} |
| 151 |
24 |
if cssHref == "" || bundleHref == "" { |
| 152 |
0 |
slog.Warn("web: built without a front-end artefact; run `make` before `go build`", |
| 153 |
0 |
"css", cssHref, "bundle", bundleHref) |
| 154 |
0 |
} |
| 155 |
24 |
chromeSvc.StyleHref = cssHref |
| 156 |
24 |
chromeSvc.Assets = map[string]string{bundleAsset: bundleHref} |
| 157 |
24 |
|
| 158 |
24 |
set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap}) |
| 159 |
24 |
if err != nil { |
| 160 |
0 |
return nil, culpa.WithHint(culpa.Wrap(err, "web: load the page templates"), |
| 161 |
0 |
"a page in web/templates defines no {{define \"content\"}}, or the layout is missing") |
| 162 |
0 |
} |
| 163 |
|
|
| 164 |
24 |
staticSub, err := fs.Sub(staticFS, "static") |
| 165 |
24 |
if err != nil { |
| 166 |
0 |
return nil, culpa.Wrap(err, "web: sub static FS") |
| 167 |
0 |
} |
| 168 |
|
|
| 169 |
24 |
s := &Server{ |
| 170 |
24 |
authorizer: authorizer, |
| 171 |
24 |
reposRoot: reposRoot, |
| 172 |
24 |
chromeSvc: chromeSvc, |
| 173 |
24 |
pages: set, |
| 174 |
24 |
} |
| 175 |
24 |
// Built after the Server exists because the not-found arm is this service's |
| 176 |
24 |
// own error page: an asset URL typed by hand lands on a page with a nav to |
| 177 |
24 |
// get out of, and a directory — /static/, which the file server alone would |
| 178 |
24 |
// answer with a listing of every artefact in the binary — lands there too. |
| 179 |
24 |
s.static = assets.Handler(staticSub, assets.DefaultPrefix, http.HandlerFunc(s.handleNotFound)) |
| 180 |
24 |
return s, nil |
| 181 |
|
} |
| 182 |
|
|
| 183 |
|
// missingKey is the refusal for a config key this service cannot start without: |
| 184 |
|
// the key in the message, and what it is for in the hint. why completes the |
| 185 |
|
// sentence "it is ...", so it reads as an answer to the question an operator |
| 186 |
|
// staring at a failed unit actually has. |
| 187 |
0 |
func missingKey(section, key, why string) error { |
| 188 |
0 |
return culpa.WithHint( |
| 189 |
0 |
culpa.Errorf("web: [%s] %s is required", section, key), |
| 190 |
0 |
"it is "+why, |
| 191 |
0 |
) |
| 192 |
0 |
} |
| 193 |
|
|
| 194 |
|
// viewData is the root value every template is executed against. |
| 195 |
|
// |
| 196 |
|
// chrome.Page is embedded rather than copied field by field, so the shared |
| 197 |
|
// partials — "srht-nav", "srht-env-banner", "srht-repo-list" — find the fields |
| 198 |
|
// they need on the dot they are handed, and a field ecore adds later arrives here |
| 199 |
|
// without an edit. The page's own payload lives under Data and is reached as |
| 200 |
|
// {{.Data.Something}}, which is what keeps a page from shadowing a chrome field. |
| 201 |
|
type viewData struct { |
| 202 |
|
chrome.Page |
| 203 |
|
|
| 204 |
|
// Data is the page's own payload. |
| 205 |
|
Data any |
| 206 |
|
} |
| 207 |
|
|
| 208 |
|
// view builds the frame for one request: the shared chrome plus a title. |
| 209 |
|
// |
| 210 |
|
// The username is whatever login.Optional resolved, which is "" for a viewer |
| 211 |
|
// whose cookie is missing, expired, unreadable or carries a name that could not |
| 212 |
|
// be one — so the nav offers login to exactly the viewers the handlers treat as |
| 213 |
|
// anonymous. |
| 214 |
32 |
func (s *Server) view(r *http.Request, title string) viewData { |
| 215 |
32 |
return viewData{Page: s.chromeSvc.Page(r, title, login.FromContext(r.Context()))} |
| 216 |
32 |
} |