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("") // cgitDiv is the element cgit puts its whole page in. var cgitDiv = []byte("
") // maxInject bounds the wait for markup that may never come. const maxInject = 64 << 10 func (w *injector) WriteHeader(status int) { if w.wrote { return } w.wrote = true // Without a readme cgit redirects the about page to a path that is not a // route. if w.summary != "" && status >= 300 && status < 400 { if strings.Contains(w.Header().Get("Location"), "..") { w.Header().Set("Location", w.summary) } } if strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") { w.html = true // The bar changes the length. w.Header().Del("Content-Length") } w.ResponseWriter.WriteHeader(status) } func (w *injector) Write(p []byte) (int, error) { if !w.wrote { w.WriteHeader(http.StatusOK) } if !w.html || w.injected { return w.ResponseWriter.Write(p) } w.buf.Write(p) // Both go in the first kilobyte of a cgit page. done := w.placed() if done || w.buf.Len() > maxInject { if !done { cgitMoved() } w.injected = true if err := w.drain(); err != nil { return 0, err } } return len(p), nil } // cgitMoved is a cgit whose markup no longer carries what the bar goes on: a // package upgrade. func cgitMoved() { log.Printf("cgit: the bar was not placed, so cgit's markup has moved") } // placed reports whether anything is left to wait for. The tabs go in first: // the header carries a cell of its own. func (w *injector) placed() bool { tabs := insertNear(&w.buf, cgitTabs, w.tabs, false) return insertNear(&w.buf, cgitDiv, w.nav, true) && tabs } // insertNear reports whether what went in. Nothing to insert, or something // already inserted, counts as done. func insertNear(buf *bytes.Buffer, needle, what []byte, after bool) bool { if len(what) == 0 || bytes.Contains(buf.Bytes(), what) { return true } at := bytes.Index(buf.Bytes(), needle) if at < 0 { return false } if after { at += len(needle) } rest := append([]byte(nil), buf.Bytes()[at:]...) buf.Truncate(at) buf.Write(what) buf.Write(rest) return true } func (w *injector) flush() { if w.buf.Len() > 0 { cgitMoved() w.drain() } } func (w *injector) drain() error { out := w.buf.Bytes() w.buf.Reset() _, err := w.ResponseWriter.Write(out) return err }