1package cmd
2
3import (
4 "errors"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "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)
22
23var (
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"})
30
31 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"})
37
38 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"})
44
45 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"})
51
52 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"})
58
59 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"})
65
66 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"})
72
73 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"})
79
80 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"})
86
87 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"})
93
94 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)
101
102// 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 }
111
112 return cmd
113}
114
115// 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 }
124
125 return cmd
126}
127
128// 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 }
137
138 return cmd
139}
140
141// 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 }
150
151 return cmd
152}
153
154// 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 }
163
164 return cmd
165}
166
167func 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()
173
174 // 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-layout
182 repoDir := name + ".git"
183 reposDir := filepath.Join(cfg.DataPath, "repos")
184 if err := git.EnsureWithin(reposDir, repoDir); err != nil {
185 return err //nolint:wrapcheck
186 }
187
188 // Set repo in context
189 repo, _ := be.Repository(ctx, name)
190 ctx = proto.WithRepositoryContext(ctx, repo)
191
192 // 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 }
199
200 if user != nil {
201 envs = append(envs,
202 "SOFT_SERVE_USERNAME="+user.Username(),
203 )
204 }
205
206 envs = append(envs, cfg.Environ()...)
207
208 // 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 break
214 }
215 }
216 }
217
218 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 }
230
231 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.ErrNotAuthed
239 }
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 err //nolint:wrapcheck
244 }
245 createRepoCounter.WithLabelValues(name).Inc()
246 }
247
248 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:errcheck,gosec
254 }
255 }()
256
257 return git.ErrSystemMalfunction
258 }
259
260 if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {
261 logger.Error("failed to ensure default branch", "err", err, "repo", name)
262 return git.ErrSystemMalfunction
263 }
264
265 receivePackCounter.WithLabelValues(name).Inc()
266
267 return nil
268 case git.UploadPackService, git.UploadArchiveService:
269 if accessLevel < access.ReadOnlyAccess {
270 return git.ErrNotAuthed
271 }
272
273 if repo == nil {
274 return git.ErrInvalidRepo
275 }
276
277 switch service { //nolint:exhaustive
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 }
289
290 err := service.Handler(ctx, scmd)
291 if errors.Is(err, git.ErrInvalidRepo) {
292 return git.ErrInvalidRepo
293 } else if err != nil {
294 logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)
295 return git.ErrSystemMalfunction
296 }
297
298 return nil
299 case git.LFSTransferService, git.LFSAuthenticateService:
300 operation := args[1]
301 switch operation {
302 case lfs.OperationDownload:
303 if accessLevel < access.ReadOnlyAccess {
304 return git.ErrNotAuthed
305 }
306 case lfs.OperationUpload:
307 if accessLevel < access.ReadWriteAccess {
308 return git.ErrNotAuthed
309 }
310 default:
311 return git.ErrInvalidRequest
312 }
313
314 if repo == nil {
315 return git.ErrInvalidRepo
316 }
317
318 scmd.Args = []string{
319 name,
320 args[1],
321 }
322
323 switch service { //nolint:exhaustive
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 }
335
336 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.ErrSystemMalfunction
339 }
340
341 return nil
342 }
343
344 return errors.New("unsupported git service")
345}