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