// Package server is the HTTP layer of the git browser. The upspin path is the // URL path: GET //. 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(`

Index of `) body.WriteString(template.HTMLEscapeString(displayPath(user, clean))) body.WriteString("

\n\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 ` + template.HTMLEscapeString(viewer) + ` · Sign out`) } if s.ssoEnabled() { return template.HTML(`Sign in`) } 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) }) }