git.go

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