Files
2026-05-11 20:10:45 +02:00

326 lines
9.3 KiB
Go

package handlers
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
type GitHTTPHandler struct {
db *xorm.Engine
cfg *config.Config
bus events.EventBus
}
func NewGitHTTPHandler(db *xorm.Engine, cfg *config.Config, bus events.EventBus) *GitHTTPHandler {
return &GitHTTPHandler{db: db, cfg: cfg, bus: bus}
}
// refUpdate captures one ref-update line from a git-receive-pack request.
type refUpdate struct {
OldRev string
NewRev string
Ref string
}
// ServeGit is the entry point for all git smart-HTTP requests.
// URL shape: /{owner}/{repo}.git/{service}
func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoGit := chi.URLParam(r, "repoGit") // e.g. "myrepo.git"
repoName := strings.TrimSuffix(repoGit, ".git")
// Resolve repo from DB
var ownerUser models.User
if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found {
http.NotFound(w, r)
return
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", ownerUser.ID, repoName).Get(&repo); !found {
http.NotFound(w, r)
return
}
// Determine service from URL / query string
service := r.URL.Query().Get("service")
if service == "" {
// POST bodies encode the service in the path
path := r.URL.Path
if strings.HasSuffix(path, "/git-upload-pack") {
service = "git-upload-pack"
} else if strings.HasSuffix(path, "/git-receive-pack") {
service = "git-receive-pack"
}
}
// Authenticate and enforce permission checks.
// Priority: user account → deploy key → anonymous (public repos only).
var authedUser string
var authedReadOnly bool
if _, p, hasAuth := r.BasicAuth(); hasAuth {
if user, ok := h.basicAuth(r); ok {
authedUser = user
// User account: enforce member permissions.
if service == "git-receive-pack" && !HasPermission(h.db, &repo, user, "write") {
http.Error(w, "forbidden: you do not have write access to this repository", http.StatusForbidden)
return
}
if repo.IsPrivate && !HasPermission(h.db, &repo, user, "read") {
http.Error(w, "forbidden: you do not have read access to this repository", http.StatusForbidden)
return
}
} else if rdOnly, ok := AuthenticateDeployKey(h.db, repo.ID, p); ok {
// Deploy key: the password field carries the raw token; username is ignored.
authedUser = "deploy-key"
authedReadOnly = rdOnly
if service == "git-receive-pack" && rdOnly {
http.Error(w, "forbidden: this deploy key is read-only", http.StatusForbidden)
return
}
} else if _, repoID, hasWrite, ok := LookupAccessToken(h.db, p); ok && repoID == repo.ID {
// Access token used as git credential (username ignored, password = token).
authedUser = "access-token"
if service == "git-receive-pack" && !hasWrite {
http.Error(w, "forbidden: this access token has read-only scope", http.StatusForbidden)
return
}
} else {
// Credentials provided but invalid.
w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
} else if service == "git-receive-pack" || repo.IsPrivate {
w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
_ = authedReadOnly
// Branch protection check: parse pkt-lines from the receive-pack body,
// check each ref against stored protection rules, then restore the body.
var pushedRefs []refUpdate
if service == "git-receive-pack" {
reason, refs, newBody := parseAndCheckBody(h.db, repo.ID, authedUser, r.Body)
if reason != "" {
http.Error(w, reason, http.StatusForbidden)
return
}
pushedRefs = refs
r.Body = io.NopCloser(newBody)
}
// Build PATH_INFO: /{reponame}.git/{suffix}
// Strip the /{owner}/{repoGit} prefix from the raw URL path to get the suffix.
prefix := "/" + owner + "/" + repoGit
suffix := strings.TrimPrefix(r.URL.Path, prefix)
if suffix == "" {
suffix = "/"
}
pathInfo := "/" + repoGit + suffix
projectRoot := filepath.Dir(repo.DiskPath)
env := []string{
"GIT_PROJECT_ROOT=" + projectRoot,
"GIT_HTTP_EXPORT_ALL=1",
"PATH_INFO=" + pathInfo,
"QUERY_STRING=" + r.URL.RawQuery,
"REQUEST_METHOD=" + r.Method,
"SERVER_PROTOCOL=HTTP/1.1",
"SERVER_SOFTWARE=ForgeBucket/1.0",
"REMOTE_ADDR=" + r.RemoteAddr,
"HOME=/tmp",
"GIT_TERMINAL_PROMPT=0",
}
if ct := r.Header.Get("Content-Type"); ct != "" {
env = append(env, "CONTENT_TYPE="+ct)
}
if cl := r.ContentLength; cl > 0 {
env = append(env, "CONTENT_LENGTH="+strconv.FormatInt(cl, 10))
}
if authedUser != "" {
env = append(env, "REMOTE_USER="+authedUser)
}
gitExec, err := exec.LookPath("git")
if err != nil {
http.Error(w, "git not found on server", http.StatusInternalServerError)
return
}
if err := runGitBackend(r.Context(), w, r.Body, gitExec, env); err != nil {
http.Error(w, fmt.Sprintf("git http-backend: %v", err), http.StatusInternalServerError)
return
}
// Publish push.received for each ref pushed so the CI orchestrator can react.
if service == "git-receive-pack" {
zeroOID := strings.Repeat("0", 40)
for _, ref := range pushedRefs {
if ref.NewRev == zeroOID {
continue // branch deletion — skip CI trigger
}
go h.bus.Publish(events.SubjectPushReceived, events.PushEvent{ //nolint:errcheck
RepoID: repo.ID,
RepoName: repoName,
OwnerName: owner,
Ref: ref.Ref,
Before: ref.OldRev,
After: ref.NewRev,
Pusher: authedUser,
At: time.Now().UTC(),
})
}
}
}
// basicAuth validates HTTP Basic Auth credentials against the DB.
func (h *GitHTTPHandler) basicAuth(r *http.Request) (username string, ok bool) {
u, p, hasAuth := r.BasicAuth()
if !hasAuth {
return "", false
}
var user models.User
found, _ := h.db.Where("username = ?", u).Get(&user)
if !found {
return "", false
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(p)) != nil {
return "", false
}
return u, true
}
// runGitBackend executes `git http-backend` as a CGI subprocess and forwards
// its response (headers + body) to the HTTP response writer.
func runGitBackend(ctx context.Context, w http.ResponseWriter, body io.Reader, gitExec string, env []string) error {
cmd := exec.CommandContext(ctx, gitExec, "http-backend")
cmd.Env = env
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stdin = body
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
// Close write-end of pipe once git finishes
done := make(chan error, 1)
go func() {
err := cmd.Wait()
pw.Close()
done <- err
}()
// Parse CGI response: headers then body
br := bufio.NewReader(pr)
statusCode := http.StatusOK
for {
line, err := br.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(line, "Status:") {
rest := strings.TrimSpace(strings.TrimPrefix(line, "Status:"))
if parts := strings.SplitN(rest, " ", 2); len(parts) >= 1 {
if code, e := strconv.Atoi(parts[0]); e == nil {
statusCode = code
}
}
} else if idx := strings.Index(line, ":"); idx > 0 {
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
w.Header().Set(key, val)
}
if err != nil {
break
}
}
w.WriteHeader(statusCode)
io.Copy(w, br) //nolint:errcheck
waitErr := <-done
if waitErr != nil && stderrBuf.Len() > 0 {
return fmt.Errorf("%w: %s", waitErr, stderrBuf.String())
}
return waitErr
}
// parseAndCheckBody parses git pkt-line ref updates from a receive-pack body,
// checks each ref against stored branch protection rules, and returns a denial
// reason (or ""), the list of parsed ref updates, and a restored reader.
func parseAndCheckBody(db *xorm.Engine, repoID int64, pusher string, body io.Reader) (reason string, refs []refUpdate, restored io.Reader) {
var buf bytes.Buffer
zeroOID := strings.Repeat("0", 40)
for {
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(body, lenBuf); err != nil {
break
}
buf.Write(lenBuf)
pktLen64, err := strconv.ParseInt(string(lenBuf), 16, 32)
if err != nil {
break
}
if pktLen64 == 0 {
break // flush packet — end of ref-update list
}
dataLen := int(pktLen64) - 4
if dataLen <= 0 {
break
}
data := make([]byte, dataLen)
if _, err := io.ReadFull(body, data); err != nil {
break
}
buf.Write(data)
// Strip NUL-separated capabilities (only on first pkt-line) and trailing newline.
line := strings.TrimRight(strings.SplitN(string(data), "\x00", 2)[0], "\n")
parts := strings.SplitN(line, " ", 3)
if len(parts) != 3 {
continue
}
oldRev, newRev, refname := parts[0], parts[1], parts[2]
refs = append(refs, refUpdate{OldRev: oldRev, NewRev: newRev, Ref: refname})
if oldRev == zeroOID {
continue // new branch — not subject to protection
}
isForcePush := newRev == zeroOID
if msg := CheckBranchProtection(db, repoID, pusher, refname, isForcePush); msg != "" {
return msg, refs, io.MultiReader(&buf, body)
}
}
return "", refs, io.MultiReader(&buf, body)
}