205 lines
5.4 KiB
Go
205 lines
5.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bufio"
|
|
"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.
|
|
var authedUser string
|
|
user, authed := h.basicAuth(r)
|
|
if authed {
|
|
authedUser = user
|
|
// Push requires write or admin permission.
|
|
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
|
|
}
|
|
// Pull on a private repo requires at least read permission.
|
|
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 service == "git-receive-pack" || repo.IsPrivate {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|