Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / handlers_git_http.go
package main

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"os/exec"
	"strings"
	"time"
)

// This file implements just enough of git's "smart HTTP" transport
// (https://git-scm.com/docs/http-protocol) to let anyone run
// `git clone https://<domain>/<owner>/<repo>.git` against a public repo
// with no account, no SSH key, nothing — plus the small "?go-get=1"
// carve-out (serveGoImport, below) that lets `go install` resolve a public
// repo hosted here the same way. It is deliberately narrow:
//
//   - Read-only. Only git-upload-pack (clone/fetch) is served; there is no
//     git-receive-pack over HTTP, ever. Pushing still only works over SSH,
//     authenticated by key or federated certificate (see HOW_IT_WORKS.md).
//   - Public repos only. repo.Public is re-checked on every request — nothing
//     is cached across requests, and a repo that's private (or doesn't
//     exist) 404s identically, the same "don't confirm what you can't see"
//     rule the web repo browser already follows (see canView).
//
// Routed at the site root ("/{owner}/{repo}.git/...") rather than under a
// "/git/" prefix so clone URLs look exactly like what people expect from
// GitHub/GitLab/Codeberg. Real git HTTP clients always append "/info/refs"
// or "/git-upload-pack" to whatever base URL they were given, so the actual
// route just has to capture "everything else" and split the suffix back
// off — see gitHTTPRepoName. Go's ServeMux always prefers a more specific
// literal route (/dashboard, /r/{repo...}, etc.) over this catch-all, so it
// can't shadow any other page. The only real edge case: a local username
// that happens to collide with another top-level route name (e.g. someone
// named "settings") would have its HTTPS clone URL 404 — SSH cloning and
// the web browser are unaffected either way.

func (s *server) handleGitInfoRefs(w http.ResponseWriter, r *http.Request) {
	if r.URL.Query().Get("go-get") == "1" {
		s.serveGoImport(w, r)
		return
	}
	repoName, ok := gitHTTPRepoName(r.PathValue("gitpath"), "/info/refs")
	if !ok {
		http.NotFound(w, r)
		return
	}
	if !s.gitHTTPByIP.allowed(clientIP(r)) {
		http.Error(w, "rate limited", http.StatusTooManyRequests)
		return
	}
	s.gitHTTPByIP.record(clientIP(r))
	if r.URL.Query().Get("service") != "git-upload-pack" {
		// No dumb-HTTP fallback and no receive-pack advertisement — smart
		// upload-pack only.
		http.NotFound(w, r)
		return
	}

	repo, err := s.ops.GetRepo(repoName)
	if err != nil || !repo.Public {
		http.NotFound(w, r)
		return
	}
	extendWriteDeadline(w)

	w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
	w.Header().Set("Cache-Control", "no-cache")
	writePktLine(w, "# service=git-upload-pack\n")
	writeFlushPkt(w)

	var stderr bytes.Buffer
	cmd := exec.Command("git", "upload-pack", "--stateless-rpc", "--advertise-refs", repo.Path)
	cmd.Stdout = w
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		log.Printf("gitfed-web: git-http info/refs %s: %v: %s", repoName, err, stderr.String())
	}
}

// serveGoImport answers the discovery request Go's own tooling makes when
// resolving a module path it doesn't otherwise recognize — a plain GET
// with "?go-get=1" appended, expecting an HTML page with a go-import meta
// tag back (see https://go.dev/ref/mod#vcs-find). This is what makes
// `go install <domain>/<owner>/<repo>/cmd/x@version` work directly against
// any public repo hosted here, without a manual git clone first. Same
// visibility rule as the anonymous HTTPS clone above: a private (or
// nonexistent) repo 404s identically, never confirming which.
func (s *server) serveGoImport(w http.ResponseWriter, r *http.Request) {
	repoName := strings.Trim(r.PathValue("gitpath"), "/")
	repo, err := s.ops.GetRepo(repoName)
	if err != nil || !repo.Public {
		http.NotFound(w, r)
		return
	}
	root := template.HTMLEscapeString(s.domain + "/" + repoName)
	repoURL := template.HTMLEscapeString("https://" + s.domain + "/" + repoName + ".git")
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta name="go-import" content="%s git %s"></head></html>`, root, repoURL)
}

func (s *server) handleGitUploadPack(w http.ResponseWriter, r *http.Request) {
	repoName, ok := gitHTTPRepoName(r.PathValue("gitpath"), "/git-upload-pack")
	if !ok {
		http.NotFound(w, r)
		return
	}
	if !s.gitHTTPByIP.allowed(clientIP(r)) {
		http.Error(w, "rate limited", http.StatusTooManyRequests)
		return
	}
	s.gitHTTPByIP.record(clientIP(r))

	repo, err := s.ops.GetRepo(repoName)
	if err != nil || !repo.Public {
		http.NotFound(w, r)
		return
	}
	extendWriteDeadline(w)

	body := r.Body
	if r.Header.Get("Content-Encoding") == "gzip" {
		gz, err := gzip.NewReader(r.Body)
		if err != nil {
			http.Error(w, "bad gzip body", http.StatusBadRequest)
			return
		}
		defer gz.Close()
		body = gz
	}

	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
	w.Header().Set("Cache-Control", "no-cache")

	var stderr bytes.Buffer
	cmd := exec.Command("git", "upload-pack", "--stateless-rpc", repo.Path)
	cmd.Stdin = body
	cmd.Stdout = w
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		log.Printf("gitfed-web: git-http upload-pack %s: %v: %s", repoName, err, stderr.String())
	}
}

// gitHTTPRepoName strips the request-verb suffix git's HTTP client always
// appends, plus the conventional ".git" extension, from the captured
// wildcard path, giving back a store.Repo.Name to look up. ok is false when
// the path doesn't end in the expected suffix at all — i.e. this request
// isn't a git-smart-HTTP request in the first place.
func gitHTTPRepoName(path, suffix string) (string, bool) {
	if !strings.HasSuffix(path, suffix) {
		return "", false
	}
	repo := strings.TrimSuffix(strings.TrimSuffix(path, suffix), ".git")
	if repo == "" {
		return "", false
	}
	return repo, true
}

// extendWriteDeadline lifts the server-wide WriteTimeout (tuned short, for
// the rest of the app's small HTML/JSON responses) for this one response —
// a pack transfer for even a modest repo can easily take longer than that
// over a slow connection.
func extendWriteDeadline(w http.ResponseWriter) {
	if rc := http.NewResponseController(w); rc != nil {
		_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Minute))
	}
}

// writePktLine and writeFlushPkt implement just enough of git's pkt-line
// framing (see Documentation/technical/protocol-common.txt in git's own
// source) for the one line the smart-HTTP info/refs response needs before
// git's own --advertise-refs output.
func writePktLine(w io.Writer, s string) {
	fmt.Fprintf(w, "%04x%s", len(s)+4, s)
}

func writeFlushPkt(w io.Writer) {
	io.WriteString(w, "0000")
}