1package cmd23import (4 "errors"5 "path/filepath"6 "strings"7 "time"89 "github.com/charmbracelet/log/v2"10 "github.com/charmbracelet/soft-serve/pkg/access"11 "github.com/charmbracelet/soft-serve/pkg/backend"12 "github.com/charmbracelet/soft-serve/pkg/config"13 "github.com/charmbracelet/soft-serve/pkg/git"14 "github.com/charmbracelet/soft-serve/pkg/lfs"15 "github.com/charmbracelet/soft-serve/pkg/proto"16 "github.com/charmbracelet/soft-serve/pkg/sshutils"17 "github.com/charmbracelet/soft-serve/pkg/utils"18 "github.com/prometheus/client_golang/prometheus"19 "github.com/prometheus/client_golang/prometheus/promauto"20 "github.com/spf13/cobra"21)2223var (24 uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{25 Namespace: "soft_serve",26 Subsystem: "git",27 Name: "upload_pack_total",28 Help: "The total number of git-upload-pack requests",29 }, []string{"repo"})3031 receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{32 Namespace: "soft_serve",33 Subsystem: "git",34 Name: "receive_pack_total",35 Help: "The total number of git-receive-pack requests",36 }, []string{"repo"})3738 uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{39 Namespace: "soft_serve",40 Subsystem: "git",41 Name: "upload_archive_total",42 Help: "The total number of git-upload-archive requests",43 }, []string{"repo"})4445 lfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{46 Namespace: "soft_serve",47 Subsystem: "git",48 Name: "lfs_authenticate_total",49 Help: "The total number of git-lfs-authenticate requests",50 }, []string{"repo", "operation"})5152 lfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{53 Namespace: "soft_serve",54 Subsystem: "git",55 Name: "lfs_transfer_total",56 Help: "The total number of git-lfs-transfer requests",57 }, []string{"repo", "operation"})5859 uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{60 Namespace: "soft_serve",61 Subsystem: "git",62 Name: "upload_pack_seconds_total",63 Help: "The total time spent on git-upload-pack requests",64 }, []string{"repo"})6566 receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{67 Namespace: "soft_serve",68 Subsystem: "git",69 Name: "receive_pack_seconds_total",70 Help: "The total time spent on git-receive-pack requests",71 }, []string{"repo"})7273 uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{74 Namespace: "soft_serve",75 Subsystem: "git",76 Name: "upload_archive_seconds_total",77 Help: "The total time spent on git-upload-archive requests",78 }, []string{"repo"})7980 lfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{81 Namespace: "soft_serve",82 Subsystem: "git",83 Name: "lfs_authenticate_seconds_total",84 Help: "The total time spent on git-lfs-authenticate requests",85 }, []string{"repo", "operation"})8687 lfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{88 Namespace: "soft_serve",89 Subsystem: "git",90 Name: "lfs_transfer_seconds_total",91 Help: "The total time spent on git-lfs-transfer requests",92 }, []string{"repo", "operation"})9394 createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{95 Namespace: "soft_serve",96 Subsystem: "ssh",97 Name: "create_repo_total",98 Help: "The total number of create repo requests",99 }, []string{"repo"})100)101102// GitUploadPackCommand returns a cobra command for git-upload-pack.103func GitUploadPackCommand() *cobra.Command {104 cmd := &cobra.Command{105 Use: "git-upload-pack REPO",106 Short: "Git upload pack",107 Args: cobra.ExactArgs(1),108 Hidden: true,109 RunE: gitRunE,110 }111112 return cmd113}114115// GitUploadArchiveCommand returns a cobra command for git-upload-archive.116func GitUploadArchiveCommand() *cobra.Command {117 cmd := &cobra.Command{118 Use: "git-upload-archive REPO",119 Short: "Git upload archive",120 Args: cobra.ExactArgs(1),121 Hidden: true,122 RunE: gitRunE,123 }124125 return cmd126}127128// GitReceivePackCommand returns a cobra command for git-receive-pack.129func GitReceivePackCommand() *cobra.Command {130 cmd := &cobra.Command{131 Use: "git-receive-pack REPO",132 Short: "Git receive pack",133 Args: cobra.ExactArgs(1),134 Hidden: true,135 RunE: gitRunE,136 }137138 return cmd139}140141// GitLFSAuthenticateCommand returns a cobra command for git-lfs-authenticate.142func GitLFSAuthenticateCommand() *cobra.Command {143 cmd := &cobra.Command{144 Use: "git-lfs-authenticate REPO OPERATION",145 Short: "Git LFS authenticate",146 Args: cobra.ExactArgs(2),147 Hidden: true,148 RunE: gitRunE,149 }150151 return cmd152}153154// GitLFSTransfer returns a cobra command for git-lfs-transfer.155func GitLFSTransfer() *cobra.Command {156 cmd := &cobra.Command{157 Use: "git-lfs-transfer REPO OPERATION",158 Short: "Git LFS transfer",159 Args: cobra.ExactArgs(2),160 Hidden: true,161 RunE: gitRunE,162 }163164 return cmd165}166167func gitRunE(cmd *cobra.Command, args []string) error {168 ctx := cmd.Context()169 cfg := config.FromContext(ctx)170 be := backend.FromContext(ctx)171 logger := log.FromContext(ctx)172 start := time.Now()173174 // repo should be in the form of "repo.git"175 name := utils.SanitizeRepo(args[0])176 pk := sshutils.PublicKeyFromContext(ctx)177 ak := sshutils.MarshalAuthorizedKey(pk)178 user := proto.UserFromContext(ctx)179 accessLevel := be.AccessLevelForUser(ctx, name, user)180 // git bare repositories should end in ".git"181 // https://git-scm.com/docs/gitrepository-layout182 repoDir := name + ".git"183 reposDir := filepath.Join(cfg.DataPath, "repos")184 if err := git.EnsureWithin(reposDir, repoDir); err != nil {185 return err186 }187188 // Set repo in context189 repo, _ := be.Repository(ctx, name)190 ctx = proto.WithRepositoryContext(ctx, repo)191192 // Environment variables to pass down to git hooks.193 envs := []string{194 "SOFT_SERVE_REPO_NAME=" + name,195 "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),196 "SOFT_SERVE_PUBLIC_KEY=" + ak,197 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),198 }199200 if user != nil {201 envs = append(envs,202 "SOFT_SERVE_USERNAME="+user.Username(),203 )204 }205206 envs = append(envs, cfg.Environ()...)207208 // Add GIT_PROTOCOL from session.209 if sess := sshutils.SessionFromContext(ctx); sess != nil {210 for _, env := range sess.Environ() {211 if strings.HasPrefix(env, "GIT_PROTOCOL=") {212 envs = append(envs, env)213 break214 }215 }216 }217218 repoPath := filepath.Join(reposDir, repoDir)219 service := git.Service(cmd.Name())220 stdin := cmd.InOrStdin()221 stdout := cmd.OutOrStdout()222 stderr := cmd.ErrOrStderr()223 scmd := git.ServiceCommand{224 Stdin: stdin,225 Stdout: stdout,226 Stderr: stderr,227 Env: envs,228 Dir: repoPath,229 }230231 switch service {232 case git.ReceivePackService:233 receivePackCounter.WithLabelValues(name).Inc()234 defer func() {235 receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())236 }()237 if accessLevel < access.ReadWriteAccess {238 return git.ErrNotAuthed239 }240 if repo == nil {241 if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {242 log.Errorf("failed to create repo: %s", err)243 return err244 }245 createRepoCounter.WithLabelValues(name).Inc()246 }247248 if err := service.Handler(ctx, scmd); err != nil {249 logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)250 defer func() {251 if repo == nil {252 // If the repo was created, but the request failed, delete it.253 be.DeleteRepository(ctx, name) // nolint: errcheck254 }255 }()256257 return git.ErrSystemMalfunction258 }259260 if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {261 logger.Error("failed to ensure default branch", "err", err, "repo", name)262 return git.ErrSystemMalfunction263 }264265 receivePackCounter.WithLabelValues(name).Inc()266267 return nil268 case git.UploadPackService, git.UploadArchiveService:269 if accessLevel < access.ReadOnlyAccess {270 return git.ErrNotAuthed271 }272273 if repo == nil {274 return git.ErrInvalidRepo275 }276277 switch service {278 case git.UploadArchiveService:279 uploadArchiveCounter.WithLabelValues(name).Inc()280 defer func() {281 uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())282 }()283 default:284 uploadPackCounter.WithLabelValues(name).Inc()285 defer func() {286 uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())287 }()288 }289290 err := service.Handler(ctx, scmd)291 if errors.Is(err, git.ErrInvalidRepo) {292 return git.ErrInvalidRepo293 } else if err != nil {294 logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)295 return git.ErrSystemMalfunction296 }297298 return nil299 case git.LFSTransferService, git.LFSAuthenticateService:300 operation := args[1]301 switch operation {302 case lfs.OperationDownload:303 if accessLevel < access.ReadOnlyAccess {304 return git.ErrNotAuthed305 }306 case lfs.OperationUpload:307 if accessLevel < access.ReadWriteAccess {308 return git.ErrNotAuthed309 }310 default:311 return git.ErrInvalidRequest312 }313314 if repo == nil {315 return git.ErrInvalidRepo316 }317318 scmd.Args = []string{319 name,320 args[1],321 }322323 switch service {324 case git.LFSTransferService:325 lfsTransferCounter.WithLabelValues(name, operation).Inc()326 defer func() {327 lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())328 }()329 default:330 lfsAuthenticateCounter.WithLabelValues(name, operation).Inc()331 defer func() {332 lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())333 }()334 }335336 if err := service.Handler(ctx, scmd); err != nil {337 logger.Error("failed to handle lfs service", "service", service, "err", err, "repo", name)338 return git.ErrSystemMalfunction339 }340341 return nil342 }343344 return errors.New("unsupported git service")345}