git.go

  1package web
  2
  3import (
  4	"bytes"
  5	"compress/gzip"
  6	"context"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"net/http"
 11	"os"
 12	"path/filepath"
 13	"regexp"
 14	"strings"
 15	"time"
 16
 17	"github.com/charmbracelet/log"
 18	gitb "github.com/charmbracelet/soft-serve/git"
 19	"github.com/charmbracelet/soft-serve/server/access"
 20	"github.com/charmbracelet/soft-serve/server/backend"
 21	"github.com/charmbracelet/soft-serve/server/config"
 22	"github.com/charmbracelet/soft-serve/server/git"
 23	"github.com/charmbracelet/soft-serve/server/proto"
 24	"github.com/charmbracelet/soft-serve/server/utils"
 25	"github.com/prometheus/client_golang/prometheus"
 26	"github.com/prometheus/client_golang/prometheus/promauto"
 27	"goji.io/pat"
 28	"goji.io/pattern"
 29)
 30
 31// GitRoute is a route for git services.
 32type GitRoute struct {
 33	method  []string
 34	pattern *regexp.Regexp
 35	handler http.HandlerFunc
 36}
 37
 38var _ Route = GitRoute{}
 39
 40// Match implements goji.Pattern.
 41func (g GitRoute) Match(r *http.Request) *http.Request {
 42	re := g.pattern
 43	ctx := r.Context()
 44	cfg := config.FromContext(ctx)
 45	if m := re.FindStringSubmatch(r.URL.Path); m != nil {
 46		// This finds the Git objects & packs filenames in the URL.
 47		file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
 48		repo := utils.SanitizeRepo(m[1])
 49
 50		switch {
 51		case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
 52			ctx = context.WithValue(ctx, pattern.Variable("service"), git.UploadPackService.String())
 53		case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
 54			ctx = context.WithValue(ctx, pattern.Variable("service"), git.ReceivePackService.String())
 55		case len(m) > 1:
 56			// XXX: right now, the only pattern that captures more than one group
 57			// is the Git LFS basic upload/download handler. This captures the LFS
 58			// object Oid.
 59			// See the Git LFS basic handler down below.
 60			// TODO: make this more generic.
 61			ctx = context.WithValue(ctx, pattern.Variable("oid"), m[2])
 62		}
 63
 64		ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(cfg.DataPath, "repos", repo+".git"))
 65		ctx = context.WithValue(ctx, pattern.Variable("repo"), repo)
 66		ctx = context.WithValue(ctx, pattern.Variable("file"), file)
 67
 68		return r.WithContext(ctx)
 69	}
 70
 71	return nil
 72}
 73
 74// ServeHTTP implements http.Handler.
 75func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 76	var hasMethod bool
 77	for _, m := range g.method {
 78		if m == r.Method {
 79			hasMethod = true
 80			break
 81		}
 82	}
 83
 84	if !hasMethod {
 85		renderMethodNotAllowed(w, r)
 86		return
 87	}
 88
 89	g.handler(w, r)
 90}
 91
 92var (
 93	//nolint:revive
 94	gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 95		Namespace: "soft_serve",
 96		Subsystem: "http",
 97		Name:      "git_receive_pack_total",
 98		Help:      "The total number of git push requests",
 99	}, []string{"repo"})
100
101	//nolint:revive
102	gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{
103		Namespace: "soft_serve",
104		Subsystem: "http",
105		Name:      "git_upload_pack_total",
106		Help:      "The total number of git fetch/pull requests",
107	}, []string{"repo", "file"})
108)
109
110var gitRoutes = []GitRoute{
111	// Git services
112	// These routes don't handle authentication/authorization.
113	// This is handled through wrapping the handlers for each route.
114	// See below (withAccess).
115	{
116		pattern: regexp.MustCompile("(.*?)/git-upload-pack$"),
117		method:  []string{http.MethodPost},
118		handler: serviceRpc,
119	},
120	{
121		pattern: regexp.MustCompile("(.*?)/git-receive-pack$"),
122		method:  []string{http.MethodPost},
123		handler: serviceRpc,
124	},
125	{
126		pattern: regexp.MustCompile("(.*?)/info/refs$"),
127		method:  []string{http.MethodGet},
128		handler: getInfoRefs,
129	},
130	{
131		pattern: regexp.MustCompile("(.*?)/HEAD$"),
132		method:  []string{http.MethodGet},
133		handler: getTextFile,
134	},
135	{
136		pattern: regexp.MustCompile("(.*?)/objects/info/alternates$"),
137		method:  []string{http.MethodGet},
138		handler: getTextFile,
139	},
140	{
141		pattern: regexp.MustCompile("(.*?)/objects/info/http-alternates$"),
142		method:  []string{http.MethodGet},
143		handler: getTextFile,
144	},
145	{
146		pattern: regexp.MustCompile("(.*?)/objects/info/packs$"),
147		method:  []string{http.MethodGet},
148		handler: getInfoPacks,
149	},
150	{
151		pattern: regexp.MustCompile("(.*?)/objects/info/[^/]*$"),
152		method:  []string{http.MethodGet},
153		handler: getTextFile,
154	},
155	{
156		pattern: regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$"),
157		method:  []string{http.MethodGet},
158		handler: getLooseObject,
159	},
160	{
161		pattern: regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.pack$`),
162		method:  []string{http.MethodGet},
163		handler: getPackFile,
164	},
165	{
166		pattern: regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.idx$`),
167		method:  []string{http.MethodGet},
168		handler: getIdxFile,
169	},
170	// Git LFS
171	{
172		pattern: regexp.MustCompile(`(.*?)/info/lfs/objects/batch$`),
173		method:  []string{http.MethodPost},
174		handler: serviceLfsBatch,
175	},
176	{
177		// Git LFS basic object handler
178		pattern: regexp.MustCompile(`(.*?)/info/lfs/objects/basic/([0-9a-f]{64})$`),
179		method:  []string{http.MethodGet, http.MethodPut},
180		handler: serviceLfsBasic,
181	},
182	{
183		pattern: regexp.MustCompile(`(.*?)/info/lfs/objects/basic/verify$`),
184		method:  []string{http.MethodPost},
185		handler: serviceLfsBasicVerify,
186	},
187	// Git LFS locks
188	// TODO: implement locks
189	// {
190	// 	pattern: regexp.MustCompile(`(.*?)/info/lfs/locks$`),
191	// 	method:  []string{http.MethodPost},
192	// 	handler: serviceLfsLocksCreate,
193	// },
194}
195
196// withAccess handles auth.
197func withAccess(fn http.HandlerFunc) http.HandlerFunc {
198	return func(w http.ResponseWriter, r *http.Request) {
199		ctx := r.Context()
200		be := backend.FromContext(ctx)
201		logger := log.FromContext(ctx)
202
203		if !be.AllowKeyless(ctx) {
204			renderForbidden(w)
205			return
206		}
207
208		repo := pat.Param(r, "repo")
209		service := git.Service(pat.Param(r, "service"))
210		accessLevel := be.AccessLevel(ctx, repo, "")
211
212		switch service {
213		case git.ReceivePackService:
214			if accessLevel < access.ReadWriteAccess {
215				renderUnauthorized(w)
216				return
217			}
218
219			// Create the repo if it doesn't exist.
220			if _, err := be.Repository(ctx, repo); err != nil {
221				if _, err := be.CreateRepository(ctx, repo, proto.RepositoryOptions{}); err != nil {
222					logger.Error("failed to create repository", "repo", repo, "err", err)
223					renderInternalServerError(w)
224					return
225				}
226			}
227		default:
228			if accessLevel < access.ReadOnlyAccess {
229				renderUnauthorized(w)
230				return
231			}
232		}
233
234		fn(w, r)
235	}
236}
237
238//nolint:revive
239func serviceRpc(w http.ResponseWriter, r *http.Request) {
240	ctx := r.Context()
241	cfg := config.FromContext(ctx)
242	logger := log.FromContext(ctx)
243	service, dir, repo := git.Service(pat.Param(r, "service")), pat.Param(r, "dir"), pat.Param(r, "repo")
244
245	if !isSmart(r, service) {
246		renderForbidden(w)
247		return
248	}
249
250	if service == git.ReceivePackService {
251		gitHttpReceiveCounter.WithLabelValues(repo)
252	}
253
254	w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
255	w.Header().Set("Connection", "Keep-Alive")
256	w.Header().Set("Transfer-Encoding", "chunked")
257	w.Header().Set("X-Content-Type-Options", "nosniff")
258	w.WriteHeader(http.StatusOK)
259
260	version := r.Header.Get("Git-Protocol")
261
262	var stdout bytes.Buffer
263	cmd := git.ServiceCommand{
264		Stdout: &stdout,
265		Dir:    dir,
266		Args:   []string{"--stateless-rpc"},
267	}
268
269	if len(version) != 0 {
270		cmd.Env = append(cmd.Env, []string{
271			// TODO: add the rest of env vars when we support pushing using http
272			"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
273			fmt.Sprintf("GIT_PROTOCOL=%s", version),
274		}...)
275	}
276
277	// Handle gzip encoding
278	reader := r.Body
279	defer reader.Close() // nolint: errcheck
280	switch r.Header.Get("Content-Encoding") {
281	case "gzip":
282		reader, err := gzip.NewReader(reader)
283		if err != nil {
284			logger.Errorf("failed to create gzip reader: %v", err)
285			renderInternalServerError(w)
286			return
287		}
288		defer reader.Close() // nolint: errcheck
289	}
290
291	cmd.Stdin = reader
292
293	if err := service.Handler(ctx, cmd); err != nil {
294		if errors.Is(err, git.ErrInvalidRepo) {
295			renderNotFound(w)
296			return
297		}
298		renderInternalServerError(w)
299		return
300	}
301
302	// Handle buffered output
303	// Useful when using proxies
304
305	// We know that `w` is an `http.ResponseWriter`.
306	flusher, ok := w.(http.Flusher)
307	if !ok {
308		logger.Errorf("expected http.ResponseWriter to be an http.Flusher, got %T", w)
309		return
310	}
311
312	p := make([]byte, 1024)
313	for {
314		nRead, err := stdout.Read(p)
315		if err == io.EOF {
316			break
317		}
318		nWrite, err := w.Write(p[:nRead])
319		if err != nil {
320			logger.Errorf("failed to write data: %v", err)
321			return
322		}
323		if nRead != nWrite {
324			logger.Errorf("failed to write data: %d read, %d written", nRead, nWrite)
325			return
326		}
327		flusher.Flush()
328	}
329}
330
331func getInfoRefs(w http.ResponseWriter, r *http.Request) {
332	ctx := r.Context()
333	dir, repo, file := pat.Param(r, "dir"), pat.Param(r, "repo"), pat.Param(r, "file")
334	service := getServiceType(r)
335	version := r.Header.Get("Git-Protocol")
336
337	gitHttpUploadCounter.WithLabelValues(repo, file).Inc()
338
339	if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
340		// Smart HTTP
341		var refs bytes.Buffer
342		cmd := git.ServiceCommand{
343			Stdout: &refs,
344			Dir:    dir,
345			Args:   []string{"--stateless-rpc", "--advertise-refs"},
346		}
347
348		if len(version) != 0 {
349			cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version))
350		}
351
352		if err := service.Handler(ctx, cmd); err != nil {
353			renderNotFound(w)
354			return
355		}
356
357		hdrNocache(w)
358		w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
359		w.WriteHeader(http.StatusOK)
360		if len(version) == 0 {
361			git.WritePktline(w, "# service="+service.String()) // nolint: errcheck
362		}
363
364		w.Write(refs.Bytes()) // nolint: errcheck
365	} else {
366		// Dumb HTTP
367		updateServerInfo(ctx, dir) // nolint: errcheck
368		hdrNocache(w)
369		sendFile("text/plain; charset=utf-8", w, r)
370	}
371}
372
373func getInfoPacks(w http.ResponseWriter, r *http.Request) {
374	hdrCacheForever(w)
375	sendFile("text/plain; charset=utf-8", w, r)
376}
377
378func getLooseObject(w http.ResponseWriter, r *http.Request) {
379	hdrCacheForever(w)
380	sendFile("application/x-git-loose-object", w, r)
381}
382
383func getPackFile(w http.ResponseWriter, r *http.Request) {
384	hdrCacheForever(w)
385	sendFile("application/x-git-packed-objects", w, r)
386}
387
388func getIdxFile(w http.ResponseWriter, r *http.Request) {
389	hdrCacheForever(w)
390	sendFile("application/x-git-packed-objects-toc", w, r)
391}
392
393func getTextFile(w http.ResponseWriter, r *http.Request) {
394	hdrNocache(w)
395	sendFile("text/plain", w, r)
396}
397
398func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
399	dir, file := pat.Param(r, "dir"), pat.Param(r, "file")
400	reqFile := filepath.Join(dir, file)
401
402	f, err := os.Stat(reqFile)
403	if os.IsNotExist(err) {
404		renderNotFound(w)
405		return
406	}
407
408	w.Header().Set("Content-Type", contentType)
409	w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
410	w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
411	http.ServeFile(w, r, reqFile)
412}
413
414func getServiceType(r *http.Request) git.Service {
415	service := r.FormValue("service")
416	if !strings.HasPrefix(service, "git-") {
417		return ""
418	}
419
420	return git.Service(service)
421}
422
423func isSmart(r *http.Request, service git.Service) bool {
424	return r.Header.Get("Content-Type") == fmt.Sprintf("application/x-%s-request", service)
425}
426
427func updateServerInfo(ctx context.Context, dir string) error {
428	return gitb.UpdateServerInfo(ctx, dir)
429}
430
431// HTTP error response handling functions
432
433func renderBadRequest(w http.ResponseWriter) {
434	renderStatus(http.StatusBadRequest)(w, nil)
435}
436
437func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
438	if r.Proto == "HTTP/1.1" {
439		renderStatus(http.StatusMethodNotAllowed)(w, r)
440	} else {
441		renderBadRequest(w)
442	}
443}
444
445func renderNotFound(w http.ResponseWriter) {
446	renderStatus(http.StatusNotFound)(w, nil)
447}
448
449func renderUnauthorized(w http.ResponseWriter) {
450	renderStatus(http.StatusUnauthorized)(w, nil)
451}
452
453func renderForbidden(w http.ResponseWriter) {
454	renderStatus(http.StatusForbidden)(w, nil)
455}
456
457func renderInternalServerError(w http.ResponseWriter) {
458	renderStatus(http.StatusInternalServerError)(w, nil)
459}
460
461// Header writing functions
462
463func hdrNocache(w http.ResponseWriter) {
464	w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
465	w.Header().Set("Pragma", "no-cache")
466	w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
467}
468
469func hdrCacheForever(w http.ResponseWriter) {
470	now := time.Now().Unix()
471	expires := now + 31536000
472	w.Header().Set("Date", fmt.Sprintf("%d", now))
473	w.Header().Set("Expires", fmt.Sprintf("%d", expires))
474	w.Header().Set("Cache-Control", "public, max-age=31536000")
475}