package main import ( "bytes" "fmt" "log" "net/http" "net/http/cgi" "os" "path/filepath" "strings" ) // Cgit is the browser. One config per repository, because under scan-path any // request reaching cgit's index lists every repository. The configs are // derived state in a temporary directory. type Cgit struct { binary string css []byte // cgit's own stylesheet, read from beside the binary logo []byte // and its logo, which is the default one favicon []byte dir string store *Store } // cgitBinary is the CGI in Alpine's cgit package; the override is for running // outside that image. func cgitBinary() string { if path := os.Getenv("GIT_FORGE_CGIT"); path != "" { return path } return "/usr/share/webapps/cgit/cgit.cgi" } func newCgit(store *Store, forgeCSS []byte) (*Cgit, error) { dir, err := os.MkdirTemp("", "git-forge-cgit") if err != nil { return nil, err } c := &Cgit{binary: cgitBinary(), dir: dir, store: store} if _, err := os.Stat(c.binary); err != nil { log.Printf("cgit: %v, so browsing will not work", err) } // cgit's own, read beside the CGI: its licence, and it changes with the // package. beside := filepath.Dir(c.binary) if c.css, err = os.ReadFile(filepath.Join(beside, "cgit.css")); err != nil { log.Printf("cgit: %v, so pages will be unstyled", err) } // One file at the path cgit links to, or the injected bar is unstyled. c.css = append(c.css, forgeCSS...) // The defaults, linked to when no logo is configured. if c.logo, err = os.ReadFile(filepath.Join(beside, "cgit.png")); err != nil { log.Printf("cgit: %v, so /cgit.png answers 404", err) } if c.favicon, err = os.ReadFile(filepath.Join(beside, "favicon.ico")); err != nil { log.Printf("cgit: %v, so /favicon.ico answers 404", err) } log.Printf("cgit configs in %s, written on demand and never backed up", dir) return c, c.writeFilters() } func (c *Cgit) close() { os.RemoveAll(c.dir) } // writeFilters generates the two filter shims: an exec filter is not split on // spaces, so "git-forge md" cannot be named directly. func (c *Cgit) writeFilters() error { exe, err := os.Executable() if err != nil { return err } // Quoted: an unquoted path with a space in it execs its first word. quoted := "'" + strings.ReplaceAll(exe, "'", `'\''`) + "'" for _, sub := range []string{"md", "highlight"} { shim := fmt.Sprintf("#!/bin/sh\nexec %s %s \"$@\"\n", quoted, sub) if err := writeFile(filepath.Join(c.dir, sub), []byte(shim), 0700); err != nil { return err } } return nil } // config writes one repository's cgitrc if it is not there. The content // depends on the path alone. func (c *Cgit) config(path, kind string) (string, error) { name := c.configPath(path, kind) if _, err := os.Stat(name); err == nil { return name, nil } return c.write(path, kind) } func (c *Cgit) configPath(path, kind string) string { // A path holds at most one slash and no colon, so this is unambiguous. return filepath.Join(c.dir, strings.ReplaceAll(urlPath(path, kind), "/", ":")+".rc") } func (c *Cgit) repoDir(path, kind string) string { return filepath.Join(c.store.reposDir, path+gitSuffix(kind)) } // virtual-root is unset: with SCRIPT_NAME empty, cgit emits links of the form // /infra/dns/log/, the forge's own routes. No repo.desc: the description is in // repos.json. func (c *Cgit) write(path, kind string) (string, error) { url := urlPath(path, kind) var b strings.Builder // css stays /cgit.css; the injected bar carries the name. fmt.Fprintf(&b, "logo=\n") fmt.Fprintf(&b, "noheader=1\n") fmt.Fprintf(&b, "enable-blame=1\n") fmt.Fprintf(&b, "enable-http-clone=0\n") fmt.Fprintf(&b, "enable-log-filecount=1\n") fmt.Fprintf(&b, "enable-log-linecount=1\n") fmt.Fprintf(&b, "snapshots=tar.gz\n") // Both spellings, since a repository may carry either. fmt.Fprintf(&b, "readme=:README.md\n") fmt.Fprintf(&b, "readme=:README\n") // highlight is fed arbitrary blobs, so capped. In kilobytes. fmt.Fprintf(&b, "max-blob-size=512\n") // No mimetype mapping, so every blob comes back as text, not run on this // origin. fmt.Fprintf(&b, "mimetype-file=\n") fmt.Fprintf(&b, "about-filter=%s\n", filepath.Join(c.dir, "md")) fmt.Fprintf(&b, "source-filter=%s\n", filepath.Join(c.dir, "highlight")) fmt.Fprintf(&b, "clone-url=%s/%s.git\n", c.store.cfg.BaseURL, url) fmt.Fprintf(&b, "repo.url=%s\n", url) fmt.Fprintf(&b, "repo.path=%s\n", c.repoDir(path, kind)) name := c.configPath(path, kind) return name, writeFile(name, []byte(b.String()), 0600) } // urlPath is how a repository and its siblings appear in a URL: infra/dns, // infra/dns.issues, infra/dns.wiki. func urlPath(path, kind string) string { if kind == "" { return path } return path + "." + kind } // browsing bounds the cgit children in flight. Its own budget: a page must not // queue behind a clone. var browsing = make(chan struct{}, 8) // Authorisation has already happened: cgit is only ever told about a // repository the viewer may read. func (f *Forge) handleCgit(w http.ResponseWriter, r *http.Request, path, kind, rest string, repo Repo) { config, err := f.cgit.config(path, kind) if err != nil { f.problem(w, r, http.StatusInternalServerError, err) return } // The neutral configuration gitEnv gives: cgit reads repositories with // git's library. env := []string{ "CGIT_CONFIG=" + config, "PATH_INFO=/" + urlPath(path, kind) + "/" + rest, "SCRIPT_NAME=", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", } nav := &injector{ResponseWriter: w, nav: f.navPart(r, "header", path, kind, repo)} // The forge's tabs go inside cgit's row; a sibling's history has none. if kind == "" { nav.tabs = f.navPart(r, "tabs", path, kind, repo) } if strings.HasPrefix(rest, "about") { nav.summary = "/" + urlPath(path, kind) + "/" } release, ok := childSlot(browsing, r) if !ok { return } defer release() (&cgi.Handler{Path: f.cgit.binary, Env: env, Stderr: os.Stdout}).ServeHTTP(nav, r) nav.flush() } // navPart renders one of nav.html's partials for injection into a cgit page: // injected, so it follows the knobs and names the account. func (f *Forge) navPart(r *http.Request, part, path, kind string, repo Repo) []byte { d := f.page(r) d.Repo = f.repoBar(r, path, kind, "", repo) var buf bytes.Buffer if err := f.nav.ExecuteTemplate(&buf, part, d); err != nil { log.Printf("nav %s: %v", part, err) return nil } return buf.Bytes() } // injector puts the header inside cgit's div and the tabs inside its tab row. // Only text/html is buffered, and only until both are placed or the window // runs out. type injector struct { http.ResponseWriter nav []byte tabs []byte summary string // where to send an about page cgit declined to answer html bool wrote bool injected bool buf bytes.Buffer } // cgitTabs is where cgit's own tab cell ends; the forge's go in before it. var cgitTabs = []byte("