blob: fae16787b7500ae157beacd7237bbd7fd899672d [file] [log] [blame]
package dockerimg
import (
"bytes"
"context"
"crypto/subtle"
_ "embed"
"fmt"
"log/slog"
"net/http"
"net/http/cgi"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"text/template"
"time"
)
//go:embed pre-receive.sh
var preReceiveScript string
//go:embed post-receive.sh
var postReceiveScript string
type gitHTTP struct {
gitRepoRoot string
hooksDir string
pass []byte
browserC chan bool // browser launch requests
}
// setupHooksDir creates a temporary directory with git hooks for this session.
//
// This automatically forwards pushes from refs/remotes/origin/Y to origin/Y
// when users push to remote tracking refs through the git HTTP backend.
//
// How it works:
// 1. User pushes to refs/remotes/origin/feature-branch
// 2. Pre-receive hook detects the pattern and extracts branch name
// 3. Hook checks if it's a force push (declined if so)
// 4. Hook runs "git push origin <commit>:feature-branch"
// 5. If origin push fails, user's push also fails
//
// Note:
// - Error propagation from origin push to user push
// - Session isolation with temporary hooks directory
func setupHooksDir(upstream string) (string, error) {
hooksDir, err := os.MkdirTemp("", "sketch-git-hooks-*")
if err != nil {
return "", fmt.Errorf("failed to create hooks directory: %w", err)
}
preReceiveHook := filepath.Join(hooksDir, "pre-receive")
if err := os.WriteFile(preReceiveHook, []byte(preReceiveScript), 0o755); err != nil {
return "", fmt.Errorf("failed to write pre-receive hook: %w", err)
}
if upstream != "" {
tmpl, err := template.New("post-receive").Parse(postReceiveScript)
if err != nil {
return "", fmt.Errorf("failed to parse post-receive template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, map[string]string{"Upstream": upstream}); err != nil {
return "", fmt.Errorf("failed to execute post-receive template: %w", err)
}
postReceiveHook := filepath.Join(hooksDir, "post-receive")
if err := os.WriteFile(postReceiveHook, buf.Bytes(), 0o755); err != nil {
return "", fmt.Errorf("failed to write post-receive hook: %w", err)
}
}
return hooksDir, nil
}
func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.ErrorContext(r.Context(), "gitHTTP.ServeHTTP panic", slog.Any("recovered_err", err))
// Return an error response to the client
http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
}
}()
// Get the Authorization header
username, password, ok := r.BasicAuth()
// Check if credentials were provided
if !ok {
// No credentials provided, return 401 Unauthorized
w.Header().Set("WWW-Authenticate", `Basic realm="Sketch Git Repository"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
return
}
// Check if credentials are valid
if username != "sketch" || subtle.ConstantTimeCompare([]byte(password), g.pass) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="Git Repository"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
return
}
// TODO: real mux?
if strings.HasPrefix(r.URL.Path, "/browser") {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
select {
case g.browserC <- true:
slog.InfoContext(r.Context(), "open browser requested")
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "Too many browser launch requests", http.StatusTooManyRequests)
}
return
}
if runtime.GOOS == "darwin" {
// On the Mac, Docker connections show up from localhost. On Linux, the docker
// network is more arbitrary, so we don't do this additional check there.
if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
slog.InfoContext(r.Context(), "githttp: denied", "remote addr", r.RemoteAddr)
http.Error(w, "no", http.StatusUnauthorized)
return
}
}
gitBin, err := exec.LookPath("git")
if err != nil {
http.Error(w, "no git: "+err.Error(), http.StatusInternalServerError)
return
}
// Check if this is a .git/info/refs request for git-upload-pack service (which happens during git fetch)
// Use `GIT_CURL_VERBOSE=1 git fetch origin` to inspect what's going on under the covers for git.
if r.Method == http.MethodGet && strings.Contains(r.URL.Path, ".git/info/refs") && r.URL.Query().Get("service") == "git-upload-pack" {
slog.InfoContext(r.Context(), "detected git info/refs request, running git fetch origin")
// Create a context with a 5 second timeout
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Run git fetch origin in the background
cmd := exec.CommandContext(ctx, gitBin, "fetch", "origin")
cmd.Dir = g.gitRepoRoot
// Execute the command
output, err := cmd.CombinedOutput()
if err != nil {
slog.WarnContext(r.Context(), "git fetch failed",
"error", err,
"output", string(output))
// We don't return here, continue with normal processing
} else {
slog.InfoContext(r.Context(), "git fetch completed successfully")
}
}
// Dumb hack for bare repos: if the path starts with .git, and there is no .git, strip it off.
path := r.URL.Path
if _, err := os.Stat(filepath.Join(g.gitRepoRoot, path)); os.IsNotExist(err) {
path = strings.TrimPrefix(path, "/.git") // turn /.git/info/refs into /info/refs
}
w.Header().Set("Cache-Control", "no-cache")
var args []string
if g.hooksDir != "" {
args = append(args,
"-c", "core.hooksPath="+g.hooksDir,
"-c", "receive.denyCurrentBranch=refuse",
)
}
args = append(args, "http-backend")
h := &cgi.Handler{
Path: gitBin,
Args: args,
Dir: g.gitRepoRoot,
Env: []string{
"GIT_PROJECT_ROOT=" + g.gitRepoRoot,
"PATH_INFO=" + path,
"QUERY_STRING=" + r.URL.RawQuery,
"REQUEST_METHOD=" + r.Method,
"GIT_HTTP_EXPORT_ALL=true",
"GIT_HTTP_ALLOW_REPACK=true",
"GIT_HTTP_ALLOW_PUSH=true",
"GIT_HTTP_VERBOSE=1",
// We need to pass through the SSH auth sock to the CGI script
// so that we can use the user's existing SSH key infra to authenticate.
"SSH_AUTH_SOCK=" + os.Getenv("SSH_AUTH_SOCK"),
},
}
h.ServeHTTP(w, r)
}