f211cfc7db
CRUD rules with pattern (exact or glob like release/*), requirePR, blockForcePush, bypass user list Enforcement via pkt-line parsing inside the git HTTP handler — before any data reaches git http-backend, each ref update is extracted and checked against stored rules Direct push to main with requirePR: true → 403 with message; push to unprotected branches still works Inline checkboxes in the UI update rules immediately Branching model — stored config: GET/PUT per repo, defaults to feature/bugfix/release/hotfix prefixes Toggle enabled/disabled, custom prefix per type with live preview No enforcement (naming guide only, as Bitbucket does) Merge strategies — enforced in PR merge endpoint: GET/PUT per repo, defaults all three allowed Merge handler now accepts strategy: "merge"|"squash"|"rebase" in request body, checks against stored policy Disallowed strategy → 409 with clear error; allowed strategy → merges and fires pull_request webhook Must have at least one strategy enabled (validated server-side) Webhooks — full delivery with HMAC: CRUD with title, URL, secret (optional), events (push/pull_request/issue), active toggle Test button sends live HTTP POST to the configured URL and shows status code in UI FireWebhooks() fires asynchronously from PR merge and can be called from any handler X-ForgeBucket-Signature-256: sha256=<hmac> header when secret is set Last delivery status and timestamp stored on webhook record and shown in list
296 lines
8.5 KiB
Go
296 lines
8.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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/models"
|
|
)
|
|
|
|
type GitHTTPHandler struct {
|
|
db *xorm.Engine
|
|
cfg *config.Config
|
|
}
|
|
|
|
func NewGitHTTPHandler(db *xorm.Engine, cfg *config.Config) *GitHTTPHandler {
|
|
return &GitHTTPHandler{db: db, cfg: cfg}
|
|
}
|
|
|
|
// 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.
|
|
if service == "git-receive-pack" {
|
|
if reason, newBody := checkProtectionsFromBody(h.db, repo.ID, authedUser, r.Body); reason != "" {
|
|
http.Error(w, reason, http.StatusForbidden)
|
|
return
|
|
} else {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// checkProtectionsFromBody 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 "") plus a restored reader so the body can still be passed to http-backend.
|
|
func checkProtectionsFromBody(db *xorm.Engine, repoID int64, pusher string, body io.Reader) (reason string, restored io.Reader) {
|
|
var buf bytes.Buffer
|
|
zeroOID := strings.Repeat("0", 40)
|
|
|
|
for {
|
|
// Every pkt-line starts with a 4-hex-digit length that includes itself.
|
|
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 {
|
|
// Flush packet — end of ref-update list.
|
|
break
|
|
}
|
|
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]
|
|
|
|
// New branches (oldRev all zeros) are not subject to protection.
|
|
if oldRev == zeroOID {
|
|
continue
|
|
}
|
|
// Detect force push: if newRev is all zeros it's a branch deletion.
|
|
isForcePush := newRev == zeroOID
|
|
|
|
if msg := CheckBranchProtection(db, repoID, pusher, refname, isForcePush); msg != "" {
|
|
return msg, io.MultiReader(&buf, body)
|
|
}
|
|
}
|
|
return "", io.MultiReader(&buf, body)
|
|
}
|