aboutsummaryrefslogtreecommitdiff
path: root/cgit.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /cgit.go
downloadgit-forge-master.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'cgit.go')
-rw-r--r--cgit.go304
1 files changed, 304 insertions, 0 deletions
diff --git a/cgit.go b/cgit.go
new file mode 100644
index 0000000..e2687d3
--- /dev/null
+++ b/cgit.go
@@ -0,0 +1,304 @@
+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("</td><td class='form'>")
+
+// cgitDiv is the element cgit puts its whole page in.
+var cgitDiv = []byte("<div id='cgit'>")
+
+// 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
+}