1package cmd
2
3import (
4 "errors"
5 "path/filepath"
6 "time"
7
8 "github.com/charmbracelet/log"
9 "github.com/charmbracelet/soft-serve/server/access"
10 "github.com/charmbracelet/soft-serve/server/backend"
11 "github.com/charmbracelet/soft-serve/server/config"
12 "github.com/charmbracelet/soft-serve/server/git"
13 "github.com/charmbracelet/soft-serve/server/lfs"
14 "github.com/charmbracelet/soft-serve/server/proto"
15 "github.com/charmbracelet/soft-serve/server/sshutils"
16 "github.com/charmbracelet/soft-serve/server/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", "operation"})
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"})
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 // Add ssh session & config environ
206 s := sshutils.SessionFromContext(ctx)
207 envs = append(envs, s.Environ()...)
208 envs = append(envs, cfg.Environ()...)
209
210 repoPath := filepath.Join(reposDir, repoDir)
211 service := git.Service(cmd.Name())
212 scmd := git.ServiceCommand{
213 Stdin: cmd.InOrStdin(),
214 Stdout: s,
215 Stderr: s.Stderr(),
216 Env: envs,
217 Dir: repoPath,
218 }
219
220 logger.Debug("git middleware", "cmd", service, "access", accessLevel.String())
221
222 switch service {
223 case git.ReceivePackService:
224 receivePackCounter.WithLabelValues(name).Inc()
225 defer func() {
226 receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
227 }()
228 if accessLevel < access.ReadWriteAccess {
229 return git.ErrNotAuthed
230 }
231 if repo == nil {
232 if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
233 log.Errorf("failed to create repo: %s", err)
234 return err
235 }
236 createRepoCounter.WithLabelValues(name).Inc()
237 }
238
239 if err := service.Handler(ctx, scmd); err != nil {
240 defer func() {
241 if repo == nil {
242 // If the repo was created, but the request failed, delete it.
243 be.DeleteRepository(ctx, name) // nolint: errcheck
244 }
245 }()
246 return git.ErrSystemMalfunction
247 }
248
249 if err := git.EnsureDefaultBranch(ctx, scmd); err != nil {
250 return git.ErrSystemMalfunction
251 }
252
253 receivePackCounter.WithLabelValues(name).Inc()
254
255 return nil
256 case git.UploadPackService, git.UploadArchiveService:
257 if accessLevel < access.ReadOnlyAccess {
258 return git.ErrNotAuthed
259 }
260
261 if repo == nil {
262 return git.ErrInvalidRepo
263 }
264
265 switch service {
266 case git.UploadArchiveService:
267 uploadArchiveCounter.WithLabelValues(name).Inc()
268 defer func() {
269 uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
270 }()
271 default:
272 uploadPackCounter.WithLabelValues(name).Inc()
273 defer func() {
274 uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
275 }()
276 }
277
278 err := service.Handler(ctx, scmd)
279 if errors.Is(err, git.ErrInvalidRepo) {
280 return git.ErrInvalidRepo
281 } else if err != nil {
282 logger.Error("git middleware", "err", err)
283 return git.ErrSystemMalfunction
284 }
285
286 return nil
287 case git.LFSTransferService, git.LFSAuthenticateService:
288 operation := args[1]
289 switch operation {
290 case lfs.OperationDownload:
291 if accessLevel < access.ReadOnlyAccess {
292 return git.ErrNotAuthed
293 }
294 case lfs.OperationUpload:
295 if accessLevel < access.ReadWriteAccess {
296 return git.ErrNotAuthed
297 }
298 default:
299 return git.ErrInvalidRequest
300 }
301
302 if repo == nil {
303 return git.ErrInvalidRepo
304 }
305
306 scmd.Args = []string{
307 name,
308 args[1],
309 }
310
311 switch service {
312 case git.LFSTransferService:
313 lfsTransferCounter.WithLabelValues(name, operation).Inc()
314 defer func() {
315 lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
316 }()
317 default:
318 lfsAuthenticateCounter.WithLabelValues(name, operation).Inc()
319 defer func() {
320 lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
321 }()
322 }
323
324 if err := service.Handler(ctx, scmd); err != nil {
325 logger.Error("git middleware", "err", err)
326 return git.ErrSystemMalfunction
327 }
328
329 return nil
330 }
331
332 return errors.New("unsupported git service")
333}