blob: eaeb23ae0dd1549a89357f2a542db288f0492f74 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package dockerimg
2
3import (
Philip Zeyliger51e8e2b2025-05-09 21:41:12 +00004 "context"
Philip Zeyliger5e227dd2025-04-21 15:55:29 -07005 "crypto/subtle"
Philip Zeyliger254c49f2025-07-17 17:26:24 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "fmt"
8 "log/slog"
9 "net/http"
10 "net/http/cgi"
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +000011 "os"
Earl Lee2e463fb2025-04-17 11:22:22 -070012 "os/exec"
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +000013 "path/filepath"
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070014 "runtime"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "strings"
Philip Zeyliger51e8e2b2025-05-09 21:41:12 +000016 "time"
Earl Lee2e463fb2025-04-17 11:22:22 -070017)
18
Philip Zeyliger254c49f2025-07-17 17:26:24 -070019//go:embed pre-receive.sh
20var preReceiveScript string
21
Earl Lee2e463fb2025-04-17 11:22:22 -070022type gitHTTP struct {
23 gitRepoRoot string
Philip Zeyliger254c49f2025-07-17 17:26:24 -070024 hooksDir string
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -070025 pass []byte
Josh Bleecher Snyder99570462025-05-05 10:26:14 -070026 browserC chan bool // browser launch requests
Earl Lee2e463fb2025-04-17 11:22:22 -070027}
28
Philip Zeyliger254c49f2025-07-17 17:26:24 -070029// setupHooksDir creates a temporary directory with git hooks for this session.
30//
31// This automatically forwards pushes from refs/remotes/origin/Y to origin/Y
32// when users push to remote tracking refs through the git HTTP backend.
33//
34// How it works:
35// 1. User pushes to refs/remotes/origin/feature-branch
36// 2. Pre-receive hook detects the pattern and extracts branch name
37// 3. Hook checks if it's a force push (declined if so)
38// 4. Hook runs "git push origin <commit>:feature-branch"
39// 5. If origin push fails, user's push also fails
40//
41// Note:
42// - Error propagation from origin push to user push
43// - Session isolation with temporary hooks directory
44func setupHooksDir(gitRepoRoot string) (string, error) {
45 hooksDir, err := os.MkdirTemp("", "sketch-git-hooks-*")
46 if err != nil {
47 return "", fmt.Errorf("failed to create hooks directory: %w", err)
48 }
49
50 preReceiveHook := filepath.Join(hooksDir, "pre-receive")
51 if err := os.WriteFile(preReceiveHook, []byte(preReceiveScript), 0o755); err != nil {
52 return "", fmt.Errorf("failed to write pre-receive hook: %w", err)
53 }
54
55 return hooksDir, nil
56}
57
Earl Lee2e463fb2025-04-17 11:22:22 -070058func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
59 defer func() {
60 if err := recover(); err != nil {
61 slog.ErrorContext(r.Context(), "gitHTTP.ServeHTTP panic", slog.Any("recovered_err", err))
62
63 // Return an error response to the client
64 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
65 }
66 }()
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070067
68 // Get the Authorization header
69 username, password, ok := r.BasicAuth()
70
71 // Check if credentials were provided
72 if !ok {
73 // No credentials provided, return 401 Unauthorized
74 w.Header().Set("WWW-Authenticate", `Basic realm="Sketch Git Repository"`)
75 http.Error(w, "Unauthorized", http.StatusUnauthorized)
76 slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
Earl Lee2e463fb2025-04-17 11:22:22 -070077 return
78 }
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070079
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070080 // Check if credentials are valid
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -070081 if username != "sketch" || subtle.ConstantTimeCompare([]byte(password), g.pass) != 1 {
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070082 w.Header().Set("WWW-Authenticate", `Basic realm="Git Repository"`)
83 http.Error(w, "Unauthorized", http.StatusUnauthorized)
84 slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
85 return
86 }
87
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000088 // TODO: real mux?
89 if strings.HasPrefix(r.URL.Path, "/browser") {
90 if r.Method != http.MethodPost {
91 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
92 return
93 }
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000094 defer r.Body.Close()
Josh Bleecher Snyder99570462025-05-05 10:26:14 -070095
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000096 select {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -070097 case g.browserC <- true:
98 slog.InfoContext(r.Context(), "open browser requested")
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000099 w.WriteHeader(http.StatusOK)
100 default:
101 http.Error(w, "Too many browser launch requests", http.StatusTooManyRequests)
102 }
103 return
104 }
105
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700106 if runtime.GOOS == "darwin" {
107 // On the Mac, Docker connections show up from localhost. On Linux, the docker
108 // network is more arbitrary, so we don't do this additional check there.
109 if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
110 slog.InfoContext(r.Context(), "githttp: denied", "remote addr", r.RemoteAddr)
111 http.Error(w, "no", http.StatusUnauthorized)
112 return
113 }
114 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700115 gitBin, err := exec.LookPath("git")
116 if err != nil {
117 http.Error(w, "no git: "+err.Error(), http.StatusInternalServerError)
118 return
119 }
120
Philip Zeyligerc898abf2025-06-04 16:33:57 +0000121 // Check if this is a .git/info/refs request for git-upload-pack service (which happens during git fetch)
Philip Zeyliger222bf412025-06-04 16:42:58 +0000122 // Use `GIT_CURL_VERBOSE=1 git fetch origin` to inspect what's going on under the covers for git.
Philip Zeyligerc898abf2025-06-04 16:33:57 +0000123 if r.Method == http.MethodGet && strings.Contains(r.URL.Path, ".git/info/refs") && r.URL.Query().Get("service") == "git-upload-pack" {
Philip Zeyliger51e8e2b2025-05-09 21:41:12 +0000124 slog.InfoContext(r.Context(), "detected git info/refs request, running git fetch origin")
125
126 // Create a context with a 5 second timeout
127 ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
128 defer cancel()
129
130 // Run git fetch origin in the background
131 cmd := exec.CommandContext(ctx, gitBin, "fetch", "origin")
132 cmd.Dir = g.gitRepoRoot
133
134 // Execute the command
135 output, err := cmd.CombinedOutput()
136 if err != nil {
137 slog.WarnContext(r.Context(), "git fetch failed",
138 "error", err,
139 "output", string(output))
140 // We don't return here, continue with normal processing
141 } else {
142 slog.InfoContext(r.Context(), "git fetch completed successfully")
143 }
144 }
145
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000146 // Dumb hack for bare repos: if the path starts with .git, and there is no .git, strip it off.
147 path := r.URL.Path
148 if _, err := os.Stat(filepath.Join(g.gitRepoRoot, path)); os.IsNotExist(err) {
149 path = strings.TrimPrefix(path, "/.git") // turn /.git/info/refs into /info/refs
150 }
151
Earl Lee2e463fb2025-04-17 11:22:22 -0700152 w.Header().Set("Cache-Control", "no-cache")
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700153
154 args := []string{"http-backend"}
155 if g.hooksDir != "" {
156 // Use -c flag to set core.hooksPath for this git command only
Josh Bleecher Snyderebe74cd2025-07-17 18:57:34 -0700157 args = []string{"-c", "core.hooksPath=" + g.hooksDir, "-c", "receive.denyCurrentBranch=refuse", "http-backend"}
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700158 }
159
Earl Lee2e463fb2025-04-17 11:22:22 -0700160 h := &cgi.Handler{
161 Path: gitBin,
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700162 Args: args,
Earl Lee2e463fb2025-04-17 11:22:22 -0700163 Dir: g.gitRepoRoot,
164 Env: []string{
165 "GIT_PROJECT_ROOT=" + g.gitRepoRoot,
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000166 "PATH_INFO=" + path,
Earl Lee2e463fb2025-04-17 11:22:22 -0700167 "QUERY_STRING=" + r.URL.RawQuery,
168 "REQUEST_METHOD=" + r.Method,
169 "GIT_HTTP_EXPORT_ALL=true",
170 "GIT_HTTP_ALLOW_REPACK=true",
171 "GIT_HTTP_ALLOW_PUSH=true",
172 "GIT_HTTP_VERBOSE=1",
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700173 // We need to pass through the SSH auth sock to the CGI script
174 // so that we can use the user's existing SSH key infra to authenticate.
175 "SSH_AUTH_SOCK=" + os.Getenv("SSH_AUTH_SOCK"),
Earl Lee2e463fb2025-04-17 11:22:22 -0700176 },
177 }
178 h.ServeHTTP(w, r)
179}