tinyrelayfile
tinyrelay/tinygit/http.go/

tinygit/http.go raw
1package tinygit
2
3import (
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.
25func (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
92func parsePath(path string) (owner, id, suffix string, ok bool) {
93 owner, id, suffix, _, ok = parsePathMode(path)
94 return
95}
96
97func 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
134const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
135
136func 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
165func 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}
176func 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}
204func 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
231func (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.
295func 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
321type cgiStreamError struct{ error }
322
323// Only diagnostic text is bounded; pack streams have no product size limit.
324type cgiDiagnostic struct{ bytes.Buffer }
325
326func (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
334func 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
379type cgiFlushWriter struct {
380 writer io.Writer
381 controller *http.ResponseController
382}
383
384func (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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs