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

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