git@cosmocr.at/gitweb.git @ main · summary refs log
main / internal / server / server.go /
internal/server/server.go · 14.2 KiB · raw
view: SourceMarkdown ☑ force
  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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
// Package server is the HTTP layer of the git browser. The upspin path is the
// URL path: GET /<user@domain>/<path...>. If the path resolves inside a bare
// git repository the request is handled by the git browser (summary, tree,
// blob, log, commit, refs); otherwise it falls back to plain upspin namespace
// browsing (directory listings and raw files) so users can drill down to repos.
package server

import (
	"bytes"
	"context"
	"embed"
	"errors"
	"html/template"
	"io"
	"log"
	"mime"
	"net/http"
	"net/url"
	"os"
	"path"
	"strings"

	"gitweb/internal/auth"
	"gitweb/internal/gitrepo"
	"gitweb/internal/render"
	"gitweb/internal/store"
)

//go:embed templates/*.html
var tmplFS embed.FS

var pageTmpl = template.Must(template.ParseFS(tmplFS, "templates/page.html"))

// Config configures a Server.
type Config struct {
	Store store.WriteStore // read for browse/clone; write for push
	Web   http.FileSystem  // static assets (style.css, landing index.html)
	Realm string           // HTTP Basic auth realm; defaults to "gitweb"
	// Vault, if non-nil, verifies issuer-signed SSO bearer tokens (the passkey
	// login path). When nil, only PATs are accepted.
	Vault *auth.VaultVerifier
	// SSOAuthorizeURL is the issuer's browser login endpoint (e.g.
	// https://vault.cosmocr.at/authorize). When set (with Vault non-nil), the
	// browser "Sign in" flow at /_auth/login is enabled. Empty disables it.
	SSOAuthorizeURL string
	// SSOAudience is the audience requested in the browser SSO flow (e.g.
	// "gitweb"); it must match the audience Vault accepts.
	SSOAudience string
	// BaseURL is this service's public origin (e.g. https://git.cosmocr.at),
	// used to build the SSO redirect_uri. Not derived from the Host header, so a
	// spoofed Host cannot redirect the token elsewhere.
	BaseURL string
	// CacheDir is where repos are materialized on disk. Its contents are
	// deleted at startup. Empty means an ephemeral directory under the system
	// temp dir (dev and tests).
	CacheDir string
	// CacheBudgetBytes and MaxRepoBytes bound the repo cache; 0 means the
	// gitrepo defaults (512 MiB / 256 MiB).
	CacheBudgetBytes int64
	MaxRepoBytes     int64
}

// Server serves upspin-stored git repositories over HTTP: a browse UI, plus the
// git smart-HTTP protocol for clone and push. Access is gated by upspin Access
// files; private repos require Basic-auth credentials (a vault SSO token or a
// personal access token).
type Server struct {
	store      store.Store
	web        http.FileSystem
	renderer   *render.Renderer
	renderers  *render.Registry
	repos      *gitrepo.Cache
	authz      *auth.Authorizer
	pat        *auth.PATVerifier
	vault      *auth.VaultVerifier // may be nil
	pusher     *gitrepo.Pusher
	realm      string
	ssoAuthURL string // issuer login endpoint; "" disables browser SSO
	ssoAud     string // audience requested in the browser SSO flow
	baseURL    string // this service's public origin, for redirect_uri
	chroma     []byte // generated highlighter stylesheet
	landing    []byte // /index.html, served at "/"
}

// New builds a Server.
func New(c Config) (*Server, error) {
	css, err := render.CSS()
	if err != nil {
		return nil, err
	}
	rnd := render.New()
	cacheDir := c.CacheDir
	if cacheDir == "" {
		cacheDir, err = os.MkdirTemp("", "gitweb-cache-")
		if err != nil {
			return nil, err
		}
	}
	repos, err := gitrepo.NewCache(c.Store, gitrepo.CacheOptions{
		Dir:          cacheDir,
		BudgetBytes:  c.CacheBudgetBytes,
		MaxRepoBytes: c.MaxRepoBytes,
	})
	if err != nil {
		return nil, err
	}
	realm := c.Realm
	if realm == "" {
		realm = "gitweb"
	}
	s := &Server{
		store:      c.Store,
		web:        c.Web,
		renderer:   rnd,
		renderers:  render.NewRegistry(rnd),
		repos:      repos,
		authz:      auth.NewAuthorizer(c.Store),
		pat:        auth.NewPATVerifier(c.Store),
		vault:      c.Vault,
		pusher:     gitrepo.NewPusher(c.Store, repos),
		realm:      realm,
		chroma:     css,
		ssoAuthURL: c.SSOAuthorizeURL,
		ssoAud:     c.SSOAudience,
		baseURL:    strings.TrimRight(c.BaseURL, "/"),
	}
	if f, err := c.Web.Open("index.html"); err == nil {
		s.landing, _ = io.ReadAll(f)
		f.Close()
	}
	return s, nil
}

// Handler returns the HTTP handler.
func (s *Server) Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /_assets/chroma.css", s.handleChromaCSS)
	mux.Handle("GET /_assets/", http.StripPrefix("/_assets/", http.FileServer(s.web)))
	// Browser SSO: /_auth/* cannot collide with an upspin path (users are
	// user@domain). Enabled only when an issuer login endpoint is configured.
	if s.ssoEnabled() {
		mux.HandleFunc("GET /_auth/login", s.handleLogin)
		mux.HandleFunc("GET /_auth/callback", s.handleCallback)
		mux.HandleFunc("POST /_auth/session", s.handleSession)
		mux.HandleFunc("GET /_auth/logout", s.handleLogout)
	}
	// The catch-all accepts every method: git smart-HTTP needs POST, and
	// ServeMux cannot match the "/info/refs", "/git-upload-pack",
	// "/git-receive-pack" suffixes, so handleRoot dispatches by suffix itself.
	mux.HandleFunc("/", s.handleRoot)
	return securityHeaders(mux)
}

// handleRoot dispatches between the git smart-HTTP endpoints and the browse UI.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
	if ep, ok := gitEndpoint(r); ok {
		s.handleGitHTTP(w, r, ep)
		return
	}
	if r.Method != http.MethodGet {
		http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
		return
	}
	s.handlePath(w, r)
}

func (s *Server) handleChromaCSS(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/css; charset=utf-8")
	w.Write(s.chroma)
}

// handlePath dispatches a request whose URL path is an upspin path.
func (s *Server) handlePath(w http.ResponseWriter, r *http.Request) {
	raw := strings.TrimPrefix(r.URL.Path, "/")
	if raw == "" {
		s.serveLanding(w)
		return
	}

	// First element is the upspin user; validate before building any path.
	user, rest, _ := strings.Cut(raw, "/")
	if !store.ValidUser(user) {
		s.notFound(w)
		return
	}
	// Clean the remainder; path.Clean collapses any ".." so it cannot escape
	// the user's tree. clean always begins with "/".
	clean := path.Clean("/" + rest)
	upath := user + clean // e.g. "u@d.com/proj.git"; root is "u@d.com/"

	// Identify the caller (anonymous if no/blank credentials) before deciding
	// what they may see.
	authUser, ok := s.authenticate(w, r)
	if !ok {
		return // 401 already written
	}
	// Carry the viewer identity so page rendering can show sign-in/out state.
	r = r.WithContext(context.WithValue(r.Context(), viewerKey{}, authUser))

	// If the path lies inside a bare git repo, hand off to the git browser;
	// otherwise fall back to plain upspin namespace browsing.
	repoPath, gitRest, isRepo, err := gitrepo.Locate(s.store, user, clean)
	if err != nil {
		s.storeErr(w, err)
		return
	}

	// Gate the browse UI by the same public/private rule as clone: anonymous
	// users see only world-readable paths; others must be granted read.
	resource := upath
	if isRepo {
		resource = repoPath
	}
	allowed, err := s.authz.CanRead(authUser, resource)
	if err != nil {
		s.serverErr(w, err)
		return
	}
	if !allowed {
		s.denyRead(w, r, authUser)
		return
	}

	if isRepo {
		s.serveGit(w, r, repoPath, gitRest)
		return
	}

	isDir, err := s.store.Stat(upath)
	if err != nil {
		s.storeErr(w, err)
		return
	}

	if isDir {
		s.serveDir(w, r, user, clean, upath)
		return
	}
	s.serveFile(w, r, upath, clean)
}

// serveDir renders a directory: README.md/index.md if present, else a listing.
func (s *Server) serveDir(w http.ResponseWriter, r *http.Request, user, clean, upath string) {
	// A trailing slash is required so relative links resolve against the dir.
	if !strings.HasSuffix(r.URL.Path, "/") {
		http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
		return
	}

	entries, err := s.store.List(upath)
	if err != nil {
		s.storeErr(w, err)
		return
	}

	// Prefer an index document if the directory has one.
	for _, name := range []string{"index.md", "README.md", "readme.md"} {
		if hasEntry(entries, name) {
			s.serveFile(w, r, strings.TrimRight(upath, "/")+"/"+name, clean)
			return
		}
	}

	var body bytes.Buffer
	body.WriteString(`<h1>Index of `)
	body.WriteString(template.HTMLEscapeString(displayPath(user, clean)))
	body.WriteString("</h1>\n<ul class=\"dir\">\n")
	if clean != "/" {
		body.WriteString(`<li><a href="../">../</a></li>` + "\n")
	}
	for _, e := range entries {
		if isAccessFile(e.Name) {
			continue // never expose upspin ACLs in a listing
		}
		name := e.Name
		if e.IsDir {
			name += "/"
		}
		esc := template.HTMLEscapeString(name)
		body.WriteString(`<li><a href="` + esc + `">` + esc + "</a></li>\n")
	}
	body.WriteString("</ul>\n")
	s.renderPage(w, r, displayPath(user, clean), template.HTML(body.String()))
}

// serveFile renders a markdown file, or serves any other file raw.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, upath, clean string) {
	// Never serve an upspin Access file's contents (its user/group list),
	// even when requested directly by path.
	if isAccessFile(path.Base(upath)) {
		s.notFound(w)
		return
	}
	data, err := s.store.Get(upath)
	if err != nil {
		s.storeErr(w, err)
		return
	}

	if isMarkdown(upath) {
		html, err := s.renderer.ToHTML(data)
		if err != nil {
			s.serverErr(w, err)
			return
		}
		s.renderPage(w, r, path.Base(clean), template.HTML(html))
		return
	}

	// Raw asset (images referenced by markdown, etc.).
	ctype := mime.TypeByExtension(path.Ext(upath))
	if ctype == "" {
		ctype = http.DetectContentType(data)
	}
	w.Header().Set("Content-Type", ctype)
	w.Write(data)
}

func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, title string, body template.HTML) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err := pageTmpl.Execute(w, struct {
		Title string
		Body  template.HTML
		Nav   template.HTML
	}{Title: title, Body: body, Nav: s.navHTML(r)})
	if err != nil {
		log.Printf("gitweb: template: %v", err)
	}
}

// viewerKey stashes the authenticated viewer (or "") on the request context, so
// renderPage can show sign-in state without every render path taking it as an
// argument.
type viewerKey struct{}

// navHTML builds the account nav shown on every rendered page: sign-out when the
// viewer is authenticated, sign-in when SSO is available, nothing otherwise.
func (s *Server) navHTML(r *http.Request) template.HTML {
	if r == nil {
		return ""
	}
	viewer, _ := r.Context().Value(viewerKey{}).(string)
	if viewer != "" {
		return template.HTML(`Signed in as <strong>` + template.HTMLEscapeString(viewer) +
			`</strong> &middot; <a href="/_auth/logout">Sign out</a>`)
	}
	if s.ssoEnabled() {
		return template.HTML(`<a href="/_auth/login?next=` +
			template.HTMLEscapeString(url.QueryEscape(r.URL.Path)) + `">Sign in</a>`)
	}
	return ""
}

func (s *Server) serveLanding(w http.ResponseWriter) {
	if s.landing == nil {
		s.notFound(w)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Write(s.landing)
}

// --- errors ---

func (s *Server) storeErr(w http.ResponseWriter, err error) {
	if errors.Is(err, store.ErrNotFound) {
		s.notFound(w)
		return
	}
	s.serverErr(w, err)
}

func (s *Server) notFound(w http.ResponseWriter) {
	http.Error(w, "404 not found", http.StatusNotFound)
}

// denyRead responds to a caller who may not read the requested resource. An
// already-authenticated caller gets a 404, which does not reveal whether the
// resource exists. An anonymous caller gets either a redirect into the browser
// SSO flow (if this looks like a browser and SSO is enabled) or a Basic-auth
// challenge (so a git/curl client can retry with credentials).
func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, authUser string) {
	if authUser != "" {
		s.notFound(w)
		return
	}
	if s.ssoEnabled() && wantsHTML(r) {
		http.Redirect(w, r, "/_auth/login?next="+url.QueryEscape(r.URL.Path), http.StatusSeeOther)
		return
	}
	s.challenge(w)
}

// wantsHTML reports whether the request looks like a browser navigation (as
// opposed to git/curl), so we can redirect it to the login page rather than
// issue a Basic-auth challenge it can't satisfy interactively.
func wantsHTML(r *http.Request) bool {
	return strings.Contains(r.Header.Get("Accept"), "text/html")
}

// challenge writes a 401 with a Basic-auth challenge. The body is deliberately
// vague so anonymous callers cannot distinguish "private" from "absent".
func (s *Server) challenge(w http.ResponseWriter) {
	w.Header().Set("WWW-Authenticate", `Basic realm="`+s.realm+`", charset="UTF-8"`)
	http.Error(w, "not found, or authentication required", http.StatusUnauthorized)
}

func (s *Server) serverErr(w http.ResponseWriter, err error) {
	log.Printf("gitweb: 500: %v", err)
	http.Error(w, "500 internal server error", http.StatusInternalServerError)
}

// --- helpers ---

func isMarkdown(p string) bool {
	switch strings.ToLower(path.Ext(p)) {
	case ".md", ".markdown":
		return true
	}
	return false
}

// isAccessFile reports whether name is an upspin Access file. gitweb never lists
// or renders these: their contents enumerate the upspin users and groups granted
// access, which must not be exposed to browsers.
func isAccessFile(name string) bool { return name == "Access" }

func hasEntry(entries []store.Entry, name string) bool {
	for _, e := range entries {
		if !e.IsDir && e.Name == name {
			return true
		}
	}
	return false
}

// displayPath renders the upspin path for a heading: "u@d.com/docs/".
func displayPath(user, clean string) string {
	if clean == "/" {
		return user + "/"
	}
	return user + clean
}

func securityHeaders(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("Referrer-Policy", "no-referrer")
		// script-src 'self': the only scripts are our own, same-origin mermaid
		// files (mermaid-init.js and the vendored bundle). No 'unsafe-inline', so
		// script injected via rendered markdown cannot execute. style-src allows
		// 'unsafe-inline' because mermaid injects diagram styles at render time;
		// this permits inline styles but not scripts. Markdown may embed external
		// https images; everything else is same-origin.
		w.Header().Set("Content-Security-Policy",
			"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
				"img-src 'self' data: https:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
		h.ServeHTTP(w, r)
	})
}