~bigbes/sr-ht-dolt

ref: f88846acf59598f9d235819608ba4d9ac7cc26c8 sr-ht-dolt/web/router.go -rw-r--r-- 9.5 KiB
f88846ac — Eugene Blikh web: serve the static tree through ecore's assets 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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
package web

import (
	"fmt"
	"io/fs"
	"net/http"
	"os"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// serviceName is our own service key: the config section, the JWT audience and
// the entry the shared switcher has to recognise as the current service. One
// constant, because a service that spelled its section differently in two
// places would appear in the instance's navigation and fail to find itself in
// it.
const serviceName = "dolt.sr.ht"

// The two names the static tree is searched for at startup: the hashed artefact
// `make css` produces, and the unhashed stylesheet `make static/main.css` leaves
// in a working copy.
const (
	hashedStyleGlob = "main.min.*.css"
	devStyleFile    = "main.css"
)

// app bundles the parsed templates, the shared chrome and the injected config.
// Handlers are methods on *app so they share this state without a global.
type app struct {
	cfg       Config
	templates templateSet
	// chrome is sr-ht-ecore's shared page frame: the brand, the service
	// switcher, the login block and the environment banner, built once from the
	// instance config and asked for a per-request chrome.Page (see page below).
	chrome *chrome.Service
	// static is the built asset tree, mounted under /static/ by ecore's assets
	// handler and globbed once at startup for the hashed stylesheet.
	static fs.FS
	// views is a snapshot of the global registeredViews taken at Register time.
	// Handlers read this (never the global) so tests can inject their own set.
	views []View
}

// page builds the chrome for one request: the shared frame plus the per-page
// <title>. The caller sets any page-specific fields on its own view struct,
// which embeds the returned chrome.Page.
//
// The username handed over is the resolved caller's and not whatever the cookie
// said — an unreadable or expired cookie has already become anonymity by the
// time a handler runs — so the nav and the page content cannot disagree about
// who is looking.
func (a *app) page(r *http.Request, title string) chrome.Page {
	var username string
	if ac := authn.CallerFromContext(r.Context()); ac != nil {
		username = ac.Username
	}
	return a.chrome.Page(r, title, username)
}

// Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3
// main) installs the config/database/cookie middleware upstream on the router
// group it passes here, then calls Register with the assembled Config.
//
// It parses templates and discovers the stylesheet once, at registration time,
// so a broken template fails startup loudly rather than a request later. A
// parse failure returns an error the caller must surface.
func Register(r chi.Router, cfg Config) error {
	a, err := newApp(cfg)
	if err != nil {
		return err
	}
	a.mount(r)
	return nil
}

// newApp validates cfg, parses templates and snapshots the view registry into a
// ready *app. Register uses it; tests build an *app directly so they can inspect
// and override its fields (e.g. app.views) before mounting.
func newApp(cfg Config) (*app, error) {
	if cfg.Repos == nil || cfg.Stores == nil || cfg.Browse == nil ||
		cfg.Users == nil || cfg.RepoDiskPath == nil {
		return nil, fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
	}
	if cfg.Conf == nil {
		return nil, fmt.Errorf("web: Register requires Conf (the chrome and the origins are built from it)")
	}

	templates, err := loadTemplates()
	if err != nil {
		return nil, err
	}

	// The static tree ships beside the binary rather than inside it (the Makefile
	// installs it into $SHAREDIR), so the asset FS is an os.DirFS; assets takes
	// an fs.FS precisely so both shapes work.
	static := staticFS(cfg.StaticDir)
	styleHref, err := assets.Resolve(static, hashedStyleGlob, assets.DefaultPrefix)
	if err != nil {
		return nil, fmt.Errorf("web: resolve the stylesheet: %w", err)
	}
	if styleHref == "" {
		// The unhashed stylesheet of a working copy, linked only when it is
		// really there: an href to a file this deployment does not ship would
		// 404 once per page load, which is what an empty Resolve avoids. An
		// empty href is guarded by the layout.
		if _, err := fs.Stat(static, devStyleFile); err == nil {
			styleHref = assets.NormalizePrefix(assets.DefaultPrefix) + devStyleFile
		}
	}

	// The switcher, the brand and the login links come from the shared config
	// read once here; the stylesheet is discovered separately because its name
	// carries a build hash, which no config file can know.
	chromeSvc := chrome.NewService(cfg.Conf, serviceName)
	chromeSvc.StyleHref = styleHref

	return &app{
		cfg:       cfg,
		templates: templates,
		chrome:    chromeSvc,
		static:    static,
		// Snapshot the registry so all handlers see a stable set and tests can
		// override it per-app without mutating the global.
		views: append([]View{}, registeredViews...),
	}, nil
}

// mount installs every dolt.sr.ht web route onto r. Split from Register so tests
// can mount an *app they retain a handle to.
func (a *app) mount(r chi.Router) {
	r.Get("/", a.handleIndex)
	r.Get("/create", a.handleCreateForm)
	r.Post("/create", a.handleCreate)

	// Service-to-service companion provisioning (git.sr.ht post-update hook).
	// Guarded by internal-network + network-key auth, not the cookie/CSRF the
	// browser routes use.
	r.With(internalAuthGuard).Post("/internal/repos", a.handleInternalCreate)

	r.Get("/settings/keys", a.handleKeys)
	r.Post("/settings/keys", a.handleKeysPost)

	r.Get("/~{user}", a.handleUser)
	r.Get("/~{user}/{db}", a.handleOverview)
	r.Get("/~{user}/{db}/log", a.handleLog)
	r.Get("/~{user}/{db}/commit/{hash}", a.handleCommit)
	r.Get("/~{user}/{db}/tree/{ref}", a.handleTree)
	r.Get("/~{user}/{db}/table/{ref}/{table}", a.handleTable)
	r.Get("/~{user}/{db}/view/{view}", a.handleView)
	r.Get("/~{user}/{db}/settings", a.handleSettings)
	r.Post("/~{user}/{db}/settings", a.handleSettingsPost)

	// The static tree, with the cache policy the hashed names imply and no
	// directory listing — a listing of /static/ would publish this build's
	// stylesheet hash, which nothing else on the surface discloses. An asset URL
	// typed by hand lands on our own 404 rather than net/http's plain text.
	r.Handle(assets.DefaultPrefix+"*",
		assets.Handler(a.static, assets.DefaultPrefix, http.HandlerFunc(a.notFound)))
}

// staticFS is the asset tree for a configured static directory.
//
// An unconfigured one yields an FS with nothing in it rather than os.DirFS(""),
// which resolves every name against the filesystem root: that is not "this
// build ships no assets" but "this build serves all of them".
func staticFS(dir string) fs.FS {
	if dir == "" {
		return emptyFS{}
	}
	return os.DirFS(dir)
}

// emptyFS is an fs.FS in which nothing exists.
type emptyFS struct{}

func (emptyFS) Open(name string) (fs.File, error) {
	return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}

// --- shared response helpers -------------------------------------------------

// notFound renders the 404 page. Used both for genuinely missing repos and to
// hide the existence of PRIVATE repos the caller may not browse.
func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
	view := struct {
		chrome.Page
	}{Page: a.page(r, "Not found — "+serviceName)}
	a.render(w, http.StatusNotFound, "404.html", view)
}

// forbidden renders the 403 page for a denied but non-hidden request.
func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) {
	view := struct {
		chrome.Page
		Message string
	}{Page: a.page(r, "Forbidden — "+serviceName), Message: msg}
	a.render(w, http.StatusForbidden, "403.html", view)
}

// redirectLogin sends an unauthenticated caller to meta's login, returning them
// to the current URL afterwards.
func (a *app) redirectLogin(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, a.page(r, "").LoginURL, http.StatusSeeOther)
}

// loadRepoForBrowse loads the repo named by the {user}/{db} URL params and
// enforces read (OpBrowse) authorization. On any denial it writes the response
// (404 for hidden PRIVATE repos, 403 otherwise) and returns ok=false. On
// success it returns the repo, the (possibly nil) caller and the caller's ACL
// grant for reuse by the handler.
func (a *app) loadRepoForBrowse(w http.ResponseWriter, r *http.Request) (repo *core.Repo, caller *core.Caller, aclMode *core.AccessMode, ok bool) {
	owner := chi.URLParam(r, "user")
	name := chi.URLParam(r, "db")

	_, caller = callerOf(r.Context())

	repo, err := a.cfg.Repos.GetRepoByOwnerAndName(r.Context(), owner, name)
	if err != nil {
		// A missing repo is reported as not found regardless of the caller.
		a.notFound(w, r)
		return nil, nil, nil, false
	}

	aclMode = a.effectiveACL(r, caller, repo)
	if !core.Allowed(caller, repo, aclMode, core.OpBrowse) {
		if core.NotFoundForPrivate(caller, repo, aclMode) {
			a.notFound(w, r)
		} else {
			a.forbidden(w, r, "You do not have access to this database.")
		}
		return nil, nil, nil, false
	}
	return repo, caller, aclMode, true
}

// effectiveACL resolves the caller's ACL grant on repo, or nil for an anonymous
// caller or a caller with no grant. A lookup error degrades to nil (no grant):
// access then falls back to visibility, which never over-grants.
func (a *app) effectiveACL(r *http.Request, caller *core.Caller, repo *core.Repo) *core.AccessMode {
	if caller == nil {
		return nil
	}
	mode, err := a.cfg.Repos.EffectiveAccess(r.Context(), caller.UserID, repo.ID)
	if err != nil {
		return nil
	}
	return mode
}