1package cmd
  2
  3import (
  4	"errors"
  5	"path/filepath"
  6	"time"
  7
  8	"github.com/charmbracelet/log"
  9	"github.com/charmbracelet/soft-serve/pkg/access"
 10	"github.com/charmbracelet/soft-serve/pkg/backend"
 11	"github.com/charmbracelet/soft-serve/pkg/config"
 12	"github.com/charmbracelet/soft-serve/pkg/git"
 13	"github.com/charmbracelet/soft-serve/pkg/lfs"
 14	"github.com/charmbracelet/soft-serve/pkg/proto"
 15	"github.com/charmbracelet/soft-serve/pkg/sshutils"
 16	"github.com/charmbracelet/soft-serve/pkg/utils"
 17	"github.com/prometheus/client_golang/prometheus"
 18	"github.com/prometheus/client_golang/prometheus/promauto"
 19	"github.com/spf13/cobra"
 20)
 21
 22var (
 23	uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 24		Namespace: "soft_serve",
 25		Subsystem: "git",
 26		Name:      "upload_pack_total",
 27		Help:      "The total number of git-upload-pack requests",
 28	}, []string{"repo"})
 29
 30	receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 31		Namespace: "soft_serve",
 32		Subsystem: "git",
 33		Name:      "receive_pack_total",
 34		Help:      "The total number of git-receive-pack requests",
 35	}, []string{"repo"})
 36
 37	uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 38		Namespace: "soft_serve",
 39		Subsystem: "git",
 40		Name:      "upload_archive_total",
 41		Help:      "The total number of git-upload-archive requests",
 42	}, []string{"repo"})
 43
 44	lfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 45		Namespace: "soft_serve",
 46		Subsystem: "git",
 47		Name:      "lfs_authenticate_total",
 48		Help:      "The total number of git-lfs-authenticate requests",
 49	}, []string{"repo", "operation"})
 50
 51	lfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 52		Namespace: "soft_serve",
 53		Subsystem: "git",
 54		Name:      "lfs_transfer_total",
 55		Help:      "The total number of git-lfs-transfer requests",
 56	}, []string{"repo", "operation"})
 57
 58	uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 59		Namespace: "soft_serve",
 60		Subsystem: "git",
 61		Name:      "upload_pack_seconds_total",
 62		Help:      "The total time spent on git-upload-pack requests",
 63	}, []string{"repo"})
 64
 65	receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 66		Namespace: "soft_serve",
 67		Subsystem: "git",
 68		Name:      "receive_pack_seconds_total",
 69		Help:      "The total time spent on git-receive-pack requests",
 70	}, []string{"repo"})
 71
 72	uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 73		Namespace: "soft_serve",
 74		Subsystem: "git",
 75		Name:      "upload_archive_seconds_total",
 76		Help:      "The total time spent on git-upload-archive requests",
 77	}, []string{"repo"})
 78
 79	lfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 80		Namespace: "soft_serve",
 81		Subsystem: "git",
 82		Name:      "lfs_authenticate_seconds_total",
 83		Help:      "The total time spent on git-lfs-authenticate requests",
 84	}, []string{"repo", "operation"})
 85
 86	lfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 87		Namespace: "soft_serve",
 88		Subsystem: "git",
 89		Name:      "lfs_transfer_seconds_total",
 90		Help:      "The total time spent on git-lfs-transfer requests",
 91	}, []string{"repo", "operation"})
 92
 93	createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 94		Namespace: "soft_serve",
 95		Subsystem: "ssh",
 96		Name:      "create_repo_total",
 97		Help:      "The total number of create repo requests",
 98	}, []string{"repo"})
 99)
100
101// GitUploadPackCommand returns a cobra command for git-upload-pack.
102func GitUploadPackCommand() *cobra.Command {
103	cmd := &cobra.Command{
104		Use:    "git-upload-pack REPO",
105		Short:  "Git upload pack",
106		Args:   cobra.ExactArgs(1),
107		Hidden: true,
108		RunE:   gitRunE,
109	}
110
111	return cmd
112}
113
114// GitUploadArchiveCommand returns a cobra command for git-upload-archive.
115func GitUploadArchiveCommand() *cobra.Command {
116	cmd := &cobra.Command{
117		Use:    "git-upload-archive REPO",
118		Short:  "Git upload archive",
119		Args:   cobra.ExactArgs(1),
120		Hidden: true,
121		RunE:   gitRunE,
122	}
123
124	return cmd
125}
126
127// GitReceivePackCommand returns a cobra command for git-receive-pack.
128func GitReceivePackCommand() *cobra.Command {
129	cmd := &cobra.Command{
130		Use:    "git-receive-pack REPO",
131		Short:  "Git receive pack",
132		Args:   cobra.ExactArgs(1),
133		Hidden: true,
134		RunE:   gitRunE,
135	}
136
137	return cmd
138}
139
140// GitLFSAuthenticateCommand returns a cobra command for git-lfs-authenticate.
141func GitLFSAuthenticateCommand() *cobra.Command {
142	cmd := &cobra.Command{
143		Use:    "git-lfs-authenticate REPO OPERATION",
144		Short:  "Git LFS authenticate",
145		Args:   cobra.ExactArgs(2),
146		Hidden: true,
147		RunE:   gitRunE,
148	}
149
150	return cmd
151}
152
153// GitLFSTransfer returns a cobra command for git-lfs-transfer.
154func GitLFSTransfer() *cobra.Command {
155	cmd := &cobra.Command{
156		Use:    "git-lfs-transfer REPO OPERATION",
157		Short:  "Git LFS transfer",
158		Args:   cobra.ExactArgs(2),
159		Hidden: true,
160		RunE:   gitRunE,
161	}
162
163	return cmd
164}
165
166func gitRunE(cmd *cobra.Command, args []string) error {
167	ctx := cmd.Context()
168	cfg := config.FromContext(ctx)
169	be := backend.FromContext(ctx)
170	logger := log.FromContext(ctx)
171	start := time.Now()
172
173	// repo should be in the form of "repo.git"
174	name := utils.SanitizeRepo(args[0])
175	pk := sshutils.PublicKeyFromContext(ctx)
176	ak := sshutils.MarshalAuthorizedKey(pk)
177	user := proto.UserFromContext(ctx)
178	accessLevel := be.AccessLevelForUser(ctx, name, user)
179	// git bare repositories should end in ".git"
180	// https://git-scm.com/docs/gitrepository-layout
181	repoDir := name + ".git"
182	reposDir := filepath.Join(cfg.DataPath, "repos")
183	if err := git.EnsureWithin(reposDir, repoDir); err != nil {
184		return err
185	}
186
187	// Set repo in context
188	repo, _ := be.Repository(ctx, name)
189	ctx = proto.WithRepositoryContext(ctx, repo)
190
191	// Environment variables to pass down to git hooks.
192	envs := []string{
193		"SOFT_SERVE_REPO_NAME=" + name,
194		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
195		"SOFT_SERVE_PUBLIC_KEY=" + ak,
196		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
197	}
198
199	if user != nil {
200		envs = append(envs,
201			"SOFT_SERVE_USERNAME="+user.Username(),
202		)
203	}
204
205	envs = append(envs, cfg.Environ()...)
206
207	repoPath := filepath.Join(reposDir, repoDir)
208	service := git.Service(cmd.Name())
209	stdin := cmd.InOrStdin()
210	stdout := cmd.OutOrStdout()
211	stderr := cmd.ErrOrStderr()
212	scmd := git.ServiceCommand{
213		Stdin:  stdin,
214		Stdout: stdout,
215		Stderr: stderr,
216		Env:    envs,
217		Dir:    repoPath,
218	}
219
220	switch service {
221	case git.ReceivePackService:
222		receivePackCounter.WithLabelValues(name).Inc()
223		defer func() {
224			receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
225		}()
226		if accessLevel < access.ReadWriteAccess {
227			return git.ErrNotAuthed
228		}
229		if repo == nil {
230			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
231				log.Errorf("failed to create repo: %s", err)
232				return err
233			}
234			createRepoCounter.WithLabelValues(name).Inc()
235		}
236
237		if err := service.Handler(ctx, scmd); err != nil {
238			logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)
239			defer func() {
240				if repo == nil {
241					// If the repo was created, but the request failed, delete it.
242					be.DeleteRepository(ctx, name) // nolint: errcheck
243				}
244			}()
245
246			return git.ErrSystemMalfunction
247		}
248
249		if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {
250			logger.Error("failed to ensure default branch", "err", err, "repo", name)
251			return git.ErrSystemMalfunction
252		}
253
254		receivePackCounter.WithLabelValues(name).Inc()
255
256		return nil
257	case git.UploadPackService, git.UploadArchiveService:
258		if accessLevel < access.ReadOnlyAccess {
259			return git.ErrNotAuthed
260		}
261
262		if repo == nil {
263			return git.ErrInvalidRepo
264		}
265
266		switch service {
267		case git.UploadArchiveService:
268			uploadArchiveCounter.WithLabelValues(name).Inc()
269			defer func() {
270				uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
271			}()
272		default:
273			uploadPackCounter.WithLabelValues(name).Inc()
274			defer func() {
275				uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
276			}()
277		}
278
279		err := service.Handler(ctx, scmd)
280		if errors.Is(err, git.ErrInvalidRepo) {
281			return git.ErrInvalidRepo
282		} else if err != nil {
283			logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)
284			return git.ErrSystemMalfunction
285		}
286
287		return nil
288	case git.LFSTransferService, git.LFSAuthenticateService:
289		operation := args[1]
290		switch operation {
291		case lfs.OperationDownload:
292			if accessLevel < access.ReadOnlyAccess {
293				return git.ErrNotAuthed
294			}
295		case lfs.OperationUpload:
296			if accessLevel < access.ReadWriteAccess {
297				return git.ErrNotAuthed
298			}
299		default:
300			return git.ErrInvalidRequest
301		}
302
303		if repo == nil {
304			return git.ErrInvalidRepo
305		}
306
307		scmd.Args = []string{
308			name,
309			args[1],
310		}
311
312		switch service {
313		case git.LFSTransferService:
314			lfsTransferCounter.WithLabelValues(name, operation).Inc()
315			defer func() {
316				lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
317			}()
318		default:
319			lfsAuthenticateCounter.WithLabelValues(name, operation).Inc()
320			defer func() {
321				lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
322			}()
323		}
324
325		if err := service.Handler(ctx, scmd); err != nil {
326			logger.Error("failed to handle lfs service", "service", service, "err", err, "repo", name)
327			return git.ErrSystemMalfunction
328		}
329
330		return nil
331	}
332
333	return errors.New("unsupported git service")
334}