diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /cgit.go | |
| download | git-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz | |
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.go | 304 |
1 files changed, 304 insertions, 0 deletions
@@ -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 +} |