~bigbes/sr-ht-compare

ref: 1a83202b75c069e1968095f6f820af989cd2acb6 sr-ht-compare/web/server.go -rw-r--r-- 7.9 KiB
1a83202b — bigbes deps: sr-ht-ecore with the web-tier packages 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
// 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
}