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