🏡 index : quine-global-labs/nugitea.git

// Package httpgit implements the git smart-HTTP protocol (info/refs
// discovery, git-upload-pack, git-receive-pack), mirroring the subprocess
// invocation Gitea uses in routers/web/repo/githttp.go: git run with
// --stateless-rpc, stdin/stdout wired directly to the HTTP request/response.
package httpgit

import (
	"compress/gzip"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"strings"

	"nugitea/internal/auth"
	"nugitea/internal/gitcmd"
	"nugitea/internal/repo"
)

// Handler serves the smart-HTTP git protocol under a single mount point,
// expecting paths of the form /{repo}.git/info/refs, /{repo}.git/git-upload-pack,
// /{repo}.git/git-receive-pack.
type Handler struct {
	Store *repo.Store
	Auth  auth.Authorizer
}

// New returns a Handler for the given repo store. If a is nil, AllowAll is used.
func New(store *repo.Store, a auth.Authorizer) *Handler {
	if a == nil {
		a = auth.AllowAll{}
	}
	return &Handler{Store: store, Auth: a}
}

var pathRE = regexp.MustCompile(`^/([A-Za-z0-9_][A-Za-z0-9_.-]*)\.git/(info/refs|git-upload-pack|git-receive-pack)$`)

// safeProtocolHeader matches the values git actually sends for Git-Protocol,
// e.g. "version=2", to avoid forwarding arbitrary header content into the
// subprocess environment.
var safeProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z=;,._-]+$`)

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	m := pathRE.FindStringSubmatch(r.URL.Path)
	if m == nil {
		http.NotFound(w, r)
		return
	}
	repoName, endpoint := m[1], m[2]

	if err := repo.ValidateName(repoName); err != nil {
		http.NotFound(w, r)
		return
	}
	if !h.Store.Exists(repoName) {
		http.NotFound(w, r)
		return
	}
	repoPath, err := h.Store.Path(repoName)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	switch endpoint {
	case "info/refs":
		h.infoRefs(w, r, repoName, repoPath)
	case "git-upload-pack":
		h.service(w, r, repoName, repoPath, "upload-pack", h.Auth.AllowPull)
	case "git-receive-pack":
		h.service(w, r, repoName, repoPath, "receive-pack", h.Auth.AllowPush)
	}
}

func (h *Handler) infoRefs(w http.ResponseWriter, r *http.Request, repoName, repoPath string) {
	service := strings.TrimPrefix(r.URL.Query().Get("service"), "git-")
	var allowed func(string) bool
	switch service {
	case "upload-pack":
		allowed = h.Auth.AllowPull
	case "receive-pack":
		allowed = h.Auth.AllowPush
	default:
		http.Error(w, "smart HTTP only: service must be git-upload-pack or git-receive-pack", http.StatusBadRequest)
		return
	}
	if !allowed(repoName) {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}

	var stdout strings.Builder
	var stderr strings.Builder
	cmd := gitcmd.New(service, "--stateless-rpc", "--advertise-refs", ".").
		WithDir(repoPath).
		WithStdio(nil, &stdout, &stderr)
	if err := cmd.Run(r.Context()); err != nil {
		log.Printf("info/refs %s %s: %v: %s", repoName, service, err, stderr.String())
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service))
	w.Header().Set("Cache-Control", "no-cache")
	w.WriteHeader(http.StatusOK)

	pkt := fmt.Sprintf("# service=git-%s\n", service)
	fmt.Fprintf(w, "%04x%s0000", len(pkt)+4, pkt)
	io.WriteString(w, stdout.String())
}

func (h *Handler) service(w http.ResponseWriter, r *http.Request, repoName, repoPath, service string, allowed func(string) bool) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if !allowed(repoName) {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}

	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
	}

	env := buildEnv(r)

	w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service))
	w.Header().Set("Cache-Control", "no-cache")
	w.WriteHeader(http.StatusOK)

	var stderr strings.Builder
	cmd := gitcmd.New(service, "--stateless-rpc", ".").
		WithDir(repoPath).
		WithEnv(env).
		WithStdio(body, w, &stderr)
	if err := cmd.Run(r.Context()); err != nil {
		log.Printf("%s %s: %v: %s", repoName, service, err, stderr.String())
	}
}

// buildEnv forwards the client's requested git wire protocol version, same
// as Gitea's handling of the Git-Protocol header in routers/web/repo/githttp.go.
func buildEnv(r *http.Request) []string {
	env := os.Environ()
	if protocol := r.Header.Get("Git-Protocol"); protocol != "" && safeProtocolHeader.MatchString(protocol) {
		env = append(env, "GIT_PROTOCOL="+protocol)
	}
	return env
}