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