| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "bytes" |
| 6 | "compress/gzip" |
| 7 | "context" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "html" |
| 11 | "io" |
| 12 | "log/slog" |
| 13 | "net/http" |
| 14 | "net/url" |
| 15 | "os" |
| 16 | "os/exec" |
| 17 | "path/filepath" |
| 18 | "strconv" |
| 19 | "strings" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | // ServeHTTP exposes Git smart HTTP at /npub/<identifier>.git and /prs/... . |
| 24 | // The subprocess boundary keeps pack parsing and object integrity in Git. |
| 25 | func (g *GitRelay) ServeHTTP(w http.ResponseWriter, req *http.Request) { |
| 26 | w.Header().Set("Access-Control-Allow-Origin" , "*" ) |
| 27 | w.Header().Set("Access-Control-Allow-Methods" , "GET, HEAD, POST, OPTIONS" ) |
| 28 | w.Header().Set("Access-Control-Allow-Headers" , "Content-Type, Git-Protocol, Authorization" ) |
| 29 | if req.Method == http.MethodOptions { |
| 30 | w.WriteHeader(http.StatusNoContent) |
| 31 | return |
| 32 | } |
| 33 | owner, id, suffix, alternative, ok := parsePathMode(req.URL.Path) |
| 34 | if !ok { |
| 35 | http.NotFound(w, req) |
| 36 | return |
| 37 | } |
| 38 | r, err := g.lookup(owner, id) |
| 39 | if alternative { |
| 40 | g.mu.RLock() |
| 41 | candidate, found := g.repos[key(owner, id)] |
| 42 | g.mu.RUnlock() |
| 43 | if found && candidate.Alternative { |
| 44 | r, err = candidate, nil |
| 45 | } |
| 46 | } |
| 47 | if err != nil { |
| 48 | http.NotFound(w, req) |
| 49 | return |
| 50 | } |
| 51 | if r.Private { |
| 52 | // Private authorization may replace the body with a verified disk |
| 53 | // spool. Close that replacement as well as the server-owned body. |
| 54 | defer func() { _ = req.Body.Close() }() |
| 55 | if g.authorizeHTTP == nil { |
| 56 | http.Error(w, "private repository requires authorization" , http.StatusForbidden) |
| 57 | return |
| 58 | } |
| 59 | if err := g.authorizeHTTP(req.Context(), req, r); err != nil { |
| 60 | http.Error(w, err.Error(), http.StatusForbidden) |
| 61 | return |
| 62 | } |
| 63 | } |
| 64 | if err := g.ensureRepo(r); err != nil { |
| 65 | http.Error(w, err.Error(), 500) |
| 66 | return |
| 67 | } |
| 68 | if suffix == "" && (req.Method == http.MethodGet || req.Method == http.MethodHead) { |
| 69 | w.Header().Set("Content-Type" , "text/html; charset=utf-8" ) |
| 70 | _, _ = io.WriteString(w, "<html><body><code>" +html.EscapeString(r.Owner+"/" +r.Identifier)+"</code></body></html>\n" ) |
| 71 | return |
| 72 | } |
| 73 | if err := g.cgi(req, w, r, suffix); err != nil { |
| 74 | var streamed *cgiStreamError |
| 75 | if errors.As(err, &streamed) { |
| 76 | slog.ErrorContext(req.Context(), "Git response stream failed" , "error" , err) |
| 77 | // Close an incomplete pack, rather than mark it complete or append |
| 78 | // an HTTP error page to Git's binary protocol. |
| 79 | panic(http.ErrAbortHandler) |
| 80 | } |
| 81 | http.Error(w, err.Error(), 500) |
| 82 | return |
| 83 | } |
| 84 | if req.Method == http.MethodPost { |
| 85 | // The CGI response has already been committed. Promotion callbacks are |
| 86 | // retried by the next scheduler pass; never corrupt a successful Git |
| 87 | // receive-pack response with a late HTTP error. |
| 88 | _ = g.PromotePending(req.Context(), r) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func parsePath(path string) (owner, id, suffix string, ok bool) { |
| 93 | owner, id, suffix, _, ok = parsePathMode(path) |
| 94 | return |
| 95 | } |
| 96 | |
| 97 | func parsePathMode(path string) (owner, id, suffix string, alternative, ok bool) { |
| 98 | var err error |
| 99 | parts := strings.Split(strings.Trim(path, "/" ), "/" ) |
| 100 | if len(parts) < 2 { |
| 101 | return |
| 102 | } |
| 103 | if parts[0] == "prs" { |
| 104 | if len(parts) < 3 { |
| 105 | return |
| 106 | } |
| 107 | parts = parts[1:] |
| 108 | alternative = true |
| 109 | } |
| 110 | if len(parts) > 0 && parts[0] == "npub" { |
| 111 | parts = parts[1:] |
| 112 | } |
| 113 | if len(parts) < 2 { |
| 114 | return |
| 115 | } |
| 116 | if !strings.HasPrefix(parts[0], "npub1" ) || !strings.HasSuffix(parts[1], ".git" ) { |
| 117 | return |
| 118 | } |
| 119 | owner, err = decodeNPub(parts[0]) |
| 120 | if err != nil { |
| 121 | return |
| 122 | } |
| 123 | id, err = url.PathUnescape(strings.TrimSuffix(parts[1], ".git" )) |
| 124 | if err != nil || !validIdentifier(id) { |
| 125 | return |
| 126 | } |
| 127 | if len(parts) > 2 { |
| 128 | suffix = "/" + strings.Join(parts[2:], "/" ) |
| 129 | } |
| 130 | ok = true |
| 131 | return |
| 132 | } |
| 133 | |
| 134 | const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" |
| 135 | |
| 136 | func decodeNPub(s string) (string, error) { |
| 137 | s = strings.ToLower(s) |
| 138 | if !strings.HasPrefix(s, "npub1" ) { |
| 139 | return "" , errors.New("not an npub" ) |
| 140 | } |
| 141 | // Bech32 uses the first separator; the payload may itself contain the |
| 142 | // character "1". |
| 143 | pos := strings.IndexByte(s, '1' ) |
| 144 | if pos < len("npub" ) || len(s)-pos < 7 { |
| 145 | return "" , errors.New("invalid npub" ) |
| 146 | } |
| 147 | data := make([]byte, 0, len(s)-pos-1) |
| 148 | for _, c := range s[pos+1:] { |
| 149 | i := strings.IndexRune(bech32Charset, c) |
| 150 | if i < 0 { |
| 151 | return "" , errors.New("invalid npub character" ) |
| 152 | } |
| 153 | data = append(data, byte(i)) |
| 154 | } |
| 155 | if bech32Polymod(append(hrpExpand(s[:pos]), data...)) != 1 { |
| 156 | return "" , errors.New("invalid npub checksum" ) |
| 157 | } |
| 158 | payload, err := convertBits(data[:len(data)-6], 5, 8, false) |
| 159 | if err != nil || len(payload) != 32 { |
| 160 | return "" , errors.New("invalid npub payload" ) |
| 161 | } |
| 162 | return fmt.Sprintf("%x" , payload), nil |
| 163 | } |
| 164 | |
| 165 | func hrpExpand(s string) []byte { |
| 166 | out := make([]byte, 0, len(s)*2+1) |
| 167 | for _, c := range s { |
| 168 | out = append(out, byte(c>>5)) |
| 169 | } |
| 170 | out = append(out, 0) |
| 171 | for _, c := range s { |
| 172 | out = append(out, byte(c&31)) |
| 173 | } |
| 174 | return out |
| 175 | } |
| 176 | func bech32Polymod(v []byte) uint64 { |
| 177 | const g0 = 0x3b6a57b2 |
| 178 | const g1 = 0x26508e6d |
| 179 | const g2 = 0x1ea119fa |
| 180 | const g3 = 0x3d4233dd |
| 181 | const g4 = 0x2a1462b3 |
| 182 | chk := uint64(1) |
| 183 | for _, x := range v { |
| 184 | top := chk >> 25 |
| 185 | chk = (chk&0x1ffffff)<<5 | uint64(x) |
| 186 | if top&1 != 0 { |
| 187 | chk ^= g0 |
| 188 | } |
| 189 | if top&2 != 0 { |
| 190 | chk ^= g1 |
| 191 | } |
| 192 | if top&4 != 0 { |
| 193 | chk ^= g2 |
| 194 | } |
| 195 | if top&8 != 0 { |
| 196 | chk ^= g3 |
| 197 | } |
| 198 | if top&16 != 0 { |
| 199 | chk ^= g4 |
| 200 | } |
| 201 | } |
| 202 | return chk |
| 203 | } |
| 204 | func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) { |
| 205 | acc := uint(0) |
| 206 | bits := uint(0) |
| 207 | maxv := uint((1 << to) - 1) |
| 208 | maxacc := uint((1 << (from + to - 1)) - 1) |
| 209 | out := make([]byte, 0) |
| 210 | for _, v := range data { |
| 211 | if uint(v)>>from != 0 { |
| 212 | return nil, errors.New("invalid bech32 data" ) |
| 213 | } |
| 214 | acc = (acc<<from | uint(v)) & maxacc |
| 215 | bits += from |
| 216 | for bits >= to { |
| 217 | bits -= to |
| 218 | out = append(out, byte((acc>>bits)&maxv)) |
| 219 | } |
| 220 | } |
| 221 | if pad { |
| 222 | if bits > 0 { |
| 223 | out = append(out, byte((acc<<(to-bits))&maxv)) |
| 224 | } |
| 225 | } else if bits >= from || byte((acc<<(to-bits))&maxv) != 0 { |
| 226 | return nil, errors.New("invalid bech32 padding" ) |
| 227 | } |
| 228 | return out, nil |
| 229 | } |
| 230 | |
| 231 | func (g *GitRelay) cgi(req *http.Request, w http.ResponseWriter, r Repository, suffix string) error { |
| 232 | prefix := "" |
| 233 | if r.Alternative { |
| 234 | prefix = "/prs" |
| 235 | } |
| 236 | pathInfo := prefix + "/" + r.Owner + "/" + r.Identifier + ".git" + suffix |
| 237 | ctx, cancel := context.WithCancel(req.Context()) |
| 238 | defer cancel() |
| 239 | // Browser clients such as gitworkshop.dev fetch one commit at a time with |
| 240 | // a filter, and refuse servers that do not advertise filter and |
| 241 | // sha1-in-want. The -c settings reach upload-pack through the environment. |
| 242 | cmd := exec.CommandContext(ctx, "git" , "-c" , "uploadpack.allowFilter=true" , "-c" , "uploadpack.allowAnySHA1InWant=true" , "http-backend" ) |
| 243 | cmd.Dir = g.root |
| 244 | cmd.WaitDelay = 5 * time.Second |
| 245 | stdin, contentLength, cleanup, err := cgiInput(req) |
| 246 | if err != nil { |
| 247 | return err |
| 248 | } |
| 249 | defer cleanup() |
| 250 | cmd.Stdin = stdin |
| 251 | var stderr cgiDiagnostic |
| 252 | cmd.Stderr = &stderr |
| 253 | stdout, err := cmd.StdoutPipe() |
| 254 | if err != nil { |
| 255 | return err |
| 256 | } |
| 257 | defer stdout.Close() |
| 258 | query := req.URL.RawQuery |
| 259 | env := os.Environ() |
| 260 | env = append(env, "GIT_PROJECT_ROOT=" +g.root, "GIT_HTTP_EXPORT_ALL=1" , "PATH_INFO=" +pathInfo, "PATH_TRANSLATED=" +filepath.Join(g.root, strings.TrimPrefix(pathInfo, "/" )), "REQUEST_METHOD=" +req.Method, "QUERY_STRING=" +query, "REMOTE_ADDR=" +req.RemoteAddr) |
| 261 | if value := req.Header.Get("Content-Type" ); value != "" { |
| 262 | env = append(env, "CONTENT_TYPE=" +value) |
| 263 | } |
| 264 | if contentLength != "" { |
| 265 | env = append(env, "CONTENT_LENGTH=" +contentLength) |
| 266 | } |
| 267 | if value := req.Header.Get("Git-Protocol" ); value != "" { |
| 268 | env = append(env, "HTTP_GIT_PROTOCOL=" +value) |
| 269 | } |
| 270 | cmd.Env = env |
| 271 | if err := cmd.Start(); err != nil { |
| 272 | return fmt.Errorf("start git http-backend: %w" , err) |
| 273 | } |
| 274 | controller := http.NewResponseController(w) |
| 275 | _ = controller.EnableFullDuplex() |
| 276 | reader := bufio.NewReader(stdout) |
| 277 | started, streamErr := streamCGI(w, controller, reader) |
| 278 | if streamErr != nil { |
| 279 | cancel() |
| 280 | _ = stdout.Close() |
| 281 | _ = req.Body.Close() |
| 282 | } |
| 283 | waitErr := cmd.Wait() |
| 284 | if waitErr != nil { |
| 285 | streamErr = errors.Join(streamErr, fmt.Errorf("git http-backend: %w: %s" , waitErr, strings.TrimSpace(stderr.String()))) |
| 286 | } |
| 287 | if streamErr != nil && started { |
| 288 | return &cgiStreamError{streamErr} |
| 289 | } |
| 290 | return streamErr |
| 291 | } |
| 292 | |
| 293 | // Compressed smart-HTTP negotiation needs a decoded CONTENT_LENGTH for CGI. |
| 294 | // Spool this uncommon request form to disk so memory stays independent of size. |
| 295 | func cgiInput(req *http.Request) (io.Reader, string, func(), error) { |
| 296 | length := req.Header.Get("Content-Length" ) |
| 297 | if !strings.EqualFold(req.Header.Get("Content-Encoding" ), "gzip" ) { |
| 298 | return req.Body, length, func() {}, nil |
| 299 | } |
| 300 | zr, err := gzip.NewReader(req.Body) |
| 301 | if err != nil { |
| 302 | return nil, "" , nil, fmt.Errorf("decode compressed Git request: %w" , err) |
| 303 | } |
| 304 | defer zr.Close() |
| 305 | file, err := os.CreateTemp("" , "tiny-git-request-*" ) |
| 306 | if err != nil { |
| 307 | return nil, "" , nil, err |
| 308 | } |
| 309 | cleanup := func() { _ = file.Close(); _ = os.Remove(file.Name()) } |
| 310 | n, err := io.Copy(file, zr) |
| 311 | if err == nil { |
| 312 | _, err = file.Seek(0, io.SeekStart) |
| 313 | } |
| 314 | if err != nil { |
| 315 | cleanup() |
| 316 | return nil, "" , nil, fmt.Errorf("spool compressed Git request: %w" , err) |
| 317 | } |
| 318 | return file, strconv.FormatInt(n, 10), cleanup, nil |
| 319 | } |
| 320 | |
| 321 | type cgiStreamError struct{ error } |
| 322 | |
| 323 | // Only diagnostic text is bounded; pack streams have no product size limit. |
| 324 | type cgiDiagnostic struct{ bytes.Buffer } |
| 325 | |
| 326 | func (b *cgiDiagnostic) Write(p []byte) (int, error) { |
| 327 | n := len(p) |
| 328 | if remaining := 64*1024 - b.Len(); remaining > 0 { |
| 329 | _, _ = b.Buffer.Write(p[:min(remaining, n)]) |
| 330 | } |
| 331 | return n, nil |
| 332 | } |
| 333 | |
| 334 | func streamCGI(w http.ResponseWriter, controller *http.ResponseController, reader *bufio.Reader) (bool, error) { |
| 335 | status := http.StatusOK |
| 336 | responseHeaders := make(http.Header) |
| 337 | for { |
| 338 | line, err := reader.ReadString('\n' ) |
| 339 | if err != nil { |
| 340 | return false, fmt.Errorf("read Git response headers: %w" , err) |
| 341 | } |
| 342 | line = strings.TrimRight(line, "\r\n" ) |
| 343 | if line == "" { |
| 344 | break |
| 345 | } |
| 346 | k, v, ok := strings.Cut(line, ":" ) |
| 347 | if !ok { |
| 348 | return false, errors.New("malformed Git CGI response header" ) |
| 349 | } |
| 350 | if strings.EqualFold(k, "Status" ) { |
| 351 | if _, err := fmt.Sscanf(v, "%d" , &status); err != nil || status < 200 || status > 599 { |
| 352 | return false, errors.New("invalid Git CGI response status" ) |
| 353 | } |
| 354 | } else { |
| 355 | responseHeaders.Add(k, strings.TrimSpace(v)) |
| 356 | } |
| 357 | } |
| 358 | // Hold the response until git produces its first body byte. HTTP/1.1 |
| 359 | // proxies without full duplex discard the unread request body once a |
| 360 | // response starts, which would truncate a pack that is still uploading. |
| 361 | // git-http-backend writes its headers before reading the request, but |
| 362 | // writes body bytes only after it has consumed the pack. |
| 363 | if _, err := reader.Peek(1); err != nil && !errors.Is(err, io.EOF) { |
| 364 | return false, fmt.Errorf("read Git response body: %w" , err) |
| 365 | } |
| 366 | for k, values := range responseHeaders { |
| 367 | for _, value := range values { |
| 368 | w.Header().Add(k, value) |
| 369 | } |
| 370 | } |
| 371 | w.WriteHeader(status) |
| 372 | if err := controller.Flush(); err != nil && !errors.Is(err, http.ErrNotSupported) { |
| 373 | return true, err |
| 374 | } |
| 375 | _, err := io.Copy(cgiFlushWriter{w, controller}, reader) |
| 376 | return true, err |
| 377 | } |
| 378 | |
| 379 | type cgiFlushWriter struct { |
| 380 | writer io.Writer |
| 381 | controller *http.ResponseController |
| 382 | } |
| 383 | |
| 384 | func (w cgiFlushWriter) Write(p []byte) (int, error) { |
| 385 | n, err := w.writer.Write(p) |
| 386 | if err == nil { |
| 387 | err = w.controller.Flush() |
| 388 | if errors.Is(err, http.ErrNotSupported) { |
| 389 | err = nil |
| 390 | } |
| 391 | } |
| 392 | return n, err |
| 393 | } |