blob: fae16787b7500ae157beacd7237bbd7fd899672d [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package dockerimg
2
3import (
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +00004 "bytes"
Philip Zeyliger51e8e2b2025-05-09 21:41:12 +00005 "context"
Philip Zeyliger5e227dd2025-04-21 15:55:29 -07006 "crypto/subtle"
Philip Zeyliger254c49f2025-07-17 17:26:24 -07007 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07008 "fmt"
9 "log/slog"
10 "net/http"
11 "net/http/cgi"
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +000012 "os"
Earl Lee2e463fb2025-04-17 11:22:22 -070013 "os/exec"
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +000014 "path/filepath"
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070015 "runtime"
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "strings"
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +000017 "text/template"
Philip Zeyliger51e8e2b2025-05-09 21:41:12 +000018 "time"
Earl Lee2e463fb2025-04-17 11:22:22 -070019)
20
Philip Zeyliger254c49f2025-07-17 17:26:24 -070021//go:embed pre-receive.sh
22var preReceiveScript string
23
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +000024//go:embed post-receive.sh
25var postReceiveScript string
26
Earl Lee2e463fb2025-04-17 11:22:22 -070027type gitHTTP struct {
28 gitRepoRoot string
Philip Zeyliger254c49f2025-07-17 17:26:24 -070029 hooksDir string
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -070030 pass []byte
Josh Bleecher Snyder99570462025-05-05 10:26:14 -070031 browserC chan bool // browser launch requests
Earl Lee2e463fb2025-04-17 11:22:22 -070032}
33
Philip Zeyliger254c49f2025-07-17 17:26:24 -070034// setupHooksDir creates a temporary directory with git hooks for this session.
35//
36// This automatically forwards pushes from refs/remotes/origin/Y to origin/Y
37// when users push to remote tracking refs through the git HTTP backend.
38//
39// How it works:
40// 1. User pushes to refs/remotes/origin/feature-branch
41// 2. Pre-receive hook detects the pattern and extracts branch name
42// 3. Hook checks if it's a force push (declined if so)
43// 4. Hook runs "git push origin <commit>:feature-branch"
44// 5. If origin push fails, user's push also fails
45//
46// Note:
47// - Error propagation from origin push to user push
48// - Session isolation with temporary hooks directory
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +000049func setupHooksDir(upstream string) (string, error) {
Philip Zeyliger254c49f2025-07-17 17:26:24 -070050 hooksDir, err := os.MkdirTemp("", "sketch-git-hooks-*")
51 if err != nil {
52 return "", fmt.Errorf("failed to create hooks directory: %w", err)
53 }
54
55 preReceiveHook := filepath.Join(hooksDir, "pre-receive")
56 if err := os.WriteFile(preReceiveHook, []byte(preReceiveScript), 0o755); err != nil {
57 return "", fmt.Errorf("failed to write pre-receive hook: %w", err)
58 }
59
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +000060 if upstream != "" {
61 tmpl, err := template.New("post-receive").Parse(postReceiveScript)
62 if err != nil {
63 return "", fmt.Errorf("failed to parse post-receive template: %w", err)
64 }
65 var buf bytes.Buffer
66 if err := tmpl.Execute(&buf, map[string]string{"Upstream": upstream}); err != nil {
67 return "", fmt.Errorf("failed to execute post-receive template: %w", err)
68 }
69 postReceiveHook := filepath.Join(hooksDir, "post-receive")
70 if err := os.WriteFile(postReceiveHook, buf.Bytes(), 0o755); err != nil {
71 return "", fmt.Errorf("failed to write post-receive hook: %w", err)
72 }
73 }
74
Philip Zeyliger254c49f2025-07-17 17:26:24 -070075 return hooksDir, nil
76}
77
Earl Lee2e463fb2025-04-17 11:22:22 -070078func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
79 defer func() {
80 if err := recover(); err != nil {
81 slog.ErrorContext(r.Context(), "gitHTTP.ServeHTTP panic", slog.Any("recovered_err", err))
82
83 // Return an error response to the client
84 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
85 }
86 }()
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070087
88 // Get the Authorization header
89 username, password, ok := r.BasicAuth()
90
91 // Check if credentials were provided
92 if !ok {
93 // No credentials provided, return 401 Unauthorized
94 w.Header().Set("WWW-Authenticate", `Basic realm="Sketch Git Repository"`)
95 http.Error(w, "Unauthorized", http.StatusUnauthorized)
96 slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
Earl Lee2e463fb2025-04-17 11:22:22 -070097 return
98 }
Philip Zeyliger5e227dd2025-04-21 15:55:29 -070099
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700100 // Check if credentials are valid
Josh Bleecher Snyder9f6a9982025-04-22 17:34:15 -0700101 if username != "sketch" || subtle.ConstantTimeCompare([]byte(password), g.pass) != 1 {
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700102 w.Header().Set("WWW-Authenticate", `Basic realm="Git Repository"`)
103 http.Error(w, "Unauthorized", http.StatusUnauthorized)
104 slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
105 return
106 }
107
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000108 // TODO: real mux?
109 if strings.HasPrefix(r.URL.Path, "/browser") {
110 if r.Method != http.MethodPost {
111 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
112 return
113 }
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000114 defer r.Body.Close()
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700115
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000116 select {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700117 case g.browserC <- true:
118 slog.InfoContext(r.Context(), "open browser requested")
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000119 w.WriteHeader(http.StatusOK)
120 default:
121 http.Error(w, "Too many browser launch requests", http.StatusTooManyRequests)
122 }
123 return
124 }
125
Philip Zeyliger5e227dd2025-04-21 15:55:29 -0700126 if runtime.GOOS == "darwin" {
127 // On the Mac, Docker connections show up from localhost. On Linux, the docker
128 // network is more arbitrary, so we don't do this additional check there.
129 if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
130 slog.InfoContext(r.Context(), "githttp: denied", "remote addr", r.RemoteAddr)
131 http.Error(w, "no", http.StatusUnauthorized)
132 return
133 }
134 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700135 gitBin, err := exec.LookPath("git")
136 if err != nil {
137 http.Error(w, "no git: "+err.Error(), http.StatusInternalServerError)
138 return
139 }
140
Philip Zeyligerc898abf2025-06-04 16:33:57 +0000141 // 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 +0000142 // 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 +0000143 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 +0000144 slog.InfoContext(r.Context(), "detected git info/refs request, running git fetch origin")
145
146 // Create a context with a 5 second timeout
147 ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
148 defer cancel()
149
150 // Run git fetch origin in the background
151 cmd := exec.CommandContext(ctx, gitBin, "fetch", "origin")
152 cmd.Dir = g.gitRepoRoot
153
154 // Execute the command
155 output, err := cmd.CombinedOutput()
156 if err != nil {
157 slog.WarnContext(r.Context(), "git fetch failed",
158 "error", err,
159 "output", string(output))
160 // We don't return here, continue with normal processing
161 } else {
162 slog.InfoContext(r.Context(), "git fetch completed successfully")
163 }
164 }
165
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000166 // Dumb hack for bare repos: if the path starts with .git, and there is no .git, strip it off.
167 path := r.URL.Path
168 if _, err := os.Stat(filepath.Join(g.gitRepoRoot, path)); os.IsNotExist(err) {
169 path = strings.TrimPrefix(path, "/.git") // turn /.git/info/refs into /info/refs
170 }
171
Earl Lee2e463fb2025-04-17 11:22:22 -0700172 w.Header().Set("Cache-Control", "no-cache")
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700173
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +0000174 var args []string
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700175 if g.hooksDir != "" {
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +0000176 args = append(args,
177 "-c", "core.hooksPath="+g.hooksDir,
178 "-c", "receive.denyCurrentBranch=refuse",
179 )
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700180 }
Josh Bleecher Snyder725cfe02025-07-18 01:31:06 +0000181 args = append(args, "http-backend")
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700182
Earl Lee2e463fb2025-04-17 11:22:22 -0700183 h := &cgi.Handler{
184 Path: gitBin,
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700185 Args: args,
Earl Lee2e463fb2025-04-17 11:22:22 -0700186 Dir: g.gitRepoRoot,
187 Env: []string{
188 "GIT_PROJECT_ROOT=" + g.gitRepoRoot,
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000189 "PATH_INFO=" + path,
Earl Lee2e463fb2025-04-17 11:22:22 -0700190 "QUERY_STRING=" + r.URL.RawQuery,
191 "REQUEST_METHOD=" + r.Method,
192 "GIT_HTTP_EXPORT_ALL=true",
193 "GIT_HTTP_ALLOW_REPACK=true",
194 "GIT_HTTP_ALLOW_PUSH=true",
195 "GIT_HTTP_VERBOSE=1",
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700196 // We need to pass through the SSH auth sock to the CGI script
197 // so that we can use the user's existing SSH key infra to authenticate.
198 "SSH_AUTH_SOCK=" + os.Getenv("SSH_AUTH_SOCK"),
Earl Lee2e463fb2025-04-17 11:22:22 -0700199 },
200 }
201 h.ServeHTTP(w, r)
202}