~bigbes/sr-ht-dolt

ref: bf7897cd3026881c99dfef141933e333a38d4ed6 sr-ht-dolt/web/router.go -rw-r--r-- 7.3 KiB
bf7897cd — Eugene Blikh web: draw the chrome from sr-ht-ecore 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
package web

import (
	"fmt"
	"net/http"

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

	"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"

// 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
	// 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 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 = discoverStyleHref(cfg.StaticDir)

	return &app{
		cfg:       cfg,
		templates: templates,
		chrome:    chromeSvc,
		// 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)

	r.Handle("/static/*", httpStaticHandler(a.cfg.StaticDir))
}

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