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")
}