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/lfs"
 24	"github.com/charmbracelet/soft-serve/server/proto"
 25	"github.com/charmbracelet/soft-serve/server/utils"
 26	"github.com/prometheus/client_golang/prometheus"
 27	"github.com/prometheus/client_golang/prometheus/promauto"
 28	"goji.io/pat"
 29	"goji.io/pattern"
 30)
 31
 32// GitRoute is a route for git services.
 33type GitRoute struct {
 34	method  []string
 35	pattern *regexp.Regexp
 36	handler http.HandlerFunc
 37}
 38
 39var _ Route = GitRoute{}
 40
 41// Match implements goji.Pattern.
 42func (g GitRoute) Match(r *http.Request) *http.Request {
 43	re := g.pattern
 44	ctx := r.Context()
 45	cfg := config.FromContext(ctx)
 46	if m := re.FindStringSubmatch(r.URL.Path); m != nil {
 47		// This finds the Git objects & packs filenames in the URL.
 48		file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
 49		repo := utils.SanitizeRepo(m[1])
 50
 51		var service git.Service
 52		var oid string    // LFS object ID
 53		var lockID string // LFS lock ID
 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		case len(m) > 2:
 60			if strings.HasPrefix(file, "info/lfs/objects/basic/") {
 61				oid = m[2]
 62			} else if strings.HasPrefix(file, "info/lfs/locks/") && strings.HasSuffix(file, "/unlock") {
 63				lockID = m[2]
 64			}
 65			fallthrough
 66		case strings.HasPrefix(file, "info/lfs"):
 67			service = gitLfsService
 68		}
 69
 70		ctx = context.WithValue(ctx, pattern.Variable("lock_id"), lockID)
 71		ctx = context.WithValue(ctx, pattern.Variable("oid"), oid)
 72		ctx = context.WithValue(ctx, pattern.Variable("service"), service.String())
 73		ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(cfg.DataPath, "repos", repo+".git"))
 74		ctx = context.WithValue(ctx, pattern.Variable("repo"), repo)
 75		ctx = context.WithValue(ctx, pattern.Variable("file"), file)
 76
 77		return r.WithContext(ctx)
 78	}
 79
 80	return nil
 81}
 82
 83// ServeHTTP implements http.Handler.
 84func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 85	var hasMethod bool
 86	for _, m := range g.method {
 87		if m == r.Method {
 88			hasMethod = true
 89			break
 90		}
 91	}
 92
 93	if !hasMethod {
 94		renderMethodNotAllowed(w, r)
 95		return
 96	}
 97
 98	g.handler(w, r)
 99}
100
101var (
102	//nolint:revive
103	gitHttpReceiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
104		Namespace: "soft_serve",
105		Subsystem: "http",
106		Name:      "git_receive_pack_total",
107		Help:      "The total number of git push requests",
108	}, []string{"repo"})
109
110	//nolint:revive
111	gitHttpUploadCounter = promauto.NewCounterVec(prometheus.CounterOpts{
112		Namespace: "soft_serve",
113		Subsystem: "http",
114		Name:      "git_upload_pack_total",
115		Help:      "The total number of git fetch/pull requests",
116	}, []string{"repo", "file"})
117)
118
119var (
120	serviceRpcMatcher            = regexp.MustCompile("(.*?)/(?:git-upload-pack|git-receive-pack)$") // nolint: revive
121	getInfoRefsMatcher           = regexp.MustCompile("(.*?)/info/refs$")
122	getTextFileMatcher           = regexp.MustCompile("(.*?)/(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$")
123	getInfoPacksMatcher          = regexp.MustCompile("(.*?)/objects/info/packs$")
124	getLooseObjectMatcher        = regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$")
125	getPackFileMatcher           = regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.pack$`)
126	getIdxFileMatcher            = regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.idx$`)
127	serviceLfsBatchMatcher       = regexp.MustCompile("(.*?)/info/lfs/objects/batch$")
128	serviceLfsBasicMatcher       = regexp.MustCompile("(.*?)/info/lfs/objects/basic/([0-9a-f]{64})$")
129	serviceLfsBasicVerifyMatcher = regexp.MustCompile("(.*?)/info/lfs/objects/basic/verify$")
130)
131
132var gitRoutes = []GitRoute{
133	// Git services
134	// These routes don't handle authentication/authorization.
135	// This is handled through wrapping the handlers for each route.
136	// See below (withAccess).
137	{
138		pattern: serviceRpcMatcher,
139		method:  []string{http.MethodPost},
140		handler: serviceRpc,
141	},
142	{
143		pattern: getInfoRefsMatcher,
144		method:  []string{http.MethodGet},
145		handler: getInfoRefs,
146	},
147	{
148		pattern: getTextFileMatcher,
149		method:  []string{http.MethodGet},
150		handler: getTextFile,
151	},
152	{
153		pattern: getTextFileMatcher,
154		method:  []string{http.MethodGet},
155		handler: getTextFile,
156	},
157	{
158		pattern: getInfoPacksMatcher,
159		method:  []string{http.MethodGet},
160		handler: getInfoPacks,
161	},
162	{
163		pattern: getLooseObjectMatcher,
164		method:  []string{http.MethodGet},
165		handler: getLooseObject,
166	},
167	{
168		pattern: getPackFileMatcher,
169		method:  []string{http.MethodGet},
170		handler: getPackFile,
171	},
172	{
173		pattern: getIdxFileMatcher,
174		method:  []string{http.MethodGet},
175		handler: getIdxFile,
176	},
177	// Git LFS
178	{
179		pattern: serviceLfsBatchMatcher,
180		method:  []string{http.MethodPost},
181		handler: serviceLfsBatch,
182	},
183	{
184		// Git LFS basic object handler
185		pattern: serviceLfsBasicMatcher,
186		method:  []string{http.MethodGet, http.MethodPut},
187		handler: serviceLfsBasic,
188	},
189	{
190		pattern: serviceLfsBasicVerifyMatcher,
191		method:  []string{http.MethodPost},
192		handler: serviceLfsBasicVerify,
193	},
194	// Git LFS locks
195	{
196		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks$`),
197		method:  []string{http.MethodPost, http.MethodGet},
198		handler: serviceLfsLocks,
199	},
200	{
201		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/verify$`),
202		method:  []string{http.MethodPost},
203		handler: serviceLfsLocksVerify,
204	},
205	{
206		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/([0-9]+)/unlock$`),
207		method:  []string{http.MethodPost},
208		handler: serviceLfsLocksDelete,
209	},
210}
211
212func askCredentials(w http.ResponseWriter, _ *http.Request) {
213	w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`)
214	w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`)
215}
216
217// withAccess handles auth.
218func withAccess(next http.Handler) http.HandlerFunc {
219	return func(w http.ResponseWriter, r *http.Request) {
220		ctx := r.Context()
221		logger := log.FromContext(ctx)
222		be := backend.FromContext(ctx)
223
224		// Store repository in context
225		// We're not checking for errors here because we want to allow
226		// repo creation on the fly.
227		repoName := pat.Param(r, "repo")
228		repo, _ := be.Repository(ctx, repoName)
229		ctx = proto.WithRepositoryContext(ctx, repo)
230		r = r.WithContext(ctx)
231
232		user, err := authenticate(r)
233		if err != nil {
234			switch {
235			case errors.Is(err, ErrInvalidToken):
236			case errors.Is(err, proto.ErrUserNotFound):
237			default:
238				logger.Error("failed to authenticate", "err", err)
239			}
240		}
241
242		if user == nil && !be.AllowKeyless(ctx) {
243			askCredentials(w, r)
244			renderUnauthorized(w)
245			return
246		}
247
248		// Store user in context
249		ctx = proto.WithUserContext(ctx, user)
250		r = r.WithContext(ctx)
251
252		if user != nil {
253			logger.Info("found user", "username", user.Username())
254		}
255
256		service := git.Service(pat.Param(r, "service"))
257		if service == "" {
258			// Get service from request params
259			service = getServiceType(r)
260		}
261
262		accessLevel := be.AccessLevelForUser(ctx, repoName, user)
263		ctx = access.WithContext(ctx, accessLevel)
264		r = r.WithContext(ctx)
265
266		logger.Info("access level", "repo", repoName, "level", accessLevel)
267
268		file := pat.Param(r, "file")
269
270		// We only allow these services to proceed any other services should return 403
271		// - git-upload-pack
272		// - git-receive-pack
273		// - git-lfs
274		switch service {
275		case git.UploadPackService:
276		case git.ReceivePackService:
277			if accessLevel < access.ReadWriteAccess {
278				askCredentials(w, r)
279				renderUnauthorized(w)
280				return
281			}
282
283			// Create the repo if it doesn't exist.
284			if repo == nil {
285				repo, err = be.CreateRepository(ctx, repoName, proto.RepositoryOptions{})
286				if err != nil {
287					logger.Error("failed to create repository", "repo", repoName, "err", err)
288					renderInternalServerError(w)
289					return
290				}
291			}
292		case gitLfsService:
293			switch {
294			case strings.HasPrefix(file, "info/lfs/locks"):
295				switch {
296				case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost:
297					// Create lock, list locks, and delete lock require write access
298					fallthrough
299				case strings.HasSuffix(file, "lfs/locks/verify"):
300					// Locks verify requires write access
301					// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2
302					if accessLevel < access.ReadWriteAccess {
303						renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
304							Message: "write access required",
305						})
306						return
307					}
308				}
309			case strings.HasPrefix(file, "info/lfs/objects/basic"):
310				switch r.Method {
311				case http.MethodPut:
312					// Basic upload
313					if accessLevel < access.ReadWriteAccess {
314						renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
315							Message: "write access required",
316						})
317						return
318					}
319				case http.MethodGet:
320					// Basic download
321				case http.MethodPost:
322					// Basic verify
323				}
324			}
325			if accessLevel < access.ReadOnlyAccess {
326				askCredentials(w, r)
327				renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
328					Message: "credentials needed",
329				})
330				return
331			}
332		default:
333			renderForbidden(w)
334			return
335		}
336
337		// If the repo doesn't exist, return 404
338		if repo == nil {
339			renderNotFound(w)
340			return
341		}
342
343		if accessLevel < access.ReadOnlyAccess {
344			askCredentials(w, r)
345			renderUnauthorized(w)
346			return
347		}
348
349		next.ServeHTTP(w, r)
350	}
351}
352
353//nolint:revive
354func serviceRpc(w http.ResponseWriter, r *http.Request) {
355	ctx := r.Context()
356	cfg := config.FromContext(ctx)
357	logger := log.FromContext(ctx)
358	service, dir, repoName := git.Service(pat.Param(r, "service")), pat.Param(r, "dir"), pat.Param(r, "repo")
359
360	if !isSmart(r, service) {
361		renderForbidden(w)
362		return
363	}
364
365	if service == git.ReceivePackService {
366		gitHttpReceiveCounter.WithLabelValues(repoName)
367	}
368
369	w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
370	w.Header().Set("Connection", "Keep-Alive")
371	w.Header().Set("Transfer-Encoding", "chunked")
372	w.Header().Set("X-Content-Type-Options", "nosniff")
373	w.WriteHeader(http.StatusOK)
374
375	version := r.Header.Get("Git-Protocol")
376
377	var stdout bytes.Buffer
378	cmd := git.ServiceCommand{
379		Stdout: &stdout,
380		Dir:    dir,
381		Args:   []string{"--stateless-rpc"},
382	}
383
384	user := proto.UserFromContext(ctx)
385	cmd.Env = cfg.Environ()
386	cmd.Env = append(cmd.Env, []string{
387		"SOFT_SERVE_REPO_NAME=" + repoName,
388		"SOFT_SERVE_REPO_PATH=" + dir,
389		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
390	}...)
391	if user != nil {
392		cmd.Env = append(cmd.Env, []string{
393			"SOFT_SERVE_USERNAME=" + user.Username(),
394		}...)
395	}
396	if len(version) != 0 {
397		cmd.Env = append(cmd.Env, []string{
398			fmt.Sprintf("GIT_PROTOCOL=%s", version),
399		}...)
400	}
401
402	// Handle gzip encoding
403	reader := r.Body
404	defer reader.Close() // nolint: errcheck
405	switch r.Header.Get("Content-Encoding") {
406	case "gzip":
407		reader, err := gzip.NewReader(reader)
408		if err != nil {
409			logger.Errorf("failed to create gzip reader: %v", err)
410			renderInternalServerError(w)
411			return
412		}
413		defer reader.Close() // nolint: errcheck
414	}
415
416	cmd.Stdin = reader
417
418	if err := service.Handler(ctx, cmd); err != nil {
419		if errors.Is(err, git.ErrInvalidRepo) {
420			renderNotFound(w)
421			return
422		}
423		renderInternalServerError(w)
424		return
425	}
426
427	// Handle buffered output
428	// Useful when using proxies
429
430	// We know that `w` is an `http.ResponseWriter`.
431	flusher, ok := w.(http.Flusher)
432	if !ok {
433		logger.Errorf("expected http.ResponseWriter to be an http.Flusher, got %T", w)
434		return
435	}
436
437	p := make([]byte, 1024)
438	for {
439		nRead, err := stdout.Read(p)
440		if err == io.EOF {
441			break
442		}
443		nWrite, err := w.Write(p[:nRead])
444		if err != nil {
445			logger.Errorf("failed to write data: %v", err)
446			return
447		}
448		if nRead != nWrite {
449			logger.Errorf("failed to write data: %d read, %d written", nRead, nWrite)
450			return
451		}
452		flusher.Flush()
453	}
454
455	if service == git.ReceivePackService {
456		if err := git.EnsureDefaultBranch(ctx, cmd); err != nil {
457			logger.Errorf("failed to ensure default branch: %s", err)
458		}
459	}
460}
461
462func getInfoRefs(w http.ResponseWriter, r *http.Request) {
463	ctx := r.Context()
464	cfg := config.FromContext(ctx)
465	dir, repoName, file := pat.Param(r, "dir"), pat.Param(r, "repo"), pat.Param(r, "file")
466	service := getServiceType(r)
467	version := r.Header.Get("Git-Protocol")
468
469	gitHttpUploadCounter.WithLabelValues(repoName, file).Inc()
470
471	if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
472		// Smart HTTP
473		var refs bytes.Buffer
474		cmd := git.ServiceCommand{
475			Stdout: &refs,
476			Dir:    dir,
477			Args:   []string{"--stateless-rpc", "--advertise-refs"},
478		}
479
480		user := proto.UserFromContext(ctx)
481		cmd.Env = cfg.Environ()
482		cmd.Env = append(cmd.Env, []string{
483			"SOFT_SERVE_REPO_NAME=" + repoName,
484			"SOFT_SERVE_REPO_PATH=" + dir,
485			"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
486		}...)
487		if user != nil {
488			cmd.Env = append(cmd.Env, []string{
489				"SOFT_SERVE_USERNAME=" + user.Username(),
490			}...)
491		}
492		if len(version) != 0 {
493			cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version))
494		}
495
496		if err := service.Handler(ctx, cmd); err != nil {
497			renderNotFound(w)
498			return
499		}
500
501		hdrNocache(w)
502		w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
503		w.WriteHeader(http.StatusOK)
504		if len(version) == 0 {
505			git.WritePktline(w, "# service="+service.String()) // nolint: errcheck
506		}
507
508		w.Write(refs.Bytes()) // nolint: errcheck
509	} else {
510		// Dumb HTTP
511		updateServerInfo(ctx, dir) // nolint: errcheck
512		hdrNocache(w)
513		sendFile("text/plain; charset=utf-8", w, r)
514	}
515}
516
517func getInfoPacks(w http.ResponseWriter, r *http.Request) {
518	hdrCacheForever(w)
519	sendFile("text/plain; charset=utf-8", w, r)
520}
521
522func getLooseObject(w http.ResponseWriter, r *http.Request) {
523	hdrCacheForever(w)
524	sendFile("application/x-git-loose-object", w, r)
525}
526
527func getPackFile(w http.ResponseWriter, r *http.Request) {
528	hdrCacheForever(w)
529	sendFile("application/x-git-packed-objects", w, r)
530}
531
532func getIdxFile(w http.ResponseWriter, r *http.Request) {
533	hdrCacheForever(w)
534	sendFile("application/x-git-packed-objects-toc", w, r)
535}
536
537func getTextFile(w http.ResponseWriter, r *http.Request) {
538	hdrNocache(w)
539	sendFile("text/plain", w, r)
540}
541
542func sendFile(contentType string, w http.ResponseWriter, r *http.Request) {
543	dir, file := pat.Param(r, "dir"), pat.Param(r, "file")
544	reqFile := filepath.Join(dir, file)
545
546	f, err := os.Stat(reqFile)
547	if os.IsNotExist(err) {
548		renderNotFound(w)
549		return
550	}
551
552	w.Header().Set("Content-Type", contentType)
553	w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size()))
554	w.Header().Set("Last-Modified", f.ModTime().Format(http.TimeFormat))
555	http.ServeFile(w, r, reqFile)
556}
557
558func getServiceType(r *http.Request) git.Service {
559	service := r.FormValue("service")
560	if !strings.HasPrefix(service, "git-") {
561		return ""
562	}
563
564	return git.Service(service)
565}
566
567func isSmart(r *http.Request, service git.Service) bool {
568	contentType := r.Header.Get("Content-Type")
569	return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service))
570}
571
572func updateServerInfo(ctx context.Context, dir string) error {
573	return gitb.UpdateServerInfo(ctx, dir)
574}
575
576// HTTP error response handling functions
577
578func renderBadRequest(w http.ResponseWriter) {
579	renderStatus(http.StatusBadRequest)(w, nil)
580}
581
582func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
583	if r.Proto == "HTTP/1.1" {
584		renderStatus(http.StatusMethodNotAllowed)(w, r)
585	} else {
586		renderBadRequest(w)
587	}
588}
589
590func renderNotFound(w http.ResponseWriter) {
591	renderStatus(http.StatusNotFound)(w, nil)
592}
593
594func renderUnauthorized(w http.ResponseWriter) {
595	renderStatus(http.StatusUnauthorized)(w, nil)
596}
597
598func renderForbidden(w http.ResponseWriter) {
599	renderStatus(http.StatusForbidden)(w, nil)
600}
601
602func renderInternalServerError(w http.ResponseWriter) {
603	renderStatus(http.StatusInternalServerError)(w, nil)
604}
605
606// Header writing functions
607
608func hdrNocache(w http.ResponseWriter) {
609	w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
610	w.Header().Set("Pragma", "no-cache")
611	w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
612}
613
614func hdrCacheForever(w http.ResponseWriter) {
615	now := time.Now().Unix()
616	expires := now + 31536000
617	w.Header().Set("Date", fmt.Sprintf("%d", now))
618	w.Header().Set("Expires", fmt.Sprintf("%d", expires))
619	w.Header().Set("Cache-Control", "public, max-age=31536000")
620}